mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-06 06:02:37 +08:00
Compare commits
12
Commits
cli-v0.1.6
...
mcp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b6248b05b | ||
|
|
c0fa5e6474 | ||
|
|
5e34360d25 | ||
|
|
751cf04434 | ||
|
|
6f7c5a29ab | ||
|
|
4d8a650afc | ||
|
|
dba7ac3203 | ||
|
|
47e16622f8 | ||
|
|
049cc0c8de | ||
|
|
fc1529ee2b | ||
|
|
e1ae449d36 | ||
|
|
f57b4cc4e3 |
@@ -0,0 +1,34 @@
|
|||||||
|
name: MCP package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths: ['apps/mcp/**', 'apps/cli/package.json', 'apps/cli/npm/platforms.json', '.github/workflows/build-mcp.yml']
|
||||||
|
pull_request:
|
||||||
|
paths: ['apps/mcp/**', 'apps/cli/package.json', 'apps/cli/npm/platforms.json', '.github/workflows/build-mcp.yml']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: apps/mcp/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
working-directory: apps/mcp
|
||||||
|
- run: npm run build
|
||||||
|
working-directory: apps/mcp
|
||||||
|
- run: npm run pack:npm
|
||||||
|
working-directory: apps/mcp
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: mcp-package
|
||||||
|
path: build/npm-mcp/*.tgz
|
||||||
|
if-no-files-found: error
|
||||||
+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)
|
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
|
||||||
- [私有桥接协议](docs/bridge-protocol.md)
|
- [私有桥接协议](docs/bridge-protocol.md)
|
||||||
- [实际验证记录](docs/validation.md)
|
- [实际验证记录](docs/validation.md)
|
||||||
@@ -87,4 +89,4 @@ crossdesk-cli 123456789 --display 1
|
|||||||
|
|
||||||
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
|
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,835 @@
|
|||||||
|
#include "agent.h"
|
||||||
|
#include "automation_protocol.h"
|
||||||
|
#include "terminal_io.h"
|
||||||
|
#include "video_server.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cmath>
|
||||||
|
#include <deque>
|
||||||
|
#include <frame_image.h>
|
||||||
|
#include <future>
|
||||||
|
#include <memory>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <terminal_renderer.h>
|
||||||
|
|
||||||
|
namespace crossdesk::cli {
|
||||||
|
namespace {
|
||||||
|
using nlohmann::json;
|
||||||
|
using namespace automation;
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
using Ms = std::chrono::milliseconds;
|
||||||
|
constexpr size_t kMaxResponse = 4 * 1024 * 1024;
|
||||||
|
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 TerminalEncoding {
|
||||||
|
json id;
|
||||||
|
uint64_t generation, epoch;
|
||||||
|
Clock::time_point queued;
|
||||||
|
bool cancelled = false;
|
||||||
|
std::future<terminal::Grid> result;
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
for (int count = 0; count < 16 && !quit; ++count) {
|
||||||
|
const auto end = buffer.find('\n');
|
||||||
|
if ((end == std::string::npos && buffer.size() > 65536) ||
|
||||||
|
(end != std::string::npos && end > 65536)) {
|
||||||
|
Error(nullptr, "MESSAGE_TOO_LARGE");
|
||||||
|
quit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (end == std::string::npos)
|
||||||
|
break;
|
||||||
|
auto request = ParseBoundedJson(buffer.substr(0, end));
|
||||||
|
buffer.erase(0, end + 1);
|
||||||
|
quit = Handle(request);
|
||||||
|
}
|
||||||
|
if (!quit && buffer.size() > 128 * 1024) {
|
||||||
|
Error(nullptr, "INPUT_BACKPRESSURE");
|
||||||
|
quit = true;
|
||||||
|
}
|
||||||
|
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["cursor"] = Cursor();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
json Cursor() const {
|
||||||
|
const bool visible = cursor_visible_ && frame_ && session_.Connected() &&
|
||||||
|
frame_->generation == session_.Generation() && allowed_ && !paused_ &&
|
||||||
|
session_.ControlEnabled();
|
||||||
|
return {{"type", "cursor"}, {"visible", visible},
|
||||||
|
{"x", cursor_x_}, {"y", cursor_y_},
|
||||||
|
{"generation", session_.Generation()}, {"geometry_epoch", epoch_},
|
||||||
|
{"width", frame_ ? frame_->width : 0},
|
||||||
|
{"height", frame_ ? frame_->height : 0}};
|
||||||
|
}
|
||||||
|
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 auto cursor = Cursor();
|
||||||
|
if (cursor != video_cursor_) {
|
||||||
|
video_->Cursor(cursor);
|
||||||
|
video_cursor_ = cursor;
|
||||||
|
}
|
||||||
|
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 FinishAction(const char *error = nullptr) {
|
||||||
|
if (!action_)
|
||||||
|
return;
|
||||||
|
session_.ReleaseInput();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (terminal_encoding_ && !terminal_encoding_->cancelled) {
|
||||||
|
Error(terminal_encoding_->id, code);
|
||||||
|
terminal_encoding_->cancelled = true;
|
||||||
|
}
|
||||||
|
observations_.clear();
|
||||||
|
preview_ = json();
|
||||||
|
}
|
||||||
|
void Tick() {
|
||||||
|
session_.Tick();
|
||||||
|
// 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)) {
|
||||||
|
if (!frame_ || f->generation != frame_->generation ||
|
||||||
|
f->width != frame_->width || f->height != frame_->height) {
|
||||||
|
++epoch_;
|
||||||
|
cursor_visible_ = false;
|
||||||
|
observations_.clear();
|
||||||
|
preview_ = json();
|
||||||
|
}
|
||||||
|
frame_ = std::make_shared<DecodedFrame>(std::move(*f));
|
||||||
|
}
|
||||||
|
if (frame_ && frame_->generation != session_.Generation()) {
|
||||||
|
frame_.reset();
|
||||||
|
++epoch_;
|
||||||
|
cursor_visible_ = false;
|
||||||
|
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 == StepKind::Pointer
|
||||||
|
? session_.Pointer(step.x, step.y, step.flag, step.wheel)
|
||||||
|
: step.kind == StepKind::Key ? session_.Key(step.key, step.down)
|
||||||
|
: session_.Text(step.text);
|
||||||
|
if (!sent) {
|
||||||
|
FinishAction("ACTION_OUTCOME_UNKNOWN");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (step.kind == StepKind::Pointer) {
|
||||||
|
cursor_x_ = step.x;
|
||||||
|
cursor_y_ = step.y;
|
||||||
|
cursor_visible_ = true;
|
||||||
|
}
|
||||||
|
action_->submitted = true;
|
||||||
|
}
|
||||||
|
if (action_ && action_->next == action_->steps.size())
|
||||||
|
FinishAction();
|
||||||
|
}
|
||||||
|
if (terminal_encoding_ &&
|
||||||
|
terminal_encoding_->result.wait_for(Ms(0)) == std::future_status::ready) {
|
||||||
|
auto job = std::move(terminal_encoding_);
|
||||||
|
try {
|
||||||
|
auto grid = job->result.get();
|
||||||
|
if (job->cancelled) {
|
||||||
|
// Consume the worker result without replying twice.
|
||||||
|
} else if (!session_.Connected() || job->generation != session_.Generation() ||
|
||||||
|
job->epoch != epoch_)
|
||||||
|
Error(job->id, "STALE_OBSERVATION");
|
||||||
|
else if (Age(job->queued) > 2000)
|
||||||
|
Error(job->id, "FRAME_TIMEOUT");
|
||||||
|
else {
|
||||||
|
json cursor = nullptr;
|
||||||
|
if (Cursor()["visible"].get<bool>()) {
|
||||||
|
const int x = grid.content.x + std::clamp(
|
||||||
|
int(cursor_x_ * grid.content.width), 0, grid.content.width - 1);
|
||||||
|
const int y = grid.content.y + std::clamp(
|
||||||
|
int(cursor_y_ * grid.content.height), 0, grid.content.height - 1);
|
||||||
|
cursor = {{"column", x / grid.samples_x}, {"row", y / grid.samples_y}};
|
||||||
|
}
|
||||||
|
Reply(job->id, {{"columns", grid.viewport.columns}, {"rows", grid.viewport.rows},
|
||||||
|
{"lines", terminal::Lines(grid)}, {"cursor", cursor},
|
||||||
|
{"generation", job->generation}, {"geometry_epoch", job->epoch}});
|
||||||
|
}
|
||||||
|
} catch (...) { if (!job->cancelled) Error(job->id, "FRAME_RENDER_FAILED"); }
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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_ && session_.Connected() &&
|
||||||
|
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");
|
||||||
|
const int timeout = Number(j, "timeout_ms", 0, 5000, 2000);
|
||||||
|
Capture c{id, j, preview, Clock::now() + Ms(timeout)};
|
||||||
|
// timeout_ms bounds waiting for a suitable frame, not PNG encoding.
|
||||||
|
// A zero timeout can capture an already available frame immediately.
|
||||||
|
const bool available = frame_ && frame_->generation == session_.Generation() &&
|
||||||
|
Age(frame_->queued) <= 2000 &&
|
||||||
|
(!j.contains("after_frame_id") || frame_->frame_id > Sequence(j, "after_frame_id"));
|
||||||
|
if (!encoding_ && captures_.empty() && available) {
|
||||||
|
Encode(std::move(c));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timeout == 0)
|
||||||
|
throw std::invalid_argument(encoding_ || !captures_.empty() ? "BUSY" : "FRAME_TIMEOUT");
|
||||||
|
if (preview)
|
||||||
|
captures_.push_back(c);
|
||||||
|
else
|
||||||
|
captures_.insert(std::find_if(captures_.begin(), captures_.end(),
|
||||||
|
[](const Capture &queued) { return queued.preview; }), 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_.ControlEnabled())
|
||||||
|
throw std::invalid_argument("CONTROL_DENIED");
|
||||||
|
if (!session_.Connected() || !frame_ || Age(frame_->queued) > 2000)
|
||||||
|
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;
|
||||||
|
if (type == "type_text" && !session_.Status()["text_input"].get<bool>())
|
||||||
|
throw std::invalid_argument("TEXT_INPUT_UNSUPPORTED");
|
||||||
|
running->steps = automation::BuildInputSteps(a,
|
||||||
|
{o.width, o.height, o.source_width, o.source_height, o.region});
|
||||||
|
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"},
|
||||||
|
{"terminal_protocol", "ansi/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 == "terminal_frame") {
|
||||||
|
Fields(j, {"id", "method", "columns", "rows", "colors", "cell_aspect"});
|
||||||
|
if (terminal_encoding_) throw std::invalid_argument("BUSY");
|
||||||
|
if (!session_.Connected() || !frame_ ||
|
||||||
|
frame_->generation != session_.Generation() || Age(frame_->queued) > 2000)
|
||||||
|
throw std::invalid_argument("NOT_READY");
|
||||||
|
terminal::Viewport viewport;
|
||||||
|
viewport.columns = Number(j, "columns", 1, 160, 80);
|
||||||
|
viewport.rows = Number(j, "rows", 1, 60, 22);
|
||||||
|
viewport.colors = Number(j, "colors", 8, 256, 24);
|
||||||
|
if (viewport.colors != 8 && viewport.colors != 24 && viewport.colors != 256)
|
||||||
|
throw std::invalid_argument("INVALID_REQUEST");
|
||||||
|
viewport.cell_aspect = j.value("cell_aspect", .5);
|
||||||
|
if (!std::isfinite(viewport.cell_aspect) || viewport.cell_aspect < .2 || viewport.cell_aspect > 2)
|
||||||
|
throw std::invalid_argument("INVALID_REQUEST");
|
||||||
|
viewport.detail = terminal::AnsiDetail::Quadrant;
|
||||||
|
auto job = std::make_unique<TerminalEncoding>();
|
||||||
|
job->id = id;
|
||||||
|
job->generation = frame_->generation;
|
||||||
|
job->epoch = epoch_;
|
||||||
|
job->queued = frame_->queued;
|
||||||
|
job->result = std::async(std::launch::async, [frame = frame_, viewport] {
|
||||||
|
return terminal::Convert(*frame, viewport);
|
||||||
|
});
|
||||||
|
terminal_encoding_ = std::move(job);
|
||||||
|
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"});
|
||||||
|
const int index = Number(j, "index", 0, 63, -1);
|
||||||
|
if (!session_.Connected() || index < 0 ||
|
||||||
|
size_t(index) >= session_.Status()["displays"].size())
|
||||||
|
throw std::invalid_argument("DISPLAY_UNAVAILABLE");
|
||||||
|
Stop("SESSION_CHANGED");
|
||||||
|
if (!session_.SelectDisplay(index))
|
||||||
|
throw std::invalid_argument("DISPLAY_UNAVAILABLE");
|
||||||
|
frame_.reset();
|
||||||
|
++epoch_;
|
||||||
|
cursor_visible_ = false;
|
||||||
|
} 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");
|
||||||
|
}
|
||||||
|
if (terminal_encoding_ && !terminal_encoding_->cancelled && terminal_encoding_->id == target) {
|
||||||
|
terminal_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<DecodedFrame> frame_;
|
||||||
|
std::deque<Observation> observations_;
|
||||||
|
std::deque<Capture> captures_;
|
||||||
|
std::unique_ptr<Encoding> encoding_;
|
||||||
|
std::unique_ptr<TerminalEncoding> terminal_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;
|
||||||
|
bool cursor_visible_ = false;
|
||||||
|
float cursor_x_ = 0, cursor_y_ = 0;
|
||||||
|
json video_cursor_;
|
||||||
|
};
|
||||||
|
} // 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,262 @@
|
|||||||
|
#include "automation_protocol.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <map>
|
||||||
|
#include <set>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace crossdesk::cli::automation {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
namespace {
|
||||||
|
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},
|
||||||
|
{"SEMICOLON", 186},
|
||||||
|
{"EQUAL", 187},
|
||||||
|
{"COMMA", 188},
|
||||||
|
{"MINUS", 189},
|
||||||
|
{"PERIOD", 190},
|
||||||
|
{"SLASH", 191},
|
||||||
|
{"BACKQUOTE", 192},
|
||||||
|
{"BRACKET_LEFT", 219},
|
||||||
|
{"BACKSLASH", 220},
|
||||||
|
{"BRACKET_RIGHT", 221},
|
||||||
|
{"QUOTE", 222}};
|
||||||
|
auto it = keys.find(key);
|
||||||
|
if (it == keys.end())
|
||||||
|
throw std::invalid_argument("INVALID_KEY");
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<Step> BuildInputSteps(const json &a, const InputGeometry &o) {
|
||||||
|
const auto type = String(a, "type", 32);
|
||||||
|
std::vector<Step> steps;
|
||||||
|
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");
|
||||||
|
const auto center = std::min(value + .5, double(size) - .5);
|
||||||
|
return float(std::clamp((offset + center * 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);
|
||||||
|
steps.push_back(std::move(step));
|
||||||
|
};
|
||||||
|
auto key = [&](int code, bool down, int delay) {
|
||||||
|
Step step;
|
||||||
|
step.kind = StepKind::Key;
|
||||||
|
step.key = code;
|
||||||
|
step.down = down;
|
||||||
|
step.delay = delay;
|
||||||
|
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",
|
||||||
|
"modifiers"}
|
||||||
|
: std::initializer_list<const char *>{"type", "x", "y",
|
||||||
|
"button", "modifiers"});
|
||||||
|
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", "modifiers"});
|
||||||
|
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", "button", "modifiers"});
|
||||||
|
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");
|
||||||
|
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, flag);
|
||||||
|
for (size_t i = 1; i < path.size(); ++i)
|
||||||
|
point(path[i], 10 + int(i * duration / (path.size() - 1)));
|
||||||
|
point(path.back(), duration + 30, flag + 1);
|
||||||
|
} else if (type == "key_press") {
|
||||||
|
Fields(a, {"type", "key", "modifiers"});
|
||||||
|
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"});
|
||||||
|
Step step;
|
||||||
|
step.kind = StepKind::Text;
|
||||||
|
step.text = String(a, "text", 16384);
|
||||||
|
steps.push_back(std::move(step));
|
||||||
|
} else
|
||||||
|
throw std::invalid_argument("UNSUPPORTED_ACTION");
|
||||||
|
if (a.contains("modifiers")) {
|
||||||
|
const auto &modifiers = a["modifiers"];
|
||||||
|
if (!modifiers.is_array() || modifiers.size() > 4)
|
||||||
|
throw std::invalid_argument("INVALID_KEYS");
|
||||||
|
std::set<int> seen;
|
||||||
|
std::vector<Step> prefix;
|
||||||
|
for (const auto &item : modifiers) {
|
||||||
|
if (!item.is_string())
|
||||||
|
throw std::invalid_argument("INVALID_KEY");
|
||||||
|
const auto name = item.get<std::string>();
|
||||||
|
if (name != "CTRL" && name != "ALT" && name != "SHIFT" && name != "META")
|
||||||
|
throw std::invalid_argument("INVALID_KEY");
|
||||||
|
const int code = KeyCode(name);
|
||||||
|
if (!seen.insert(code).second ||
|
||||||
|
(type == "key_press" && String(a, "key", 16) == name))
|
||||||
|
throw std::invalid_argument("DUPLICATE_KEY");
|
||||||
|
Step step;
|
||||||
|
step.kind = StepKind::Key;
|
||||||
|
step.key = code;
|
||||||
|
step.down = true;
|
||||||
|
step.delay = int(prefix.size()) * 15;
|
||||||
|
prefix.push_back(step);
|
||||||
|
}
|
||||||
|
const int offset = int(prefix.size()) * 15;
|
||||||
|
for (auto &step : steps)
|
||||||
|
step.delay += offset;
|
||||||
|
steps.insert(steps.begin(), prefix.begin(), prefix.end());
|
||||||
|
int delay = steps.back().delay + 15;
|
||||||
|
for (auto it = prefix.rbegin(); it != prefix.rend(); ++it, delay += 15)
|
||||||
|
key(it->key, false, delay);
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
} // namespace crossdesk::cli::automation
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-11-03
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _AUTOMATION_PROTOCOL_H_
|
||||||
|
#define _AUTOMATION_PROTOCOL_H_
|
||||||
|
|
||||||
|
#include <frame_image.h>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace crossdesk::cli::automation {
|
||||||
|
using nlohmann::json;
|
||||||
|
void Fields(const json &, std::initializer_list<const char *> names);
|
||||||
|
int Number(const json &, const char *key, int low, int high, int fallback);
|
||||||
|
uint64_t Sequence(const json &, const char *key, uint64_t fallback = 0);
|
||||||
|
bool Flag(const json &, const char *key, bool fallback = false);
|
||||||
|
std::string String(const json &, const char *key, size_t limit = 128);
|
||||||
|
|
||||||
|
struct InputGeometry {
|
||||||
|
int width, height, source_width, source_height;
|
||||||
|
ImageRegion region;
|
||||||
|
};
|
||||||
|
enum class StepKind { Pointer, Key, Text };
|
||||||
|
struct Step {
|
||||||
|
StepKind kind = StepKind::Pointer;
|
||||||
|
int delay = 0, key = 0, flag = 0, wheel = 0;
|
||||||
|
float x = 0, y = 0;
|
||||||
|
bool down = false;
|
||||||
|
std::string text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure validation and translation: no session access or input side effects.
|
||||||
|
std::vector<Step> BuildInputSteps(const json &action,
|
||||||
|
const InputGeometry &geometry);
|
||||||
|
} // namespace crossdesk::cli::automation
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# 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 |
|
||||||
|
| `terminal_frame` | `columns` (1–160), `rows` (1–60), `colors` (8/24/256), `cell_aspect` (0.2–2); private terminal viewer only |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
The terminal viewer additionally requires `automation_terminal_protocol: "ansi/1"` in `doctor` and `terminal_protocol: "ansi/1"` in `hello`. `terminal_frame` asynchronously converts the current decoded frame using the existing ANSI quadrant renderer and returns `columns`, `rows`, `lines`, `generation`, `geometry_epoch`, and a nullable `cursor` with zero-based terminal `column`/`row`. Only one render may be in flight; obsolete geometry or frames older than two seconds are rejected. These rendered text rows travel through the private authenticated terminal IPC, never through public MCP tools or model content. The viewer requests at most 8 FPS and waits for terminal writes before requesting another frame. It shares the automation connection; no second media session is opened.
|
||||||
|
|
||||||
|
Status includes `cursor` with normalized full-frame `x`/`y`, `visible`, `generation`, `geometry_epoch`, `width`, and `height`. The native layer updates it after each successfully sent pointer step, including drag steps, using the same ROI-to-source mapping as remote input. It hides on pause/control disable and clears on geometry changes. It is an Agent position indicator, not remote cursor telemetry or execution confirmation; observation PNGs and source video remain unchanged.
|
||||||
|
|
||||||
|
Authenticated video sockets also receive small JSON text messages with `type: "cursor"` and these fields, independently of binary video frames. Pointer updates are coalesced to at most 30 Hz, retained for newly authorized viewers, and deferred under socket backpressure; they require no video ACK. Browser/Apps render a separate transparent overlay with the same aspect-ratio fitting as the video, including when no new video frame arrives.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
`timeout_ms` bounds frame/worker availability, not the subsequent PNG encoding. With `timeout_ms: 0`, a fresh eligible frame is encoded immediately if the worker and queue are idle; otherwise return `FRAME_TIMEOUT` or `BUSY`. Captures of the same priority retain arrival order. Encoding still rejects obsolete geometry and frames that become older than two seconds. Terminal rendering also honors cancellation without sending a second result when the worker finishes.
|
||||||
|
|
||||||
|
## 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), optional `button` (`left`, `right`, `middle`; default `left`).
|
||||||
|
- `key_press`: `key`; `hotkey`: 2–6 unique `keys`.
|
||||||
|
- `type_text`: `text`, at most 16 KiB UTF-8; requires negotiated remote text-input capability.
|
||||||
|
|
||||||
|
All pointer actions and `key_press` accept optional `modifiers`, an array of at most four unique names from `CTRL`, `ALT`, `SHIFT`, `META`. They are pressed before the action and released afterwards in reverse order; a `key_press` cannot also list its own key as a modifier. `hotkey` already describes the complete chord and does not accept `modifiers`. Punctuation VK names are `SEMICOLON`, `EQUAL`, `COMMA`, `MINUS`, `PERIOD`, `SLASH`, `BACKQUOTE`, `BRACKET_LEFT`, `BACKSLASH`, `BRACKET_RIGHT`, `QUOTE`; these name keys, not layout-independent text.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
New actions require a current frame no older than two seconds as well as a valid observation. Cached operation lookups remain available with a stalled feed or paused control. Failed key/button releases remain tracked for subsequent cleanup, including after control is disabled; a successful local send still does not prove remote execution. Invalid display indices leave existing observations and input sequences intact.
|
||||||
|
|
||||||
|
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。
|
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
|
||||||
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
|
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
|
||||||
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
|
- 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 或媒体协议。
|
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` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
|
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty / Sixel 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
|
||||||
- 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。
|
- 画面比例异常时查看 `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
-7
@@ -1,4 +1,5 @@
|
|||||||
#include "terminal_io.h"
|
#include "terminal_io.h"
|
||||||
|
#include "agent.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <clocale>
|
#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 view <9-digit-device-id> [options] (compatibility alias)
|
||||||
crossdesk-cli doctor [--json]
|
crossdesk-cli doctor [--json]
|
||||||
crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099]
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
No IP direct connection, audio playback, clipboard or file transfer support.
|
||||||
)";
|
)";
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -1087,12 +1090,18 @@ int main(int argc, char **argv) {
|
|||||||
{"kitty", "probe_on_view"},
|
{"kitty", "probe_on_view"},
|
||||||
{"sixel", "probe_on_view"},
|
{"sixel", "probe_on_view"},
|
||||||
{"audio_playback", false},
|
{"audio_playback", false},
|
||||||
{"bridge_version", 1}};
|
{"bridge_version", 1},
|
||||||
|
{"automation_terminal_protocol", "ansi/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";
|
std::cout << d.dump(argc == 3 ? -1 : 2) << "\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
int i = 2;
|
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)
|
if (command.find_first_not_of("0123456789") != std::string::npos)
|
||||||
throw std::invalid_argument(
|
throw std::invalid_argument(
|
||||||
"expected a 9-digit device ID or a command; use --help");
|
"expected a 9-digit device ID or a command; use --help");
|
||||||
@@ -1105,6 +1114,8 @@ int main(int argc, char **argv) {
|
|||||||
double zoom = 1, cell_aspect = 0;
|
double zoom = 1, cell_aspect = 0;
|
||||||
bool control = false, stdio = false, native = false, fps_explicit = false;
|
bool control = false, stdio = false, native = false, fps_explicit = false;
|
||||||
int secret_fd = 3;
|
int secret_fd = 3;
|
||||||
|
bool video_websocket = false;
|
||||||
|
if (command == "agent") options.fps = 30;
|
||||||
if (command == "view") {
|
if (command == "view") {
|
||||||
if (i >= argc)
|
if (i >= argc)
|
||||||
throw std::invalid_argument("device ID required");
|
throw std::invalid_argument("device ID required");
|
||||||
@@ -1124,8 +1135,10 @@ int main(int argc, char **argv) {
|
|||||||
std::string a = argv[i];
|
std::string a = argv[i];
|
||||||
if (a == "--control" && command == "view")
|
if (a == "--control" && command == "view")
|
||||||
control = true;
|
control = true;
|
||||||
else if (a == "--stdio" && command == "bridge")
|
else if (a == "--stdio" && command != "view")
|
||||||
stdio = true;
|
stdio = true;
|
||||||
|
else if (a == "--video-ws" && command == "agent")
|
||||||
|
video_websocket = true;
|
||||||
else {
|
else {
|
||||||
if (i + 1 >= argc)
|
if (i + 1 >= argc)
|
||||||
throw std::invalid_argument("option value missing");
|
throw std::invalid_argument("option value missing");
|
||||||
@@ -1134,6 +1147,8 @@ int main(int argc, char **argv) {
|
|||||||
options.host = value;
|
options.host = value;
|
||||||
else if (a == "--port")
|
else if (a == "--port")
|
||||||
options.port = number(value, 1, 65535);
|
options.port = number(value, 1, 65535);
|
||||||
|
else if (a == "--log-dir" && command == "agent")
|
||||||
|
options.log_directory = value;
|
||||||
else if (a == "--fps" && command == "view") {
|
else if (a == "--fps" && command == "view") {
|
||||||
options.fps = number(value, 1, 30);
|
options.fps = number(value, 1, 30);
|
||||||
fps_explicit = true;
|
fps_explicit = true;
|
||||||
@@ -1158,7 +1173,7 @@ int main(int argc, char **argv) {
|
|||||||
} else if (a == "--zoom" && command == "view") {
|
} else if (a == "--zoom" && command == "view") {
|
||||||
native = value == "native";
|
native = value == "native";
|
||||||
zoom = native ? 1 : number(value, 1, 32);
|
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);
|
secret_fd = number(value, 3, 1024);
|
||||||
else
|
else
|
||||||
throw std::invalid_argument("unsupported option");
|
throw std::invalid_argument("unsupported option");
|
||||||
@@ -1167,14 +1182,14 @@ int main(int argc, char **argv) {
|
|||||||
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" &&
|
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" &&
|
||||||
renderer != "sixel")
|
renderer != "sixel")
|
||||||
throw std::invalid_argument("unknown renderer");
|
throw std::invalid_argument("unknown renderer");
|
||||||
if (command == "bridge" && !stdio)
|
if (command != "view" && !stdio)
|
||||||
throw std::invalid_argument("bridge requires --stdio");
|
throw std::invalid_argument("automation and bridge require --stdio");
|
||||||
Descriptor output(Duplicate(1));
|
Descriptor output(Duplicate(1));
|
||||||
if (output.value < 0)
|
if (output.value < 0)
|
||||||
throw std::runtime_error("cannot preserve output channel");
|
throw std::runtime_error("cannot preserve output channel");
|
||||||
// All library stdout writes go to diagnostics. Only Output uses the saved
|
// All library stdout writes go to diagnostics. Only Output uses the saved
|
||||||
// fd.
|
// fd.
|
||||||
options.log_directory = Logs();
|
if (options.log_directory.empty()) options.log_directory = Logs();
|
||||||
if (command == "view") {
|
if (command == "view") {
|
||||||
Descriptor logs(OpenLog(options.log_directory + "/client.log"));
|
Descriptor logs(OpenLog(options.log_directory + "/client.log"));
|
||||||
if (logs.value < 0 || Redirect(logs.value, 2) < 0)
|
if (logs.value < 0 || Redirect(logs.value, 2) < 0)
|
||||||
@@ -1193,6 +1208,8 @@ int main(int argc, char **argv) {
|
|||||||
return command == "view" ? View(options, device, renderer, control,
|
return command == "view" ? View(options, device, renderer, control,
|
||||||
output.value, detail, zoom, native,
|
output.value, detail, zoom, native,
|
||||||
cell_aspect, fps_explicit)
|
cell_aspect, fps_explicit)
|
||||||
|
: command == "agent"
|
||||||
|
? Agent(output.value, options, secret_fd, video_websocket)
|
||||||
: Bridge(output.value, options, secret_fd);
|
: Bridge(output.value, options, secret_fd);
|
||||||
} catch (const std::exception &e) {
|
} catch (const std::exception &e) {
|
||||||
if (command == "view") {
|
if (command == "view") {
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stdio = ['inherit', 'inherit', 'inherit'];
|
const stdio = ['inherit', 'inherit', 'inherit'];
|
||||||
if (args[0] === 'bridge') {
|
if (args[0] === 'bridge' || args[0] === 'agent') {
|
||||||
const index = args.lastIndexOf('--password-fd');
|
for (const [option, fallback] of [['--password-fd', 3]]) {
|
||||||
const fd = index === -1 ? 3 : Number(args[index + 1]);
|
const index = args.lastIndexOf(option);
|
||||||
|
const fd = index === -1 ? fallback : Number(args[index + 1]);
|
||||||
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
|
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
|
||||||
try {
|
try {
|
||||||
const stat = fs.fstatSync(fd);
|
const stat = fs.fstatSync(fd);
|
||||||
@@ -46,7 +47,8 @@ function main() {
|
|||||||
while (stdio.length <= fd) stdio.push('ignore');
|
while (stdio.length <= fd) stdio.push('ignore');
|
||||||
stdio[fd] = fd;
|
stdio[fd] = fd;
|
||||||
}
|
}
|
||||||
} catch { /* The native bridge reports a missing credential descriptor. */ }
|
} catch { /* Native code reports unavailable descriptors. */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const child = spawn(binary, args, { stdio, shell: false });
|
const child = spawn(binary, args, { stdio, shell: false });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chunkdash/crossdesk-cli",
|
"name": "@chunkdash/crossdesk-cli",
|
||||||
"version": "0.1.6",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CrossDesk remote desktop controller for the terminal",
|
"description": "CrossDesk remote desktop controller for the terminal",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ function inspect(binary, target) {
|
|||||||
}
|
}
|
||||||
const doctor = JSON.parse(run(binary, ['doctor', '--json'], { timeout: 15000 }));
|
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.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;
|
return info;
|
||||||
}
|
}
|
||||||
function pack(directory, output) {
|
function pack(directory, output) {
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
#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;
|
||||||
|
uint64_t cursor_revision = 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;
|
||||||
|
json cursor;
|
||||||
|
uint64_t cursor_revision = 0;
|
||||||
|
|
||||||
|
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(
|
||||||
|
33, [this](const websocketpp::lib::error_code &error) {
|
||||||
|
if (error)
|
||||||
|
return;
|
||||||
|
const auto now = Clock::now();
|
||||||
|
std::string pointer;
|
||||||
|
uint64_t revision;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
revision = cursor_revision;
|
||||||
|
if (!cursor.is_null()) pointer = cursor.dump();
|
||||||
|
}
|
||||||
|
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 && c.cursor_revision != revision && !pointer.empty()) {
|
||||||
|
websocketpp::lib::error_code ec;
|
||||||
|
auto connection = server.get_con_from_hdl(h, ec);
|
||||||
|
// Coalesce pointer changes while a viewer is backpressured.
|
||||||
|
if (!ec && connection->get_buffered_amount() < 256 * 1024) {
|
||||||
|
server.send(h, pointer, websocketpp::frame::opcode::TEXT, ec);
|
||||||
|
if (!ec) c.cursor_revision = revision;
|
||||||
|
}
|
||||||
|
if (ec) close.push_back(h);
|
||||||
|
}
|
||||||
|
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)});
|
||||||
|
}
|
||||||
|
void VideoServer::Cursor(json cursor) {
|
||||||
|
auto &s = *state_;
|
||||||
|
std::lock_guard lock(s.mutex);
|
||||||
|
s.cursor = std::move(cursor);
|
||||||
|
++s.cursor_revision;
|
||||||
|
}
|
||||||
|
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,49 @@
|
|||||||
|
/*
|
||||||
|
* @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);
|
||||||
|
void Cursor(nlohmann::json cursor);
|
||||||
|
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")
|
local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc")
|
||||||
|
|
||||||
add_requires("spdlog 1.14.1", {system = false})
|
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
|
if is_plat("windows") then
|
||||||
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
|
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
|
||||||
end
|
end
|
||||||
@@ -11,7 +13,7 @@ end
|
|||||||
target("crossdesk_controller")
|
target("crossdesk_controller")
|
||||||
set_kind("static")
|
set_kind("static")
|
||||||
add_deps("minirtc", "crossdesk_wire")
|
add_deps("minirtc", "crossdesk_wire")
|
||||||
add_packages("spdlog")
|
add_packages("spdlog", "libpng", "libyuv")
|
||||||
add_includedirs(path.join(controller_dir, "include"),
|
add_includedirs(path.join(controller_dir, "include"),
|
||||||
path.join(minirtc_dir, "src/api"), {public = true})
|
path.join(minirtc_dir, "src/api"), {public = true})
|
||||||
add_includedirs(path.join(minirtc_dir, "src/log"))
|
add_includedirs(path.join(minirtc_dir, "src/log"))
|
||||||
@@ -32,12 +34,14 @@ target_end()
|
|||||||
|
|
||||||
target("crossdesk-cli")
|
target("crossdesk-cli")
|
||||||
set_kind("binary")
|
set_kind("binary")
|
||||||
set_version("0.1.6")
|
set_version("0.2.0")
|
||||||
add_deps("crossdesk_terminal")
|
add_deps("crossdesk_terminal")
|
||||||
add_files("main.cpp")
|
add_files("main.cpp")
|
||||||
|
add_files("agent.cpp", "automation_protocol.cpp", "video_server.cpp")
|
||||||
|
add_packages("asio", "websocketpp")
|
||||||
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
|
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
|
||||||
if is_plat("windows") then
|
if is_plat("windows") then
|
||||||
add_syslinks("advapi32", "user32")
|
add_syslinks("advapi32", "user32", "ws2_32")
|
||||||
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
|
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
|
||||||
elseif is_plat("linux") then
|
elseif is_plat("linux") then
|
||||||
add_syslinks("rt")
|
add_syslinks("rt")
|
||||||
|
|||||||
@@ -230,7 +230,10 @@ std::string Read(const std::filesystem::path& path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EnsureDirectory(const std::filesystem::path& directory, bool protect_existing) {
|
void EnsureDirectory(const std::filesystem::path& directory, bool protect_existing) {
|
||||||
std::filesystem::create_directories(directory.parent_path());
|
const auto parent = directory.parent_path();
|
||||||
|
if (!parent.empty()) {
|
||||||
|
std::filesystem::create_directories(parent);
|
||||||
|
}
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
CreatePrivateDirectory(directory, protect_existing);
|
CreatePrivateDirectory(directory, protect_existing);
|
||||||
#else
|
#else
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
#include "fa_solid_900.h"
|
#include "fa_solid_900.h"
|
||||||
#include "features/about/about_catalog.h"
|
#include "features/about/about_catalog.h"
|
||||||
#include "features/file_transfer/transfer_notification_layout.h"
|
#include "features/file_transfer/transfer_notification_layout.h"
|
||||||
|
#include "features/file_transfer/transfer_notification_state.h"
|
||||||
|
#include "features/file_transfer/file_browser_window.h"
|
||||||
#include "features/gallery/gallery_fullscreen_transition.h"
|
#include "features/gallery/gallery_fullscreen_transition.h"
|
||||||
#include "features/gallery/gallery_window_state.h"
|
#include "features/gallery/gallery_window_state.h"
|
||||||
#include "features/gallery/gallery_workspace.h"
|
#include "features/gallery/gallery_workspace.h"
|
||||||
@@ -95,6 +97,7 @@
|
|||||||
#include "updater/update_controller.h"
|
#include "updater/update_controller.h"
|
||||||
#endif
|
#endif
|
||||||
#include "window_geometry.h"
|
#include "window_geometry.h"
|
||||||
|
#include "window_presentation.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -1064,9 +1067,10 @@ struct GuiApplication::SlintUi {
|
|||||||
struct TransferNotification {
|
struct TransferNotification {
|
||||||
std::optional<slint::ComponentHandle<ui::FileTransferNotification>> window;
|
std::optional<slint::ComponentHandle<ui::FileTransferNotification>> window;
|
||||||
RemoteSession::FileTransferInfo info;
|
RemoteSession::FileTransferInfo info;
|
||||||
bool dismissed = false;
|
transfer_notification::Presentation presentation;
|
||||||
};
|
};
|
||||||
std::map<uint32_t, TransferNotification> transfer_notifications;
|
std::map<uint32_t, TransferNotification> transfer_notifications;
|
||||||
|
std::map<std::string, std::unique_ptr<FileBrowserWindow>> file_browsers;
|
||||||
SDL_DisplayID transfer_display = 0;
|
SDL_DisplayID transfer_display = 0;
|
||||||
std::shared_ptr<slint::VectorModel<ui::NetworkStatsRow>> stats_model =
|
std::shared_ptr<slint::VectorModel<ui::NetworkStatsRow>> stats_model =
|
||||||
std::make_shared<slint::VectorModel<ui::NetworkStatsRow>>();
|
std::make_shared<slint::VectorModel<ui::NetworkStatsRow>>();
|
||||||
@@ -1113,9 +1117,6 @@ struct GuiApplication::SlintUi {
|
|||||||
bool server_file_dialog_open = false;
|
bool server_file_dialog_open = false;
|
||||||
bool server_suspended_for_dialog = false;
|
bool server_suspended_for_dialog = false;
|
||||||
server_window_state::EdgeLayout server_layout{};
|
server_window_state::EdgeLayout server_layout{};
|
||||||
#if _WIN32
|
|
||||||
int server_native_window_attempts = 0;
|
|
||||||
#endif
|
|
||||||
int localized_language = -1;
|
int localized_language = -1;
|
||||||
std::chrono::steady_clock::time_point last_clipboard_poll{};
|
std::chrono::steady_clock::time_point last_clipboard_poll{};
|
||||||
slint::Timer video_timer;
|
slint::Timer video_timer;
|
||||||
@@ -1980,9 +1981,14 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
|||||||
if (updater_ && updater_->busy()) updater_->Cancel();
|
if (updater_ && updater_->busy()) updater_->Cancel();
|
||||||
#endif
|
#endif
|
||||||
});
|
});
|
||||||
main->on_open_download([this, main] {
|
main->on_open_manual_download([this] {
|
||||||
const auto& latest = update_checker_->latest();
|
const auto& latest = update_checker_->latest();
|
||||||
|
OpenUrl(latest && !latest->download_url.empty() ? latest->download_url
|
||||||
|
: "https://crossdesk.cn");
|
||||||
|
});
|
||||||
|
main->on_open_download([this, main] {
|
||||||
#if CROSSDESK_HAS_UPDATER
|
#if CROSSDESK_HAS_UPDATER
|
||||||
|
const auto& latest = update_checker_->latest();
|
||||||
if (updater_ && updater_->busy()) return;
|
if (updater_ && updater_->busy()) return;
|
||||||
if (updater_ && latest && updater_->Available(latest->update_envelope)) {
|
if (updater_ && latest && updater_->Available(latest->update_envelope)) {
|
||||||
main->set_about_open(false);
|
main->set_about_open(false);
|
||||||
@@ -1991,8 +1997,7 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
OpenUrl(latest && !latest->download_url.empty() ? latest->download_url
|
main->invoke_open_manual_download();
|
||||||
: "https://crossdesk.cn");
|
|
||||||
});
|
});
|
||||||
main->on_confirm_update([this, main] {
|
main->on_confirm_update([this, main] {
|
||||||
if (!main->get_update_confirm_open()) return;
|
if (!main->get_update_confirm_open()) return;
|
||||||
@@ -2004,8 +2009,12 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
|||||||
!updater_->Available(latest->update_envelope)) return;
|
!updater_->Available(latest->update_envelope)) return;
|
||||||
updater_->Start(latest->update_envelope);
|
updater_->Start(latest->update_envelope);
|
||||||
// Disable both windows immediately, before the next polling tick.
|
// Disable both windows immediately, before the next polling tick.
|
||||||
for (const auto& window : {ui_->main, ui_->wall})
|
for (const auto& window : {ui_->main, ui_->wall}) {
|
||||||
window->set_update_install_busy(updater_->busy());
|
window->set_update_install_busy(updater_->busy());
|
||||||
|
window->set_update_install_failed(
|
||||||
|
updater_->state() == updater::Controller::State::Failed);
|
||||||
|
window->set_update_install_status("");
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
});
|
});
|
||||||
about::Populate(ui_->Window(gallery_window));
|
about::Populate(ui_->Window(gallery_window));
|
||||||
@@ -2319,16 +2328,7 @@ void GuiApplication::BindStreamCallbacks() {
|
|||||||
SyncAudioPlayback();
|
SyncAudioPlayback();
|
||||||
});
|
});
|
||||||
stream->on_select_file([this] {
|
stream->on_select_file([this] {
|
||||||
auto props = SelectedSession();
|
if (auto props = SelectedSession()) OpenFileManager(props->remote_id_);
|
||||||
if (!props) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
OpenFileDialog(localization::select_file[localization_language_index_],
|
|
||||||
[this, props](std::string path) {
|
|
||||||
if (!path.empty() && !props->closing_ &&
|
|
||||||
props->connection_status_ == ConnectionStatus::Connected)
|
|
||||||
transfers_.ProcessSelectedFile(path, props, props->file_label_);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
stream->on_can_drop_file(
|
stream->on_can_drop_file(
|
||||||
[](const slint::DataTransfer& data) { return data.has_plain_text(); });
|
[](const slint::DataTransfer& data) { return data.has_plain_text(); });
|
||||||
@@ -2441,40 +2441,7 @@ void GuiApplication::BindServerCallbacks() {
|
|||||||
selected_server_remote_id_ = ui_->controller_ids[index];
|
selected_server_remote_id_ = ui_->controller_ids[index];
|
||||||
});
|
});
|
||||||
server->on_select_file([this] {
|
server->on_select_file([this] {
|
||||||
if (selected_server_remote_id_.empty() || ui_->server_file_dialog_open) {
|
if (!selected_server_remote_id_.empty()) OpenFileManager(selected_server_remote_id_);
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Keep the recipient stable if another controller connects or leaves while
|
|
||||||
// the asynchronous native picker is open.
|
|
||||||
const std::string recipient = selected_server_remote_id_;
|
|
||||||
ui_->server_file_dialog_open = true;
|
|
||||||
(*ui_->server)->window().request_redraw();
|
|
||||||
const auto select_file = [this, recipient] {
|
|
||||||
const auto connected = [this, recipient] {
|
|
||||||
std::shared_lock lock(connection_status_mutex_);
|
|
||||||
const auto found = connection_status_.find(recipient);
|
|
||||||
return found != connection_status_.end() &&
|
|
||||||
found->second == ConnectionStatus::Connected;
|
|
||||||
};
|
|
||||||
if (!connected()) {
|
|
||||||
ui_->server_file_dialog_open = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
OpenFileDialog(localization::select_file[localization_language_index_],
|
|
||||||
[this, recipient, connected](std::string path) {
|
|
||||||
ui_->server_file_dialog_open = false;
|
|
||||||
if (connected() && !path.empty())
|
|
||||||
transfers_.ProcessSelectedFile(path, nullptr, file_label_, recipient);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
#if defined(__APPLE__)
|
|
||||||
// The native picker is already dispatched outside the clicked callback.
|
|
||||||
select_file();
|
|
||||||
#else
|
|
||||||
// Let TouchArea release and repaint before the blocking native picker.
|
|
||||||
ui_->server_file_dialog_timer.start(
|
|
||||||
slint::TimerMode::SingleShot, std::chrono::milliseconds(32), select_file);
|
|
||||||
#endif
|
|
||||||
});
|
});
|
||||||
server->on_disconnect_controller([this](slint::SharedString id) {
|
server->on_disconnect_controller([this](slint::SharedString id) {
|
||||||
CloseServerController(std::string(id));
|
CloseServerController(std::string(id));
|
||||||
@@ -2725,6 +2692,14 @@ void GuiApplication::Tick() {
|
|||||||
SyncStreamWindow();
|
SyncStreamWindow();
|
||||||
SyncServerWindow();
|
SyncServerWindow();
|
||||||
SyncTrustedDevices();
|
SyncTrustedDevices();
|
||||||
|
for (auto it = ui_->file_browsers.begin(); it != ui_->file_browsers.end();) {
|
||||||
|
if (!it->second->Tick()) it = ui_->file_browsers.erase(it);
|
||||||
|
else {
|
||||||
|
if (it->second->Visible())
|
||||||
|
it->second->SetPeerName(FileManagerPeerName(it->first));
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
SyncFileTransferNotifications();
|
SyncFileTransferNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2743,18 +2718,22 @@ void GuiApplication::PollUpdateCheck() {
|
|||||||
}
|
}
|
||||||
const auto& latest = update_checker_->latest();
|
const auto& latest = update_checker_->latest();
|
||||||
const bool can_install = latest && updater_->Available(latest->update_envelope);
|
const bool can_install = latest && updater_->Available(latest->update_envelope);
|
||||||
|
const bool failed = updater_->state() == updater::Controller::State::Failed;
|
||||||
const int language = localization::detail::ClampLanguageIndex(localization_language_index_);
|
const int language = localization::detail::ClampLanguageIndex(localization_language_index_);
|
||||||
std::string status;
|
std::string status;
|
||||||
if (updater_->busy()) {
|
if (updater_->busy()) {
|
||||||
status = localization::update_preparing[language];
|
status = localization::update_preparing[language];
|
||||||
if (updater_->state() == updater::Controller::State::Downloading)
|
if (updater_->state() == updater::Controller::State::Downloading)
|
||||||
status += " " + std::to_string(updater_->progress()) + "%";
|
status += " " + std::to_string(updater_->progress()) + "%";
|
||||||
} else if (updater_->state() == updater::Controller::State::Failed) {
|
} else if (failed) {
|
||||||
status = localization::update_install_failed[language] + "\n" + updater_->error();
|
status = (can_install ? localization::update_install_failed[language]
|
||||||
|
: localization::update_install_failed_manual[language]) +
|
||||||
|
"\n" + updater_->error();
|
||||||
}
|
}
|
||||||
for (const auto& main : {ui_->main, ui_->wall}) {
|
for (const auto& main : {ui_->main, ui_->wall}) {
|
||||||
main->set_update_install_available(can_install);
|
main->set_update_install_available(can_install);
|
||||||
main->set_update_install_busy(updater_->busy());
|
main->set_update_install_busy(updater_->busy());
|
||||||
|
main->set_update_install_failed(failed);
|
||||||
main->set_update_install_status(UiText(status));
|
main->set_update_install_status(UiText(status));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3210,6 +3189,8 @@ void GuiApplication::UpdateLocalization() {
|
|||||||
if (ui_->stream) {
|
if (ui_->stream) {
|
||||||
ui_localization::ApplyStreamWindowStrings(*ui_->stream, language);
|
ui_localization::ApplyStreamWindowStrings(*ui_->stream, language);
|
||||||
}
|
}
|
||||||
|
for (auto& [_, browser] : ui_->file_browsers)
|
||||||
|
browser->UpdateActionLabels(language);
|
||||||
ui_->localized_language = language;
|
ui_->localized_language = language;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3781,9 +3762,10 @@ void GuiApplication::SyncStreamWindow() {
|
|||||||
(*ui_->stream)->set_displays(ui_->display_model);
|
(*ui_->stream)->set_displays(ui_->display_model);
|
||||||
(*ui_->stream)->set_stats_rows(ui_->stats_model);
|
(*ui_->stream)->set_stats_rows(ui_->stats_model);
|
||||||
BindStreamCallbacks();
|
BindStreamCallbacks();
|
||||||
(*ui_->stream)->show();
|
(*ui_->stream)->window().set_size(slint::LogicalSize({
|
||||||
// Repeat after the next layout pass because the backend resolves the
|
kStreamWindowLogicalWidth,
|
||||||
// initial DPI and preferred size asynchronously on some platforms.
|
StreamWindowLogicalHeight((*ui_->stream)->get_custom_titlebar())}));
|
||||||
|
// Resolve placement and any display-DPI change while still hidden.
|
||||||
ui_->stream_initial_position_attempts = 2;
|
ui_->stream_initial_position_attempts = 2;
|
||||||
#if defined(__APPLE__)
|
#if defined(__APPLE__)
|
||||||
ui_->stream_live_resize_configuration_attempts = 30;
|
ui_->stream_live_resize_configuration_attempts = 30;
|
||||||
@@ -3798,8 +3780,10 @@ void GuiApplication::SyncStreamWindow() {
|
|||||||
// A wall takeover hides an existing stream window without destroying it.
|
// A wall takeover hides an existing stream window without destroying it.
|
||||||
// Opening a recent device must also restore that existing window.
|
// Opening a recent device must also restore that existing window.
|
||||||
if (need_to_create_stream_window_) {
|
if (need_to_create_stream_window_) {
|
||||||
|
if (ui_->stream_initial_position_attempts == 0) {
|
||||||
(*ui_->stream)->window().set_minimized(false);
|
(*ui_->stream)->window().set_minimized(false);
|
||||||
(*ui_->stream)->show();
|
(*ui_->stream)->show();
|
||||||
|
}
|
||||||
need_to_create_stream_window_ = false;
|
need_to_create_stream_window_ = false;
|
||||||
}
|
}
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
@@ -3819,7 +3803,8 @@ void GuiApplication::SyncStreamWindow() {
|
|||||||
if (ui_->video_presenter && ui_->video_presenter->EnsureAttached()) {
|
if (ui_->video_presenter && ui_->video_presenter->EnsureAttached()) {
|
||||||
video_frame_dirty_.store(true, std::memory_order_release);
|
video_frame_dirty_.store(true, std::memory_order_release);
|
||||||
}
|
}
|
||||||
if (ui_->stream_initial_position_attempts > 0) {
|
if (has_sessions && ui_->stream_initial_position_attempts > 0 &&
|
||||||
|
NativeWindowReady((*ui_->stream)->window())) {
|
||||||
if (PositionWindowAtCenter(
|
if (PositionWindowAtCenter(
|
||||||
(*ui_->stream)->window(), ui_->main->window(),
|
(*ui_->stream)->window(), ui_->main->window(),
|
||||||
kStreamWindowLogicalWidth,
|
kStreamWindowLogicalWidth,
|
||||||
@@ -3828,6 +3813,7 @@ void GuiApplication::SyncStreamWindow() {
|
|||||||
} else {
|
} else {
|
||||||
ui_->stream_initial_position_attempts = 0;
|
ui_->stream_initial_position_attempts = 0;
|
||||||
}
|
}
|
||||||
|
if (ui_->stream_initial_position_attempts == 0) (*ui_->stream)->show();
|
||||||
}
|
}
|
||||||
if (!has_sessions) {
|
if (!has_sessions) {
|
||||||
SetStreamKeyboardFocus(false);
|
SetStreamKeyboardFocus(false);
|
||||||
@@ -4228,11 +4214,7 @@ void GuiApplication::SetServerPanelExpanded(bool expanded) {
|
|||||||
server_window_collapsed_ = !expanded;
|
server_window_collapsed_ = !expanded;
|
||||||
(*ui_->server_bar)->set_expanded(expanded);
|
(*ui_->server_bar)->set_expanded(expanded);
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
PositionServerWindows();
|
// SyncServerWindow shows the panel after native creation and placement.
|
||||||
(*ui_->server)->show();
|
|
||||||
#if _WIN32
|
|
||||||
ConfigureWindowsServerWindow((*ui_->server)->window());
|
|
||||||
#endif
|
|
||||||
PositionServerWindows();
|
PositionServerWindows();
|
||||||
} else {
|
} else {
|
||||||
(*ui_->server)->set_confirming_disconnect(false);
|
(*ui_->server)->set_confirming_disconnect(false);
|
||||||
@@ -4464,10 +4446,6 @@ void GuiApplication::SyncServerWindow() {
|
|||||||
BindServerCallbacks();
|
BindServerCallbacks();
|
||||||
ui_localization::ApplyServerStrings(*ui_->server, localization_language_index_);
|
ui_localization::ApplyServerStrings(*ui_->server, localization_language_index_);
|
||||||
ui_localization::ApplyServerStrings(*ui_->server_bar, localization_language_index_);
|
ui_localization::ApplyServerStrings(*ui_->server_bar, localization_language_index_);
|
||||||
(*ui_->server_bar)->show();
|
|
||||||
#if _WIN32
|
|
||||||
ui_->server_native_window_attempts = 30;
|
|
||||||
#endif
|
|
||||||
server_window_created_ = true;
|
server_window_created_ = true;
|
||||||
server_window_inited_ = true;
|
server_window_inited_ = true;
|
||||||
}
|
}
|
||||||
@@ -4487,13 +4465,9 @@ void GuiApplication::SyncServerWindow() {
|
|||||||
ui_->server_drag.active = false;
|
ui_->server_drag.active = false;
|
||||||
server_window_created_ = false;
|
server_window_created_ = false;
|
||||||
server_window_inited_ = false;
|
server_window_inited_ = false;
|
||||||
#if _WIN32
|
|
||||||
ui_->server_native_window_attempts = 0;
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
if (!ui_->server) return;
|
if (!ui_->server) return;
|
||||||
if (ui_->server_suspended_for_dialog) {
|
if (ui_->server_suspended_for_dialog) {
|
||||||
(*ui_->server_bar)->show();
|
|
||||||
ui_->server_suspended_for_dialog = false;
|
ui_->server_suspended_for_dialog = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4534,16 +4508,17 @@ void GuiApplication::SyncServerWindow() {
|
|||||||
(*ui_->server_bar)->set_controller_count(static_cast<int>(ui_->controller_ids.size()));
|
(*ui_->server_bar)->set_controller_count(static_cast<int>(ui_->controller_ids.size()));
|
||||||
ui_localization::ApplyServerStrings(*ui_->server, localization_language_index_);
|
ui_localization::ApplyServerStrings(*ui_->server, localization_language_index_);
|
||||||
ui_localization::ApplyServerStrings(*ui_->server_bar, localization_language_index_);
|
ui_localization::ApplyServerStrings(*ui_->server_bar, localization_language_index_);
|
||||||
#if _WIN32
|
|
||||||
if (ui_->server_native_window_attempts > 0) {
|
|
||||||
if (ConfigureWindowsServerWindow((*ui_->server_bar)->window()))
|
|
||||||
ui_->server_native_window_attempts = 0;
|
|
||||||
else
|
|
||||||
--ui_->server_native_window_attempts;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
(*ui_->server)->set_sending_file(transfers_.global_state().file_sending_.load());
|
(*ui_->server)->set_sending_file(transfers_.global_state().file_sending_.load());
|
||||||
PositionServerWindows();
|
PositionServerWindows();
|
||||||
|
const auto show_prepared = [](slint::Window& window) {
|
||||||
|
if (window.is_visible() || !NativeWindowReady(window)) return;
|
||||||
|
#if _WIN32
|
||||||
|
if (!ConfigureWindowsServerWindow(window)) return;
|
||||||
|
#endif
|
||||||
|
window.show();
|
||||||
|
};
|
||||||
|
show_prepared((*ui_->server_bar)->window());
|
||||||
|
if (!server_window_collapsed_) show_prepared((*ui_->server)->window());
|
||||||
}
|
}
|
||||||
|
|
||||||
void GuiApplication::CancelFileTransfer(uint32_t file_id) {
|
void GuiApplication::CancelFileTransfer(uint32_t file_id) {
|
||||||
@@ -4569,16 +4544,72 @@ void GuiApplication::CancelFileTransfer(uint32_t file_id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string GuiApplication::FileManagerPeerName(const std::string& remote) {
|
||||||
|
Thumbnail::RecentConnection device{};
|
||||||
|
device.remote_id = remote;
|
||||||
|
if (const auto props = FindRemoteSession(remote)) {
|
||||||
|
if (const auto direct = props->lan_session_) {
|
||||||
|
device.remote_id = Thumbnail::LanRecordId(direct->target.device_id);
|
||||||
|
}
|
||||||
|
std::lock_guard lock(props->remote_version_mutex_);
|
||||||
|
device.remote_host_name = props->remote_host_name_;
|
||||||
|
} else {
|
||||||
|
std::shared_lock lock(connection_status_mutex_);
|
||||||
|
if (const auto host = connection_host_names_.find(remote);
|
||||||
|
host != connection_host_names_.end())
|
||||||
|
device.remote_host_name = host->second;
|
||||||
|
}
|
||||||
|
if (device.remote_host_name.empty() || device.remote_host_name == "unknown") {
|
||||||
|
for (const auto& [_, recent] : recent_connections_) {
|
||||||
|
if (recent.remote_id == device.remote_id) {
|
||||||
|
device.remote_host_name = recent.remote_host_name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto name = settings_.RecentConnectionDisplayName(device);
|
||||||
|
// Keep transport IDs out of the header while peer information is arriving.
|
||||||
|
return name == device.remote_id ? std::string{} : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GuiApplication::OpenFileManager(const std::string& remote) {
|
||||||
|
if (!transfers_.OpenBrowser(remote)) return;
|
||||||
|
if (!transfers_.BrowserSupported(remote)) {
|
||||||
|
// Host information from older clients omits the capability. Use their
|
||||||
|
// existing file protocol and picker directly, without a directory RPC.
|
||||||
|
const auto generation = transfers_.browser().Generation(remote);
|
||||||
|
OpenFileDialog(std::string(localization::select_file[localization_language_index_]),
|
||||||
|
[this, remote, generation](std::string path) {
|
||||||
|
if (path.empty() || !transfers_.BrowserConnected(remote) ||
|
||||||
|
transfers_.browser().Generation(remote) != generation) return;
|
||||||
|
const auto props = FindRemoteSession(remote);
|
||||||
|
transfers_.ProcessSelectedFile(path, props,
|
||||||
|
props ? props->file_label_ : file_label_, remote);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto& browser = ui_->file_browsers[remote];
|
||||||
|
if (browser && !browser->Tick()) browser.reset();
|
||||||
|
if (!browser) {
|
||||||
|
const bool controller = static_cast<bool>(FindRemoteSession(remote));
|
||||||
|
browser = std::make_unique<FileBrowserWindow>(transfers_, remote, controller,
|
||||||
|
localization_language_index_);
|
||||||
|
}
|
||||||
|
browser->SetPeerName(FileManagerPeerName(remote));
|
||||||
|
browser->Show();
|
||||||
|
SyncFileTransferNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
void GuiApplication::SyncFileTransferNotifications() {
|
void GuiApplication::SyncFileTransferNotifications() {
|
||||||
using Status = RemoteSession::FileTransferStatus;
|
using Status = RemoteSession::FileTransferStatus;
|
||||||
using namespace transfer_notification;
|
using namespace transfer_notification;
|
||||||
std::map<uint32_t, RemoteSession::FileTransferInfo> files;
|
std::map<uint32_t, RemoteSession::FileTransferInfo> files;
|
||||||
const auto collect = [&](FileTransferState& state) {
|
const auto collect = [&](FileTransferState& state) {
|
||||||
std::lock_guard lock(state.file_transfer_list_mutex_);
|
std::lock_guard lock(state.file_transfer_list_mutex_);
|
||||||
for (const auto& info : state.file_transfer_list_) files.emplace(info.file_id, info);
|
for (const auto& info : state.file_transfer_list_)
|
||||||
|
if (!info.receiving) files.emplace(info.file_id, info);
|
||||||
};
|
};
|
||||||
collect(transfers_.global_state());
|
collect(transfers_.global_state());
|
||||||
collect(transfers_.received_state());
|
|
||||||
{
|
{
|
||||||
std::shared_lock lock(remote_sessions_mutex_);
|
std::shared_lock lock(remote_sessions_mutex_);
|
||||||
for (const auto& [_, props] : remote_sessions_) {
|
for (const auto& [_, props] : remote_sessions_) {
|
||||||
@@ -4593,11 +4624,17 @@ void GuiApplication::SyncFileTransferNotifications() {
|
|||||||
operation.state != SlintUi::BatchOperation::State::Finished)
|
operation.state != SlintUi::BatchOperation::State::Finished)
|
||||||
pending_files.insert(operation.file_id);
|
pending_files.insert(operation.file_id);
|
||||||
// Retain the last progress independently of session lifetime. Results stay
|
// Retain the last progress independently of session lifetime. Results stay
|
||||||
// visible until dismissed, including after the connection is closed.
|
// visible until dismissed or presented in the browser, even after disconnect.
|
||||||
for (const auto& [id, info] : files)
|
for (const auto& [id, info] : files)
|
||||||
ui_->transfer_notifications[id].info = info;
|
ui_->transfer_notifications[id].info = info;
|
||||||
std::erase_if(ui_->transfer_notifications, [&](auto& item) {
|
std::erase_if(ui_->transfer_notifications, [&](auto& item) {
|
||||||
if (!item.second.dismissed || files.count(item.first) ||
|
// Progress belongs to the sender. Remove any cached receive popup as well,
|
||||||
|
// regardless of window visibility, peer version or final transfer status.
|
||||||
|
if (item.second.info.receiving) {
|
||||||
|
if (item.second.window) (*item.second.window)->hide();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ((!item.second.presentation.dismissed && !item.second.presentation.result_seen) || files.count(item.first) ||
|
||||||
pending_files.count(item.first)) return false;
|
pending_files.count(item.first)) return false;
|
||||||
if (item.second.window) (*item.second.window)->hide();
|
if (item.second.window) (*item.second.window)->hide();
|
||||||
return true;
|
return true;
|
||||||
@@ -4637,7 +4674,11 @@ void GuiApplication::SyncFileTransferNotifications() {
|
|||||||
}
|
}
|
||||||
const bool completed = info.status == Status::Completed;
|
const bool completed = info.status == Status::Completed;
|
||||||
const bool failed = info.status == Status::Failed;
|
const bool failed = info.status == Status::Failed;
|
||||||
if (notification.dismissed || slot >= layout.capacity()) {
|
const auto browser = ui_->file_browsers.find(info.remote_id);
|
||||||
|
const bool in_browser = browser != ui_->file_browsers.end() && browser->second->PresentsTransfers();
|
||||||
|
const bool result_presented = in_browser && browser->second->PresentsResult(id, info.status);
|
||||||
|
if (!notification.presentation.ShowNotification(in_browser, result_presented) ||
|
||||||
|
slot >= layout.capacity()) {
|
||||||
if (notification.window) {
|
if (notification.window) {
|
||||||
(*notification.window)->hide();
|
(*notification.window)->hide();
|
||||||
notification.window.reset();
|
notification.window.reset();
|
||||||
@@ -4651,7 +4692,7 @@ void GuiApplication::SyncFileTransferNotifications() {
|
|||||||
const auto dismiss = [this, id] {
|
const auto dismiss = [this, id] {
|
||||||
const auto found = ui_->transfer_notifications.find(id);
|
const auto found = ui_->transfer_notifications.find(id);
|
||||||
if (found == ui_->transfer_notifications.end()) return;
|
if (found == ui_->transfer_notifications.end()) return;
|
||||||
found->second.dismissed = true;
|
found->second.presentation.dismissed = true;
|
||||||
if (found->second.window) (*found->second.window)->hide();
|
if (found->second.window) (*found->second.window)->hide();
|
||||||
};
|
};
|
||||||
(*notification.window)->on_dismissed(dismiss);
|
(*notification.window)->on_dismissed(dismiss);
|
||||||
@@ -4681,15 +4722,12 @@ void GuiApplication::SyncFileTransferNotifications() {
|
|||||||
auto display_id = info.remote_id;
|
auto display_id = info.remote_id;
|
||||||
std::erase_if(display_id, [](unsigned char c) { return c < '0' || c > '9'; });
|
std::erase_if(display_id, [](unsigned char c) { return c < '0' || c > '9'; });
|
||||||
component->set_remote_id(UiText(display_id));
|
component->set_remote_id(UiText(display_id));
|
||||||
component->set_heading(UiText(info.receiving
|
component->set_heading(UiText(localization::file_sending_progress[localization_language_index_]));
|
||||||
? localization::file_receiving_progress[localization_language_index_]
|
component->set_transferring(info.status == Status::Sending);
|
||||||
: localization::file_sending_progress[localization_language_index_]));
|
|
||||||
component->set_transferring(info.status == Status::Sending || info.status == Status::Receiving);
|
|
||||||
component->set_completed(completed);
|
component->set_completed(completed);
|
||||||
component->set_failed(failed);
|
component->set_failed(failed);
|
||||||
component->set_cancelled(info.status == Status::Cancelled || info.status == Status::Cancelling);
|
component->set_cancelled(info.status == Status::Cancelled || info.status == Status::Cancelling);
|
||||||
component->set_can_cancel(!info.receiving &&
|
component->set_can_cancel(info.status == Status::Queued || info.status == Status::Sending);
|
||||||
(info.status == Status::Queued || info.status == Status::Sending));
|
|
||||||
component->set_cancel_text(UiText(localization::cancel[localization_language_index_]));
|
component->set_cancel_text(UiText(localization::cancel[localization_language_index_]));
|
||||||
auto& window = component->window();
|
auto& window = component->window();
|
||||||
const auto size = window.size();
|
const auto size = window.size();
|
||||||
@@ -4713,13 +4751,8 @@ void GuiApplication::SyncFileTransferNotifications() {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
if (!window.is_visible()) {
|
if (!window.is_visible()) {
|
||||||
#if defined(__APPLE__)
|
// Wait until native placement and focus restoration can be synchronous.
|
||||||
// Winit creates native windows on the next event-loop turn. Wait until
|
if (!NativeWindowReady(window)) continue;
|
||||||
// it exists so show() and focus restoration happen synchronously.
|
|
||||||
if (!window.appkit_view()) continue;
|
|
||||||
#elif _WIN32
|
|
||||||
if (!window.win32_hwnd()) continue;
|
|
||||||
#endif
|
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
// Tool windows stay out of Alt-Tab and must not interrupt remote input.
|
// Tool windows stay out of Alt-Tab and must not interrupt remote input.
|
||||||
const auto configure = [&] {
|
const auto configure = [&] {
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ private:
|
|||||||
void SyncServerWindow();
|
void SyncServerWindow();
|
||||||
void SyncTrustedDevices();
|
void SyncTrustedDevices();
|
||||||
void SyncFileTransferNotifications();
|
void SyncFileTransferNotifications();
|
||||||
|
void OpenFileManager(const std::string& remote);
|
||||||
|
std::string FileManagerPeerName(const std::string& remote);
|
||||||
void CancelFileTransfer(uint32_t file_id);
|
void CancelFileTransfer(uint32_t file_id);
|
||||||
void SetServerPanelExpanded(bool expanded);
|
void SetServerPanelExpanded(bool expanded);
|
||||||
void PositionServerWindows();
|
void PositionServerWindows();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <slint.h>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
// Winit creates hidden native windows on a later event-loop turn. Wait for
|
||||||
|
// that window before applying geometry that depends on its frame or DPI.
|
||||||
|
inline bool NativeWindowReady([[maybe_unused]] const slint::Window& window) {
|
||||||
|
#if defined(_WIN32)
|
||||||
|
return window.win32_hwnd() != nullptr;
|
||||||
|
#elif defined(__APPLE__)
|
||||||
|
return window.appkit_view() != nullptr;
|
||||||
|
#else
|
||||||
|
// Other backends accept initial geometry before show(); Wayland placement
|
||||||
|
// is controlled by the compositor and must not wait for a mapped surface.
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -519,6 +519,12 @@ inline constexpr TranslationRow kTranslationRows[] = {
|
|||||||
{"请核对控制端指纹。", {"请核对控制端指纹。", "Check the fingerprint on the controller.", "Сверьте отпечаток с управляющим устройством.", "請核對控制端指紋。", "操作側のフィンガープリントと照合してください。", "제어 장치의 지문과 대조하세요.", "Mit dem Fingerabdruck des steuernden Geräts vergleichen.", "Vérifiez l’empreinte sur l’appareil de contrôle.", "Verifique la huella en el dispositivo de control.", "Confira a impressão digital no dispositivo controlador."}},
|
{"请核对控制端指纹。", {"请核对控制端指纹。", "Check the fingerprint on the controller.", "Сверьте отпечаток с управляющим устройством.", "請核對控制端指紋。", "操作側のフィンガープリントと照合してください。", "제어 장치의 지문과 대조하세요.", "Mit dem Fingerabdruck des steuernden Geräts vergleichen.", "Vérifiez l’empreinte sur l’appareil de contrôle.", "Verifique la huella en el dispositivo de control.", "Confira a impressão digital no dispositivo controlador."}},
|
||||||
{"配对信息已复制。", {"配对信息已复制。", "Pairing information copied.", "Данные сопряжения скопированы.", "配對資訊已複製。", "ペアリング情報をコピーしました。", "페어링 정보를 복사했습니다.", "Kopplungsdaten kopiert.", "Informations d’association copiées.", "Información de emparejamiento copiada.", "Informações de pareamento copiadas."}},
|
{"配对信息已复制。", {"配对信息已复制。", "Pairing information copied.", "Данные сопряжения скопированы.", "配對資訊已複製。", "ペアリング情報をコピーしました。", "페어링 정보를 복사했습니다.", "Kopplungsdaten kopiert.", "Informations d’association copiées.", "Información de emparejamiento copiada.", "Informações de pareamento copiadas."}},
|
||||||
{"无法复制配对信息,请重试。", {"无法复制配对信息,请重试。", "Unable to copy pairing information. Please try again.", "Не удалось скопировать данные сопряжения. Повторите попытку.", "無法複製配對資訊,請重試。", "ペアリング情報をコピーできません。もう一度お試しください。", "페어링 정보를 복사할 수 없습니다. 다시 시도하세요.", "Kopplungsdaten konnten nicht kopiert werden. Bitte erneut versuchen.", "Impossible de copier les informations d’association. Réessayez.", "No se pudo copiar la información de emparejamiento. Inténtelo de nuevo.", "Não foi possível copiar as informações de pareamento. Tente novamente."}},
|
{"无法复制配对信息,请重试。", {"无法复制配对信息,请重试。", "Unable to copy pairing information. Please try again.", "Не удалось скопировать данные сопряжения. Повторите попытку.", "無法複製配對資訊,請重試。", "ペアリング情報をコピーできません。もう一度お試しください。", "페어링 정보를 복사할 수 없습니다. 다시 시도하세요.", "Kopplungsdaten konnten nicht kopiert werden. Bitte erneut versuchen.", "Impossible de copier les informations d’association. Réessayez.", "No se pudo copiar la información de emparejamiento. Inténtelo de nuevo.", "Não foi possível copiar as informações de pareamento. Tente novamente."}},
|
||||||
|
{"重试更新", {"重试更新", "Retry update", "Повторить", "重試更新", "再試行", "다시 시도", "Erneut versuchen", "Réessayer", "Reintentar", "Tentar novamente"}},
|
||||||
|
{"更新未完成,请手动下载安装。", {"更新未完成,请手动下载安装。", "Update did not complete. Download and install manually.", "Обновление не завершено. Скачайте и установите вручную.", "更新未完成,請手動下載安裝。", "アップデートが完了しませんでした。手動でダウンロードしてインストールしてください。", "업데이트가 완료되지 않았습니다. 수동으로 다운로드하여 설치하세요.", "Das Update wurde nicht abgeschlossen. Laden Sie es herunter und installieren Sie es manuell.", "La mise à jour n'a pas été effectuée. Téléchargez-la et installez-la manuellement.", "La actualización no se completó. Descárguela e instálela manualmente.", "A atualização não foi concluída. Baixe e instale manualmente."}},
|
||||||
|
{"发送到远端", {"发送到远端", "Send to remote", "Отправить на удалённый ПК", "傳送到遠端", "リモートへ送信", "원격으로 보내기", "An Remote-PC senden", "Envoyer au PC distant", "Enviar al equipo remoto", "Enviar ao computador remoto"}},
|
||||||
|
{"发送到本机", {"发送到本机", "Send to local", "Отправить на этот ПК", "傳送到本機", "この端末へ送信", "이 기기로 보내기", "An diesen PC senden", "Envoyer à ce PC", "Enviar a este equipo", "Enviar a este computador"}},
|
||||||
|
{"清除已完成", {"清除已完成", "Clear completed", "Убрать завершённые", "清除已完成", "完了済みをクリア", "완료 항목 지우기", "Abgeschlossene entfernen", "Effacer les tâches terminées", "Limpiar completadas", "Limpar concluídas"}},
|
||||||
|
{"打开目录", {"打开目录", "Open folder", "Открыть папку", "開啟目錄", "フォルダーを開く", "폴더 열기", "Ordner öffnen", "Ouvrir le dossier", "Abrir carpeta", "Abrir pasta"}},
|
||||||
};
|
};
|
||||||
|
|
||||||
struct NamedTranslation { const char* name; const char* literal; };
|
struct NamedTranslation { const char* name; const char* literal; };
|
||||||
@@ -733,10 +739,12 @@ inline constexpr NamedTranslation kNamedTranslations[] = {
|
|||||||
{"access_website", "访问官网: "},
|
{"access_website", "访问官网: "},
|
||||||
{"update", "更新"},
|
{"update", "更新"},
|
||||||
{"install_update", "安装更新"},
|
{"install_update", "安装更新"},
|
||||||
|
{"retry_update", "重试更新"},
|
||||||
{"manual_download", "手动下载"},
|
{"manual_download", "手动下载"},
|
||||||
{"install_update_confirm", "下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?"},
|
{"install_update_confirm", "下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?"},
|
||||||
{"update_preparing", "正在准备更新…"},
|
{"update_preparing", "正在准备更新…"},
|
||||||
{"update_install_failed", "更新未完成,可重试或手动安装。"},
|
{"update_install_failed", "更新未完成,可重试或手动安装。"},
|
||||||
|
{"update_install_failed_manual", "更新未完成,请手动下载安装。"},
|
||||||
{"connection_alias", "修改名称"},
|
{"connection_alias", "修改名称"},
|
||||||
{"delete_connection", "删除连接"},
|
{"delete_connection", "删除连接"},
|
||||||
{"connect_to_this_connection", "发起连接"},
|
{"connect_to_this_connection", "发起连接"},
|
||||||
@@ -1229,10 +1237,12 @@ inline constexpr const char* kSlintLiterals[] = {
|
|||||||
X(access_website) \
|
X(access_website) \
|
||||||
X(update) \
|
X(update) \
|
||||||
X(install_update) \
|
X(install_update) \
|
||||||
|
X(retry_update) \
|
||||||
X(manual_download) \
|
X(manual_download) \
|
||||||
X(install_update_confirm) \
|
X(install_update_confirm) \
|
||||||
X(update_preparing) \
|
X(update_preparing) \
|
||||||
X(update_install_failed) \
|
X(update_install_failed) \
|
||||||
|
X(update_install_failed_manual) \
|
||||||
X(connection_alias) \
|
X(connection_alias) \
|
||||||
X(delete_connection) \
|
X(delete_connection) \
|
||||||
X(connect_to_this_connection) \
|
X(connect_to_this_connection) \
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
#include "features/file_transfer/file_browser.h"
|
||||||
|
|
||||||
|
#include <file_transfer_core.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <charconv>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <ctime>
|
||||||
|
#include <fstream>
|
||||||
|
#include <set>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include "filesystem_utf8.h"
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
using Json = FileBrowser::Json;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
constexpr size_t kPageEntries = 512;
|
||||||
|
constexpr size_t kPageBytes = 45000;
|
||||||
|
std::string Utf8(const fs::path& path) {
|
||||||
|
const auto value = path.u8string();
|
||||||
|
return {value.begin(), value.end()};
|
||||||
|
}
|
||||||
|
fs::path Home() {
|
||||||
|
#ifdef _WIN32
|
||||||
|
const auto* home = std::getenv("USERPROFILE");
|
||||||
|
#else
|
||||||
|
const auto* home = std::getenv("HOME");
|
||||||
|
#endif
|
||||||
|
return home ? PathFromUtf8(home) : fs::current_path();
|
||||||
|
}
|
||||||
|
Json Failure(const std::string& reason) { return {{"ok", false}, {"error", reason}}; }
|
||||||
|
template <typename FileTime>
|
||||||
|
auto SystemTime(FileTime time, FileTime file_now, std::chrono::system_clock::time_point system_now) {
|
||||||
|
using Clock = typename FileTime::clock;
|
||||||
|
if constexpr (requires { Clock::to_sys(time); })
|
||||||
|
return std::chrono::time_point_cast<std::chrono::system_clock::duration>(Clock::to_sys(time));
|
||||||
|
else
|
||||||
|
return std::chrono::time_point_cast<std::chrono::system_clock::duration>(time - file_now + system_now);
|
||||||
|
}
|
||||||
|
std::pair<std::string, size_t> ParseCursor(const std::string& cursor) {
|
||||||
|
const auto split = cursor.find(':');
|
||||||
|
if (cursor.size() > 64 || split == std::string::npos || !split)
|
||||||
|
throw std::runtime_error("invalid_listing");
|
||||||
|
size_t offset = 0;
|
||||||
|
const auto end = cursor.data() + cursor.size();
|
||||||
|
const auto parsed = std::from_chars(cursor.data() + split + 1, end, offset);
|
||||||
|
if (parsed.ec != std::errc{} || parsed.ptr != end)
|
||||||
|
throw std::runtime_error("invalid_listing");
|
||||||
|
return {cursor.substr(0, split), offset};
|
||||||
|
}
|
||||||
|
Json Page(const Json& listing, size_t offset, const std::string& snapshot) {
|
||||||
|
const auto& entries = listing.at("entries");
|
||||||
|
if (offset > entries.size()) throw std::runtime_error("invalid_listing");
|
||||||
|
Json page{{"ok", true}, {"path", listing.at("path")}, {"parent", listing.at("parent")},
|
||||||
|
{"entries", Json::array()}, {"offset", offset}, {"total", entries.size()}};
|
||||||
|
size_t next = offset, bytes = 0;
|
||||||
|
while (next < entries.size() && next - offset < kPageEntries) {
|
||||||
|
const auto entry_bytes = entries[next].dump(-1, ' ', false, Json::error_handler_t::replace).size() + 1;
|
||||||
|
if (entry_bytes > kPageBytes) throw std::runtime_error("invalid_listing");
|
||||||
|
if (bytes + entry_bytes > kPageBytes) break;
|
||||||
|
page["entries"].push_back(entries[next++]);
|
||||||
|
bytes += entry_bytes;
|
||||||
|
}
|
||||||
|
page["truncated"] = next < entries.size();
|
||||||
|
page["next"] = next < entries.size() && !snapshot.empty()
|
||||||
|
? snapshot + ":" + std::to_string(next) : "";
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
std::string Text(const Json& value, const char* key) {
|
||||||
|
const auto& field = value.at(key);
|
||||||
|
if (!field.is_string()) throw std::runtime_error("invalid_request");
|
||||||
|
auto text = field.get<std::string>();
|
||||||
|
if (text.size() > 4096 || text.find('\0') != std::string::npos)
|
||||||
|
throw std::runtime_error("invalid_path");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
fs::path ExistingDirectory(const std::string& path) {
|
||||||
|
if (path.empty()) throw std::runtime_error("invalid_path");
|
||||||
|
auto input = PathFromUtf8(path);
|
||||||
|
if (!input.is_absolute()) throw std::runtime_error("invalid_path");
|
||||||
|
std::error_code ec;
|
||||||
|
auto result = fs::canonical(input, ec);
|
||||||
|
if (ec || !fs::is_directory(result, ec)) throw std::runtime_error("directory_unavailable");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
fs::path Destination(const fs::path& root, const std::string& relative) {
|
||||||
|
auto path = PathFromUtf8(relative);
|
||||||
|
if (path.is_absolute() || path.has_root_name()) throw std::runtime_error("invalid_path");
|
||||||
|
auto result = root;
|
||||||
|
for (const auto& component : path) {
|
||||||
|
if (component == "." || component == "..") throw std::runtime_error("invalid_path");
|
||||||
|
result /= component;
|
||||||
|
std::error_code ec;
|
||||||
|
// Do not follow links within a transferred folder hierarchy.
|
||||||
|
if (fs::is_symlink(fs::symlink_status(result, ec))) throw std::runtime_error("symbolic_link");
|
||||||
|
ec.clear();
|
||||||
|
fs::create_directory(result, ec);
|
||||||
|
if (ec && ec != std::errc::file_exists) throw std::runtime_error("directory_not_writable");
|
||||||
|
if (!fs::is_directory(result)) throw std::runtime_error("directory_not_writable");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileBrowser::FileBrowser(Send send, Enqueue enqueue, Reserve reserve, Cancel cancel)
|
||||||
|
: send_(std::move(send)), enqueue_(std::move(enqueue)), reserve_(std::move(reserve)), cancel_(std::move(cancel)),
|
||||||
|
worker_([this] { Run(); }) {}
|
||||||
|
FileBrowser::~FileBrowser() { Stop(); }
|
||||||
|
void FileBrowser::Stop() {
|
||||||
|
{ std::lock_guard lock(mutex_); stopping_ = true; jobs_.clear();
|
||||||
|
for (const auto& [_, listing] : listings_) listing->cancelled = true;
|
||||||
|
listings_.clear();
|
||||||
|
}
|
||||||
|
wake_.notify_all();
|
||||||
|
if (worker_.joinable()) worker_.join();
|
||||||
|
}
|
||||||
|
void FileBrowser::Post(const std::string& peer, std::function<void()> work) {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
if (stopping_ || jobs_.size() >= 512) return;
|
||||||
|
const auto generation = generations_[peer];
|
||||||
|
jobs_.push_back([this, peer, generation, work = std::move(work)] {
|
||||||
|
{ std::lock_guard lock(mutex_);
|
||||||
|
if (stopping_ || generations_[peer] != generation) return;
|
||||||
|
}
|
||||||
|
active_peer_ = peer; active_generation_ = generation;
|
||||||
|
work();
|
||||||
|
});
|
||||||
|
wake_.notify_one();
|
||||||
|
}
|
||||||
|
void FileBrowser::Run() {
|
||||||
|
for (;;) {
|
||||||
|
std::function<void()> work;
|
||||||
|
{
|
||||||
|
std::unique_lock lock(mutex_);
|
||||||
|
wake_.wait_for(lock, std::chrono::milliseconds(250), [this] { return stopping_ || !jobs_.empty(); });
|
||||||
|
if (stopping_) return;
|
||||||
|
if (!jobs_.empty()) { work = std::move(jobs_.front()); jobs_.pop_front(); }
|
||||||
|
}
|
||||||
|
try { if (work) work(); } catch (...) { /* Invalid peer data cannot kill the worker. */ }
|
||||||
|
std::erase_if(directory_snapshots_, [](const auto& entry) {
|
||||||
|
return Clock::now() >= entry.second.expires;
|
||||||
|
});
|
||||||
|
for (auto it = pending_.begin(); it != pending_.end();) {
|
||||||
|
if (Clock::now() < it->second.deadline) { ++it; continue; }
|
||||||
|
auto pending = std::move(it->second); it = pending_.erase(it);
|
||||||
|
if (Current(pending.peer, pending.generation)) {
|
||||||
|
active_peer_ = pending.peer; active_generation_ = pending.generation;
|
||||||
|
pending.complete(Failure("timeout"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool FileBrowser::Current(const std::string& peer, uint64_t generation) {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
return !stopping_ && generations_[peer] == generation;
|
||||||
|
}
|
||||||
|
void FileBrowser::Report(const std::string& peer, Json result, bool notify_peer) {
|
||||||
|
if (!Current(peer, active_generation_)) return;
|
||||||
|
Publish(peer, 0, result);
|
||||||
|
if (notify_peer) {
|
||||||
|
result["op"] = "notice"; result["id"] = next_id_++;
|
||||||
|
SendMessage(peer, std::move(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool FileBrowser::SendMessage(const std::string& peer, Json message) {
|
||||||
|
{ std::lock_guard lock(mutex_);
|
||||||
|
if (stopping_ || (peer == active_peer_ && generations_[peer] != active_generation_)) return false;
|
||||||
|
}
|
||||||
|
message["type"] = "file_browser"; message["version"] = 1;
|
||||||
|
return send_(peer, message);
|
||||||
|
}
|
||||||
|
void FileBrowser::Publish(const std::string& peer, uint32_t id, Json result) {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
auto& events = events_[peer];
|
||||||
|
if (id) {
|
||||||
|
for (auto& event : events) {
|
||||||
|
if (event.id == id && event.result.value("loading", false)) {
|
||||||
|
event.result = std::move(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (events.size() < 512) events.push_back({id, std::move(result)});
|
||||||
|
}
|
||||||
|
std::vector<FileBrowser::Event> FileBrowser::TakeEvents(const std::string& peer) {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
std::vector<Event> result; result.swap(events_[peer]); return result;
|
||||||
|
}
|
||||||
|
uint64_t FileBrowser::Generation(const std::string& peer) {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
const auto found = generations_.find(peer);
|
||||||
|
return found == generations_.end() ? 0 : found->second;
|
||||||
|
}
|
||||||
|
void FileBrowser::Disconnect(const std::string& peer) {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
++generations_[peer]; events_.erase(peer);
|
||||||
|
std::erase_if(listings_, [&](auto& entry) {
|
||||||
|
if (entry.second->peer != peer) return false;
|
||||||
|
entry.second->cancelled = true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Post(peer, [this, peer] {
|
||||||
|
std::erase_if(pending_, [&](const auto& entry) { return entry.second.peer == peer; });
|
||||||
|
std::erase_if(directory_snapshots_, [&](const auto& entry) { return entry.first.first == peer; });
|
||||||
|
Publish(peer, 0, Failure("disconnected"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void FileBrowser::Request(const std::string& peer, Json message,
|
||||||
|
std::function<void(const Json&)> complete) {
|
||||||
|
if (pending_.size() >= 256) { complete(Failure("busy")); return; }
|
||||||
|
const auto id = next_id_++;
|
||||||
|
message["id"] = id;
|
||||||
|
pending_.emplace(id, Pending{peer, active_generation_, Clock::now() + std::chrono::seconds(12), std::move(complete)});
|
||||||
|
if (!SendMessage(peer, std::move(message))) {
|
||||||
|
auto done = std::move(pending_.at(id).complete); pending_.erase(id);
|
||||||
|
done(Failure("disconnected"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uint32_t FileBrowser::List(const std::string& peer, const std::string& path, bool local) {
|
||||||
|
const auto id = next_id_++;
|
||||||
|
auto listing = std::make_shared<Listing>();
|
||||||
|
listing->id = id; listing->peer = peer; listing->path = path;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
listing->generation = generations_[peer];
|
||||||
|
listings_[id] = listing;
|
||||||
|
}
|
||||||
|
Post(peer, [this, listing, local] {
|
||||||
|
if (listing->cancelled) return;
|
||||||
|
if (local) FinishList(listing, Directory(listing->path, [listing] { return listing->cancelled.load(); }));
|
||||||
|
else ListPage(listing);
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
void FileBrowser::CancelList(const std::string& peer, uint32_t id) {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
const auto found = listings_.find(id);
|
||||||
|
if (found != listings_.end() && found->second->peer == peer) {
|
||||||
|
found->second->cancelled = true;
|
||||||
|
listings_.erase(found);
|
||||||
|
}
|
||||||
|
if (const auto events = events_.find(peer); events != events_.end())
|
||||||
|
std::erase_if(events->second, [id](const auto& event) { return event.id == id; });
|
||||||
|
}
|
||||||
|
void FileBrowser::FinishList(const std::shared_ptr<Listing>& listing, Json result) {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
if (listing->cancelled || stopping_ || generations_[listing->peer] != listing->generation) return;
|
||||||
|
listings_.erase(listing->id);
|
||||||
|
auto& events = events_[listing->peer];
|
||||||
|
std::erase_if(events, [&](const auto& event) { return event.id == listing->id; });
|
||||||
|
if (events.size() < 512) events.push_back({listing->id, std::move(result)});
|
||||||
|
}
|
||||||
|
void FileBrowser::ReleaseList(const std::string& peer, const std::string& cursor) {
|
||||||
|
if (!cursor.empty()) Request(peer, {{"op", "list_cancel"}, {"cursor", cursor}}, [](const Json&) {});
|
||||||
|
}
|
||||||
|
void FileBrowser::ListPage(const std::shared_ptr<Listing>& listing) {
|
||||||
|
if (listing->cancelled) { ReleaseList(listing->peer, listing->cursor); return; }
|
||||||
|
Json request{{"op", "list"}, {"path", listing->path}, {"pagination", 1}};
|
||||||
|
if (!listing->cursor.empty()) request["cursor"] = listing->cursor;
|
||||||
|
Request(listing->peer, std::move(request), [this, listing](const Json& page) {
|
||||||
|
std::string next;
|
||||||
|
try {
|
||||||
|
next = page.value("next", std::string{});
|
||||||
|
if (listing->cancelled) { ReleaseList(listing->peer, next.empty() ? listing->cursor : next); return; }
|
||||||
|
if (!page.value("ok", false)) {
|
||||||
|
ReleaseList(listing->peer, listing->cursor);
|
||||||
|
FinishList(listing, page); return;
|
||||||
|
}
|
||||||
|
const auto& entries = page.at("entries");
|
||||||
|
if (!entries.is_array() || entries.size() > 2000 || next.size() > 64)
|
||||||
|
throw std::runtime_error("invalid_listing");
|
||||||
|
if ((page.contains("offset") && !page["offset"].is_number_unsigned()) ||
|
||||||
|
(page.contains("total") && !page["total"].is_number_unsigned()) ||
|
||||||
|
(!next.empty() && !page.value("truncated", false)))
|
||||||
|
throw std::runtime_error("invalid_listing");
|
||||||
|
if (page.value("truncated", false) && next.empty())
|
||||||
|
throw std::runtime_error("pagination_unsupported");
|
||||||
|
const bool first = listing->result.is_null();
|
||||||
|
const size_t offset = first ? 0 : listing->result.at("entries").size();
|
||||||
|
if (page.value("offset", offset) != offset ||
|
||||||
|
(!first && (page.at("path") != listing->result.at("path") ||
|
||||||
|
page.at("parent") != listing->result.at("parent") ||
|
||||||
|
page.at("total") != listing->result.at("total"))))
|
||||||
|
throw std::runtime_error("invalid_listing");
|
||||||
|
const size_t total = page.value("total", entries.size());
|
||||||
|
if (total < offset || entries.size() > total - offset ||
|
||||||
|
(next.empty() ? offset + entries.size() != total : entries.empty() || offset + entries.size() >= total))
|
||||||
|
throw std::runtime_error("invalid_listing");
|
||||||
|
if (!next.empty() && ParseCursor(next).second != offset + entries.size())
|
||||||
|
throw std::runtime_error("invalid_listing");
|
||||||
|
if (first) {
|
||||||
|
listing->result = {{"ok", true}, {"path", page.at("path")}, {"parent", page.at("parent")},
|
||||||
|
{"entries", Json::array()}, {"total", total}, {"truncated", false}};
|
||||||
|
}
|
||||||
|
auto& all = listing->result["entries"].get_ref<Json::array_t&>();
|
||||||
|
all.insert(all.end(), entries.begin(), entries.end());
|
||||||
|
if (next.empty()) { FinishList(listing, std::move(listing->result)); return; }
|
||||||
|
listing->cursor = next;
|
||||||
|
Publish(listing->peer, listing->id, {{"ok", true}, {"loading", true},
|
||||||
|
{"loaded", all.size()}, {"total", total}});
|
||||||
|
// Yield between pages so newer navigation and transfer work can run.
|
||||||
|
Post(listing->peer, [this, listing] { ListPage(listing); });
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
ReleaseList(listing->peer, next.empty() ? listing->cursor : next);
|
||||||
|
FinishList(listing, Failure(std::string(error.what()) == "pagination_unsupported"
|
||||||
|
? "pagination_unsupported" : "invalid_listing"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void FileBrowser::Copy(const std::string& peer, const std::vector<std::string>& paths,
|
||||||
|
const std::string& destination, bool download) {
|
||||||
|
Post(peer, [this, peer, paths, destination, download] {
|
||||||
|
if (download) Request(peer, {{"op", "copy"}, {"paths", paths}, {"destination", destination}},
|
||||||
|
[this, peer](const Json& result) { Publish(peer, 0, result); });
|
||||||
|
else Upload(peer, paths, destination);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void FileBrowser::CancelTransfer(const std::string& peer, uint32_t id) {
|
||||||
|
Post(peer, [this, peer, id] {
|
||||||
|
Request(peer, {{"op", "cancel"}, {"file_id", id}}, [this, peer](const Json& result) {
|
||||||
|
Publish(peer, 0, result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void FileBrowser::Handle(const std::string& peer, Json message) {
|
||||||
|
Post(peer, [this, peer, message = std::move(message)] {
|
||||||
|
try {
|
||||||
|
if (message.at("version") != 1 || !message.at("id").is_number_unsigned()) return;
|
||||||
|
const auto wide_id = message.at("id").get<uint64_t>();
|
||||||
|
if (!wide_id || wide_id > UINT32_MAX) return;
|
||||||
|
const auto id = static_cast<uint32_t>(wide_id);
|
||||||
|
if (message.value("op", "") == "notice") {
|
||||||
|
if (message.at("ok").is_boolean()) Publish(peer, 0, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.value("op", "") == "reply") {
|
||||||
|
const auto it = pending_.find(id);
|
||||||
|
if (it == pending_.end() || it->second.peer != peer || !message.at("ok").is_boolean()) return;
|
||||||
|
const auto generation = it->second.generation;
|
||||||
|
auto done = std::move(it->second.complete); pending_.erase(it);
|
||||||
|
if (!Current(peer, generation)) return;
|
||||||
|
active_generation_ = generation;
|
||||||
|
done(message);
|
||||||
|
} else Serve(peer, message);
|
||||||
|
} catch (...) { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
FileBrowser::Json FileBrowser::Directory(const std::string& value, const std::function<bool()>& cancelled) {
|
||||||
|
try {
|
||||||
|
Json entries = Json::array();
|
||||||
|
if (value == "@roots") {
|
||||||
|
#ifdef _WIN32
|
||||||
|
for (char drive = 'A'; drive <= 'Z'; ++drive) {
|
||||||
|
const std::string root = std::string(1, drive) + ":\\";
|
||||||
|
std::error_code ec;
|
||||||
|
if (fs::is_directory(PathFromUtf8(root), ec))
|
||||||
|
entries.push_back({{"name", root}, {"path", root}, {"directory", true}, {"size", 0}, {"modified", ""}, {"link", false}});
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
entries.push_back({{"name", "/"}, {"path", "/"}, {"directory", true}, {"size", 0}, {"modified", ""}, {"link", false}});
|
||||||
|
#endif
|
||||||
|
return {{"ok", true}, {"path", value}, {"parent", ""}, {"entries", entries}, {"truncated", false}};
|
||||||
|
}
|
||||||
|
auto path = ExistingDirectory(value.empty() ? Utf8(Home()) : value);
|
||||||
|
std::error_code ec;
|
||||||
|
fs::directory_iterator it(path, ec), end;
|
||||||
|
if (ec) return Failure("directory_unavailable");
|
||||||
|
// Reuse one clock offset for every entry so equal timestamps stay equal.
|
||||||
|
const auto file_now = fs::file_time_type::clock::now();
|
||||||
|
const auto system_now = std::chrono::system_clock::now();
|
||||||
|
for (; it != end; it.increment(ec)) {
|
||||||
|
if (cancelled && cancelled()) return Failure("cancelled");
|
||||||
|
if (ec) return Failure("directory_unavailable");
|
||||||
|
const auto name = Utf8(it->path().filename());
|
||||||
|
if (name.rfind(".crossdesk-", 0) == 0) continue;
|
||||||
|
std::error_code error;
|
||||||
|
const auto status = it->symlink_status(error);
|
||||||
|
if (error) continue;
|
||||||
|
const bool link = fs::is_symlink(status);
|
||||||
|
const bool directory = it->is_directory(error);
|
||||||
|
if (!directory && !link && !fs::is_regular_file(status)) continue;
|
||||||
|
uint64_t size = 0;
|
||||||
|
if (!directory && !link) { size = it->file_size(error); if (error) size = 0; }
|
||||||
|
std::string modified;
|
||||||
|
Json modified_ms = nullptr;
|
||||||
|
const auto time = it->last_write_time(error);
|
||||||
|
if (!error) {
|
||||||
|
const auto system_time = SystemTime(time, file_now, system_now);
|
||||||
|
modified_ms = std::chrono::duration_cast<std::chrono::milliseconds>(system_time.time_since_epoch()).count();
|
||||||
|
const auto stamp = std::chrono::system_clock::to_time_t(system_time);
|
||||||
|
std::tm tm{};
|
||||||
|
#ifdef _WIN32
|
||||||
|
localtime_s(&tm, &stamp);
|
||||||
|
#else
|
||||||
|
localtime_r(&stamp, &tm);
|
||||||
|
#endif
|
||||||
|
char buffer[32]; std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &tm);
|
||||||
|
modified = buffer;
|
||||||
|
}
|
||||||
|
Json entry{{"name", name}, {"directory", directory}, {"size", size}, {"modified", modified},
|
||||||
|
{"modified_ms", modified_ms}, {"link", link}};
|
||||||
|
entries.push_back(std::move(entry));
|
||||||
|
}
|
||||||
|
if (ec) return Failure("directory_unavailable");
|
||||||
|
std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) {
|
||||||
|
if (a["directory"] != b["directory"]) return a["directory"] == true;
|
||||||
|
auto first = a["name"].template get<std::string>(), second = b["name"].template get<std::string>();
|
||||||
|
const auto lower = [](unsigned char c) { return std::tolower(c); };
|
||||||
|
auto folded_first = first, folded_second = second;
|
||||||
|
std::transform(folded_first.begin(), folded_first.end(), folded_first.begin(), lower);
|
||||||
|
std::transform(folded_second.begin(), folded_second.end(), folded_second.begin(), lower);
|
||||||
|
return folded_first == folded_second ? first < second : folded_first < folded_second;
|
||||||
|
});
|
||||||
|
return {{"ok", true}, {"path", Utf8(path)}, {"parent", Utf8(path.parent_path())},
|
||||||
|
{"entries", std::move(entries)}, {"truncated", false}};
|
||||||
|
} catch (...) { return Failure("directory_unavailable"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
FileBrowser::Json FileBrowser::DirectoryPage(const std::string& peer, const Json& message) {
|
||||||
|
const auto now = Clock::now();
|
||||||
|
const auto cursor = message.value("cursor", std::string{});
|
||||||
|
if (!cursor.empty()) {
|
||||||
|
const auto [token, offset] = ParseCursor(cursor);
|
||||||
|
const auto found = directory_snapshots_.find({peer, token});
|
||||||
|
if (found == directory_snapshots_.end() || found->second.generation != active_generation_ ||
|
||||||
|
now >= found->second.expires) return Failure("listing_expired");
|
||||||
|
auto page = Page(found->second.listing, offset, token);
|
||||||
|
if (page.at("next") == "") directory_snapshots_.erase(found);
|
||||||
|
else found->second.expires = now + std::chrono::seconds(30);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
const auto generation = active_generation_;
|
||||||
|
auto listing = Directory(Text(message, "path"), [this, &peer, generation] { return !Current(peer, generation); });
|
||||||
|
if (!listing.value("ok", false)) return listing;
|
||||||
|
const bool paginated = message.value("pagination", Json{}) == 1;
|
||||||
|
const auto token = paginated ? std::to_string(next_snapshot_++) : "";
|
||||||
|
auto page = Page(listing, 0, token);
|
||||||
|
if (paginated && page.at("next") != "") {
|
||||||
|
const auto snapshots = std::count_if(directory_snapshots_.begin(), directory_snapshots_.end(),
|
||||||
|
[&](const auto& entry) { return entry.first.first == peer; });
|
||||||
|
if (snapshots >= 16) return Failure("busy");
|
||||||
|
directory_snapshots_.emplace(std::make_pair(peer, token),
|
||||||
|
DirectorySnapshot{std::move(listing), active_generation_, Clock::now() + std::chrono::seconds(30)});
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileBrowser::Serve(const std::string& peer, const Json& message) {
|
||||||
|
Json result;
|
||||||
|
try {
|
||||||
|
const auto op = Text(message, "op");
|
||||||
|
if (op == "list") result = DirectoryPage(peer, message);
|
||||||
|
else if (op == "list_cancel") {
|
||||||
|
const auto [token, _] = ParseCursor(Text(message, "cursor"));
|
||||||
|
directory_snapshots_.erase({peer, token});
|
||||||
|
result = {{"ok", true}};
|
||||||
|
}
|
||||||
|
else if (op == "prepare") {
|
||||||
|
auto root = ExistingDirectory(Text(message, "destination"));
|
||||||
|
const auto& items = message.at("items");
|
||||||
|
if (!items.is_array() || items.empty() || items.size() > 64) throw std::runtime_error("invalid_request");
|
||||||
|
for (const auto& item : items) {
|
||||||
|
if (!Current(peer, active_generation_)) return;
|
||||||
|
if (!item.at("file_id").is_number_unsigned()) throw std::runtime_error("invalid_request");
|
||||||
|
const auto id = item.at("file_id").get<uint64_t>();
|
||||||
|
if (id > UINT32_MAX) throw std::runtime_error("invalid_request");
|
||||||
|
auto dir = Destination(root, Text(item, "directory"));
|
||||||
|
if (id && !reserve_(peer, static_cast<uint32_t>(id), dir)) throw std::runtime_error("directory_not_writable");
|
||||||
|
}
|
||||||
|
result = {{"ok", true}};
|
||||||
|
} else if (op == "cancel") {
|
||||||
|
const auto& id = message.at("file_id");
|
||||||
|
if (!id.is_number_unsigned() || id.get<uint64_t>() > UINT32_MAX || !id.get<uint32_t>())
|
||||||
|
throw std::runtime_error("invalid_request");
|
||||||
|
if (!cancel_ || !cancel_(peer, id.get<uint32_t>())) throw std::runtime_error("already_finished");
|
||||||
|
result = {{"ok", true}, {"message", "cancelling"}};
|
||||||
|
} else if (op == "copy") {
|
||||||
|
const auto& paths = message.at("paths");
|
||||||
|
if (!paths.is_array() || paths.empty() || paths.size() > 256) throw std::runtime_error("invalid_request");
|
||||||
|
std::vector<std::string> sources;
|
||||||
|
for (const auto& path : paths) sources.push_back(Text(Json{{"path", path}}, "path"));
|
||||||
|
Upload(peer, sources, Text(message, "destination"), true);
|
||||||
|
result = {{"ok", true}, {"message", "queued"}};
|
||||||
|
} else throw std::runtime_error("unsupported");
|
||||||
|
} catch (const std::exception& error) { result = Failure(error.what()); }
|
||||||
|
result["op"] = "reply"; result["id"] = message.at("id");
|
||||||
|
SendMessage(peer, std::move(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileBrowser::Upload(const std::string& peer, const std::vector<std::string>& paths,
|
||||||
|
const std::string& destination, bool notify_peer) {
|
||||||
|
std::vector<UploadItem> files;
|
||||||
|
try {
|
||||||
|
if (paths.empty() || paths.size() > 256 || destination.empty()) throw std::runtime_error("invalid_request");
|
||||||
|
std::set<fs::path> visited;
|
||||||
|
const auto add = [&](const fs::path& path, const fs::path& relative) {
|
||||||
|
if (!visited.insert(path).second) return;
|
||||||
|
if (files.size() >= 4096) throw std::runtime_error("too_many_files");
|
||||||
|
const auto status = fs::symlink_status(path);
|
||||||
|
if (fs::is_symlink(status)) throw std::runtime_error("symbolic_link");
|
||||||
|
if (fs::is_directory(status)) files.push_back({0, "", Utf8(relative)});
|
||||||
|
else if (fs::is_regular_file(status)) {
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
if (!input) throw std::runtime_error("file_unreadable");
|
||||||
|
files.push_back({file_transfer::Session::NextFileId(), Utf8(path), Utf8(relative.parent_path())});
|
||||||
|
} else throw std::runtime_error("unsupported_file");
|
||||||
|
};
|
||||||
|
// Validate the entire selection before creating remote folders or sending.
|
||||||
|
for (const auto& source : paths) {
|
||||||
|
auto path = PathFromUtf8(source);
|
||||||
|
if (!path.is_absolute() || path.filename().empty()) throw std::runtime_error("invalid_path");
|
||||||
|
add(path, path.filename());
|
||||||
|
if (fs::is_directory(fs::symlink_status(path))) {
|
||||||
|
for (const auto& entry : fs::recursive_directory_iterator(path)) {
|
||||||
|
{ std::lock_guard lock(mutex_); if (stopping_ || generations_[peer] != active_generation_) return; }
|
||||||
|
add(entry.path(), entry.path().lexically_relative(path.parent_path()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto job = std::make_shared<UploadJob>();
|
||||||
|
job->files = std::move(files); job->destination = destination; job->notify_peer = notify_peer;
|
||||||
|
UploadNext(peer, job);
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
Publish(peer, 0, Failure(error.what()));
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileBrowser::UploadNext(const std::string& peer, const std::shared_ptr<UploadJob>& job) {
|
||||||
|
if (!Current(peer, active_generation_)) return;
|
||||||
|
if (job->next == job->files.size()) {
|
||||||
|
Report(peer, {{"ok", true}, {"message", "queued"}}, job->notify_peer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto start = job->next;
|
||||||
|
Json items = Json::array();
|
||||||
|
size_t bytes = job->destination.size();
|
||||||
|
while (job->next < job->files.size() && items.size() < 32) {
|
||||||
|
const auto& file = job->files[job->next];
|
||||||
|
Json item{{"file_id", file.id}, {"directory", file.directory}};
|
||||||
|
const auto size = item.dump().size();
|
||||||
|
if (!items.empty() && bytes + size > 44000) break;
|
||||||
|
bytes += size; items.push_back(std::move(item)); ++job->next;
|
||||||
|
}
|
||||||
|
const auto end = job->next;
|
||||||
|
// Only one preparation batch per operation is in flight. Large folder
|
||||||
|
// copies do not exhaust pending requests or reserve thousands of paths.
|
||||||
|
Request(peer, {{"op", "prepare"}, {"destination", job->destination}, {"items", items}},
|
||||||
|
[this, peer, job, start, end](const Json& reply) {
|
||||||
|
if (!reply.value("ok", false)) { Report(peer, reply, job->notify_peer); return; }
|
||||||
|
for (auto index = start; index < end; ++index) {
|
||||||
|
if (!Current(peer, active_generation_)) return;
|
||||||
|
const auto& file = job->files[index];
|
||||||
|
if (file.id && !enqueue_(peer, file.path, file.id)) {
|
||||||
|
Report(peer, Failure("file_unreadable"), job->notify_peer); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UploadNext(peer, job);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-11-04
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _FILE_BROWSER_H_
|
||||||
|
#define _FILE_BROWSER_H_
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <atomic>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <deque>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
// Filesystem work is serialized off the UI and RTC threads. The adapter owns
|
||||||
|
// connection authorization and the existing reliable file transfer engine.
|
||||||
|
class FileBrowser {
|
||||||
|
public:
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
using Send = std::function<bool(const std::string&, const Json&)>;
|
||||||
|
using Enqueue = std::function<bool(const std::string&, const std::string&, uint32_t)>;
|
||||||
|
using Reserve = std::function<bool(const std::string&, uint32_t, const std::filesystem::path&)>;
|
||||||
|
using Cancel = std::function<bool(const std::string&, uint32_t)>;
|
||||||
|
struct Event { uint32_t id; Json result; };
|
||||||
|
FileBrowser(Send send, Enqueue enqueue, Reserve reserve, Cancel cancel = {});
|
||||||
|
~FileBrowser();
|
||||||
|
uint32_t List(const std::string& peer, const std::string& path, bool local);
|
||||||
|
void CancelList(const std::string& peer, uint32_t id);
|
||||||
|
void Copy(const std::string& peer, const std::vector<std::string>& paths,
|
||||||
|
const std::string& destination, bool download);
|
||||||
|
void CancelTransfer(const std::string& peer, uint32_t id);
|
||||||
|
void Handle(const std::string& peer, Json message);
|
||||||
|
void Disconnect(const std::string& peer);
|
||||||
|
uint64_t Generation(const std::string& peer);
|
||||||
|
std::vector<Event> TakeEvents(const std::string& peer);
|
||||||
|
void Stop();
|
||||||
|
static Json Directory(const std::string& path, const std::function<bool()>& cancelled = {});
|
||||||
|
|
||||||
|
private:
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
struct Pending {
|
||||||
|
std::string peer;
|
||||||
|
uint64_t generation;
|
||||||
|
Clock::time_point deadline;
|
||||||
|
std::function<void(const Json&)> complete;
|
||||||
|
};
|
||||||
|
struct Listing {
|
||||||
|
uint32_t id;
|
||||||
|
uint64_t generation;
|
||||||
|
std::string peer, path, cursor;
|
||||||
|
Json result;
|
||||||
|
std::atomic<bool> cancelled{false};
|
||||||
|
};
|
||||||
|
struct DirectorySnapshot {
|
||||||
|
Json listing;
|
||||||
|
uint64_t generation;
|
||||||
|
Clock::time_point expires;
|
||||||
|
};
|
||||||
|
struct UploadItem { uint32_t id; std::string path, directory; };
|
||||||
|
struct UploadJob {
|
||||||
|
std::vector<UploadItem> files;
|
||||||
|
size_t next = 0;
|
||||||
|
std::string destination;
|
||||||
|
bool notify_peer = false;
|
||||||
|
};
|
||||||
|
void Post(const std::string& peer, std::function<void()> work);
|
||||||
|
void Run();
|
||||||
|
void Request(const std::string& peer, Json message,
|
||||||
|
std::function<void(const Json&)> complete);
|
||||||
|
void Publish(const std::string& peer, uint32_t id, Json result);
|
||||||
|
void Serve(const std::string& peer, const Json& message);
|
||||||
|
void ListPage(const std::shared_ptr<Listing>& listing);
|
||||||
|
void FinishList(const std::shared_ptr<Listing>& listing, Json result);
|
||||||
|
void ReleaseList(const std::string& peer, const std::string& cursor);
|
||||||
|
Json DirectoryPage(const std::string& peer, const Json& message);
|
||||||
|
void Upload(const std::string& peer, const std::vector<std::string>& paths,
|
||||||
|
const std::string& destination, bool notify_peer = false);
|
||||||
|
void UploadNext(const std::string& peer, const std::shared_ptr<UploadJob>& job);
|
||||||
|
bool Current(const std::string& peer, uint64_t generation);
|
||||||
|
void Report(const std::string& peer, Json result, bool notify_peer);
|
||||||
|
bool SendMessage(const std::string& peer, Json message);
|
||||||
|
Send send_;
|
||||||
|
Enqueue enqueue_;
|
||||||
|
Reserve reserve_;
|
||||||
|
Cancel cancel_;
|
||||||
|
std::mutex mutex_;
|
||||||
|
std::condition_variable wake_;
|
||||||
|
bool stopping_ = false;
|
||||||
|
std::deque<std::function<void()>> jobs_;
|
||||||
|
std::map<std::string, uint64_t> generations_;
|
||||||
|
std::map<std::string, std::vector<Event>> events_;
|
||||||
|
std::map<uint32_t, std::shared_ptr<Listing>> listings_;
|
||||||
|
// Worker-thread state only.
|
||||||
|
std::map<uint32_t, Pending> pending_;
|
||||||
|
std::map<std::pair<std::string, std::string>, DirectorySnapshot> directory_snapshots_;
|
||||||
|
uint64_t next_snapshot_ = 1;
|
||||||
|
std::string active_peer_;
|
||||||
|
uint64_t active_generation_ = 0;
|
||||||
|
std::atomic<uint32_t> next_id_{1};
|
||||||
|
std::thread worker_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
#include "features/file_transfer/file_browser_window.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <any>
|
||||||
|
#include <cctype>
|
||||||
|
#include <climits>
|
||||||
|
#include <cstdio>
|
||||||
|
extern unsigned char fa_solid_900_ttf[];
|
||||||
|
extern unsigned int fa_solid_900_ttf_len;
|
||||||
|
#include "ui/model_update.h"
|
||||||
|
#include "localization.h"
|
||||||
|
#include "application/window_geometry.h"
|
||||||
|
#include "application/window_presentation.h"
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
#include "platform/window_drag.h"
|
||||||
|
#elif defined(_WIN32)
|
||||||
|
#ifndef NOMINMAX
|
||||||
|
#define NOMINMAX
|
||||||
|
#endif
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
slint::SharedString S(std::string_view value) { return slint::SharedString(value); }
|
||||||
|
std::string Size(uint64_t bytes) {
|
||||||
|
const char* units[] = {"B", "KB", "MB", "GB", "TB"};
|
||||||
|
double value = bytes; int unit = 0;
|
||||||
|
while (value >= 1024 && unit < 4) { value /= 1024; ++unit; }
|
||||||
|
char text[64]; std::snprintf(text, sizeof(text), unit ? "%.1f %s" : "%.0f %s", value, units[unit]);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
std::string Join(const std::string& parent, const std::string& name) {
|
||||||
|
const bool windows = (parent.size() > 1 && parent[1] == ':') || parent.rfind("\\\\", 0) == 0;
|
||||||
|
return parent + (parent.empty() || parent.back() == '/' || parent.back() == '\\'
|
||||||
|
? "" : windows ? "\\" : "/") + name;
|
||||||
|
}
|
||||||
|
std::string Name(const std::string& path) {
|
||||||
|
const auto slash = path.find_last_of("/\\");
|
||||||
|
return slash == std::string::npos || slash == path.size() - 1 ? path : path.substr(slash + 1);
|
||||||
|
}
|
||||||
|
bool Matches(const std::string& text, const std::string& filter) {
|
||||||
|
return std::search(text.begin(), text.end(), filter.begin(), filter.end(),
|
||||||
|
[](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); }) != text.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string FileBrowserWindow::Tr(const char* chinese, const char* english) const { return chinese_ ? chinese : english; }
|
||||||
|
FileBrowserWindow::FileBrowserWindow(FileTransferManager& transfers, std::string remote,
|
||||||
|
bool controller, int language)
|
||||||
|
: transfers_(transfers), remote_(std::move(remote)),
|
||||||
|
connection_generation_(transfers_.browser().Generation(remote_)),
|
||||||
|
chinese_(language == 0 || language == 3) {
|
||||||
|
transfers_.browser().TakeEvents(remote_);
|
||||||
|
window_->window().window_handle().register_font_from_data(
|
||||||
|
fa_solid_900_ttf, fa_solid_900_ttf_len);
|
||||||
|
// Resolve the native frame at its final size while it is still hidden.
|
||||||
|
// Otherwise show() applies the preferred size after we have centered it.
|
||||||
|
window_->window().set_size(slint::LogicalSize({900, 620}));
|
||||||
|
window_->set_chinese(chinese_);
|
||||||
|
UpdateActionLabels(language);
|
||||||
|
SetPeerName("");
|
||||||
|
window_->set_local_role(S(controller ? Tr("控制端", "Controller") : Tr("被控端", "Host")));
|
||||||
|
window_->set_remote_role(S(controller ? Tr("被控端", "Host") : Tr("控制端", "Controller")));
|
||||||
|
window_->set_local_entries(panes_[0].rows); window_->set_local_tree(panes_[0].tree);
|
||||||
|
window_->set_remote_entries(panes_[1].rows); window_->set_remote_tree(panes_[1].tree);
|
||||||
|
window_->set_tasks(tasks_);
|
||||||
|
window_->on_navigate([this](bool remote, slint::SharedString path) { Navigate(remote, std::string(path)); });
|
||||||
|
window_->on_refresh([this](bool remote) { Navigate(remote, panes_[remote].path); });
|
||||||
|
window_->on_up([this](bool remote) { Navigate(remote, panes_[remote].parent); });
|
||||||
|
window_->on_filter([this](bool remote, slint::SharedString filter) {
|
||||||
|
auto& pane = panes_[remote]; pane.filter = std::string(filter); pane.selected.clear(); UpdatePane(remote);
|
||||||
|
});
|
||||||
|
window_->on_sort([this](bool remote, int column) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
if (column < 0 || column > 2 || pane.busy || !pane.loaded ||
|
||||||
|
!pane.error.empty() || window_->get_drag_active()) return;
|
||||||
|
const auto sort_column = static_cast<SortColumn>(column);
|
||||||
|
pane.sort_descending = pane.sort_column == sort_column ? !pane.sort_descending
|
||||||
|
: sort_column != SortColumn::Name;
|
||||||
|
pane.sort_column = sort_column;
|
||||||
|
SortEntries(pane);
|
||||||
|
UpdatePane(remote);
|
||||||
|
});
|
||||||
|
window_->on_toggle_hidden([this](bool remote) {
|
||||||
|
auto& pane = panes_[remote]; pane.show_hidden = !pane.show_hidden;
|
||||||
|
pane.selected.clear(); UpdatePane(remote); UpdateTree(remote);
|
||||||
|
});
|
||||||
|
window_->on_select_entry([this](bool remote, int index) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
if (index < 0 || index >= static_cast<int>(pane.rows->row_count())) return;
|
||||||
|
auto row = *pane.rows->row_data(index);
|
||||||
|
if (row.link) return;
|
||||||
|
SelectSource(remote);
|
||||||
|
const std::string path(row.path);
|
||||||
|
if (!pane.selected.erase(path)) pane.selected.insert(path);
|
||||||
|
UpdatePane(remote);
|
||||||
|
});
|
||||||
|
window_->on_select_all([this](bool remote) {
|
||||||
|
SelectSource(remote);
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
bool all = true;
|
||||||
|
for (size_t i = 0; i < pane.rows->row_count(); ++i) {
|
||||||
|
const auto row = *pane.rows->row_data(i);
|
||||||
|
if (!row.link && !pane.selected.count(std::string(row.path))) all = false;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < pane.rows->row_count(); ++i) {
|
||||||
|
const auto row = *pane.rows->row_data(i);
|
||||||
|
if (all) pane.selected.erase(std::string(row.path));
|
||||||
|
else if (!row.link) pane.selected.insert(std::string(row.path));
|
||||||
|
}
|
||||||
|
UpdatePane(remote);
|
||||||
|
});
|
||||||
|
window_->on_clear_selection([this] {
|
||||||
|
if (window_->get_drag_active()) return;
|
||||||
|
for (int side = 0; side < 2; ++side) {
|
||||||
|
panes_[side].selected.clear();
|
||||||
|
UpdatePane(side);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window_->on_open_entry([this](bool remote, int index) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
if (index < 0 || index >= static_cast<int>(pane.rows->row_count())) return;
|
||||||
|
const auto row = *pane.rows->row_data(index);
|
||||||
|
if (row.directory) OpenDirectory(remote, std::string(row.path));
|
||||||
|
});
|
||||||
|
window_->on_open_directory([this](bool remote, slint::SharedString path) {
|
||||||
|
OpenDirectory(remote, std::string(path));
|
||||||
|
});
|
||||||
|
window_->on_tree_open([this](bool remote, int index) {
|
||||||
|
const auto& pane = panes_[remote];
|
||||||
|
if (index >= 0 && index < static_cast<int>(pane.tree->row_count()))
|
||||||
|
Navigate(remote, std::string(pane.tree->row_data(index)->path));
|
||||||
|
});
|
||||||
|
window_->on_tree_toggle([this](bool remote, int index) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
if (index < 0 || index >= static_cast<int>(pane.tree->row_count())) return;
|
||||||
|
const std::string path(pane.tree->row_data(index)->path);
|
||||||
|
if (!pane.expanded.erase(path)) {
|
||||||
|
pane.expanded.insert(path);
|
||||||
|
if (!pane.directories.count(path)) Navigate(remote, path, true);
|
||||||
|
}
|
||||||
|
UpdateTree(remote);
|
||||||
|
});
|
||||||
|
window_->on_drag_data([this](bool remote, slint::SharedString path, int) {
|
||||||
|
return MakeDragData(remote, std::string(path));
|
||||||
|
});
|
||||||
|
window_->on_drag_state([this](const slint::DataTransfer& data, bool active) {
|
||||||
|
if (!active) { CancelDrag(); return; }
|
||||||
|
const auto value = data.user_data();
|
||||||
|
const auto stored = std::any_cast<std::shared_ptr<DragPayload>>(&value);
|
||||||
|
if (!stored || !*stored) return;
|
||||||
|
const auto payload = DropPayload(!(*stored)->remote, data);
|
||||||
|
if (!payload) return;
|
||||||
|
window_->set_drag_active(true);
|
||||||
|
window_->set_drag_count(static_cast<int>(payload->paths->size()));
|
||||||
|
SelectSource(payload->remote);
|
||||||
|
panes_[payload->remote].selected = {payload->paths->begin(), payload->paths->end()};
|
||||||
|
UpdatePane(payload->remote);
|
||||||
|
});
|
||||||
|
window_->on_can_drop([this](bool remote, const slint::DataTransfer& data) {
|
||||||
|
return static_cast<bool>(DropPayload(remote, data));
|
||||||
|
});
|
||||||
|
window_->on_dropped([this](bool remote, const slint::DataTransfer& data) {
|
||||||
|
const auto payload = DropPayload(remote, data);
|
||||||
|
if (!payload) return false;
|
||||||
|
// The drag owns a snapshot of both directories and its selected paths.
|
||||||
|
// Consume before queueing so repeated release events cannot send twice.
|
||||||
|
payload->consumed = true;
|
||||||
|
return QueueTransfer(payload->remote, *payload->paths, payload->target_directory);
|
||||||
|
});
|
||||||
|
window_->on_cancel_drag([this] { CancelDrag(); });
|
||||||
|
window_->on_transfer([this](bool download) { Transfer(download); });
|
||||||
|
window_->on_cancel_task([this](int id) { transfers_.CancelBrowserTransfer(remote_, static_cast<uint32_t>(id)); });
|
||||||
|
window_->on_clear_finished([this] {
|
||||||
|
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
|
||||||
|
for (const auto& [id, task] : history_)
|
||||||
|
if (task.status == Status::Completed || task.status == Status::Failed || task.status == Status::Cancelled)
|
||||||
|
dismissed_.insert(id);
|
||||||
|
next_sync_ = {};
|
||||||
|
});
|
||||||
|
window_->window().on_close_requested([this] {
|
||||||
|
show_pending_ = false;
|
||||||
|
CancelDrag();
|
||||||
|
window_->hide(); return slint::CloseRequestResponse::KeepWindowShown;
|
||||||
|
});
|
||||||
|
Navigate(false, ""); Navigate(true, "");
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::SetPeerName(const std::string& name) {
|
||||||
|
const auto text = S(name.empty() ? Tr("远端设备", "Remote device") : name);
|
||||||
|
if (window_->get_peer_name() != text) window_->set_peer_name(text);
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::UpdateActionLabels(int language) {
|
||||||
|
window_->set_send_local_text(S(localization::Literal("发送到本机", language)));
|
||||||
|
window_->set_send_remote_text(S(localization::Literal("发送到远端", language)));
|
||||||
|
window_->set_clear_completed_text(S(localization::Literal("清除已完成", language)));
|
||||||
|
window_->set_open_directory_text(S(localization::Literal("打开目录", language)));
|
||||||
|
}
|
||||||
|
bool FileBrowserWindow::PresentsResult(uint32_t id,
|
||||||
|
FileTransferManager::FileTransferState::FileTransferStatus status) const {
|
||||||
|
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
|
||||||
|
if (!PresentsTransfers() ||
|
||||||
|
(status != Status::Completed && status != Status::Failed && status != Status::Cancelled))
|
||||||
|
return false;
|
||||||
|
const auto task = history_.find(id);
|
||||||
|
return task != history_.end() && !task->second.receiving && task->second.status == status &&
|
||||||
|
(window_->get_tasks_expanded() || dismissed_.count(id));
|
||||||
|
}
|
||||||
|
bool FileBrowserWindow::CenterOnScreen() {
|
||||||
|
auto& window = window_->window();
|
||||||
|
if (window.is_fullscreen() || window.is_maximized()) return true;
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
return CenterNativeWindowOnScreen(window.appkit_view());
|
||||||
|
#elif defined(_WIN32)
|
||||||
|
const HWND hwnd = window.win32_hwnd();
|
||||||
|
if (!hwnd) return false;
|
||||||
|
MONITORINFO monitor{sizeof(MONITORINFO)};
|
||||||
|
RECT frame{};
|
||||||
|
if (!GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &monitor) ||
|
||||||
|
!GetWindowRect(hwnd, &frame)) return false;
|
||||||
|
const auto& bounds = monitor.rcWork;
|
||||||
|
const auto target = window_geometry::CenteredPosition(
|
||||||
|
{static_cast<int>(bounds.left), static_cast<int>(bounds.top),
|
||||||
|
static_cast<int>(bounds.right - bounds.left),
|
||||||
|
static_cast<int>(bounds.bottom - bounds.top)},
|
||||||
|
{static_cast<int>(frame.right - frame.left),
|
||||||
|
static_cast<int>(frame.bottom - frame.top)});
|
||||||
|
return SetWindowPos(hwnd, nullptr, target.x, target.y, 0, 0,
|
||||||
|
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE) != 0;
|
||||||
|
#else
|
||||||
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
|
// Native Wayland placement belongs to the compositor.
|
||||||
|
if (window.wayland_surface()) return true;
|
||||||
|
#endif
|
||||||
|
const auto position = window.position();
|
||||||
|
const auto size = window.size();
|
||||||
|
if (!size.width || !size.height) return false;
|
||||||
|
const SDL_Point center{position.x + static_cast<int>(size.width / 2),
|
||||||
|
position.y + static_cast<int>(size.height / 2)};
|
||||||
|
SDL_DisplayID display = SDL_GetDisplayForPoint(¢er);
|
||||||
|
if (!display) display = SDL_GetPrimaryDisplay();
|
||||||
|
SDL_Rect bounds{};
|
||||||
|
if (!display || !SDL_GetDisplayUsableBounds(display, &bounds)) return false;
|
||||||
|
const auto target = window_geometry::CenteredPosition(
|
||||||
|
{bounds.x, bounds.y, bounds.w, bounds.h},
|
||||||
|
{static_cast<int>(size.width), static_cast<int>(size.height)});
|
||||||
|
window.set_position(slint::PhysicalPosition({target.x, target.y}));
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::Show() {
|
||||||
|
if (!Tick()) return;
|
||||||
|
if (Visible()) {
|
||||||
|
window_->window().set_minimized(false);
|
||||||
|
} else {
|
||||||
|
show_pending_ = true;
|
||||||
|
ShowWhenReady();
|
||||||
|
}
|
||||||
|
if (!panes_[1].busy && !panes_[1].error.empty()) Navigate(true, panes_[1].path);
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::ShowWhenReady() {
|
||||||
|
if (!show_pending_ || !NativeWindowReady(window_->window())) return;
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
if (!ConfigureWhiteWindowChrome(window_->window().appkit_view())) return;
|
||||||
|
#endif
|
||||||
|
if (!CenterOnScreen()) return;
|
||||||
|
show_pending_ = false;
|
||||||
|
window_->show();
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::OpenDirectory(bool remote, const std::string& path) {
|
||||||
|
const auto& pane = panes_[remote];
|
||||||
|
if (!SessionConnected() || !pane.loaded || pane.busy || !pane.error.empty() ||
|
||||||
|
window_->get_drag_active()) return;
|
||||||
|
// Resolve the menu target by path, never by a row index that can change.
|
||||||
|
const auto entry = std::find_if(pane.entries.begin(), pane.entries.end(), [&](const auto& item) {
|
||||||
|
return item.row.directory && std::string_view(item.row.path) == path;
|
||||||
|
});
|
||||||
|
if (entry != pane.entries.end()) Navigate(remote, path);
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::Navigate(bool remote, const std::string& path, bool tree) {
|
||||||
|
if (window_->get_drag_active()) CancelDrag();
|
||||||
|
if (remote && !transfers_.BrowserConnected(remote_)) { Notice(Error("disconnected"), true); return; }
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
for (auto it = requests_.begin(); it != requests_.end();) {
|
||||||
|
if (it->second.remote == remote && it->second.tree == tree &&
|
||||||
|
(!tree || it->second.path == path)) {
|
||||||
|
transfers_.browser().CancelList(remote_, it->first);
|
||||||
|
it = requests_.erase(it);
|
||||||
|
} else ++it;
|
||||||
|
}
|
||||||
|
const auto id = transfers_.browser().List(remote_, path, !remote);
|
||||||
|
requests_[id] = {remote, tree, path};
|
||||||
|
if (!tree) {
|
||||||
|
pane.request = id; pane.busy = true; pane.error.clear(); pane.loading.clear();
|
||||||
|
pane.selected.clear(); UpdatePane(remote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::SortEntries(Pane& pane) {
|
||||||
|
std::sort(pane.entries.begin(), pane.entries.end(), [&](const Entry& a, const Entry& b) {
|
||||||
|
// Folder grouping and unknown dates stay consistent in either direction.
|
||||||
|
if (a.row.directory != b.row.directory) return a.row.directory;
|
||||||
|
int order = 0;
|
||||||
|
if (pane.sort_column == SortColumn::Modified) {
|
||||||
|
const bool a_known = a.modified_ms.has_value() || !a.modified.empty();
|
||||||
|
const bool b_known = b.modified_ms.has_value() || !b.modified.empty();
|
||||||
|
if (a_known != b_known) return a_known;
|
||||||
|
if (a.modified_ms && b.modified_ms)
|
||||||
|
order = *a.modified_ms < *b.modified_ms ? -1 : *a.modified_ms > *b.modified_ms ? 1 : 0;
|
||||||
|
else order = a.modified.compare(b.modified);
|
||||||
|
} else if (pane.sort_column == SortColumn::Size && !a.row.directory) {
|
||||||
|
order = a.bytes < b.bytes ? -1 : a.bytes > b.bytes ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (!order) order = a.name_key.compare(b.name_key);
|
||||||
|
if (!order) order = std::string_view(a.row.name).compare(std::string_view(b.row.name));
|
||||||
|
if (!order) order = std::string_view(a.row.path).compare(std::string_view(b.row.path));
|
||||||
|
return pane.sort_descending ? order > 0 : order < 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::UpdatePane(bool remote) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
std::vector<ui::BrowserEntry> rows;
|
||||||
|
size_t selectable = 0;
|
||||||
|
rows.reserve(pane.entries.size());
|
||||||
|
for (const auto& item : pane.entries) {
|
||||||
|
auto entry = item.row;
|
||||||
|
if (!pane.show_hidden && std::string(entry.name).starts_with('.')) continue;
|
||||||
|
if (!Matches(std::string(entry.name), pane.filter)) continue;
|
||||||
|
entry.selected = pane.selected.count(std::string(entry.path));
|
||||||
|
if (!entry.link) ++selectable;
|
||||||
|
rows.push_back(std::move(entry));
|
||||||
|
}
|
||||||
|
ui::UpdateModelRows(*pane.rows, std::move(rows));
|
||||||
|
const auto summary = pane.busy
|
||||||
|
? pane.loading.empty() ? Tr("正在读取目录…", "Loading folder…") : pane.loading
|
||||||
|
: std::to_string(pane.rows->row_count()) + Tr(" 项", " items");
|
||||||
|
const bool up = pane.loaded && !pane.parent.empty() && pane.path != pane.parent;
|
||||||
|
const bool all = selectable > 0 && pane.selected.size() == selectable;
|
||||||
|
if (remote) {
|
||||||
|
window_->set_remote_sort_column(static_cast<int>(pane.sort_column));
|
||||||
|
window_->set_remote_sort_descending(pane.sort_descending);
|
||||||
|
window_->set_remote_current_path(S(pane.path));
|
||||||
|
window_->set_remote_filter(S(pane.filter));
|
||||||
|
window_->set_remote_hidden(pane.show_hidden);
|
||||||
|
window_->set_remote_busy(pane.busy); window_->set_remote_status(S(summary));
|
||||||
|
window_->set_remote_error(S(pane.error)); window_->set_remote_up(up);
|
||||||
|
window_->set_remote_selected(static_cast<int>(pane.selected.size())); window_->set_remote_all(all);
|
||||||
|
} else {
|
||||||
|
window_->set_local_sort_column(static_cast<int>(pane.sort_column));
|
||||||
|
window_->set_local_sort_descending(pane.sort_descending);
|
||||||
|
window_->set_local_current_path(S(pane.path));
|
||||||
|
window_->set_local_filter(S(pane.filter));
|
||||||
|
window_->set_local_hidden(pane.show_hidden);
|
||||||
|
window_->set_local_busy(pane.busy); window_->set_local_status(S(summary));
|
||||||
|
window_->set_local_error(S(pane.error)); window_->set_local_up(up);
|
||||||
|
window_->set_local_selected(static_cast<int>(pane.selected.size())); window_->set_local_all(all);
|
||||||
|
}
|
||||||
|
UpdateAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileBrowserWindow::UpdateTree(bool remote) {
|
||||||
|
auto& pane = panes_[remote];
|
||||||
|
std::vector<ui::BrowserTreeEntry> rows;
|
||||||
|
const auto append = [&](auto&& self, const std::string& path, const std::string& name, int depth) -> void {
|
||||||
|
if (depth > 24) return;
|
||||||
|
const bool expanded = pane.expanded.count(path);
|
||||||
|
rows.push_back({S(name), S(path), depth, expanded, pane.path == path});
|
||||||
|
if (expanded) {
|
||||||
|
const auto it = pane.directories.find(path);
|
||||||
|
if (it != pane.directories.end())
|
||||||
|
for (const auto& entry : it->second)
|
||||||
|
if (entry.directory && !entry.link && (pane.show_hidden || !std::string(entry.name).starts_with('.')))
|
||||||
|
self(self, std::string(entry.path), std::string(entry.name), depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!pane.home.empty()) append(append, pane.home, Tr("主目录", "Home"), 0);
|
||||||
|
append(append, "@roots", Tr("磁盘", "Drives"), 0);
|
||||||
|
if (pane.loaded && pane.path != "@roots" && pane.path != pane.home &&
|
||||||
|
std::none_of(rows.begin(), rows.end(), [&](const auto& row) { return std::string(row.path) == pane.path; }))
|
||||||
|
append(append, pane.path, Name(pane.path), 0);
|
||||||
|
ui::UpdateModelRows(*pane.tree, std::move(rows));
|
||||||
|
}
|
||||||
|
bool FileBrowserWindow::TransferReady() const {
|
||||||
|
return window_->get_connected() && panes_[0].loaded && panes_[1].loaded &&
|
||||||
|
!panes_[0].busy && !panes_[1].busy && panes_[0].error.empty() && panes_[1].error.empty() &&
|
||||||
|
panes_[0].path != "@roots" && panes_[1].path != "@roots" &&
|
||||||
|
std::string(window_->get_local_path()) == panes_[0].path &&
|
||||||
|
std::string(window_->get_remote_path()) == panes_[1].path;
|
||||||
|
}
|
||||||
|
bool FileBrowserWindow::SessionConnected() const {
|
||||||
|
// A peer can reconnect with the same ID between UI ticks. Its old window
|
||||||
|
// must still close, even if the new connection is already usable.
|
||||||
|
return !disconnected_ && transfers_.BrowserConnected(remote_) &&
|
||||||
|
connection_generation_ == transfers_.browser().Generation(remote_);
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::UpdateAvailability() {
|
||||||
|
const bool ready = TransferReady();
|
||||||
|
window_->set_transfer_ready(ready);
|
||||||
|
window_->set_can_upload(ready && !panes_[0].selected.empty());
|
||||||
|
window_->set_can_download(ready && !panes_[1].selected.empty());
|
||||||
|
// All selected rows share one immutable list, so selecting a large folder
|
||||||
|
// doesn't allocate a copy of the entire selection for every draggable row.
|
||||||
|
for (int side = 0; side < 2; ++side)
|
||||||
|
drag_selection_[side] = std::make_shared<const std::vector<std::string>>(
|
||||||
|
panes_[side].selected.begin(), panes_[side].selected.end());
|
||||||
|
// Callback bindings don't track C++ container mutations. Refresh the drag
|
||||||
|
// payload only when its selection/directory state changes, never every tick.
|
||||||
|
const int revision = window_->get_selection_revision();
|
||||||
|
window_->set_selection_revision(revision == INT_MAX ? 0 : revision + 1);
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::SelectSource(bool remote) {
|
||||||
|
window_->set_source_remote(remote);
|
||||||
|
if (!panes_[!remote].selected.empty()) {
|
||||||
|
panes_[!remote].selected.clear(); UpdatePane(!remote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slint::DataTransfer FileBrowserWindow::MakeDragData(bool remote, const std::string& path) const {
|
||||||
|
slint::DataTransfer result;
|
||||||
|
if (!TransferReady()) return result;
|
||||||
|
const auto& source = panes_[remote]; const auto& target = panes_[!remote];
|
||||||
|
const auto found = std::find_if(source.entries.begin(), source.entries.end(),
|
||||||
|
[&](const auto& entry) { return !entry.row.link && std::string(entry.row.path) == path; });
|
||||||
|
if (found == source.entries.end()) return result;
|
||||||
|
auto payload = std::make_shared<DragPayload>();
|
||||||
|
payload->owner = drag_owner_; payload->epoch = drag_epoch_; payload->remote = remote;
|
||||||
|
payload->source_request = source.request; payload->target_request = target.request;
|
||||||
|
payload->source_directory = source.path; payload->target_directory = target.path;
|
||||||
|
if (source.selected.count(path)) payload->paths = drag_selection_[remote];
|
||||||
|
else payload->paths = std::make_shared<const std::vector<std::string>>(std::initializer_list<std::string>{path});
|
||||||
|
// Opaque application data: a remote path must never masquerade as a local
|
||||||
|
// file URL when the pointer leaves this window or enters another session.
|
||||||
|
result.set_user_data(std::move(payload));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
std::shared_ptr<FileBrowserWindow::DragPayload> FileBrowserWindow::DropPayload(
|
||||||
|
bool remote, const slint::DataTransfer& data) const {
|
||||||
|
const auto value = data.user_data();
|
||||||
|
const auto stored = std::any_cast<std::shared_ptr<DragPayload>>(&value);
|
||||||
|
if (!stored || !*stored) return {};
|
||||||
|
const auto& payload = *stored;
|
||||||
|
if (payload->owner.lock() != drag_owner_ || payload->epoch != drag_epoch_ ||
|
||||||
|
payload->consumed || payload->remote == remote || !payload->paths || payload->paths->empty() ||
|
||||||
|
!TransferReady() || !SessionConnected()) return {};
|
||||||
|
const auto& source = panes_[payload->remote]; const auto& target = panes_[remote];
|
||||||
|
if (source.request != payload->source_request || target.request != payload->target_request ||
|
||||||
|
source.path != payload->source_directory || target.path != payload->target_directory) return {};
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::CancelDrag() {
|
||||||
|
++drag_epoch_;
|
||||||
|
window_->set_drag_active(false); window_->set_drag_count(0);
|
||||||
|
UpdateAvailability();
|
||||||
|
}
|
||||||
|
bool FileBrowserWindow::QueueTransfer(bool download, const std::vector<std::string>& paths,
|
||||||
|
const std::string& destination) {
|
||||||
|
if (paths.empty() || !TransferReady() || !SessionConnected()) return false;
|
||||||
|
transfers_.browser().Copy(remote_, paths, destination, download);
|
||||||
|
Notice(Tr("正在准备传输 → ", "Preparing transfer → ") + destination, false);
|
||||||
|
panes_[download].selected.clear(); UpdatePane(download);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::Transfer(bool download) {
|
||||||
|
if (!TransferReady()) return;
|
||||||
|
auto& source = panes_[download];
|
||||||
|
const std::vector<std::string> paths(source.selected.begin(), source.selected.end());
|
||||||
|
QueueTransfer(download, paths, panes_[!download].path);
|
||||||
|
}
|
||||||
|
void FileBrowserWindow::Notice(const std::string& message, bool error) {
|
||||||
|
window_->set_notice(S(message)); window_->set_notice_error(error);
|
||||||
|
}
|
||||||
|
std::string FileBrowserWindow::Error(const std::string& code) const {
|
||||||
|
if (code == "timeout") return Tr("远端未响应,请确认两端已更新至支持文件浏览的版本后重试。", "Peer did not respond. Update both ends to a version that supports file browsing, then retry.");
|
||||||
|
if (code == "disconnected") return Tr("连接已断开,请重新连接后打开文件管理器。", "Disconnected. Reconnect and reopen the file manager.");
|
||||||
|
if (code == "browser_not_open") return Tr("请先在控制端打开本连接的文件管理器,再浏览控制端目录。", "Open this connection's file manager on the controller to enable browsing its folders.");
|
||||||
|
if (code == "directory_unavailable") return Tr("无法打开目录,请检查路径和访问权限后刷新。", "Cannot open folder. Check the path and access permissions, then refresh.");
|
||||||
|
if (code == "pagination_unsupported") return Tr("远端版本无法完整读取此目录,请更新远端后重试。", "Update the remote app to load every item in this folder.");
|
||||||
|
if (code == "invalid_listing" || code == "listing_expired") return Tr("目录列表未能完整加载,请刷新重试。", "The folder listing could not be fully loaded. Refresh to retry.");
|
||||||
|
if (code == "directory_not_writable") return Tr("目标目录不可写,请选择其他目录。", "Cannot write to the destination. Choose another folder.");
|
||||||
|
if (code == "symbolic_link") return Tr("所选内容包含符号链接,请选择实际文件或目录。", "Selection contains a symbolic link. Select the original files or folders.");
|
||||||
|
if (code == "too_many_files") return Tr("单次最多传输 4096 个文件与目录,请分批选择。", "Select at most 4096 files and folders per transfer.");
|
||||||
|
if (code == "file_unreadable") return Tr("文件无法读取,可能已移动或无访问权限。", "Cannot read a file. It may have moved or access was denied.");
|
||||||
|
if (code == "busy") return Tr("请求较多,请稍后重试。", "Too many pending requests. Try again shortly.");
|
||||||
|
if (code == "already_finished") return Tr("任务已结束,无需取消。", "This transfer has already finished.");
|
||||||
|
return Tr("操作失败,请检查所选路径与文件权限后重试。", "Operation failed. Check the paths and file permissions, then retry.");
|
||||||
|
}
|
||||||
|
bool FileBrowserWindow::Tick() {
|
||||||
|
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
|
||||||
|
// Connection lifetime is checked every UI tick, outside the throttled
|
||||||
|
// directory/progress refresh. All native window operations stay on this thread.
|
||||||
|
if (!SessionConnected()) {
|
||||||
|
disconnected_ = true;
|
||||||
|
show_pending_ = false;
|
||||||
|
window_->set_connected(false);
|
||||||
|
CancelDrag();
|
||||||
|
requests_.clear();
|
||||||
|
window_->hide();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ShowWhenReady();
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
if (now < next_sync_) return true;
|
||||||
|
next_sync_ = now + std::chrono::milliseconds(150);
|
||||||
|
for (auto& event : transfers_.browser().TakeEvents(remote_)) {
|
||||||
|
if (!event.id) {
|
||||||
|
const bool ok = event.result.value("ok", false);
|
||||||
|
if (!ok || !window_->get_notice_error())
|
||||||
|
Notice(ok ? event.result.value("message", "") == "cancelling"
|
||||||
|
? Tr("正在取消传输…", "Cancelling transfer…")
|
||||||
|
: Tr("已加入传输队列", "Added to transfer queue")
|
||||||
|
: Error(event.result.value("error", "unknown")), !ok);
|
||||||
|
if (ok) refresh_at_ = now + std::chrono::seconds(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto found = requests_.find(event.id);
|
||||||
|
if (found == requests_.end()) continue;
|
||||||
|
const auto request = found->second;
|
||||||
|
auto& pane = panes_[request.remote];
|
||||||
|
if (!request.tree && pane.request != event.id) continue;
|
||||||
|
if (event.result.value("loading", false)) {
|
||||||
|
if (!request.tree) {
|
||||||
|
pane.loading = Tr("正在加载 ", "Loading ") + std::to_string(event.result.value("loaded", size_t{})) +
|
||||||
|
" / " + std::to_string(event.result.value("total", size_t{})) + Tr(" 项…", " items…");
|
||||||
|
if (request.remote) window_->set_remote_status(S(pane.loading));
|
||||||
|
else window_->set_local_status(S(pane.loading));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
requests_.erase(found);
|
||||||
|
if (!request.tree) pane.busy = false;
|
||||||
|
if (!event.result.value("ok", false)) {
|
||||||
|
pane.error = Error(event.result.value("error", "unknown"));
|
||||||
|
if (request.tree) pane.expanded.erase(request.path);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const auto path = event.result.at("path").get<std::string>();
|
||||||
|
const auto& entries = event.result.at("entries");
|
||||||
|
if (!entries.is_array()) throw std::runtime_error("invalid");
|
||||||
|
std::vector<ui::BrowserEntry> rows;
|
||||||
|
rows.reserve(entries.size());
|
||||||
|
std::vector<Entry> sortable;
|
||||||
|
if (!request.tree) sortable.reserve(entries.size());
|
||||||
|
for (const auto& entry : entries) {
|
||||||
|
ui::BrowserEntry row{};
|
||||||
|
const auto name = entry.at("name").get<std::string>();
|
||||||
|
if (name.empty() || name == "." || name == "..") continue;
|
||||||
|
// Paths from a different operating system are opaque to this UI.
|
||||||
|
row.name = S(name); row.path = S(path == "@roots" ? entry.at("path").get<std::string>() : Join(path, name));
|
||||||
|
row.directory = entry.at("directory").get<bool>(); row.link = entry.value("link", false);
|
||||||
|
const auto bytes = entry.at("size").get<uint64_t>();
|
||||||
|
const auto timestamp = entry.at("modified").get<std::string>();
|
||||||
|
row.size = S(Size(bytes));
|
||||||
|
auto modified = timestamp.substr(0, 10);
|
||||||
|
std::replace(modified.begin(), modified.end(), '-', '/');
|
||||||
|
row.modified = S(modified);
|
||||||
|
if (!request.tree) {
|
||||||
|
Entry item{row, name, timestamp, bytes, std::nullopt};
|
||||||
|
std::transform(item.name_key.begin(), item.name_key.end(), item.name_key.begin(),
|
||||||
|
[](unsigned char c) { return std::tolower(c); });
|
||||||
|
if (entry.contains("modified_ms") && entry["modified_ms"].is_number_integer())
|
||||||
|
item.modified_ms = entry["modified_ms"].get<int64_t>();
|
||||||
|
sortable.push_back(std::move(item));
|
||||||
|
}
|
||||||
|
rows.push_back(std::move(row));
|
||||||
|
}
|
||||||
|
pane.directories[path] = rows;
|
||||||
|
if (!request.tree) {
|
||||||
|
// A name filter belongs to the open folder. Preserve it on refresh,
|
||||||
|
// but don't silently hide entries after navigating somewhere else.
|
||||||
|
if (pane.path != path) pane.filter.clear();
|
||||||
|
pane.path = path; pane.parent = event.result.at("parent").get<std::string>();
|
||||||
|
if (request.path.empty()) pane.home = path;
|
||||||
|
pane.entries = std::move(sortable); pane.error.clear(); pane.loaded = true;
|
||||||
|
SortEntries(pane);
|
||||||
|
pane.expanded.insert(path);
|
||||||
|
}
|
||||||
|
} catch (...) { pane.error = Error("unknown"); }
|
||||||
|
}
|
||||||
|
if (!request.tree) {
|
||||||
|
if (request.remote) window_->set_remote_path(S(pane.path)); else window_->set_local_path(S(pane.path));
|
||||||
|
}
|
||||||
|
UpdatePane(request.remote); UpdateTree(request.remote);
|
||||||
|
}
|
||||||
|
bool new_tasks = false;
|
||||||
|
for (const auto& task : transfers_.BrowserTransfers(remote_)) {
|
||||||
|
new_tasks = new_tasks || (!task.receiving && !history_.count(task.file_id));
|
||||||
|
history_[task.file_id] = task;
|
||||||
|
}
|
||||||
|
if (new_tasks) window_->set_tasks_expanded(true);
|
||||||
|
std::vector<ui::BrowserTask> rows;
|
||||||
|
for (auto it = history_.rbegin(); it != history_.rend(); ++it) {
|
||||||
|
const auto& [id, task] = *it;
|
||||||
|
if (task.status == Status::Completed && completed_.insert(id).second)
|
||||||
|
refresh_at_ = now + std::chrono::milliseconds(500);
|
||||||
|
// Received files still trigger directory refresh, but only this computer's
|
||||||
|
// outgoing transfers appear in the task list and progress notifications.
|
||||||
|
if (task.receiving || dismissed_.count(id)) continue;
|
||||||
|
ui::BrowserTask row{};
|
||||||
|
row.id = static_cast<int>(id); row.name = S(task.file_name); row.receiving = task.receiving;
|
||||||
|
row.size = S(Size(task.file_size));
|
||||||
|
row.active = task.status == Status::Queued || task.status == Status::Sending || task.status == Status::Receiving || task.status == Status::Cancelling;
|
||||||
|
row.failed = task.status == Status::Failed;
|
||||||
|
row.can_cancel = task.status == Status::Queued || task.status == Status::Sending || task.status == Status::Receiving;
|
||||||
|
row.progress = task.status == Status::Completed ? 1.f : task.file_size ? std::clamp(float(task.sent_bytes) / task.file_size, 0.f, 1.f) : 0.f;
|
||||||
|
const auto detail = Tr("已传输 ", "Transferred ") + Size(task.sent_bytes) +
|
||||||
|
(task.rate_bps ? " · " + Size(task.rate_bps / 8) + "/s" : "");
|
||||||
|
row.detail = S(detail);
|
||||||
|
switch (task.status) {
|
||||||
|
case Status::Queued: row.status = S(Tr("排队中", "Queued")); break;
|
||||||
|
case Status::Sending: row.status = S(Tr("发送中 ", "Sending ") + std::to_string(int(row.progress * 100)) + "%"); break;
|
||||||
|
case Status::Receiving: row.status = S(Tr("接收中 ", "Receiving ") + std::to_string(int(row.progress * 100)) + "%"); break;
|
||||||
|
case Status::Completed: row.status = S(Tr("已完成", "Completed")); break;
|
||||||
|
case Status::Failed: row.status = S(Tr("失败 · 请重试", "Failed · Retry")); break;
|
||||||
|
case Status::Cancelling: row.status = S(Tr("正在取消", "Cancelling")); break;
|
||||||
|
case Status::Cancelled: row.status = S(Tr("已取消", "Cancelled")); break;
|
||||||
|
}
|
||||||
|
rows.push_back(row);
|
||||||
|
}
|
||||||
|
const auto active = std::count_if(rows.begin(), rows.end(), [](const auto& task) { return task.active; });
|
||||||
|
const auto failed = std::count_if(rows.begin(), rows.end(), [](const auto& task) { return task.failed; });
|
||||||
|
const auto summary = std::to_string(rows.size()) + Tr(" 项", " items") +
|
||||||
|
(active ? " · " + std::to_string(active) + Tr(" 项进行中", " active") : "") +
|
||||||
|
(failed ? " · " + std::to_string(failed) + Tr(" 项失败", " failed") : "");
|
||||||
|
window_->set_task_summary(S(summary));
|
||||||
|
ui::UpdateModelRows(*tasks_, std::move(rows));
|
||||||
|
if (!window_->get_drag_active() && refresh_at_ != std::chrono::steady_clock::time_point{} && now >= refresh_at_) {
|
||||||
|
refresh_at_ = {};
|
||||||
|
for (int side = 0; side < 2; ++side)
|
||||||
|
if (panes_[side].loaded && !panes_[side].busy && panes_[side].selected.empty() &&
|
||||||
|
std::string(side ? window_->get_remote_path() : window_->get_local_path()) == panes_[side].path)
|
||||||
|
Navigate(side, panes_[side].path);
|
||||||
|
}
|
||||||
|
const bool ready = TransferReady();
|
||||||
|
if (window_->get_transfer_ready() != ready) UpdateAvailability();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-11-04
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _FILE_BROWSER_WINDOW_H_
|
||||||
|
#define _FILE_BROWSER_WINDOW_H_
|
||||||
|
|
||||||
|
#include "crossdesk_ui.h"
|
||||||
|
#include "features/file_transfer/file_transfer_manager.h"
|
||||||
|
#include <optional>
|
||||||
|
#include <set>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
class FileBrowserWindow {
|
||||||
|
public:
|
||||||
|
FileBrowserWindow(FileTransferManager& transfers, std::string remote,
|
||||||
|
bool controller, int language);
|
||||||
|
void Show();
|
||||||
|
void SetPeerName(const std::string& name);
|
||||||
|
void UpdateActionLabels(int language);
|
||||||
|
// False means this connection ended; the UI owner should discard the window.
|
||||||
|
bool Tick();
|
||||||
|
bool Visible() const { return window_->window().is_visible(); }
|
||||||
|
bool PresentsTransfers() const { return Visible() && !window_->window().is_minimized(); }
|
||||||
|
bool PresentsResult(uint32_t id, FileTransferManager::FileTransferState::FileTransferStatus status) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class SortColumn { Name, Modified, Size };
|
||||||
|
struct Entry {
|
||||||
|
ui::BrowserEntry row;
|
||||||
|
std::string name_key, modified;
|
||||||
|
uint64_t bytes = 0;
|
||||||
|
std::optional<int64_t> modified_ms;
|
||||||
|
};
|
||||||
|
struct Pane {
|
||||||
|
std::string path, parent, home, filter, error, loading;
|
||||||
|
bool loaded = false, busy = false, show_hidden = false;
|
||||||
|
uint32_t request = 0;
|
||||||
|
SortColumn sort_column = SortColumn::Name;
|
||||||
|
bool sort_descending = false;
|
||||||
|
std::vector<Entry> entries;
|
||||||
|
std::set<std::string> selected, expanded;
|
||||||
|
std::map<std::string, std::vector<ui::BrowserEntry>> directories;
|
||||||
|
std::shared_ptr<slint::VectorModel<ui::BrowserEntry>> rows =
|
||||||
|
std::make_shared<slint::VectorModel<ui::BrowserEntry>>();
|
||||||
|
std::shared_ptr<slint::VectorModel<ui::BrowserTreeEntry>> tree =
|
||||||
|
std::make_shared<slint::VectorModel<ui::BrowserTreeEntry>>();
|
||||||
|
};
|
||||||
|
struct Request { bool remote, tree; std::string path; };
|
||||||
|
struct DragPayload {
|
||||||
|
std::weak_ptr<int> owner;
|
||||||
|
uint64_t epoch = 0;
|
||||||
|
bool remote = false, consumed = false;
|
||||||
|
uint32_t source_request = 0, target_request = 0;
|
||||||
|
std::string source_directory, target_directory;
|
||||||
|
std::shared_ptr<const std::vector<std::string>> paths;
|
||||||
|
};
|
||||||
|
void Navigate(bool remote, const std::string& path, bool tree = false);
|
||||||
|
void OpenDirectory(bool remote, const std::string& path);
|
||||||
|
bool CenterOnScreen();
|
||||||
|
void ShowWhenReady();
|
||||||
|
void UpdatePane(bool remote);
|
||||||
|
void SortEntries(Pane& pane);
|
||||||
|
void UpdateTree(bool remote);
|
||||||
|
bool TransferReady() const;
|
||||||
|
bool SessionConnected() const;
|
||||||
|
void UpdateAvailability();
|
||||||
|
void SelectSource(bool remote);
|
||||||
|
slint::DataTransfer MakeDragData(bool remote, const std::string& path) const;
|
||||||
|
std::shared_ptr<DragPayload> DropPayload(bool remote, const slint::DataTransfer& data) const;
|
||||||
|
void CancelDrag();
|
||||||
|
bool QueueTransfer(bool download, const std::vector<std::string>& paths,
|
||||||
|
const std::string& destination);
|
||||||
|
void Transfer(bool download);
|
||||||
|
void Notice(const std::string& message, bool error);
|
||||||
|
std::string Error(const std::string& code) const;
|
||||||
|
std::string Tr(const char* chinese, const char* english) const;
|
||||||
|
FileTransferManager& transfers_;
|
||||||
|
std::string remote_;
|
||||||
|
uint64_t connection_generation_;
|
||||||
|
bool chinese_;
|
||||||
|
bool disconnected_ = false;
|
||||||
|
bool show_pending_ = false;
|
||||||
|
slint::ComponentHandle<ui::FileBrowserWindow> window_ = ui::FileBrowserWindow::create();
|
||||||
|
Pane panes_[2];
|
||||||
|
std::shared_ptr<int> drag_owner_ = std::make_shared<int>(0);
|
||||||
|
std::shared_ptr<const std::vector<std::string>> drag_selection_[2];
|
||||||
|
uint64_t drag_epoch_ = 0;
|
||||||
|
std::map<uint32_t, Request> requests_;
|
||||||
|
std::set<uint32_t> dismissed_, completed_;
|
||||||
|
std::map<uint32_t, FileTransferManager::FileTransferState::FileTransferInfo> history_;
|
||||||
|
std::shared_ptr<slint::VectorModel<ui::BrowserTask>> tasks_ =
|
||||||
|
std::make_shared<slint::VectorModel<ui::BrowserTask>>();
|
||||||
|
std::chrono::steady_clock::time_point next_sync_{}, refresh_at_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -10,10 +10,31 @@
|
|||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
FileTransferManager::FileTransferManager(GuiRuntime& owner) : owner_(owner) {}
|
FileTransferManager::FileTransferManager(GuiRuntime& owner)
|
||||||
|
: owner_(owner), browser_(
|
||||||
|
[this](const auto& remote, const auto& message) { return SendBrowser(remote, message); },
|
||||||
|
[this](const auto& remote, const auto& path, uint32_t id) {
|
||||||
|
if (!BrowserConnected(remote)) return false;
|
||||||
|
auto props = owner_.FindRemoteSession(remote);
|
||||||
|
return ProcessSelectedFile(path, props, owner_.file_label_, remote, id);
|
||||||
|
},
|
||||||
|
[this](const auto& remote, uint32_t id, const auto& directory) {
|
||||||
|
if (!BrowserConnected(remote)) return false;
|
||||||
|
std::shared_ptr<PeerEventHandler> events;
|
||||||
|
{ std::lock_guard lock(browser_mutex_); events = browser_peers_[remote]; }
|
||||||
|
const auto receiver = ReceiverFor(events.get(), remote);
|
||||||
|
return receiver && receiver->ReserveDirectory(id, directory);
|
||||||
|
},
|
||||||
|
[this](const auto& remote, uint32_t id) {
|
||||||
|
if (!BrowserConnected(remote)) return false;
|
||||||
|
for (const auto& info : BrowserTransfers(remote))
|
||||||
|
if (!info.receiving && info.file_id == id) return CancelSend(id);
|
||||||
|
return false;
|
||||||
|
}) {}
|
||||||
FileTransferManager::~FileTransferManager() { Shutdown(); }
|
FileTransferManager::~FileTransferManager() { Shutdown(); }
|
||||||
|
|
||||||
void FileTransferManager::Shutdown() {
|
void FileTransferManager::Shutdown() {
|
||||||
|
browser_.Stop();
|
||||||
std::vector<std::future<void>> workers;
|
std::vector<std::future<void>> workers;
|
||||||
{
|
{
|
||||||
std::lock_guard lock(workers_mutex_);
|
std::lock_guard lock(workers_mutex_);
|
||||||
@@ -147,6 +168,18 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
|
|||||||
uint64_t previous = UINT64_MAX;
|
uint64_t previous = UINT64_MAX;
|
||||||
auto progress = file_transfer::SendState{id, 0, 0,
|
auto progress = file_transfer::SendState{id, 0, 0,
|
||||||
cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed};
|
cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed};
|
||||||
|
if (cancelled && peer && events && (!props || !props->closing_) &&
|
||||||
|
(props || owner_.IsAuthorizedController(file.remote_id))) {
|
||||||
|
auto callback = events->EnterCallback();
|
||||||
|
if (callback && events->DataAllowed()) {
|
||||||
|
// A queued browser file already reserved a destination on the peer.
|
||||||
|
// Release that reservation even though no data chunk was ever sent.
|
||||||
|
const auto packet = EncodeFileCancel(id, 0);
|
||||||
|
const auto wire_id = events->WireId(file.remote_id);
|
||||||
|
SendReliableDataFrameToPeer(peer, packet.data(), packet.size(),
|
||||||
|
file.file_label.c_str(), wire_id.data(), wire_id.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
if (started) {
|
if (started) {
|
||||||
do {
|
do {
|
||||||
{
|
{
|
||||||
@@ -288,6 +321,7 @@ void FileTransferManager::UpdateReceiveProgress(
|
|||||||
info.file_id = file_transfer::Session::NextFileId();
|
info.file_id = file_transfer::Session::NextFileId();
|
||||||
info.remote_id = remote_id;
|
info.remote_id = remote_id;
|
||||||
info.receiving = true;
|
info.receiving = true;
|
||||||
|
info.wire_file_id = progress.id;
|
||||||
receive_rate_samples_.emplace(info.file_id,
|
receive_rate_samples_.emplace(info.file_id,
|
||||||
ReceiveRateSample{std::chrono::steady_clock::now(), 0});
|
ReceiveRateSample{std::chrono::steady_clock::now(), 0});
|
||||||
found = notifications.emplace(progress.id, files.size()).first;
|
found = notifications.emplace(progress.id, files.size()).first;
|
||||||
@@ -310,7 +344,7 @@ void FileTransferManager::UpdateReceiveProgress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void FileTransferManager::Receive(const char* data, size_t size,
|
std::shared_ptr<FileReceiver> FileTransferManager::ReceiverFor(
|
||||||
PeerEventHandler* handler, const std::string& remote) {
|
PeerEventHandler* handler, const std::string& remote) {
|
||||||
const auto props = owner_.FindRemoteSession(remote);
|
const auto props = owner_.FindRemoteSession(remote);
|
||||||
PeerPtr* peer;
|
PeerPtr* peer;
|
||||||
@@ -324,14 +358,14 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
|||||||
events = owner_.ControllerEvents(remote);
|
events = owner_.ControllerEvents(remote);
|
||||||
peer = events ? events->DirectPeer() : nullptr;
|
peer = events ? events->DirectPeer() : nullptr;
|
||||||
}
|
}
|
||||||
if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
|
if (!peer || !events || events.get() != handler || (props && props->closing_)) return {};
|
||||||
std::shared_ptr<FileReceiver> receiver;
|
std::shared_ptr<FileReceiver> receiver;
|
||||||
std::filesystem::path default_directory;
|
std::filesystem::path default_directory;
|
||||||
{
|
{
|
||||||
std::lock_guard lock(sessions_mutex_);
|
std::lock_guard lock(sessions_mutex_);
|
||||||
const auto closed = disconnected_.find(handler);
|
const auto closed = disconnected_.find(handler);
|
||||||
if (stopping_ || (closed != disconnected_.end() &&
|
if (stopping_ || (closed != disconnected_.end() &&
|
||||||
(closed->second.count("") || closed->second.count(remote)))) return;
|
(closed->second.count("") || closed->second.count(remote)))) return {};
|
||||||
auto& entry = receivers_[handler][remote];
|
auto& entry = receivers_[handler][remote];
|
||||||
if (!entry.session) {
|
if (!entry.session) {
|
||||||
entry.session = std::make_shared<FileReceiver>(); entry.handler = events;
|
entry.session = std::make_shared<FileReceiver>(); entry.handler = events;
|
||||||
@@ -358,7 +392,12 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
|||||||
}
|
}
|
||||||
const auto configured = owner_.config_center_->GetFileTransferSavePath();
|
const auto configured = owner_.config_center_->GetFileTransferSavePath();
|
||||||
receiver->SetOutputDir(configured.empty() ? default_directory : PathFromUtf8(configured));
|
receiver->SetOutputDir(configured.empty() ? default_directory : PathFromUtf8(configured));
|
||||||
receiver->OnData(data, size);
|
return receiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileTransferManager::Receive(const char* data, size_t size,
|
||||||
|
PeerEventHandler* handler, const std::string& remote) {
|
||||||
|
if (auto receiver = ReceiverFor(handler, remote)) receiver->OnData(data, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FileTransferManager::Connected(PeerEventHandler* handler, const std::string& remote) {
|
void FileTransferManager::Connected(PeerEventHandler* handler, const std::string& remote) {
|
||||||
@@ -371,6 +410,17 @@ void FileTransferManager::Connected(PeerEventHandler* handler, const std::string
|
|||||||
}
|
}
|
||||||
|
|
||||||
void FileTransferManager::Cancel(PeerEventHandler* handler, const std::string& remote) {
|
void FileTransferManager::Cancel(PeerEventHandler* handler, const std::string& remote) {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(browser_mutex_);
|
||||||
|
for (auto it = browser_peers_.begin(); it != browser_peers_.end();) {
|
||||||
|
if (it->second.get() == handler && (remote.empty() || it->first == remote)) {
|
||||||
|
browser_.Disconnect(it->first);
|
||||||
|
browser_opened_.erase(it->first);
|
||||||
|
browser_supported_.erase(it->first);
|
||||||
|
it = browser_peers_.erase(it);
|
||||||
|
} else ++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
std::lock_guard lock(sessions_mutex_);
|
std::lock_guard lock(sessions_mutex_);
|
||||||
disconnected_[handler].insert(remote);
|
disconnected_[handler].insert(remote);
|
||||||
for (auto& [id, sender] : senders_)
|
for (auto& [id, sender] : senders_)
|
||||||
@@ -411,4 +461,129 @@ void FileTransferManager::Poll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FileTransferManager::SetBrowserSupport(PeerEventHandler* handler,
|
||||||
|
const std::string& remote, bool supported) {
|
||||||
|
const auto props = owner_.FindRemoteSession(remote);
|
||||||
|
const auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
|
||||||
|
if (!events || events.get() != handler || !events->DataAllowed()) return;
|
||||||
|
std::lock_guard lock(browser_mutex_);
|
||||||
|
if (const auto previous = browser_peers_.find(remote);
|
||||||
|
previous != browser_peers_.end() && previous->second != events) {
|
||||||
|
browser_.Disconnect(remote);
|
||||||
|
browser_opened_.erase(remote);
|
||||||
|
}
|
||||||
|
browser_peers_[remote] = events;
|
||||||
|
if (supported) browser_supported_.insert(remote);
|
||||||
|
else browser_supported_.erase(remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileTransferManager::BrowserSupported(const std::string& remote) {
|
||||||
|
std::lock_guard lock(browser_mutex_);
|
||||||
|
return browser_supported_.count(remote) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileTransferManager::OpenBrowser(const std::string& remote) {
|
||||||
|
auto props = owner_.FindRemoteSession(remote);
|
||||||
|
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
|
||||||
|
if (!events || !events->DataAllowed() || owner_.unattended_host_ ||
|
||||||
|
(props ? props->closing_ || !props->connection_established_
|
||||||
|
: !owner_.IsAuthorizedController(remote))) return false;
|
||||||
|
std::lock_guard lock(browser_mutex_);
|
||||||
|
if (const auto previous = browser_peers_.find(remote);
|
||||||
|
previous != browser_peers_.end() && previous->second != events) {
|
||||||
|
browser_.Disconnect(remote);
|
||||||
|
browser_opened_.erase(remote);
|
||||||
|
browser_supported_.erase(remote);
|
||||||
|
}
|
||||||
|
browser_peers_[remote] = events;
|
||||||
|
// Opening the legacy file picker authorizes a send, not disk browsing.
|
||||||
|
if (browser_supported_.count(remote)) browser_opened_.insert(remote);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileTransferManager::CancelBrowserTransfer(const std::string& remote, uint32_t file_id) {
|
||||||
|
for (const auto& info : BrowserTransfers(remote)) {
|
||||||
|
if (info.file_id != file_id) continue;
|
||||||
|
if (info.receiving) browser_.CancelTransfer(remote, info.wire_file_id);
|
||||||
|
else CancelSend(file_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileTransferManager::BrowserConnected(const std::string& remote) {
|
||||||
|
auto props = owner_.FindRemoteSession(remote);
|
||||||
|
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
|
||||||
|
if (!events || !events->DataAllowed() || owner_.unattended_host_ ||
|
||||||
|
(props ? props->closing_ || !props->connection_established_
|
||||||
|
: !owner_.IsAuthorizedController(remote))) return false;
|
||||||
|
std::lock_guard lock(browser_mutex_);
|
||||||
|
const auto it = browser_peers_.find(remote);
|
||||||
|
return it != browser_peers_.end() && it->second == events;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileTransferManager::SendBrowser(const std::string& remote, const FileBrowser::Json& message) {
|
||||||
|
if (!BrowserConnected(remote)) return false;
|
||||||
|
auto props = owner_.FindRemoteSession(remote);
|
||||||
|
std::shared_ptr<PeerEventHandler> events;
|
||||||
|
{ std::lock_guard lock(browser_mutex_); events = browser_peers_[remote]; }
|
||||||
|
if (!events) return false;
|
||||||
|
auto callback = events->EnterCallback();
|
||||||
|
if (!callback || !events->DataAllowed()) return false;
|
||||||
|
PeerPtr* peer = events->IsDirect() ? events->DirectPeer() : props ? props->peer_ : owner_.peer_;
|
||||||
|
const auto data = message.dump(-1, ' ', false, FileBrowser::Json::error_handler_t::replace);
|
||||||
|
if (!peer || data.size() > 60000) return false;
|
||||||
|
const auto wire_id = events->WireId(remote);
|
||||||
|
return SendReliableDataFrameToPeer(peer, data.data(), data.size(),
|
||||||
|
owner_.control_data_label_.c_str(), wire_id.data(), wire_id.size()) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileTransferManager::HandleBrowser(const char* data, size_t size,
|
||||||
|
PeerEventHandler* handler, const std::string& remote) {
|
||||||
|
if (!data || size > 60000 || std::string_view(data, size).find("file_browser") == std::string_view::npos)
|
||||||
|
return false;
|
||||||
|
auto message = FileBrowser::Json::parse(data, data + size, nullptr, false);
|
||||||
|
if (!message.is_object() || message.value("type", FileBrowser::Json{}) != "file_browser") return false;
|
||||||
|
auto props = owner_.FindRemoteSession(remote);
|
||||||
|
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
|
||||||
|
if (owner_.unattended_host_ || !events || events.get() != handler || !events->DataAllowed() ||
|
||||||
|
(props ? props->closing_ || !props->connection_established_
|
||||||
|
: !owner_.IsAuthorizedController(remote))) return true;
|
||||||
|
bool opened;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(browser_mutex_);
|
||||||
|
browser_peers_[remote] = events;
|
||||||
|
if (message.value("op", FileBrowser::Json{}) == "presentation") {
|
||||||
|
// Older browser builds synchronized window visibility. Ignore that
|
||||||
|
// optional event: progress now belongs exclusively to the local sender.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
opened = browser_opened_.count(remote);
|
||||||
|
}
|
||||||
|
// A controlled computer cannot browse the controller's disk until that
|
||||||
|
// controller explicitly opens this peer's file manager in this connection.
|
||||||
|
const auto op = message.value("op", FileBrowser::Json{});
|
||||||
|
if (props && !opened && op != "reply") {
|
||||||
|
if (message.contains("id")) SendBrowser(remote,
|
||||||
|
{{"op", "reply"}, {"id", message["id"]}, {"type", "file_browser"},
|
||||||
|
{"version", 1}, {"ok", false}, {"error", "browser_not_open"}});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
browser_.Handle(remote, std::move(message));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<FileTransferManager::FileTransferState::FileTransferInfo>
|
||||||
|
FileTransferManager::BrowserTransfers(const std::string& remote) {
|
||||||
|
std::vector<FileTransferState::FileTransferInfo> result;
|
||||||
|
const auto collect = [&](auto& state) {
|
||||||
|
std::lock_guard lock(state.file_transfer_list_mutex_);
|
||||||
|
for (const auto& info : state.file_transfer_list_)
|
||||||
|
if (info.remote_id == remote) result.push_back(info);
|
||||||
|
};
|
||||||
|
collect(global_state_); collect(received_state_);
|
||||||
|
if (auto props = owner_.FindRemoteSession(remote)) collect(props->file_transfer_);
|
||||||
|
std::sort(result.begin(), result.end(), [](const auto& a, const auto& b) { return a.file_id > b.file_id; });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "file_transfer.h"
|
#include "file_transfer.h"
|
||||||
|
#include "features/file_transfer/file_browser.h"
|
||||||
#include "runtime/remote_session.h"
|
#include "runtime/remote_session.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
@@ -40,6 +41,15 @@ class FileTransferManager {
|
|||||||
bool CancelSend(uint32_t file_id);
|
bool CancelSend(uint32_t file_id);
|
||||||
void Poll();
|
void Poll();
|
||||||
void Shutdown();
|
void Shutdown();
|
||||||
|
FileBrowser& browser() { return browser_; }
|
||||||
|
bool OpenBrowser(const std::string& remote);
|
||||||
|
void SetBrowserSupport(PeerEventHandler* handler, const std::string& remote, bool supported);
|
||||||
|
bool BrowserSupported(const std::string& remote);
|
||||||
|
bool BrowserConnected(const std::string& remote);
|
||||||
|
bool HandleBrowser(const char* data, size_t size, PeerEventHandler* handler,
|
||||||
|
const std::string& remote);
|
||||||
|
std::vector<FileTransferState::FileTransferInfo> BrowserTransfers(const std::string& remote);
|
||||||
|
void CancelBrowserTransfer(const std::string& remote, uint32_t file_id);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Sender {
|
struct Sender {
|
||||||
@@ -53,6 +63,8 @@ class FileTransferManager {
|
|||||||
std::filesystem::path default_directory;
|
std::filesystem::path default_directory;
|
||||||
};
|
};
|
||||||
void ProcessQueue(std::shared_ptr<RemoteSession> props);
|
void ProcessQueue(std::shared_ptr<RemoteSession> props);
|
||||||
|
std::shared_ptr<FileReceiver> ReceiverFor(PeerEventHandler* handler, const std::string& remote);
|
||||||
|
bool SendBrowser(const std::string& remote, const FileBrowser::Json& message);
|
||||||
void UpdateProgress(FileTransferState& state,
|
void UpdateProgress(FileTransferState& state,
|
||||||
const file_transfer::SendState& progress,
|
const file_transfer::SendState& progress,
|
||||||
std::chrono::steady_clock::time_point now);
|
std::chrono::steady_clock::time_point now);
|
||||||
@@ -76,6 +88,11 @@ class FileTransferManager {
|
|||||||
FileTransferState received_state_;
|
FileTransferState received_state_;
|
||||||
// Guarded by received_state_.file_transfer_list_mutex_.
|
// Guarded by received_state_.file_transfer_list_mutex_.
|
||||||
std::unordered_map<uint32_t, ReceiveRateSample> receive_rate_samples_;
|
std::unordered_map<uint32_t, ReceiveRateSample> receive_rate_samples_;
|
||||||
|
std::mutex browser_mutex_;
|
||||||
|
std::map<std::string, std::shared_ptr<PeerEventHandler>> browser_peers_;
|
||||||
|
std::set<std::string> browser_opened_;
|
||||||
|
std::set<std::string> browser_supported_;
|
||||||
|
FileBrowser browser_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-11-05
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _TRANSFER_NOTIFICATION_STATE_H_
|
||||||
|
#define _TRANSFER_NOTIFICATION_STATE_H_
|
||||||
|
|
||||||
|
namespace crossdesk::transfer_notification {
|
||||||
|
|
||||||
|
struct Presentation {
|
||||||
|
bool dismissed = false;
|
||||||
|
bool result_seen = false;
|
||||||
|
|
||||||
|
bool ShowNotification(bool browser_visible, bool result_presented) {
|
||||||
|
// Hiding an active transfer is temporary. A result already presented in
|
||||||
|
// the browser must not pop up again when that window closes.
|
||||||
|
result_seen = result_seen || result_presented;
|
||||||
|
return !dismissed && !result_seen && !browser_visible;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace crossdesk::transfer_notification
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -88,6 +88,11 @@ struct SlintVideoPresenter::Impl {
|
|||||||
VideoRenderSize gl_pending_size;
|
VideoRenderSize gl_pending_size;
|
||||||
bool gl_pending_dirty = false;
|
bool gl_pending_dirty = false;
|
||||||
VideoLatencyFrame gl_pending_timing;
|
VideoLatencyFrame gl_pending_timing;
|
||||||
|
bool gl_use_pixel_buffers = false;
|
||||||
|
VideoRenderer::RenderOutcome gl_last_outcome;
|
||||||
|
std::string gl_rendered_stream;
|
||||||
|
std::string gl_requested_stream;
|
||||||
|
uint64_t gl_requested_sequence = 0;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
SurfaceState CurrentState() const {
|
SurfaceState CurrentState() const {
|
||||||
@@ -125,6 +130,13 @@ struct SlintVideoPresenter::Impl {
|
|||||||
auto* opengl_renderer =
|
auto* opengl_renderer =
|
||||||
dynamic_cast<OpenGlVideoRenderer*>(&renderer);
|
dynamic_cast<OpenGlVideoRenderer*>(&renderer);
|
||||||
if (state == slint::RenderingState::RenderingSetup) {
|
if (state == slint::RenderingState::RenderingSetup) {
|
||||||
|
gl_use_pixel_buffers = false;
|
||||||
|
gl_last_outcome = {};
|
||||||
|
gl_rendered_stream.clear();
|
||||||
|
gl_requested_stream.clear();
|
||||||
|
gl_requested_sequence = 0;
|
||||||
|
displayed_frame_sequence.clear();
|
||||||
|
seeded_frame_sequence.clear();
|
||||||
GLuint texture = 0;
|
GLuint texture = 0;
|
||||||
glGenTextures(1, &texture);
|
glGenTextures(1, &texture);
|
||||||
if (texture == 0) {
|
if (texture == 0) {
|
||||||
@@ -165,9 +177,28 @@ struct SlintVideoPresenter::Impl {
|
|||||||
rounded_window ? static_cast<int>(std::lround(
|
rounded_window ? static_cast<int>(std::lround(
|
||||||
kStreamWindowCornerRadius * scale_factor))
|
kStreamWindowCornerRadius * scale_factor))
|
||||||
: 0;
|
: 0;
|
||||||
opengl_renderer->RenderLatest(
|
const auto outcome = opengl_renderer->RenderLatest(
|
||||||
surface.selected_stream, static_cast<int>(window_size.width),
|
surface.selected_stream, static_cast<int>(window_size.width),
|
||||||
static_cast<int>(window_size.height), top_inset, corner_radius);
|
static_cast<int>(window_size.height), top_inset, corner_radius);
|
||||||
|
if (outcome.result == VideoRenderer::RenderResult::idle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome.result == VideoRenderer::RenderResult::failed) {
|
||||||
|
// RenderLatest stops native submissions. Apply the UI fallback
|
||||||
|
// on the next UI tick, outside Slint's rendering callback.
|
||||||
|
gl_use_pixel_buffers = true;
|
||||||
|
LOG_WARN("Switching Slint video presentation to pixel buffers");
|
||||||
|
}
|
||||||
|
const bool changed = gl_rendered_stream != surface.selected_stream ||
|
||||||
|
gl_last_outcome.result != outcome.result ||
|
||||||
|
gl_last_outcome.sequence != outcome.sequence ||
|
||||||
|
gl_last_outcome.width != outcome.width ||
|
||||||
|
gl_last_outcome.height != outcome.height;
|
||||||
|
gl_last_outcome = outcome;
|
||||||
|
gl_rendered_stream = surface.selected_stream;
|
||||||
|
if (changed) {
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +242,8 @@ struct SlintVideoPresenter::Impl {
|
|||||||
gl_pending_size = {};
|
gl_pending_size = {};
|
||||||
gl_pending_dirty = false;
|
gl_pending_dirty = false;
|
||||||
gl_pending_frame.clear();
|
gl_pending_frame.clear();
|
||||||
|
gl_last_outcome = {};
|
||||||
|
gl_rendered_stream.clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,6 +272,7 @@ void SlintVideoPresenter::PrepareWindow(
|
|||||||
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
|
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
|
||||||
impl_->skip_attachment_once = true;
|
impl_->skip_attachment_once = true;
|
||||||
#elif !defined(__OHOS__)
|
#elif !defined(__OHOS__)
|
||||||
|
stream->set_native_video_enabled(false);
|
||||||
impl_->ConfigureOpenGlNotifier();
|
impl_->ConfigureOpenGlNotifier();
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -313,7 +347,12 @@ bool SlintVideoPresenter::SelectStream(std::string remote_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||||
const Frame& frame) {
|
const Frame& incoming_frame) {
|
||||||
|
#if !defined(__APPLE__) && !defined(__OHOS__)
|
||||||
|
Frame frame = incoming_frame;
|
||||||
|
#else
|
||||||
|
const Frame& frame = incoming_frame;
|
||||||
|
#endif
|
||||||
PresentResult result;
|
PresentResult result;
|
||||||
if (!impl_->stream || frame.remote_id.empty()) {
|
if (!impl_->stream || frame.remote_id.empty()) {
|
||||||
return result;
|
return result;
|
||||||
@@ -355,25 +394,54 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
|||||||
auto* opengl_renderer =
|
auto* opengl_renderer =
|
||||||
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
|
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
|
||||||
if (opengl_renderer && opengl_renderer->IsReady()) {
|
if (opengl_renderer && opengl_renderer->IsReady()) {
|
||||||
opengl_renderer->SetSelectedStream(frame.remote_id);
|
bool needs_redraw = opengl_renderer->SetSelectedStream(frame.remote_id);
|
||||||
if (!(*impl_->stream)->get_native_video_enabled()) {
|
if (!(*impl_->stream)->get_native_video_enabled()) {
|
||||||
(*impl_->stream)->set_native_video_enabled(true);
|
(*impl_->stream)->set_native_video_enabled(true);
|
||||||
|
needs_redraw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VideoRenderer::RenderOutcome outcome;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(impl_->gl_mutex);
|
||||||
|
if (impl_->gl_rendered_stream == frame.remote_id) {
|
||||||
|
outcome = impl_->gl_last_outcome;
|
||||||
|
}
|
||||||
|
}
|
||||||
const bool has_frame =
|
const bool has_frame =
|
||||||
frame.width > 0 && frame.height > 0 && frame.sequence > 0;
|
outcome.result == VideoRenderer::RenderResult::rendered;
|
||||||
(*impl_->stream)->set_has_frame(has_frame);
|
(*impl_->stream)->set_has_frame(has_frame);
|
||||||
if (has_frame) {
|
if (has_frame) {
|
||||||
(*impl_->stream)->set_receiving_text("");
|
(*impl_->stream)->set_receiving_text("");
|
||||||
result.width = frame.width;
|
result.width = outcome.width;
|
||||||
result.height = frame.height;
|
result.height = outcome.height;
|
||||||
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence);
|
result.new_frame = impl_->MarkPresented(frame.remote_id, outcome.sequence);
|
||||||
}
|
}
|
||||||
|
// A rendering acknowledgement also schedules Present(). Do not draw the
|
||||||
|
// same video again solely to acknowledge it, especially for low-FPS peers.
|
||||||
|
if (needs_redraw || impl_->gl_requested_stream != frame.remote_id ||
|
||||||
|
impl_->gl_requested_sequence != frame.sequence) {
|
||||||
|
impl_->gl_requested_stream = frame.remote_id;
|
||||||
|
impl_->gl_requested_sequence = frame.sequence;
|
||||||
(*impl_->stream)->window().request_redraw();
|
(*impl_->stream)->window().request_redraw();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
if ((*impl_->stream)->get_native_video_enabled()) {
|
if ((*impl_->stream)->get_native_video_enabled()) {
|
||||||
(*impl_->stream)->set_native_video_enabled(false);
|
(*impl_->stream)->set_native_video_enabled(false);
|
||||||
|
(*impl_->stream)->set_has_frame(false);
|
||||||
|
(*impl_->stream)->set_frame(slint::Image{});
|
||||||
|
impl_->displayed_frame_sequence.clear();
|
||||||
|
impl_->seeded_frame_sequence.clear();
|
||||||
|
}
|
||||||
|
if (!frame.nv12 && impl_->gl_use_pixel_buffers) {
|
||||||
|
// Native submissions intentionally release the session's CPU buffers.
|
||||||
|
// Recover the retained frame so even a static remote desktop can switch
|
||||||
|
// immediately; subsequent decode callbacks will supply CPU frames.
|
||||||
|
auto pixels = std::make_shared<std::vector<unsigned char>>();
|
||||||
|
if (impl_->renderer.CopyLatestNv12(frame.remote_id, pixels.get(),
|
||||||
|
&frame.width, &frame.height)) {
|
||||||
|
frame.nv12 = std::move(pixels);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -423,7 +491,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
|||||||
uint32_t texture_id = 0;
|
uint32_t texture_id = 0;
|
||||||
{
|
{
|
||||||
std::lock_guard lock(impl_->gl_mutex);
|
std::lock_guard lock(impl_->gl_mutex);
|
||||||
texture_id = impl_->gl_texture;
|
texture_id = impl_->gl_use_pixel_buffers ? 0 : impl_->gl_texture;
|
||||||
}
|
}
|
||||||
if (texture_id != 0) {
|
if (texture_id != 0) {
|
||||||
impl_->gl_conversion_frame.resize(static_cast<size_t>(output_width) *
|
impl_->gl_conversion_frame.resize(static_cast<size_t>(output_width) *
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
source_id == runtime->clipboard_label_ ||
|
source_id == runtime->clipboard_label_ ||
|
||||||
source_id == web_clipboard::kStream))
|
source_id == web_clipboard::kStream))
|
||||||
return;
|
return;
|
||||||
|
if (source_id == runtime->control_data_label_ &&
|
||||||
|
runtime->transfers_.HandleBrowser(data, size, handler, remote_id)) return;
|
||||||
if (source_id == web_clipboard::kStream) {
|
if (source_id == web_clipboard::kStream) {
|
||||||
runtime->clipboard_.ReceiveWeb(
|
runtime->clipboard_.ReceiveWeb(
|
||||||
data, size, remote_id, handler);
|
data, size, remote_id, handler);
|
||||||
@@ -424,6 +426,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (remote_action.type == ControlType::host_infomation) {
|
if (remote_action.type == ControlType::host_infomation) {
|
||||||
|
if (source_id == runtime->control_data_label_) {
|
||||||
|
runtime->transfers_.SetBrowserSupport(handler, remote_id,
|
||||||
|
remote_action.i.supports_file_browser);
|
||||||
|
}
|
||||||
bool is_client_mode = false;
|
bool is_client_mode = false;
|
||||||
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
remote_action.i.supports_deskhub_resolution = true;
|
remote_action.i.supports_deskhub_resolution = true;
|
||||||
remote_action.i.supports_deskhub_fps15 = true;
|
remote_action.i.supports_deskhub_fps15 = true;
|
||||||
remote_action.i.supports_deskhub_bandwidth = true;
|
remote_action.i.supports_deskhub_bandwidth = true;
|
||||||
|
remote_action.i.supports_file_browser = !runtime->unattended_host_;
|
||||||
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
||||||
LOG_INFO("Local display [{}:{}]", i + 1,
|
LOG_INFO("Local display [{}:{}]", i + 1,
|
||||||
remote_action.i.display_list[i]);
|
remote_action.i.display_list[i]);
|
||||||
@@ -528,6 +529,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
|||||||
remote_action.i.supports_deskhub_resolution = true;
|
remote_action.i.supports_deskhub_resolution = true;
|
||||||
remote_action.i.supports_deskhub_fps15 = true;
|
remote_action.i.supports_deskhub_fps15 = true;
|
||||||
remote_action.i.supports_deskhub_bandwidth = true;
|
remote_action.i.supports_deskhub_bandwidth = true;
|
||||||
|
remote_action.i.supports_file_browser = !runtime->unattended_host_;
|
||||||
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
||||||
LOG_INFO("Local display [{}:{}]", i + 1,
|
LOG_INFO("Local display [{}:{}]", i + 1,
|
||||||
remote_action.i.display_list[i]);
|
remote_action.i.display_list[i]);
|
||||||
|
|||||||
@@ -183,9 +183,11 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (submit_result == VideoRenderer::SubmitResult::dropped ||
|
if (submit_result == VideoRenderer::SubmitResult::dropped ||
|
||||||
submit_result == VideoRenderer::SubmitResult::failed) {
|
(submit_result == VideoRenderer::SubmitResult::failed &&
|
||||||
|
native_renderer->IsActive())) {
|
||||||
// Keep presenting the last native frame. A later decoded frame can
|
// Keep presenting the last native frame. A later decoded frame can
|
||||||
// reuse the renderer without changing ownership mid-window.
|
// reuse an active renderer. If rendering disabled it concurrently,
|
||||||
|
// continue below to materialize this frame for the CPU fallback.
|
||||||
props->streaming_ = true;
|
props->streaming_ = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ struct FileTransferState {
|
|||||||
uint32_t file_id = 0;
|
uint32_t file_id = 0;
|
||||||
uint32_t rate_bps = 0;
|
uint32_t rate_bps = 0;
|
||||||
bool receiving = false;
|
bool receiving = false;
|
||||||
|
uint32_t wire_file_id = 0;
|
||||||
};
|
};
|
||||||
std::vector<FileTransferInfo> file_transfer_list_;
|
std::vector<FileTransferInfo> file_transfer_list_;
|
||||||
std::mutex file_transfer_list_mutex_;
|
std::mutex file_transfer_list_mutex_;
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export global FontAwesomeIcons {
|
|||||||
out property <string> angle-right: "\u{f105}";
|
out property <string> angle-right: "\u{f105}";
|
||||||
out property <string> angle-up: "\u{f106}";
|
out property <string> angle-up: "\u{f106}";
|
||||||
out property <string> arrow-right-long: "\u{f178}";
|
out property <string> arrow-right-long: "\u{f178}";
|
||||||
|
out property <string> arrow-up: "\u{f062}";
|
||||||
out property <string> arrow-up-right-from-square: "\u{f35d}";
|
out property <string> arrow-up-right-from-square: "\u{f35d}";
|
||||||
out property <string> bars: "\u{f0c9}";
|
out property <string> bars: "\u{f0c9}";
|
||||||
out property <string> check: "\u{f00c}";
|
out property <string> check: "\u{f00c}";
|
||||||
|
|||||||
@@ -14,3 +14,5 @@ export { GalleryNetworkDevice } from "gallery_network_dialog.slint";
|
|||||||
export { DeviceGroupOption } from "device_group_dialog.slint";
|
export { DeviceGroupOption } from "device_group_dialog.slint";
|
||||||
|
|
||||||
export { TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint";
|
export { TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint";
|
||||||
|
|
||||||
|
export { FileBrowserWindow, BrowserEntry, BrowserTreeEntry, BrowserTask } from "file_browser.slint";
|
||||||
|
|||||||
@@ -0,0 +1,836 @@
|
|||||||
|
import { ScrollView, ListView, Spinner } from "std-widgets.slint";
|
||||||
|
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard } from "workbench_controls.slint";
|
||||||
|
import { FlatSurface, UiSurface, SurfaceSeparator } from "ui_surface.slint";
|
||||||
|
import { UiRadius } from "ui_radius.slint";
|
||||||
|
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
||||||
|
import { FontAwesomeIcons, ControlStyle, ImGuiFontStyle } from "common.slint";
|
||||||
|
|
||||||
|
export struct BrowserEntry {
|
||||||
|
name: string, path: string, directory: bool, selected: bool,
|
||||||
|
size: string, modified: string, link: bool,
|
||||||
|
}
|
||||||
|
export struct BrowserTreeEntry {
|
||||||
|
name: string, path: string, depth: int, expanded: bool, selected: bool,
|
||||||
|
}
|
||||||
|
export struct BrowserTask {
|
||||||
|
id: int, name: string, size: string, detail: string, status: string,
|
||||||
|
progress: float, active: bool, failed: bool, can-cancel: bool, receiving: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
component BrowserButton inherits WorkbenchButton {
|
||||||
|
mouse-cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
component BrowserScrollView inherits Rectangle {
|
||||||
|
// Fluent overlays a 14px scrollbar on its viewport. Keep a 4px gap between
|
||||||
|
// that track and row content, including while the scrollbar expands on hover.
|
||||||
|
out property <length> content-gutter: 18px;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
vertical-stretch: 1;
|
||||||
|
clip: true;
|
||||||
|
ScrollView {
|
||||||
|
x: 0px; y: 0px; width: root.width; height: root.height;
|
||||||
|
viewport-width: self.visible-width;
|
||||||
|
horizontal-scrollbar-policy: always-off;
|
||||||
|
@children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the visual compact while retaining a generous pointer target.
|
||||||
|
// Selection stays model-owned so refreshing rows cannot toggle it accidentally.
|
||||||
|
component BrowserCheckBox inherits Rectangle {
|
||||||
|
in property <bool> checked;
|
||||||
|
in property <bool> indeterminate;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
in property <string> label;
|
||||||
|
in property <bool> show-label: false;
|
||||||
|
private property <bool> keyboard-focus: false;
|
||||||
|
callback toggled;
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
forward-focus: focus;
|
||||||
|
accessible-role: checkbox;
|
||||||
|
accessible-label: root.label;
|
||||||
|
accessible-checked: root.checked;
|
||||||
|
accessible-enabled: root.enabled;
|
||||||
|
accessible-action-default => { if root.enabled { root.toggled(); } }
|
||||||
|
box := FlatSurface {
|
||||||
|
x: 4px; y: (parent.height - self.height) / 2; width: 16px; height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: !root.enabled ? #f4f6f9
|
||||||
|
: touch.pressed ? #dce7fb
|
||||||
|
: root.checked || root.indeterminate ? (touch.has-hover ? #dfe9fc : #edf2fc)
|
||||||
|
: touch.has-hover ? #f5f8fe : white;
|
||||||
|
outline-color: !root.enabled ? #dce2eb
|
||||||
|
: touch.has-hover || touch.pressed ? #7d9edb
|
||||||
|
: root.checked || root.indeterminate ? #a6bce6 : #b7c3d4;
|
||||||
|
animate background, outline-color { duration: 100ms; }
|
||||||
|
if root.checked && !root.indeterminate: Path {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
viewbox-width: 16; viewbox-height: 16;
|
||||||
|
commands: "M 4 8 L 6.75 10.75 L 12 5.5";
|
||||||
|
stroke: root.enabled ? WorkbenchStyle.accent : #aab5c6;
|
||||||
|
stroke-width: 1.7px; stroke-line-cap: round; stroke-line-join: round;
|
||||||
|
}
|
||||||
|
if root.indeterminate: Rectangle {
|
||||||
|
x: 4px; y: 7px; width: 8px; height: 2px; border-radius: 1px;
|
||||||
|
background: root.enabled ? WorkbenchStyle.accent : #aab5c6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.show-label: Text {
|
||||||
|
x: 30px; width: parent.width - self.x; height: parent.height;
|
||||||
|
text: root.label; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.base;
|
||||||
|
vertical-alignment: center; overflow: elide; accessible-role: none;
|
||||||
|
}
|
||||||
|
if root.keyboard-focus && focus.has-focus: FlatSurface {
|
||||||
|
x: 1px; y: box.y - 3px; width: 22px; height: 22px;
|
||||||
|
border-radius: 6px; outline-color: UiSurface.focus;
|
||||||
|
}
|
||||||
|
focus := FocusScope {
|
||||||
|
enabled: root.enabled;
|
||||||
|
focus-gained(reason) => { root.keyboard-focus = reason == FocusReason.tab-navigation; }
|
||||||
|
focus-lost => { root.keyboard-focus = false; }
|
||||||
|
key-pressed(event) => {
|
||||||
|
if event.text == " " || event.text == Key.Return {
|
||||||
|
root.keyboard-focus = true; root.toggled(); return accept;
|
||||||
|
}
|
||||||
|
reject
|
||||||
|
}
|
||||||
|
touch := TouchArea {
|
||||||
|
enabled: root.enabled; mouse-cursor: default;
|
||||||
|
pointer-event(event) => {
|
||||||
|
if event.kind == PointerEventKind.down { root.keyboard-focus = false; }
|
||||||
|
}
|
||||||
|
clicked => { focus.focus(); root.keyboard-focus = false; root.toggled(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component BrowserSortHeader inherits FlatSurface {
|
||||||
|
in property <string> text;
|
||||||
|
in property <bool> chinese;
|
||||||
|
in property <bool> active;
|
||||||
|
in property <bool> descending;
|
||||||
|
in property <bool> default-descending;
|
||||||
|
in property <bool> right-aligned;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
private property <bool> keyboard-focus: false;
|
||||||
|
private property <bool> next-descending: root.active ? !root.descending : root.default-descending;
|
||||||
|
callback clicked;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: UiRadius.control;
|
||||||
|
outline-enabled: focus.has-focus && root.keyboard-focus;
|
||||||
|
outline-color: UiSurface.focus;
|
||||||
|
background: touch.pressed ? ControlStyle.pressed.with-alpha(0.3)
|
||||||
|
: touch.has-hover && root.enabled ? ControlStyle.hover.with-alpha(0.2) : transparent;
|
||||||
|
forward-focus: focus;
|
||||||
|
accessible-role: button;
|
||||||
|
accessible-enabled: root.enabled;
|
||||||
|
accessible-label: root.text + (root.active ? (root.descending ? (root.chinese ? ",降序" : ", descending") : (root.chinese ? ",升序" : ", ascending")) : "")
|
||||||
|
+ (root.next-descending ? (root.chinese ? ",点击按降序排列" : ", sort descending") : (root.chinese ? ",点击按升序排列" : ", sort ascending"));
|
||||||
|
accessible-action-default => { if root.enabled { root.clicked(); } }
|
||||||
|
label := Text {
|
||||||
|
x: root.right-aligned ? parent.width - self.width - (root.active ? 14px : 0px) : 0px;
|
||||||
|
width: min(self.preferred-width, parent.width - (root.active ? 14px : 0px));
|
||||||
|
height: parent.height;
|
||||||
|
text: root.text; font-size: ImGuiFontStyle.body; font-weight: root.active ? 600 : 400;
|
||||||
|
color: root.active ? WorkbenchStyle.text : WorkbenchStyle.muted;
|
||||||
|
vertical-alignment: center; overflow: elide; accessible-role: none;
|
||||||
|
}
|
||||||
|
if root.active: Text {
|
||||||
|
x: label.x + label.width + 4px; width: 10px; height: parent.height;
|
||||||
|
text: root.descending ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-up;
|
||||||
|
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px;
|
||||||
|
color: WorkbenchStyle.muted; vertical-alignment: center; accessible-role: none;
|
||||||
|
}
|
||||||
|
focus := FocusScope {
|
||||||
|
enabled: root.enabled;
|
||||||
|
focus-gained(reason) => { root.keyboard-focus = reason == FocusReason.tab-navigation; }
|
||||||
|
focus-lost => { root.keyboard-focus = false; }
|
||||||
|
key-pressed(event) => {
|
||||||
|
if event.text == " " || event.text == Key.Return {
|
||||||
|
root.keyboard-focus = true; root.clicked(); return accept;
|
||||||
|
}
|
||||||
|
reject
|
||||||
|
}
|
||||||
|
touch := TouchArea {
|
||||||
|
enabled: root.enabled; mouse-cursor: default;
|
||||||
|
pointer-event(event) => { if event.kind == PointerEventKind.down { root.keyboard-focus = false; } }
|
||||||
|
clicked => { focus.focus(); root.keyboard-focus = false; root.clicked(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component BrowserPane inherits WorkbenchCard {
|
||||||
|
in property <bool> chinese;
|
||||||
|
in property <string> open-directory-text;
|
||||||
|
in property <string> heading;
|
||||||
|
in property <string> subtitle;
|
||||||
|
in-out property <string> path;
|
||||||
|
in property <string> current-path;
|
||||||
|
in-out property <string> filter-text;
|
||||||
|
in property <string> status;
|
||||||
|
in property <string> error;
|
||||||
|
in property <bool> busy;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
in property <bool> can-up;
|
||||||
|
in property <bool> all-selected;
|
||||||
|
in property <bool> show-hidden;
|
||||||
|
in property <int> selected-count;
|
||||||
|
in property <bool> destination;
|
||||||
|
in property <bool> transfer-ready;
|
||||||
|
in property <int> selection-revision;
|
||||||
|
in property <int> drag-count;
|
||||||
|
in property <bool> dragging;
|
||||||
|
in property <bool> drop-hover;
|
||||||
|
in property <[BrowserEntry]> entries;
|
||||||
|
in property <[BrowserTreeEntry]> tree;
|
||||||
|
in property <int> sort-column;
|
||||||
|
in property <bool> sort-descending;
|
||||||
|
private property <length> modified-column-width: 72px;
|
||||||
|
private property <length> size-column-width: 56px;
|
||||||
|
outline-color: root.destination ? UiSurface.hover : UiSurface.edge;
|
||||||
|
callback navigate(string);
|
||||||
|
callback up;
|
||||||
|
callback select-entry(int);
|
||||||
|
callback open-entry(int);
|
||||||
|
callback open-directory(string);
|
||||||
|
callback select-all;
|
||||||
|
callback tree-open(int);
|
||||||
|
callback tree-toggle(int);
|
||||||
|
callback filter(string);
|
||||||
|
callback toggle-hidden;
|
||||||
|
callback sort(int);
|
||||||
|
pure callback drag-data(string, int) -> data-transfer;
|
||||||
|
callback drag-start(data-transfer, BrowserEntry);
|
||||||
|
callback drag-move(length, length);
|
||||||
|
callback drag-end;
|
||||||
|
callback drag-cancel;
|
||||||
|
public pure function contains-drop(x: length, y: length) -> bool {
|
||||||
|
return root.enabled && root.transfer-ready
|
||||||
|
&& x >= body.absolute-position.x && x < body.absolute-position.x + body.width
|
||||||
|
&& y >= body.absolute-position.y && y < body.absolute-position.y + body.height;
|
||||||
|
}
|
||||||
|
changed enabled => { if !root.enabled { directory-popup.close(); } }
|
||||||
|
changed dragging => { if root.dragging { directory-popup.close(); } }
|
||||||
|
changed current-path => { file-list.reset-scroll(); }
|
||||||
|
changed filter-text => { file-list.reset-scroll(); }
|
||||||
|
changed sort-column => { file-list.reset-scroll(); }
|
||||||
|
changed sort-descending => { file-list.reset-scroll(); }
|
||||||
|
VerticalLayout {
|
||||||
|
padding-left: 14px; padding-right: 14px;
|
||||||
|
padding-top: 12px; padding-bottom: 12px; spacing: 6px;
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 24px; spacing: 8px;
|
||||||
|
Text { text: root.heading; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.prominent; font-weight: 600; vertical-alignment: center; }
|
||||||
|
Text {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
text: root.destination ? (root.chinese ? "接收目录" : "Destination")
|
||||||
|
: root.selected-count > 0 ? (root.chinese ? "已选 " + root.selected-count + " 项" : root.selected-count + " selected") : root.subtitle;
|
||||||
|
color: root.destination ? WorkbenchStyle.accent : WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.body; horizontal-alignment: right; vertical-alignment: center; overflow: elide;
|
||||||
|
}
|
||||||
|
RoundedLineEdit {
|
||||||
|
width: min(164px, root.width * 0.4); height: 24px;
|
||||||
|
text <=> root.filter-text; tint-opacity: 0.15;
|
||||||
|
placeholder-text: root.chinese ? "筛选名称…" : "Filter by name…";
|
||||||
|
enabled: root.enabled && !root.busy && !root.dragging;
|
||||||
|
edited => { root.filter(self.text); }
|
||||||
|
accepted => { self.clear-focus(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 32px; spacing: 6px;
|
||||||
|
BrowserButton {
|
||||||
|
width: 32px; height: 32px; quiet: true; icon: FontAwesomeIcons.arrow-up;
|
||||||
|
tooltip: root.chinese ? "打开上一级目录" : "Open parent folder";
|
||||||
|
tooltip-above: true; tooltip-align-left: true;
|
||||||
|
accessible-label: self.tooltip;
|
||||||
|
enabled: root.can-up && !root.busy && root.enabled && !root.dragging;
|
||||||
|
clicked => { root.up(); }
|
||||||
|
}
|
||||||
|
path-focus := FocusScope {
|
||||||
|
horizontal-stretch: 1; height: 32px;
|
||||||
|
forward-focus: path-editor;
|
||||||
|
key-pressed(event) => {
|
||||||
|
if event.text == Key.Escape {
|
||||||
|
root.path = root.current-path;
|
||||||
|
path-editor.clear-focus();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
reject
|
||||||
|
}
|
||||||
|
path-editor := RoundedLineEdit {
|
||||||
|
width: parent.width; height: parent.height; text <=> root.path;
|
||||||
|
tint-opacity: 0.2;
|
||||||
|
enabled: root.enabled && !root.dragging;
|
||||||
|
placeholder-text: root.chinese ? "输入路径,按回车打开" : "Enter a folder path";
|
||||||
|
accepted => { root.navigate(self.text); self.clear-focus(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tree-trigger := BrowserButton {
|
||||||
|
width: 28px; height: 32px; quiet: true; icon: FontAwesomeIcons.folder-open;
|
||||||
|
enabled: root.enabled && !root.dragging;
|
||||||
|
accessible-label: root.chinese ? "浏览目录树" : "Browse folder tree";
|
||||||
|
clicked => { if directory-popup.is-open { directory-popup.close(); } else { directory-popup.show(); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := Rectangle {
|
||||||
|
vertical-stretch: 1;
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 0px;
|
||||||
|
Rectangle {
|
||||||
|
height: 28px; background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 6px; padding-right: 8px + file-list.content-gutter; spacing: 8px;
|
||||||
|
BrowserCheckBox {
|
||||||
|
width: 24px; height: 28px; checked: root.all-selected;
|
||||||
|
indeterminate: root.selected-count > 0 && !root.all-selected;
|
||||||
|
label: (root.chinese ? "选择全部" : "Select all")
|
||||||
|
+ (self.indeterminate ? (root.chinese ? ",部分已选" : ", partially selected") : "");
|
||||||
|
enabled: root.enabled && root.entries.length > 0 && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
toggled => { root.select-all(); }
|
||||||
|
}
|
||||||
|
BrowserSortHeader {
|
||||||
|
horizontal-stretch: 1; text: root.chinese ? "名称" : "Name"; chinese: root.chinese;
|
||||||
|
active: root.sort-column == 0; descending: root.sort-descending;
|
||||||
|
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
clicked => { root.sort(0); }
|
||||||
|
}
|
||||||
|
BrowserSortHeader {
|
||||||
|
width: root.modified-column-width; text: root.chinese ? "修改日期" : "Modified"; chinese: root.chinese;
|
||||||
|
active: root.sort-column == 1; descending: root.sort-descending; default-descending: true; right-aligned: true;
|
||||||
|
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
clicked => { root.sort(1); }
|
||||||
|
}
|
||||||
|
BrowserSortHeader {
|
||||||
|
width: root.size-column-width; text: root.chinese ? "大小" : "Size"; chinese: root.chinese;
|
||||||
|
active: root.sort-column == 2; descending: root.sort-descending; default-descending: true; right-aligned: true;
|
||||||
|
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
clicked => { root.sort(2); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file-list := Rectangle {
|
||||||
|
out property <length> content-gutter: 18px;
|
||||||
|
public function reset-scroll() { visible-files.viewport-y = 0px; }
|
||||||
|
vertical-stretch: 1;
|
||||||
|
clip: true;
|
||||||
|
visible-files := ListView {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
viewport-width: self.visible-width;
|
||||||
|
horizontal-scrollbar-policy: always-off;
|
||||||
|
for entry[index] in root.entries: Rectangle {
|
||||||
|
x: 0px; width: file-list.width - file-list.content-gutter;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: UiRadius.control;
|
||||||
|
background: entry.selected
|
||||||
|
? (row-touch.pressed ? ControlStyle.selected-pressed.with-alpha(0.4) : row-touch.has-hover ? ControlStyle.selected-hover.with-alpha(0.35) : ControlStyle.selected.with-alpha(0.3))
|
||||||
|
: row-touch.pressed ? ControlStyle.pressed.with-alpha(0.3) : row-touch.has-hover ? ControlStyle.hover.with-alpha(0.2) : transparent;
|
||||||
|
opacity: root.dragging && row-touch.drag-started ? 0.55 : 1;
|
||||||
|
folder-menu := ContextMenuArea {
|
||||||
|
enabled: entry.directory && root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||||
|
Menu {
|
||||||
|
MenuItem {
|
||||||
|
title: root.open-directory-text;
|
||||||
|
activated => { root.open-directory(entry.path); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 6px; padding-right: 8px; spacing: 8px;
|
||||||
|
Rectangle { width: 24px; }
|
||||||
|
Text { width: 17px; text: entry.directory ? FontAwesomeIcons.folder-open : FontAwesomeIcons.file-lines; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 14px; color: entry.directory ? WorkbenchStyle.accent : ControlStyle.icon; vertical-alignment: center; }
|
||||||
|
Text { text: entry.name; color: entry.link ? WorkbenchStyle.muted : entry.selected ? ControlStyle.selected-text : WorkbenchStyle.text; font-size: ImGuiFontStyle.base; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; }
|
||||||
|
Text { width: root.modified-column-width; text: entry.modified != "" ? entry.modified : "—"; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; horizontal-alignment: right; }
|
||||||
|
Text { width: root.size-column-width; text: entry.directory ? "—" : entry.size; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; vertical-alignment: center; horizontal-alignment: right; }
|
||||||
|
}
|
||||||
|
row-touch := TouchArea {
|
||||||
|
// Only the row body starts drags; checkboxes remain selection controls.
|
||||||
|
x: 34px; width: parent.width - 34px; height: parent.height;
|
||||||
|
enabled: root.enabled && !root.busy && root.error == "";
|
||||||
|
private property <bool> drag-armed;
|
||||||
|
out property <bool> drag-started;
|
||||||
|
// Keep pointer capture throughout the in-window drag, so the cursor
|
||||||
|
// remains a closed hand instead of DragArea's copy/no-drop cursor.
|
||||||
|
mouse-cursor: self.drag-started && root.dragging ? MouseCursor.grabbing : MouseCursor.default;
|
||||||
|
function update-drag-position() {
|
||||||
|
root.drag-move(self.absolute-position.x + self.mouse-x,
|
||||||
|
self.absolute-position.y + self.mouse-y);
|
||||||
|
}
|
||||||
|
pointer-event(event) => {
|
||||||
|
if event.button == PointerEventButton.left && event.kind == PointerEventKind.down {
|
||||||
|
self.drag-armed = root.transfer-ready && !entry.link;
|
||||||
|
self.drag-started = false;
|
||||||
|
} else if event.kind == PointerEventKind.cancel {
|
||||||
|
self.drag-armed = false;
|
||||||
|
if self.drag-started && root.dragging { root.drag-cancel(); }
|
||||||
|
} else if event.button == PointerEventButton.left && event.kind == PointerEventKind.up {
|
||||||
|
self.drag-armed = false;
|
||||||
|
if self.drag-started && root.dragging {
|
||||||
|
self.update-drag-position();
|
||||||
|
root.drag-end();
|
||||||
|
}
|
||||||
|
} else if event.button == PointerEventButton.right && event.kind == PointerEventKind.up && folder-menu.enabled {
|
||||||
|
folder-menu.show({ x: self.mouse-x + 34px, y: self.mouse-y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moved => {
|
||||||
|
if self.drag-armed && !self.drag-started && root.transfer-ready
|
||||||
|
&& (abs(self.mouse-x - self.pressed-x) >= 5px || abs(self.mouse-y - self.pressed-y) >= 5px) {
|
||||||
|
self.drag-started = true;
|
||||||
|
root.drag-start(root.drag-data(entry.path, root.selection-revision), entry);
|
||||||
|
}
|
||||||
|
if self.drag-started && root.dragging { self.update-drag-position(); }
|
||||||
|
}
|
||||||
|
clicked => { if !self.drag-started { root.select-entry(index); } }
|
||||||
|
double-clicked => { if !self.drag-started { root.open-entry(index); } }
|
||||||
|
}
|
||||||
|
BrowserCheckBox { x: 6px; y: (parent.height - self.height) / 2; width: 24px; checked: entry.selected; label: (root.chinese ? "选择 " : "Select ") + entry.name; enabled: !entry.link && root.enabled && !root.busy && root.error == "" && !root.dragging; toggled => { root.select-entry(index); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.entries.length == 0 && !root.busy && root.error == "": VerticalLayout {
|
||||||
|
x: 12px; y: 34px; width: parent.width - 24px; height: parent.height - 40px;
|
||||||
|
alignment: center; spacing: 10px;
|
||||||
|
Text {
|
||||||
|
text: root.filter-text != "" ? (root.chinese ? "没有匹配的项目" : "No matching items")
|
||||||
|
: (root.chinese ? "此目录为空" : "This folder is empty");
|
||||||
|
color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
if root.filter-text != "": HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
BrowserButton { width: 112px; height: 28px; text-size: ImGuiFontStyle.base; text: root.chinese ? "清空筛选" : "Clear filter"; clicked => { root.filter-text = ""; root.filter(""); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.error != "" && !root.busy: Rectangle {
|
||||||
|
width: parent.width; height: parent.height; background: WorkbenchStyle.surface;
|
||||||
|
TouchArea {}
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 16px; spacing: 10px; alignment: center;
|
||||||
|
Text { text: root.chinese ? "无法读取目录" : "Unable to open folder"; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.base; font-weight: 600; horizontal-alignment: center; }
|
||||||
|
Text { text: root.error; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; }
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
BrowserButton { width: 128px; height: 28px; text-size: ImGuiFontStyle.base; text: root.chinese ? "打开主目录" : "Open home folder"; clicked => { root.navigate(""); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.busy: Rectangle {
|
||||||
|
width: parent.width; height: parent.height; background: WorkbenchStyle.surface.with-alpha(0.9);
|
||||||
|
VerticalLayout { alignment: center; spacing: 12px; Spinner { width: 24px; height: 24px; } Text { text: root.status; horizontal-alignment: center; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; } }
|
||||||
|
}
|
||||||
|
if root.drop-hover: FlatSurface {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
background: ControlStyle.selected.with-alpha(0.3); outline-color: UiSurface.focus; border-radius: UiRadius.control;
|
||||||
|
Rectangle {
|
||||||
|
x: 8px; y: max(6px, parent.height - 68px); width: parent.width - 16px; height: 60px;
|
||||||
|
background: ControlStyle.surface; border-radius: UiRadius.control;
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 9px; spacing: 4px;
|
||||||
|
Text { text: root.chinese ? "松手复制 " + root.drag-count + " 项到此目录" : "Release to copy " + root.drag-count + " items here"; color: WorkbenchStyle.accent; font-size: ImGuiFontStyle.base; font-weight: 600; horizontal-alignment: center; }
|
||||||
|
Text { text: root.path; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.body; overflow: elide; horizontal-alignment: center; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
height: 16px;
|
||||||
|
text: root.busy ? root.status
|
||||||
|
: root.path != root.current-path ? (root.chinese ? "Enter 打开路径 · Esc 还原" : "Enter to open · Esc to restore")
|
||||||
|
: root.error != "" ? (root.chinese ? "目录不可用" : "Folder unavailable")
|
||||||
|
: root.filter-text != "" ? (root.chinese ? "筛选结果 · " : "Filtered · ") + root.status : root.status;
|
||||||
|
color: root.path != root.current-path ? WorkbenchStyle.accent : WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.body; overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
directory-popup := PopupWindow {
|
||||||
|
x: 14px; y: 78px; width: root.width - 28px; height: min(330px, max(180px, root.height - 90px));
|
||||||
|
close-policy: close-on-click-outside;
|
||||||
|
FlatSurface {
|
||||||
|
width: parent.width; height: parent.height; background: WorkbenchStyle.surface; outline-color: UiSurface.edge; border-radius: UiRadius.menu;
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 12px; spacing: 10px;
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 28px; spacing: 8px;
|
||||||
|
BrowserButton { text: root.chinese ? "主目录" : "Home"; quiet: true; height: 28px; clicked => { root.navigate(""); directory-popup.close(); } }
|
||||||
|
BrowserButton { text: root.chinese ? "磁盘" : "Drives"; quiet: true; height: 28px; clicked => { root.navigate("@roots"); directory-popup.close(); } }
|
||||||
|
}
|
||||||
|
tree-list := Rectangle {
|
||||||
|
out property <length> content-gutter: 18px;
|
||||||
|
vertical-stretch: 1;
|
||||||
|
clip: true;
|
||||||
|
ListView {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
viewport-width: self.visible-width;
|
||||||
|
horizontal-scrollbar-policy: always-off;
|
||||||
|
for node[index] in root.tree: Rectangle {
|
||||||
|
x: 0px; width: tree-list.width - tree-list.content-gutter;
|
||||||
|
height: 29px; border-radius: UiRadius.control;
|
||||||
|
background: node.selected ? ControlStyle.selected.with-alpha(0.3) : tree-touch.has-hover ? ControlStyle.hover.with-alpha(0.2) : transparent;
|
||||||
|
Text { x: 24px + min(node.depth, 8) * 12px; width: parent.width - self.x - 4px; height: parent.height; text: node.name; color: node.selected ? WorkbenchStyle.accent : WorkbenchStyle.text; font-size: ImGuiFontStyle.base; vertical-alignment: center; overflow: elide; }
|
||||||
|
tree-touch := TouchArea { clicked => { root.tree-open(index); directory-popup.close(); } }
|
||||||
|
BrowserButton { x: min(node.depth, 8) * 12px; width: 22px; height: 28px; quiet: true; icon: node.expanded ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-right; clicked => { root.tree-toggle(index); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BrowserCheckBox { width: parent.width; show-label: true; label: root.chinese ? "显示隐藏项" : "Show hidden files"; checked: root.show-hidden; toggled => { root.toggle-hidden(); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component FileBrowserWindow inherits Window {
|
||||||
|
title: root.chinese ? "文件传输" : "File transfer";
|
||||||
|
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
|
||||||
|
preferred-width: 900px; preferred-height: 620px;
|
||||||
|
min-width: 760px; min-height: 500px;
|
||||||
|
background: WorkbenchStyle.canvas;
|
||||||
|
default-font-size: ImGuiFontStyle.base;
|
||||||
|
in property <bool> chinese: true;
|
||||||
|
in property <string> send-local-text: root.chinese ? "发送到本机" : "Send to local";
|
||||||
|
in property <string> send-remote-text: root.chinese ? "发送到远端" : "Send to remote";
|
||||||
|
in property <string> clear-completed-text: root.chinese ? "清除已完成" : "Clear completed";
|
||||||
|
in property <string> open-directory-text: root.chinese ? "打开目录" : "Open folder";
|
||||||
|
in property <bool> connected: true;
|
||||||
|
in property <string> peer-name;
|
||||||
|
in property <string> local-role;
|
||||||
|
in property <string> remote-role;
|
||||||
|
in-out property <string> local-path;
|
||||||
|
in-out property <string> remote-path;
|
||||||
|
in property <string> local-current-path;
|
||||||
|
in property <string> remote-current-path;
|
||||||
|
in-out property <string> local-filter;
|
||||||
|
in-out property <string> remote-filter;
|
||||||
|
in property <string> local-status;
|
||||||
|
in property <string> remote-status;
|
||||||
|
in property <string> local-error;
|
||||||
|
in property <string> remote-error;
|
||||||
|
in property <bool> local-busy;
|
||||||
|
in property <bool> remote-busy;
|
||||||
|
in property <bool> local-up;
|
||||||
|
in property <bool> remote-up;
|
||||||
|
in property <bool> local-all;
|
||||||
|
in property <bool> remote-all;
|
||||||
|
in property <bool> local-hidden;
|
||||||
|
in property <bool> remote-hidden;
|
||||||
|
in property <int> local-sort-column;
|
||||||
|
in property <int> remote-sort-column;
|
||||||
|
in property <bool> local-sort-descending;
|
||||||
|
in property <bool> remote-sort-descending;
|
||||||
|
in property <int> local-selected;
|
||||||
|
in property <int> remote-selected;
|
||||||
|
in property <[BrowserEntry]> local-entries;
|
||||||
|
in property <[BrowserEntry]> remote-entries;
|
||||||
|
in property <[BrowserTreeEntry]> local-tree;
|
||||||
|
in property <[BrowserTreeEntry]> remote-tree;
|
||||||
|
in property <[BrowserTask]> tasks;
|
||||||
|
in property <string> task-summary;
|
||||||
|
in property <string> notice;
|
||||||
|
in property <bool> notice-error;
|
||||||
|
in property <bool> can-upload;
|
||||||
|
in property <bool> can-download;
|
||||||
|
in property <bool> source-remote;
|
||||||
|
in property <bool> transfer-ready;
|
||||||
|
in property <int> selection-revision;
|
||||||
|
in property <bool> drag-active;
|
||||||
|
in property <int> drag-count;
|
||||||
|
private property <data-transfer> drag-payload;
|
||||||
|
private property <BrowserEntry> drag-entry;
|
||||||
|
private property <length> drag-x;
|
||||||
|
private property <length> drag-y;
|
||||||
|
private property <int> drop-target: -1;
|
||||||
|
changed drag-active => { if !root.drag-active { root.drop-target = -1; } }
|
||||||
|
in-out property <bool> tasks-expanded: false;
|
||||||
|
private property <length> task-list-max-height: min(144px, max(72px, root.height - 440px));
|
||||||
|
private property <int> selected-count: root.local-selected + root.remote-selected;
|
||||||
|
private property <bool> path-pending: root.local-path != root.local-current-path || root.remote-path != root.remote-current-path;
|
||||||
|
private property <bool> notice-visible: false;
|
||||||
|
changed notice => { root.notice-visible = root.notice != ""; }
|
||||||
|
Timer {
|
||||||
|
interval: 3500ms;
|
||||||
|
running: root.notice-visible && !root.notice-error;
|
||||||
|
triggered => { root.notice-visible = false; }
|
||||||
|
}
|
||||||
|
callback navigate(bool, string);
|
||||||
|
callback refresh(bool);
|
||||||
|
callback up(bool);
|
||||||
|
callback select-entry(bool, int);
|
||||||
|
callback open-entry(bool, int);
|
||||||
|
callback open-directory(bool, string);
|
||||||
|
callback select-all(bool);
|
||||||
|
callback clear-selection;
|
||||||
|
callback tree-open(bool, int);
|
||||||
|
callback tree-toggle(bool, int);
|
||||||
|
callback filter(bool, string);
|
||||||
|
callback toggle-hidden(bool);
|
||||||
|
callback sort(bool, int);
|
||||||
|
callback transfer(bool);
|
||||||
|
callback cancel-task(int);
|
||||||
|
callback clear-finished;
|
||||||
|
pure callback drag-data(bool, string, int) -> data-transfer;
|
||||||
|
callback drag-state(data-transfer, bool);
|
||||||
|
callback can-drop(bool, data-transfer) -> bool;
|
||||||
|
callback dropped(bool, data-transfer) -> bool;
|
||||||
|
callback cancel-drag;
|
||||||
|
function start-drag(data: data-transfer, entry: BrowserEntry) {
|
||||||
|
root.drag-payload = data;
|
||||||
|
root.drag-entry = entry;
|
||||||
|
root.drop-target = -1;
|
||||||
|
root.drag-state(data, true);
|
||||||
|
}
|
||||||
|
function move-drag(x: length, y: length) {
|
||||||
|
if !root.drag-active { return; }
|
||||||
|
root.drag-x = x;
|
||||||
|
root.drag-y = y;
|
||||||
|
root.drop-target = local-pane.contains-drop(x, y) && root.can-drop(false, root.drag-payload) ? 0
|
||||||
|
: remote-pane.contains-drop(x, y) && root.can-drop(true, root.drag-payload) ? 1 : -1;
|
||||||
|
}
|
||||||
|
function finish-drag() {
|
||||||
|
if !root.drag-active { return; }
|
||||||
|
// The payload stays fixed from press-and-drag through release; C++ validates
|
||||||
|
// its source, destination, session and generation again before copying.
|
||||||
|
let data = root.drag-payload;
|
||||||
|
if root.drop-target >= 0 { root.dropped(root.drop-target == 1, data); }
|
||||||
|
root.drag-state(data, false);
|
||||||
|
}
|
||||||
|
forward-focus: focus;
|
||||||
|
SurfaceSeparator { x: 0px; y: 0px; width: parent.width; }
|
||||||
|
focus := FocusScope {
|
||||||
|
focus-on-click: true;
|
||||||
|
key-pressed(event) => {
|
||||||
|
if event.text == Key.Escape { root.cancel-drag(); return accept; }
|
||||||
|
reject
|
||||||
|
}
|
||||||
|
focus-lost => { if root.drag-active { root.cancel-drag(); } }
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 12px; padding: 16px;
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 32px; spacing: 12px;
|
||||||
|
Text { text: root.chinese ? "文件传输" : "File transfer"; font-size: ImGuiFontStyle.section-title; font-weight: 600; color: WorkbenchStyle.text; vertical-alignment: center; horizontal-stretch: 1; }
|
||||||
|
Text { text: root.peer-name; color: WorkbenchStyle.muted; vertical-alignment: center; font-size: ImGuiFontStyle.base; overflow: elide; }
|
||||||
|
Rectangle {
|
||||||
|
width: root.chinese ? 104px : 144px;
|
||||||
|
BrowserButton {
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
width: parent.width; height: 28px; icon: FontAwesomeIcons.rotate;
|
||||||
|
text: root.chinese ? "刷新目录" : "Refresh folders";
|
||||||
|
text-size: ImGuiFontStyle.base;
|
||||||
|
accessible-label: root.chinese ? "刷新双方目录" : "Refresh both folders";
|
||||||
|
enabled: root.connected && !root.drag-active && !root.local-busy && !root.remote-busy;
|
||||||
|
clicked => { root.refresh(false); root.refresh(true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
vertical-stretch: 1; spacing: 12px;
|
||||||
|
local-pane := BrowserPane {
|
||||||
|
width: (parent.width - 12px) / 2;
|
||||||
|
horizontal-stretch: 1; chinese: root.chinese; heading: root.chinese ? "本机" : "Local"; subtitle: root.local-role;
|
||||||
|
path <=> root.local-path; status: root.local-status; error: root.local-error; busy: root.local-busy;
|
||||||
|
open-directory-text: root.open-directory-text;
|
||||||
|
open-directory(path) => { root.open-directory(false, path); }
|
||||||
|
current-path: root.local-current-path; filter-text <=> root.local-filter;
|
||||||
|
can-up: root.local-up; all-selected: root.local-all; entries: root.local-entries; tree: root.local-tree;
|
||||||
|
show-hidden: root.local-hidden; toggle-hidden => { root.toggle-hidden(false); }
|
||||||
|
sort-column: root.local-sort-column; sort-descending: root.local-sort-descending;
|
||||||
|
sort(column) => { root.sort(false, column); }
|
||||||
|
selected-count: root.local-selected; destination: root.remote-selected > 0;
|
||||||
|
transfer-ready: root.transfer-ready; selection-revision: root.selection-revision; dragging: root.drag-active; drag-count: root.drag-count;
|
||||||
|
drop-hover: root.drag-active && root.drop-target == 0;
|
||||||
|
navigate(path) => { root.navigate(false, path); } up => { root.up(false); }
|
||||||
|
select-entry(index) => { root.select-entry(false, index); } open-entry(index) => { root.open-entry(false, index); }
|
||||||
|
select-all => { root.select-all(false); } tree-open(index) => { root.tree-open(false, index); } tree-toggle(index) => { root.tree-toggle(false, index); }
|
||||||
|
filter(text) => { root.filter(false, text); }
|
||||||
|
drag-data(path, revision) => { return root.drag-data(false, path, revision); }
|
||||||
|
drag-start(data, entry) => { root.start-drag(data, entry); }
|
||||||
|
drag-move(x, y) => { root.move-drag(x, y); }
|
||||||
|
drag-end => { root.finish-drag(); }
|
||||||
|
drag-cancel => { root.cancel-drag(); }
|
||||||
|
}
|
||||||
|
remote-pane := BrowserPane {
|
||||||
|
width: (parent.width - 12px) / 2;
|
||||||
|
horizontal-stretch: 1; chinese: root.chinese; heading: root.chinese ? "远端" : "Remote"; subtitle: root.remote-role; enabled: root.connected;
|
||||||
|
path <=> root.remote-path; status: root.remote-status; error: root.remote-error; busy: root.remote-busy;
|
||||||
|
open-directory-text: root.open-directory-text;
|
||||||
|
open-directory(path) => { root.open-directory(true, path); }
|
||||||
|
current-path: root.remote-current-path; filter-text <=> root.remote-filter;
|
||||||
|
can-up: root.remote-up; all-selected: root.remote-all; entries: root.remote-entries; tree: root.remote-tree;
|
||||||
|
show-hidden: root.remote-hidden; toggle-hidden => { root.toggle-hidden(true); }
|
||||||
|
sort-column: root.remote-sort-column; sort-descending: root.remote-sort-descending;
|
||||||
|
sort(column) => { root.sort(true, column); }
|
||||||
|
selected-count: root.remote-selected; destination: root.local-selected > 0;
|
||||||
|
transfer-ready: root.transfer-ready; selection-revision: root.selection-revision; dragging: root.drag-active; drag-count: root.drag-count;
|
||||||
|
drop-hover: root.drag-active && root.drop-target == 1;
|
||||||
|
navigate(path) => { root.navigate(true, path); } up => { root.up(true); }
|
||||||
|
select-entry(index) => { root.select-entry(true, index); } open-entry(index) => { root.open-entry(true, index); }
|
||||||
|
select-all => { root.select-all(true); } tree-open(index) => { root.tree-open(true, index); } tree-toggle(index) => { root.tree-toggle(true, index); }
|
||||||
|
filter(text) => { root.filter(true, text); }
|
||||||
|
drag-data(path, revision) => { return root.drag-data(true, path, revision); }
|
||||||
|
drag-start(data, entry) => { root.start-drag(data, entry); }
|
||||||
|
drag-move(x, y) => { root.move-drag(x, y); }
|
||||||
|
drag-end => { root.finish-drag(); }
|
||||||
|
drag-cancel => { root.cancel-drag(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WorkbenchCard {
|
||||||
|
height: 83px + task-content.height;
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 0px; padding: 1px;
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 48px; padding-left: 12px; padding-right: 12px; spacing: 12px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center; spacing: 3px; horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
text: root.path-pending ? (root.chinese ? "按 Enter 打开路径" : "Press Enter to open the path")
|
||||||
|
: root.drag-active ? (root.chinese ? "松手复制 · Esc 取消" : "Release to copy · Esc to cancel")
|
||||||
|
: root.notice-error ? root.notice
|
||||||
|
: root.selected-count > 0 ? (root.chinese ? "已选 " + root.selected-count + " 项 · " + (root.source-remote ? "远端 → 本机" : "本机 → 远端")
|
||||||
|
: (root.source-remote ? "Remote → Local" : "Local → Remote") + " · " + root.selected-count + " selected")
|
||||||
|
: root.notice-visible ? root.notice
|
||||||
|
: (root.chinese ? "选择文件,或拖到另一侧" : "Select files or drag to the other side");
|
||||||
|
color: root.notice-error && !root.drag-active && !root.path-pending ? UiSurface.error
|
||||||
|
: root.selected-count > 0 ? WorkbenchStyle.text : WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.base; font-weight: 400; overflow: elide;
|
||||||
|
}
|
||||||
|
if root.selected-count > 0 && !root.path-pending && !root.drag-active && !root.notice-error: Text {
|
||||||
|
text: (root.chinese ? "目标:" : "To: ") + (root.source-remote ? root.local-current-path : root.remote-current-path);
|
||||||
|
color: WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.body; overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root.selected-count > 0: Rectangle {
|
||||||
|
width: root.chinese ? 76px : 100px;
|
||||||
|
BrowserButton {
|
||||||
|
y: (parent.height - self.height) / 2; width: parent.width; height: 28px; quiet: true;
|
||||||
|
text-size: ImGuiFontStyle.base; text: root.chinese ? "取消选择" : "Clear selection";
|
||||||
|
enabled: !root.drag-active; clicked => { root.clear-selection(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: max(root.chinese ? 148px : 160px, send-label-measure.preferred-width + 32px);
|
||||||
|
send-label-measure := Text {
|
||||||
|
visible: false;
|
||||||
|
text: root.source-remote ? root.send-local-text : root.send-remote-text;
|
||||||
|
font-size: ImGuiFontStyle.base; font-weight: 600;
|
||||||
|
}
|
||||||
|
BrowserButton {
|
||||||
|
y: (parent.height - self.height) / 2; width: parent.width; height: 32px; primary: true;
|
||||||
|
text-size: ImGuiFontStyle.base;
|
||||||
|
enabled: !root.drag-active && (root.source-remote ? root.can-download : root.can-upload);
|
||||||
|
text: send-label-measure.text;
|
||||||
|
clicked => { root.transfer(root.source-remote); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { height: 1px; SurfaceSeparator { x: 12px; y: 0px; width: parent.width - 24px; } }
|
||||||
|
Rectangle {
|
||||||
|
height: 32px;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 10px; padding-right: 10px; spacing: 8px;
|
||||||
|
Rectangle {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
BrowserButton { x: 0px; width: min(parent.width, task-label.preferred-width + 46px); height: 32px; quiet: true; accessible-label: task-label.text; clicked => { root.tasks-expanded = !root.tasks-expanded; } }
|
||||||
|
Text { x: 8px; width: 14px; height: parent.height; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: ImGuiFontStyle.base; color: ControlStyle.icon; vertical-alignment: center; text: root.tasks-expanded ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-right; }
|
||||||
|
task-label := Text { x: 30px; width: parent.width - 40px; height: parent.height; font-size: ImGuiFontStyle.base; color: WorkbenchStyle.muted; vertical-alignment: center; overflow: elide; text: (root.chinese ? "传输任务" : "Transfers") + " · " + (root.tasks.length == 0 ? (root.chinese ? "暂无任务" : "No tasks") : root.task-summary != "" ? root.task-summary : root.tasks.length); }
|
||||||
|
}
|
||||||
|
if root.tasks-expanded: Rectangle {
|
||||||
|
width: max(100px, clear-label-measure.preferred-width + 24px);
|
||||||
|
clear-label-measure := Text {
|
||||||
|
visible: false; text: root.clear-completed-text;
|
||||||
|
font-size: ImGuiFontStyle.body; font-weight: 500;
|
||||||
|
}
|
||||||
|
BrowserButton { y: (parent.height - self.height) / 2; width: parent.width; height: 28px; quiet: true; text-size: ImGuiFontStyle.body; text: root.clear-completed-text; clicked => { root.clear-finished(); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task-content := Rectangle {
|
||||||
|
height: root.tasks-expanded ? min(root.task-list-max-height, root.tasks.length == 0 ? 40px : task-rows.preferred-height) : 0px;
|
||||||
|
visible: root.tasks-expanded;
|
||||||
|
clip: true;
|
||||||
|
SurfaceSeparator { x: 12px; y: 0px; width: parent.width - 24px; }
|
||||||
|
if root.tasks.length == 0: Text { text: root.chinese ? "暂无传输任务" : "No transfers yet"; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
|
task-list := BrowserScrollView {
|
||||||
|
x: 16px; width: parent.width - 32px; height: parent.height;
|
||||||
|
visible: root.tasks.length > 0;
|
||||||
|
task-rows := VerticalLayout {
|
||||||
|
spacing: 2px; padding-top: 4px; padding-bottom: 4px; padding-right: task-list.content-gutter;
|
||||||
|
for task in root.tasks: Rectangle {
|
||||||
|
height: task.active ? 46px : 32px;
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
Text { width: 18px; text: task.receiving ? "↓" : "↑"; color: WorkbenchStyle.accent; font-size: 16px; vertical-alignment: center; }
|
||||||
|
VerticalLayout {
|
||||||
|
padding-top: 5px; padding-bottom: 5px; spacing: 3px; alignment: center; horizontal-stretch: 1;
|
||||||
|
Rectangle {
|
||||||
|
height: 16px;
|
||||||
|
task-name := Text {
|
||||||
|
x: 0px;
|
||||||
|
width: min(self.preferred-width, max(0px, parent.width - task-size.preferred-width - 8px));
|
||||||
|
height: parent.height; text: task.name; color: WorkbenchStyle.text;
|
||||||
|
font-size: ImGuiFontStyle.base; overflow: elide; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
task-size := Text {
|
||||||
|
x: task-name.width + 8px; width: self.preferred-width; height: parent.height;
|
||||||
|
text: task.size; color: WorkbenchStyle.muted;
|
||||||
|
font-size: ImGuiFontStyle.body; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if task.active: Text { text: task.detail; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; overflow: elide; }
|
||||||
|
}
|
||||||
|
Rectangle { width: root.width < 820px ? 80px : 110px; Rectangle { x: 0px; y: (parent.height - 4px) / 2; width: parent.width; height: 4px; background: WorkbenchStyle.separator; border-radius: 2px; Rectangle { x: 0px; y: 0px; width: parent.width * task.progress; height: 4px; border-radius: 2px; background: task.failed ? UiSurface.error : WorkbenchStyle.accent; } } }
|
||||||
|
Text { width: root.width < 820px ? 92px : 110px; text: task.status; color: task.failed ? UiSurface.error : WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; vertical-alignment: center; horizontal-alignment: right; }
|
||||||
|
Rectangle { width: 56px; if task.can-cancel: BrowserButton { y: (parent.height - self.height) / 2; width: 56px; height: 26px; text: root.chinese ? "取消" : "Cancel"; text-size: ImGuiFontStyle.body; quiet: true; clicked => { root.cancel-task(task.id); } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Visual only: pointer capture and drop validation stay with the source row.
|
||||||
|
if root.drag-active: FlatSurface {
|
||||||
|
private property <bool> label-on-left: root.drag-x - 12px + self.width > root.width - 8px;
|
||||||
|
z: 100;
|
||||||
|
width: min(300px, max(112px, drag-label.preferred-width + 54px + drag-badge.width));
|
||||||
|
height: 40px;
|
||||||
|
// Keep the icon's top-left corner at the pointer when the label flips at the right edge.
|
||||||
|
x: root.drag-x - drag-icon.x;
|
||||||
|
y: root.drag-y - drag-icon.y;
|
||||||
|
border-radius: UiRadius.control;
|
||||||
|
background: WorkbenchStyle.surface;
|
||||||
|
outline-color: root.drop-target >= 0 ? WorkbenchStyle.accent : UiSurface.hover;
|
||||||
|
opacity: 0.94;
|
||||||
|
drag-icon := Text {
|
||||||
|
x: parent.label-on-left ? parent.width - 30px : 12px;
|
||||||
|
y: (parent.height - self.height) / 2; width: 18px; height: 18px;
|
||||||
|
text: root.drag-entry.directory ? FontAwesomeIcons.folder-open : FontAwesomeIcons.file-lines;
|
||||||
|
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 17px;
|
||||||
|
color: root.drag-entry.directory ? WorkbenchStyle.accent : ControlStyle.icon;
|
||||||
|
horizontal-alignment: left; vertical-alignment: top;
|
||||||
|
}
|
||||||
|
drag-label := Text {
|
||||||
|
x: parent.label-on-left ? 12px : 40px;
|
||||||
|
width: parent.width - 54px - drag-badge.width; height: parent.height;
|
||||||
|
text: root.drag-entry.name; color: WorkbenchStyle.text;
|
||||||
|
font-size: ImGuiFontStyle.base; font-weight: 500;
|
||||||
|
vertical-alignment: center; overflow: elide;
|
||||||
|
}
|
||||||
|
drag-badge := Rectangle {
|
||||||
|
visible: root.drag-count > 1;
|
||||||
|
x: parent.width - self.width - (parent.label-on-left ? 38px : 10px); y: 10px;
|
||||||
|
width: root.drag-count > 1 ? max(24px, count-label.preferred-width + 12px) : 0px;
|
||||||
|
height: 20px; border-radius: 10px; background: WorkbenchStyle.accent;
|
||||||
|
count-label := Text {
|
||||||
|
width: parent.width; height: parent.height; text: root.drag-count;
|
||||||
|
color: white; font-size: ImGuiFontStyle.body; font-weight: 600;
|
||||||
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,7 @@ export global UiStrings {
|
|||||||
in-out property <string> later: "Later";
|
in-out property <string> later: "Later";
|
||||||
in-out property <string> download: "Download";
|
in-out property <string> download: "Download";
|
||||||
in-out property <string> install-update: "Install update";
|
in-out property <string> install-update: "Install update";
|
||||||
|
in-out property <string> retry-update: "Retry update";
|
||||||
in-out property <string> install-update-confirm;
|
in-out property <string> install-update-confirm;
|
||||||
in-out property <string> input-password: "Input password";
|
in-out property <string> input-password: "Input password";
|
||||||
in-out property <string> reinput-password: "Incorrect password, please try again";
|
in-out property <string> reinput-password: "Incorrect password, please try again";
|
||||||
@@ -593,6 +594,7 @@ export component MainWindow inherits Window {
|
|||||||
in property <bool> update-available: false;
|
in property <bool> update-available: false;
|
||||||
in property <bool> update-install-available: false;
|
in property <bool> update-install-available: false;
|
||||||
in property <bool> update-install-busy: false;
|
in property <bool> update-install-busy: false;
|
||||||
|
in property <bool> update-install-failed: false;
|
||||||
changed update-install-available => {
|
changed update-install-available => {
|
||||||
if !root.update-install-available { root.update-confirm-open = false; }
|
if !root.update-install-available { root.update-confirm-open = false; }
|
||||||
}
|
}
|
||||||
@@ -600,7 +602,8 @@ export component MainWindow inherits Window {
|
|||||||
if root.update-install-busy { root.update-confirm-open = false; }
|
if root.update-install-busy { root.update-confirm-open = false; }
|
||||||
}
|
}
|
||||||
in property <string> update-install-status: "";
|
in property <string> update-install-status: "";
|
||||||
private property <string> update-action-text: root.update-install-available ? UiStrings.install-update : UiStrings.download;
|
private property <string> update-action-text: !root.update-install-available ? UiStrings.download
|
||||||
|
: root.update-install-failed && !root.update-install-busy ? UiStrings.retry-update : UiStrings.install-update;
|
||||||
in property <UpdateCheckStatus> update-check-status: idle;
|
in property <UpdateCheckStatus> update-check-status: idle;
|
||||||
private property <string> update-status-text:
|
private property <string> update-status-text:
|
||||||
root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates :
|
root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates :
|
||||||
@@ -695,6 +698,7 @@ export component MainWindow inherits Window {
|
|||||||
callback cancel-self-hosted-settings;
|
callback cancel-self-hosted-settings;
|
||||||
callback browse-save-path;
|
callback browse-save-path;
|
||||||
callback open-download;
|
callback open-download;
|
||||||
|
callback open-manual-download;
|
||||||
callback confirm-update;
|
callback confirm-update;
|
||||||
callback cancel-update;
|
callback cancel-update;
|
||||||
callback open-website;
|
callback open-website;
|
||||||
@@ -3300,6 +3304,14 @@ export component MainWindow inherits Window {
|
|||||||
text: UiStrings.later;
|
text: UiStrings.later;
|
||||||
clicked => { root.cancel-update(); root.update-open = false; }
|
clicked => { root.cancel-update(); root.update-open = false; }
|
||||||
}
|
}
|
||||||
|
if root.update-install-failed && root.update-install-available: WorkbenchButton {
|
||||||
|
width: max(110px, manual-download-metrics.preferred-width + 24px);
|
||||||
|
height: 34px;
|
||||||
|
enabled: !root.update-install-busy;
|
||||||
|
text: UiStrings.download;
|
||||||
|
clicked => { root.open-manual-download(); }
|
||||||
|
manual-download-metrics := Text { visible: false; text: UiStrings.download; font-size: 12px; }
|
||||||
|
}
|
||||||
WorkbenchButton {
|
WorkbenchButton {
|
||||||
width: max(110px, update-action-metrics.preferred-width + 24px);
|
width: max(110px, update-action-metrics.preferred-width + 24px);
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export component RoundedLineEdit inherits Rectangle {
|
|||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
in property <length> font-size: 12px;
|
in property <length> font-size: 12px;
|
||||||
in property <color> normal-outline-color: UiSurface.edge;
|
in property <color> normal-outline-color: UiSurface.edge;
|
||||||
|
in property <float> tint-opacity: 1;
|
||||||
in property <brush> surrounding-background: white;
|
in property <brush> surrounding-background: white;
|
||||||
callback accepted();
|
callback accepted();
|
||||||
callback edited();
|
callback edited();
|
||||||
@@ -36,6 +37,7 @@ export component RoundedLineEdit inherits Rectangle {
|
|||||||
// A visual-only tint preserves the standard editor and its built-in actions.
|
// A visual-only tint preserves the standard editor and its built-in actions.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width; height: parent.height;
|
width: parent.width; height: parent.height;
|
||||||
|
opacity: root.tint-opacity;
|
||||||
background: !root.enabled ? transparent
|
background: !root.enabled ? transparent
|
||||||
: editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint;
|
: editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ inline int ApplyMainWindowStrings(
|
|||||||
strings.set_view_release_notes(Text(localization::view_release_notes[language]));
|
strings.set_view_release_notes(Text(localization::view_release_notes[language]));
|
||||||
strings.set_download(Text(localization::manual_download[language]));
|
strings.set_download(Text(localization::manual_download[language]));
|
||||||
strings.set_install_update(Text(localization::install_update[language]));
|
strings.set_install_update(Text(localization::install_update[language]));
|
||||||
|
strings.set_retry_update(Text(localization::retry_update[language]));
|
||||||
strings.set_install_update_confirm(
|
strings.set_install_update_confirm(
|
||||||
Text(localization::install_update_confirm[language]));
|
Text(localization::install_update_confirm[language]));
|
||||||
strings.set_input_password(Text(localization::input_password[language]));
|
strings.set_input_password(Text(localization::input_password[language]));
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
in property <string> icon;
|
in property <string> icon;
|
||||||
in property <string> tooltip;
|
in property <string> tooltip;
|
||||||
in property <bool> tooltip-above: false;
|
in property <bool> tooltip-above: false;
|
||||||
|
in property <bool> tooltip-align-left: false;
|
||||||
in property <bool> tooltip-suppressed: false;
|
in property <bool> tooltip-suppressed: false;
|
||||||
in property <color> icon-color: root.destructive ? white : root.primary
|
in property <color> icon-color: root.destructive ? white : root.primary
|
||||||
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
|
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
|
||||||
@@ -33,6 +34,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
in property <bool> soft: false;
|
in property <bool> soft: false;
|
||||||
in property <bool> quiet: false;
|
in property <bool> quiet: false;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
|
in property <MouseCursor> mouse-cursor: pointer;
|
||||||
out property <bool> has-focus: button-focus.has-focus;
|
out property <bool> has-focus: button-focus.has-focus;
|
||||||
private property <bool> keyboard-focus: false;
|
private property <bool> keyboard-focus: false;
|
||||||
private property <bool> show-focus: button-focus.has-focus && root.keyboard-focus;
|
private property <bool> show-focus: button-focus.has-focus && root.keyboard-focus;
|
||||||
@@ -99,15 +101,12 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
button-focus := FocusScope {
|
button-focus := FocusScope {
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
focus-gained(reason) => {
|
focus-gained(reason) => {
|
||||||
if reason == FocusReason.tab-navigation {
|
root.keyboard-focus = reason == FocusReason.tab-navigation;
|
||||||
root.keyboard-focus = true;
|
|
||||||
} else if reason == FocusReason.pointer-click {
|
|
||||||
root.keyboard-focus = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
focus-lost => { root.keyboard-focus = false; }
|
||||||
key-pressed(event) => {
|
key-pressed(event) => {
|
||||||
root.keyboard-focus = true;
|
|
||||||
if event.text == Key.Return || event.text == " " {
|
if event.text == Key.Return || event.text == " " {
|
||||||
|
root.keyboard-focus = true;
|
||||||
root.clicked();
|
root.clicked();
|
||||||
return accept;
|
return accept;
|
||||||
}
|
}
|
||||||
@@ -115,7 +114,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
}
|
}
|
||||||
touch := TouchArea {
|
touch := TouchArea {
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: root.mouse-cursor;
|
||||||
pointer-event(event) => {
|
pointer-event(event) => {
|
||||||
if event.kind == PointerEventKind.down {
|
if event.kind == PointerEventKind.down {
|
||||||
root.keyboard-focus = false;
|
root.keyboard-focus = false;
|
||||||
@@ -123,7 +122,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
}
|
}
|
||||||
// Keep focus for subsequent keyboard activation without leaving a
|
// Keep focus for subsequent keyboard activation without leaving a
|
||||||
// keyboard focus ring behind after a pointer click.
|
// keyboard focus ring behind after a pointer click.
|
||||||
clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); }
|
clicked => { button-focus.focus(); root.keyboard-focus = false; root.clicked(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tooltip-timer := Timer {
|
tooltip-timer := Timer {
|
||||||
@@ -132,7 +131,7 @@ export component WorkbenchButton inherits FlatSurface {
|
|||||||
triggered => { root.tooltip-visible = true; }
|
triggered => { root.tooltip-visible = true; }
|
||||||
}
|
}
|
||||||
if root.tooltip-active && root.tooltip-visible: Rectangle {
|
if root.tooltip-active && root.tooltip-visible: Rectangle {
|
||||||
x: min(0px, root.width - self.width);
|
x: root.tooltip-align-left ? 0px : min(0px, root.width - self.width);
|
||||||
y: root.tooltip-above ? -self.height - 6px : root.height + 6px;
|
y: root.tooltip-above ? -self.height - 6px : root.height + 6px;
|
||||||
width: min(260px, tip.preferred-width + 20px);
|
width: min(260px, tip.preferred-width + 20px);
|
||||||
height: max(28px, tip.preferred-height + 12px);
|
height: max(28px, tip.preferred-height + 12px);
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ struct OpenGlFunctions {
|
|||||||
PFNGLUNIFORM4FPROC uniform_4f = nullptr;
|
PFNGLUNIFORM4FPROC uniform_4f = nullptr;
|
||||||
PFNGLUSEPROGRAMPROC use_program = nullptr;
|
PFNGLUSEPROGRAMPROC use_program = nullptr;
|
||||||
PFNGLVERTEXATTRIBPOINTERPROC vertex_attrib_pointer = nullptr;
|
PFNGLVERTEXATTRIBPOINTERPROC vertex_attrib_pointer = nullptr;
|
||||||
|
PFNGLGENVERTEXARRAYSPROC gen_vertex_arrays = nullptr;
|
||||||
|
PFNGLDELETEVERTEXARRAYSPROC delete_vertex_arrays = nullptr;
|
||||||
|
PFNGLBINDVERTEXARRAYPROC bind_vertex_array = nullptr;
|
||||||
PFNGLGENFRAMEBUFFERSPROC gen_framebuffers = nullptr;
|
PFNGLGENFRAMEBUFFERSPROC gen_framebuffers = nullptr;
|
||||||
PFNGLDELETEFRAMEBUFFERSPROC delete_framebuffers = nullptr;
|
PFNGLDELETEFRAMEBUFFERSPROC delete_framebuffers = nullptr;
|
||||||
PFNGLBINDFRAMEBUFFERPROC bind_framebuffer = nullptr;
|
PFNGLBINDFRAMEBUFFERPROC bind_framebuffer = nullptr;
|
||||||
@@ -178,6 +181,12 @@ struct OpenGlFunctions {
|
|||||||
"glCheckFramebufferStatus");
|
"glCheckFramebufferStatus");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool LoadVertexArrayFunctions() {
|
||||||
|
return LoadOpenGlFunction(&gen_vertex_arrays, "glGenVertexArrays") &&
|
||||||
|
LoadOpenGlFunction(&delete_vertex_arrays, "glDeleteVertexArrays") &&
|
||||||
|
LoadOpenGlFunction(&bind_vertex_array, "glBindVertexArray");
|
||||||
|
}
|
||||||
|
|
||||||
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
||||||
bool LoadSyncFunctions() {
|
bool LoadSyncFunctions() {
|
||||||
return LoadOpenGlFunction(&fence_sync, "glFenceSync") &&
|
return LoadOpenGlFunction(&fence_sync, "glFenceSync") &&
|
||||||
@@ -292,6 +301,8 @@ struct OpenGlVideoRenderer::Impl {
|
|||||||
std::atomic<bool> ready{false};
|
std::atomic<bool> ready{false};
|
||||||
GLuint program = 0;
|
GLuint program = 0;
|
||||||
GLuint vertex_buffer = 0;
|
GLuint vertex_buffer = 0;
|
||||||
|
GLuint vertex_array = 0;
|
||||||
|
bool modern_gl = false;
|
||||||
GLuint y_texture = 0;
|
GLuint y_texture = 0;
|
||||||
GLuint uv_texture = 0;
|
GLuint uv_texture = 0;
|
||||||
GLint position_location = -1;
|
GLint position_location = -1;
|
||||||
@@ -644,6 +655,8 @@ struct OpenGlVideoRenderer::Impl {
|
|||||||
void ResetGlHandles() {
|
void ResetGlHandles() {
|
||||||
program = 0;
|
program = 0;
|
||||||
vertex_buffer = 0;
|
vertex_buffer = 0;
|
||||||
|
vertex_array = 0;
|
||||||
|
modern_gl = false;
|
||||||
y_texture = 0;
|
y_texture = 0;
|
||||||
uv_texture = 0;
|
uv_texture = 0;
|
||||||
position_location = -1;
|
position_location = -1;
|
||||||
@@ -698,6 +711,13 @@ bool OpenGlVideoRenderer::Setup() {
|
|||||||
std::sscanf(is_gles ? version + std::strlen("OpenGL ES ") : version,
|
std::sscanf(is_gles ? version + std::strlen("OpenGL ES ") : version,
|
||||||
"%d.%d", &major, &minor);
|
"%d.%d", &major, &minor);
|
||||||
}
|
}
|
||||||
|
// Slint may fall back from GLES to a desktop core context. In that context
|
||||||
|
// VAO 0, legacy luminance textures and legacy shader inputs are not usable.
|
||||||
|
impl_->modern_gl = major >= 3;
|
||||||
|
if (impl_->modern_gl && !impl_->gl.LoadVertexArrayFunctions()) {
|
||||||
|
LOG_WARN("OpenGL NV12 underlay unavailable: vertex array functions missing");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const auto *extensions = major < 3
|
const auto *extensions = major < 3
|
||||||
? reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS)) : nullptr;
|
? reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS)) : nullptr;
|
||||||
impl_->pixel_unpack_buffer_available = major >= 3 ||
|
impl_->pixel_unpack_buffer_available = major >= 3 ||
|
||||||
@@ -727,22 +747,30 @@ bool OpenGlVideoRenderer::Setup() {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
const std::string vertex_source =
|
const std::string shader_version = is_gles
|
||||||
std::string(is_gles ? "#version 100\n" : "#version 110\n") + R"glsl(
|
? (impl_->modern_gl ? "#version 300 es\n" : "#version 100\n")
|
||||||
attribute vec2 position;
|
: (major > 3 || (major == 3 && minor >= 2) ? "#version 150\n"
|
||||||
attribute vec2 texcoord;
|
: impl_->modern_gl ? "#version 130\n" : "#version 110\n");
|
||||||
varying vec2 video_texcoord;
|
const std::string vertex_source = shader_version +
|
||||||
|
(impl_->modern_gl ? "#define INPUT in\n#define OUTPUT out\n"
|
||||||
|
: "#define INPUT attribute\n#define OUTPUT varying\n") + R"glsl(
|
||||||
|
INPUT vec2 position;
|
||||||
|
INPUT vec2 texcoord;
|
||||||
|
OUTPUT vec2 video_texcoord;
|
||||||
void main() {
|
void main() {
|
||||||
video_texcoord = texcoord;
|
video_texcoord = texcoord;
|
||||||
gl_Position = vec4(position, 0.0, 1.0);
|
gl_Position = vec4(position, 0.0, 1.0);
|
||||||
}
|
}
|
||||||
)glsl";
|
)glsl";
|
||||||
const std::string fragment_source =
|
const std::string fragment_source = shader_version +
|
||||||
std::string(is_gles ? "#version 100\n"
|
(is_gles ? "#ifdef GL_FRAGMENT_PRECISION_HIGH\n"
|
||||||
"#ifdef GL_FRAGMENT_PRECISION_HIGH\n"
|
|
||||||
"precision highp float;\n"
|
"precision highp float;\n"
|
||||||
"#else\nprecision mediump float;\n#endif\n"
|
"#else\nprecision mediump float;\n#endif\n" : "") +
|
||||||
: "#version 110\n") + R"glsl(
|
(impl_->modern_gl
|
||||||
|
? "#define INPUT in\n#define SAMPLE texture\n"
|
||||||
|
"#define UV_CHANNELS rg\nout vec4 fragment_color;\n"
|
||||||
|
: "#define INPUT varying\n#define SAMPLE texture2D\n"
|
||||||
|
"#define UV_CHANNELS ra\n#define fragment_color gl_FragColor\n") + R"glsl(
|
||||||
uniform sampler2D y_texture;
|
uniform sampler2D y_texture;
|
||||||
uniform sampler2D uv_texture;
|
uniform sampler2D uv_texture;
|
||||||
uniform vec2 target_size;
|
uniform vec2 target_size;
|
||||||
@@ -752,7 +780,7 @@ uniform float packed_video;
|
|||||||
uniform vec4 video_rect;
|
uniform vec4 video_rect;
|
||||||
uniform float corner_radius;
|
uniform float corner_radius;
|
||||||
uniform float video_enabled;
|
uniform float video_enabled;
|
||||||
varying vec2 video_texcoord;
|
INPUT vec2 video_texcoord;
|
||||||
// A separable Lanczos-2 low-pass filter. Each pass reduces by at most 4x,
|
// A separable Lanczos-2 low-pass filter. Each pass reduces by at most 4x,
|
||||||
// so 16 taps cover its complete support even for non-integer scale ratios.
|
// so 16 taps cover its complete support even for non-integer scale ratios.
|
||||||
float lanczos2(float x) {
|
float lanczos2(float x) {
|
||||||
@@ -766,7 +794,7 @@ vec4 sample_plane(sampler2D plane, vec2 size, vec2 coord) {
|
|||||||
// FBO textures have the opposite vertical origin to the decoded NV12 planes.
|
// FBO textures have the opposite vertical origin to the decoded NV12 planes.
|
||||||
if (packed_video > 0.5) coord.y = 1.0 - coord.y;
|
if (packed_video > 0.5) coord.y = 1.0 - coord.y;
|
||||||
if (filter_direction.x + filter_direction.y < 0.5)
|
if (filter_direction.x + filter_direction.y < 0.5)
|
||||||
return texture2D(plane, coord);
|
return SAMPLE(plane, coord);
|
||||||
float scale = max(1.0, dot(size / video_rect.zw, filter_direction));
|
float scale = max(1.0, dot(size / video_rect.zw, filter_direction));
|
||||||
float center = dot(coord * size - vec2(0.5), filter_direction);
|
float center = dot(coord * size - vec2(0.5), filter_direction);
|
||||||
float first = floor(center - 2.0 * scale) + 1.0;
|
float first = floor(center - 2.0 * scale) + 1.0;
|
||||||
@@ -776,7 +804,7 @@ vec4 sample_plane(sampler2D plane, vec2 size, vec2 coord) {
|
|||||||
float offset = first + float(tap) - center;
|
float offset = first + float(tap) - center;
|
||||||
if (offset >= 2.0 * scale) break;
|
if (offset >= 2.0 * scale) break;
|
||||||
float weight = lanczos2(offset / scale);
|
float weight = lanczos2(offset / scale);
|
||||||
sum += weight * texture2D(plane, coord + filter_direction * offset / size);
|
sum += weight * SAMPLE(plane, coord + filter_direction * offset / size);
|
||||||
weight_sum += weight;
|
weight_sum += weight;
|
||||||
}
|
}
|
||||||
return sum / weight_sum;
|
return sum / weight_sum;
|
||||||
@@ -785,7 +813,7 @@ vec3 sample_video(vec2 coord) {
|
|||||||
vec4 y_sample = sample_plane(y_texture, source_size, coord);
|
vec4 y_sample = sample_plane(y_texture, source_size, coord);
|
||||||
if (packed_video > 0.5) return y_sample.rgb;
|
if (packed_video > 0.5) return y_sample.rgb;
|
||||||
return vec3(y_sample.r,
|
return vec3(y_sample.r,
|
||||||
sample_plane(uv_texture, source_size * 0.5, coord).ra);
|
sample_plane(uv_texture, source_size * 0.5, coord).UV_CHANNELS);
|
||||||
}
|
}
|
||||||
void main() {
|
void main() {
|
||||||
vec2 surface_point = video_texcoord * target_size;
|
vec2 surface_point = video_texcoord * target_size;
|
||||||
@@ -800,7 +828,7 @@ void main() {
|
|||||||
vec3 yuv = sample_video(frame_texcoord);
|
vec3 yuv = sample_video(frame_texcoord);
|
||||||
if (filter_direction.x + filter_direction.y > 0.5) {
|
if (filter_direction.x + filter_direction.y > 0.5) {
|
||||||
// Intermediate RGBA textures carry Y/U/V until the final conversion.
|
// Intermediate RGBA textures carry Y/U/V until the final conversion.
|
||||||
gl_FragColor = vec4(yuv, 1.0);
|
fragment_color = vec4(yuv, 1.0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
float y = 1.16438356 * (yuv.x - 16.0 / 255.0);
|
float y = 1.16438356 * (yuv.x - 16.0 / 255.0);
|
||||||
@@ -820,7 +848,7 @@ void main() {
|
|||||||
min(max(corner.x, corner.y), 0.0) - corner_radius;
|
min(max(corner.x, corner.y), 0.0) - corner_radius;
|
||||||
coverage = clamp(0.5 - distance_to_edge, 0.0, 1.0);
|
coverage = clamp(0.5 - distance_to_edge, 0.0, 1.0);
|
||||||
}
|
}
|
||||||
gl_FragColor = vec4(rgb * coverage, coverage);
|
fragment_color = vec4(rgb * coverage, coverage);
|
||||||
}
|
}
|
||||||
)glsl";
|
)glsl";
|
||||||
|
|
||||||
@@ -903,6 +931,11 @@ void main() {
|
|||||||
};
|
};
|
||||||
GLint previous_array_buffer = 0;
|
GLint previous_array_buffer = 0;
|
||||||
glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous_array_buffer);
|
glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous_array_buffer);
|
||||||
|
while (glGetError() != GL_NO_ERROR) {
|
||||||
|
}
|
||||||
|
if (impl_->modern_gl) {
|
||||||
|
impl_->gl.gen_vertex_arrays(1, &impl_->vertex_array);
|
||||||
|
}
|
||||||
impl_->gl.gen_buffers(1, &impl_->vertex_buffer);
|
impl_->gl.gen_buffers(1, &impl_->vertex_buffer);
|
||||||
impl_->gl.bind_buffer(GL_ARRAY_BUFFER, impl_->vertex_buffer);
|
impl_->gl.bind_buffer(GL_ARRAY_BUFFER, impl_->vertex_buffer);
|
||||||
impl_->gl.buffer_data(GL_ARRAY_BUFFER, sizeof(kVertices), kVertices,
|
impl_->gl.buffer_data(GL_ARRAY_BUFFER, sizeof(kVertices), kVertices,
|
||||||
@@ -918,8 +951,6 @@ void main() {
|
|||||||
impl_->gl.active_texture(GL_TEXTURE1);
|
impl_->gl.active_texture(GL_TEXTURE1);
|
||||||
GLint previous_texture_1 = 0;
|
GLint previous_texture_1 = 0;
|
||||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
|
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
|
||||||
while (glGetError() != GL_NO_ERROR) {
|
|
||||||
}
|
|
||||||
glGenTextures(1, &impl_->y_texture);
|
glGenTextures(1, &impl_->y_texture);
|
||||||
glGenTextures(1, &impl_->uv_texture);
|
glGenTextures(1, &impl_->uv_texture);
|
||||||
for (const auto [unit, texture] :
|
for (const auto [unit, texture] :
|
||||||
@@ -940,6 +971,7 @@ void main() {
|
|||||||
|
|
||||||
const GLenum setup_error = glGetError();
|
const GLenum setup_error = glGetError();
|
||||||
if (impl_->program == 0 || impl_->vertex_buffer == 0 ||
|
if (impl_->program == 0 || impl_->vertex_buffer == 0 ||
|
||||||
|
(impl_->modern_gl && impl_->vertex_array == 0) ||
|
||||||
impl_->y_texture == 0 || impl_->uv_texture == 0 ||
|
impl_->y_texture == 0 || impl_->uv_texture == 0 ||
|
||||||
setup_error != GL_NO_ERROR) {
|
setup_error != GL_NO_ERROR) {
|
||||||
LOG_ERROR("OpenGL NV12 resource setup failed, error={}", setup_error);
|
LOG_ERROR("OpenGL NV12 resource setup failed, error={}", setup_error);
|
||||||
@@ -950,9 +982,16 @@ void main() {
|
|||||||
impl_->ready.store(true, std::memory_order_release);
|
impl_->ready.store(true, std::memory_order_release);
|
||||||
const auto *vendor = reinterpret_cast<const char *>(glGetString(GL_VENDOR));
|
const auto *vendor = reinterpret_cast<const char *>(glGetString(GL_VENDOR));
|
||||||
const auto *renderer = reinterpret_cast<const char *>(glGetString(GL_RENDERER));
|
const auto *renderer = reinterpret_cast<const char *>(glGetString(GL_RENDERER));
|
||||||
LOG_INFO("OpenGL NV12 underlay initialized ({}), vendor={}, renderer={}",
|
GLint profile = 0;
|
||||||
|
if (!is_gles && (major > 3 || (major == 3 && minor >= 2))) {
|
||||||
|
glGetIntegerv(GL_CONTEXT_PROFILE_MASK, &profile);
|
||||||
|
}
|
||||||
|
LOG_INFO("OpenGL NV12 underlay initialized ({}), vendor={}, renderer={}, "
|
||||||
|
"profile={}, textures={}, vao={}",
|
||||||
version ? version : "unknown OpenGL version",
|
version ? version : "unknown OpenGL version",
|
||||||
vendor ? vendor : "unknown", renderer ? renderer : "unknown");
|
vendor ? vendor : "unknown", renderer ? renderer : "unknown",
|
||||||
|
profile, impl_->modern_gl ? "R/RG" : "luminance",
|
||||||
|
impl_->vertex_array);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,6 +1012,9 @@ void OpenGlVideoRenderer::Teardown() {
|
|||||||
if (impl_->vertex_buffer != 0 && impl_->gl.delete_buffers) {
|
if (impl_->vertex_buffer != 0 && impl_->gl.delete_buffers) {
|
||||||
impl_->gl.delete_buffers(1, &impl_->vertex_buffer);
|
impl_->gl.delete_buffers(1, &impl_->vertex_buffer);
|
||||||
}
|
}
|
||||||
|
if (impl_->vertex_array != 0 && impl_->gl.delete_vertex_arrays) {
|
||||||
|
impl_->gl.delete_vertex_arrays(1, &impl_->vertex_array);
|
||||||
|
}
|
||||||
if (impl_->program != 0 && impl_->gl.delete_program) {
|
if (impl_->program != 0 && impl_->gl.delete_program) {
|
||||||
impl_->gl.delete_program(impl_->program);
|
impl_->gl.delete_program(impl_->program);
|
||||||
}
|
}
|
||||||
@@ -1056,6 +1098,9 @@ OpenGlVideoRenderer::SubmitResult OpenGlVideoRenderer::SubmitNv12Internal(
|
|||||||
|
|
||||||
auto &frames = *impl_->frames;
|
auto &frames = *impl_->frames;
|
||||||
std::lock_guard lock(frames.mutex);
|
std::lock_guard lock(frames.mutex);
|
||||||
|
if (!IsReady()) {
|
||||||
|
return SubmitResult::failed;
|
||||||
|
}
|
||||||
if (frames.selected_stream != remote_id) {
|
if (frames.selected_stream != remote_id) {
|
||||||
return SubmitResult::not_selected;
|
return SubmitResult::not_selected;
|
||||||
}
|
}
|
||||||
@@ -1194,9 +1239,12 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
int target_width, int target_height,
|
int target_width, int target_height,
|
||||||
int top_inset_pixels,
|
int top_inset_pixels,
|
||||||
int corner_radius_pixels) {
|
int corner_radius_pixels) {
|
||||||
if (!IsReady() || target_width <= 0 || target_height <= 0) {
|
if (!IsReady()) {
|
||||||
return {RenderResult::failed};
|
return {RenderResult::failed};
|
||||||
}
|
}
|
||||||
|
if (target_width <= 0 || target_height <= 0) {
|
||||||
|
return {RenderResult::idle};
|
||||||
|
}
|
||||||
|
|
||||||
size_t slot_index = kFrameSlotCount;
|
size_t slot_index = kFrameSlotCount;
|
||||||
uint64_t sequence = 0;
|
uint64_t sequence = 0;
|
||||||
@@ -1286,10 +1334,18 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
GLint previous_texture_1 = 0;
|
GLint previous_texture_1 = 0;
|
||||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
|
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
|
||||||
|
|
||||||
const auto position_state = CaptureVertexAttrib(
|
GLint previous_vertex_array = 0;
|
||||||
|
VertexAttribState position_state;
|
||||||
|
VertexAttribState texcoord_state;
|
||||||
|
if (impl_->vertex_array != 0) {
|
||||||
|
glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &previous_vertex_array);
|
||||||
|
impl_->gl.bind_vertex_array(impl_->vertex_array);
|
||||||
|
} else {
|
||||||
|
position_state = CaptureVertexAttrib(
|
||||||
impl_->gl, static_cast<GLuint>(impl_->position_location));
|
impl_->gl, static_cast<GLuint>(impl_->position_location));
|
||||||
const auto texcoord_state = CaptureVertexAttrib(
|
texcoord_state = CaptureVertexAttrib(
|
||||||
impl_->gl, static_cast<GLuint>(impl_->texcoord_location));
|
impl_->gl, static_cast<GLuint>(impl_->texcoord_location));
|
||||||
|
}
|
||||||
|
|
||||||
glDisable(GL_BLEND);
|
glDisable(GL_BLEND);
|
||||||
glDisable(GL_CULL_FACE);
|
glDisable(GL_CULL_FACE);
|
||||||
@@ -1401,20 +1457,26 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
if (uploaded_texture_width != source_width ||
|
if (uploaded_texture_width != source_width ||
|
||||||
uploaded_texture_height != source_height) {
|
uploaded_texture_height != source_height) {
|
||||||
impl_->gl.active_texture(GL_TEXTURE0);
|
impl_->gl.active_texture(GL_TEXTURE0);
|
||||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, source_width,
|
glTexImage2D(GL_TEXTURE_2D, 0,
|
||||||
source_height, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE,
|
impl_->modern_gl ? GL_R8 : GL_LUMINANCE, source_width,
|
||||||
|
source_height, 0,
|
||||||
|
impl_->modern_gl ? GL_RED : GL_LUMINANCE, GL_UNSIGNED_BYTE,
|
||||||
y_pixels);
|
y_pixels);
|
||||||
impl_->gl.active_texture(GL_TEXTURE1);
|
impl_->gl.active_texture(GL_TEXTURE1);
|
||||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE_ALPHA, source_width / 2,
|
glTexImage2D(GL_TEXTURE_2D, 0,
|
||||||
source_height / 2, 0, GL_LUMINANCE_ALPHA,
|
impl_->modern_gl ? GL_RG8 : GL_LUMINANCE_ALPHA,
|
||||||
|
source_width / 2, source_height / 2, 0,
|
||||||
|
impl_->modern_gl ? GL_RG : GL_LUMINANCE_ALPHA,
|
||||||
GL_UNSIGNED_BYTE, uv_pixels);
|
GL_UNSIGNED_BYTE, uv_pixels);
|
||||||
} else {
|
} else {
|
||||||
impl_->gl.active_texture(GL_TEXTURE0);
|
impl_->gl.active_texture(GL_TEXTURE0);
|
||||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width, source_height,
|
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width, source_height,
|
||||||
GL_LUMINANCE, GL_UNSIGNED_BYTE, y_pixels);
|
impl_->modern_gl ? GL_RED : GL_LUMINANCE,
|
||||||
|
GL_UNSIGNED_BYTE, y_pixels);
|
||||||
impl_->gl.active_texture(GL_TEXTURE1);
|
impl_->gl.active_texture(GL_TEXTURE1);
|
||||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width / 2,
|
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width / 2,
|
||||||
source_height / 2, GL_LUMINANCE_ALPHA,
|
source_height / 2,
|
||||||
|
impl_->modern_gl ? GL_RG : GL_LUMINANCE_ALPHA,
|
||||||
GL_UNSIGNED_BYTE, uv_pixels);
|
GL_UNSIGNED_BYTE, uv_pixels);
|
||||||
}
|
}
|
||||||
const GLenum upload_error = glGetError();
|
const GLenum upload_error = glGetError();
|
||||||
@@ -1562,10 +1624,23 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
has_video ? source_width : 0, has_video ? source_height : 0,
|
has_video ? source_width : 0, has_video ? source_height : 0,
|
||||||
has_video ? sequence : 0};
|
has_video ? sequence : 0};
|
||||||
|
|
||||||
|
if (outcome.result == RenderResult::failed) {
|
||||||
|
LOG_WARN("OpenGL NV12 presentation failed: upload={}, draw_error={}, "
|
||||||
|
"vao={}; disabling native video until the context is recreated",
|
||||||
|
upload_succeeded, draw_error, impl_->vertex_array);
|
||||||
|
// Do not expose the desktop during the UI tick that switches to pixels.
|
||||||
|
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||||
|
glClear(GL_COLOR_BUFFER_BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (impl_->vertex_array != 0) {
|
||||||
|
impl_->gl.bind_vertex_array(static_cast<GLuint>(previous_vertex_array));
|
||||||
|
} else {
|
||||||
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->position_location),
|
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->position_location),
|
||||||
position_state);
|
position_state);
|
||||||
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->texcoord_location),
|
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->texcoord_location),
|
||||||
texcoord_state);
|
texcoord_state);
|
||||||
|
}
|
||||||
impl_->gl.bind_buffer(GL_ARRAY_BUFFER,
|
impl_->gl.bind_buffer(GL_ARRAY_BUFFER,
|
||||||
static_cast<GLuint>(previous_array_buffer));
|
static_cast<GLuint>(previous_array_buffer));
|
||||||
if (impl_->pixel_unpack_buffer_available) {
|
if (impl_->pixel_unpack_buffer_available) {
|
||||||
@@ -1600,9 +1675,13 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
|||||||
const GLenum restore_error = glGetError();
|
const GLenum restore_error = glGetError();
|
||||||
if (restore_error != GL_NO_ERROR) {
|
if (restore_error != GL_NO_ERROR) {
|
||||||
LOG_WARN("OpenGL NV12 state restoration failed, error={}", restore_error);
|
LOG_WARN("OpenGL NV12 state restoration failed, error={}", restore_error);
|
||||||
if (outcome.result == RenderResult::rendered) {
|
|
||||||
outcome.result = RenderResult::failed;
|
outcome.result = RenderResult::failed;
|
||||||
}
|
}
|
||||||
|
if (outcome.result == RenderResult::failed) {
|
||||||
|
// Keep the last decoded frame available for a CPU snapshot. GL resources
|
||||||
|
// are released by RenderingTeardown while the owning context is current.
|
||||||
|
std::lock_guard lock(impl_->frames->mutex);
|
||||||
|
impl_->ready.store(false, std::memory_order_release);
|
||||||
}
|
}
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,62 @@
|
|||||||
|
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <initializer_list>
|
#include <initializer_list>
|
||||||
|
#include <memory>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <X11/Xlib.h>
|
||||||
|
#include <X11/extensions/Xrandr.h>
|
||||||
|
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
|
|
||||||
#include "platform/common/gui/cuda_opengl_interop.h"
|
#include "platform/common/gui/cuda_opengl_interop.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
#if USE_CUDA && (defined(__x86_64__) || defined(__amd64__))
|
||||||
|
bool HasNvidiaOffloadProvider() {
|
||||||
|
// CUDA availability says nothing about the X server's PRIME topology.
|
||||||
|
// In particular, NVIDIA-0 is a normal display provider, not an offload
|
||||||
|
// GPU screen. Forcing offload in that configuration can render into a GL
|
||||||
|
// buffer successfully while leaving the actual window blank.
|
||||||
|
const char* display_name = std::getenv("DISPLAY");
|
||||||
|
if (!display_name || display_name[0] == '\0') return false;
|
||||||
|
std::unique_ptr<Display, decltype(&XCloseDisplay)> display(
|
||||||
|
XOpenDisplay(display_name), XCloseDisplay);
|
||||||
|
if (!display) return false;
|
||||||
|
|
||||||
|
int major = 0, minor = 0;
|
||||||
|
if (!XRRQueryVersion(display.get(), &major, &minor) || major < 1 ||
|
||||||
|
(major == 1 && minor < 4)) return false;
|
||||||
|
|
||||||
|
const auto root = XDefaultRootWindow(display.get());
|
||||||
|
std::unique_ptr<XRRScreenResources, decltype(&XRRFreeScreenResources)> screen(
|
||||||
|
XRRGetScreenResourcesCurrent(display.get(), root), XRRFreeScreenResources);
|
||||||
|
std::unique_ptr<XRRProviderResources, decltype(&XRRFreeProviderResources)>
|
||||||
|
providers(XRRGetProviderResources(display.get(), root),
|
||||||
|
XRRFreeProviderResources);
|
||||||
|
if (!screen || !providers) return false;
|
||||||
|
|
||||||
|
for (int i = 0; i < providers->nproviders; ++i) {
|
||||||
|
std::unique_ptr<XRRProviderInfo, decltype(&XRRFreeProviderInfo)> provider(
|
||||||
|
XRRGetProviderInfo(display.get(), screen.get(), providers->providers[i]),
|
||||||
|
XRRFreeProviderInfo);
|
||||||
|
if (!provider || !provider->name || provider->nameLen <= 0) continue;
|
||||||
|
const std::string_view name(provider->name, provider->nameLen);
|
||||||
|
// NVIDIA documents offload GPU screens as NVIDIA-G0, NVIDIA-G1, etc.
|
||||||
|
// Their RandR capability mask can be zero, so SourceOffload is not a
|
||||||
|
// reliable test for the proprietary driver's offload support.
|
||||||
|
constexpr std::string_view prefix = "NVIDIA-G";
|
||||||
|
if (name.size() > prefix.size() && name.substr(0, prefix.size()) == prefix &&
|
||||||
|
name.find_first_not_of("0123456789", prefix.size()) ==
|
||||||
|
std::string_view::npos) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void ConfigureLinuxCudaGraphics() {
|
void ConfigureLinuxCudaGraphics() {
|
||||||
#if USE_CUDA && (defined(__x86_64__) || defined(__amd64__))
|
#if USE_CUDA && (defined(__x86_64__) || defined(__amd64__))
|
||||||
@@ -28,6 +78,11 @@ void ConfigureLinuxCudaGraphics() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!HasNvidiaOffloadProvider()) {
|
||||||
|
LOG_INFO("Keeping default Linux graphics selection: no NVIDIA PRIME "
|
||||||
|
"offload GPU screen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!IsCudaDeviceAvailable()) {
|
if (!IsCudaDeviceAvailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
// Call before SDL or Slint initializes EGL/GLX. Hybrid laptops otherwise
|
// Call before SDL or Slint initializes EGL/GLX. Select NVIDIA on hybrid
|
||||||
// create the GUI context on the integrated GPU while NVDEC uses NVIDIA.
|
// systems with a PRIME offload GPU screen so GL can share NVDEC buffers.
|
||||||
|
// Keep the default driver when NVIDIA already owns the display or the
|
||||||
|
// display server does not advertise offload support.
|
||||||
void ConfigureLinuxCudaGraphics();
|
void ConfigureLinuxCudaGraphics();
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -126,6 +126,42 @@ float MainWindowInitialPositionYOffset() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool CenterNativeWindowOnScreen(void *opaque_slint_view) {
|
||||||
|
@autoreleasepool {
|
||||||
|
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||||
|
NSWindow *window = slint_view.window;
|
||||||
|
NSScreen *screen = window.screen ?: NSScreen.mainScreen;
|
||||||
|
if (!window || !screen) return false;
|
||||||
|
CenterWindowOnScreen(window, screen);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigureWhiteWindowChrome(void *opaque_slint_view) {
|
||||||
|
@autoreleasepool {
|
||||||
|
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||||
|
NSWindow *window = slint_view.window;
|
||||||
|
if (window == nil ||
|
||||||
|
(window.styleMask & NSWindowStyleMaskTitled) == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Winit creates a transparent AppKit backing even when Slint paints an
|
||||||
|
// opaque client area. macOS 27 can expose that backing in the titlebar.
|
||||||
|
// Use a fixed white backing instead of AppKit's theme-dependent titlebar
|
||||||
|
// material. Aqua keeps the native title and controls readable on white.
|
||||||
|
window.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
|
||||||
|
window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua];
|
||||||
|
window.titlebarAppearsTransparent = YES;
|
||||||
|
window.opaque = YES;
|
||||||
|
window.backgroundColor = NSColor.whiteColor;
|
||||||
|
NSView *frame_view = window.contentView.superview;
|
||||||
|
frame_view.needsLayout = YES;
|
||||||
|
[frame_view layoutSubtreeIfNeeded];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
|
bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||||
@@ -150,21 +186,8 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
|
|||||||
NSWindowCollectionBehaviorFullScreenAllowsTiling)) |
|
NSWindowCollectionBehaviorFullScreenAllowsTiling)) |
|
||||||
NSWindowCollectionBehaviorFullScreenNone;
|
NSWindowCollectionBehaviorFullScreenNone;
|
||||||
|
|
||||||
// Winit creates a transparent AppKit backing even when Slint paints an
|
|
||||||
// opaque client area. macOS 27 can expose that backing in the titlebar.
|
|
||||||
// Use a fixed white backing instead of AppKit's theme-dependent titlebar
|
|
||||||
// material. Aqua keeps the native title and controls readable on white.
|
|
||||||
window.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
|
|
||||||
window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua];
|
|
||||||
window.titlebarAppearsTransparent = YES;
|
|
||||||
window.opaque = YES;
|
|
||||||
window.backgroundColor = NSColor.whiteColor;
|
|
||||||
HideMainWindowZoomButton(window);
|
HideMainWindowZoomButton(window);
|
||||||
|
return ConfigureWhiteWindowChrome(opaque_slint_view);
|
||||||
NSView *frame_view = window.contentView.superview;
|
|
||||||
frame_view.needsLayout = YES;
|
|
||||||
[frame_view layoutSubtreeIfNeeded];
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ namespace crossdesk {
|
|||||||
// left-mouse-down event. Returns false when no suitable native event exists.
|
// left-mouse-down event. Returns false when no suitable native event exists.
|
||||||
bool StartNativeWindowDrag();
|
bool StartNativeWindowDrag();
|
||||||
|
|
||||||
|
// Gives a titled AppKit window an opaque white backing, preserving its resize
|
||||||
|
// and fullscreen behavior. Returns false until its native window is ready.
|
||||||
|
bool ConfigureWhiteWindowChrome(void* slint_view);
|
||||||
|
// Centers the complete AppKit window frame in its screen's usable area.
|
||||||
|
// Returns false until its native window and screen are ready.
|
||||||
|
bool CenterNativeWindowOnScreen(void* slint_view);
|
||||||
|
|
||||||
// Configures the connection or DeskHub window with a white native titlebar.
|
// Configures the connection or DeskHub window with a white native titlebar.
|
||||||
// Only DeskHub is resizable. |slint_view| is its AppKit NSView*.
|
// Only DeskHub is resizable. |slint_view| is its AppKit NSView*.
|
||||||
// Returns false while the native window or its titlebar controls are not ready.
|
// Returns false while the native window or its titlebar controls are not ready.
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class FileReceiver {
|
|||||||
bool OnData(const char* data, size_t size) { return session_.Receive(data, size); }
|
bool OnData(const char* data, size_t size) { return session_.Receive(data, size); }
|
||||||
void Poll() { session_.Pump(); }
|
void Poll() { session_.Pump(); }
|
||||||
void Cancel() { session_.Cancel(); }
|
void Cancel() { session_.Cancel(); }
|
||||||
|
bool ReserveDirectory(uint32_t id, const std::filesystem::path& directory) {
|
||||||
|
return session_.ReserveDirectory(id, directory);
|
||||||
|
}
|
||||||
// Set once, before the receiver is published to transport callbacks.
|
// Set once, before the receiver is published to transport callbacks.
|
||||||
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = std::move(cb); }
|
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = std::move(cb); }
|
||||||
void SetOnReceiveProgress(OnReceiveProgress cb) { on_receive_progress_ = std::move(cb); }
|
void SetOnReceiveProgress(OnReceiveProgress cb) { on_receive_progress_ = std::move(cb); }
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#include "platform/linux/gui/cuda_graphics.h"
|
||||||
|
#include "rd_log.h"
|
||||||
|
|
||||||
|
#include <X11/Xlib.h>
|
||||||
|
#include <X11/extensions/Xrandr.h>
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Exercise the actual startup policy without requiring a particular GPU or
|
||||||
|
// X server. NVIDIA offload providers deliberately have a zero capability mask.
|
||||||
|
std::vector<std::string> provider_names;
|
||||||
|
std::vector<RRProvider> provider_ids;
|
||||||
|
bool display_available = true, cuda_available = true, randr_available = true;
|
||||||
|
bool resources_available = true, info_available = true;
|
||||||
|
int randr_minor = 4, display_opens = 0, display_closes = 0;
|
||||||
|
|
||||||
|
constexpr const char* selection_variables[] = {
|
||||||
|
"__NV_PRIME_RENDER_OFFLOAD", "__NV_PRIME_RENDER_OFFLOAD_PROVIDER",
|
||||||
|
"__GLX_VENDOR_LIBRARY_NAME", "__EGL_VENDOR_LIBRARY_FILENAMES",
|
||||||
|
"__EGL_VENDOR_LIBRARY_DIRS", "DRI_PRIME", "LIBGL_ALWAYS_SOFTWARE",
|
||||||
|
"MESA_LOADER_DRIVER_OVERRIDE"};
|
||||||
|
|
||||||
|
void Reset(std::vector<std::string> names) {
|
||||||
|
for (const auto* name : selection_variables) unsetenv(name);
|
||||||
|
setenv("DISPLAY", ":test", 1);
|
||||||
|
provider_names = std::move(names);
|
||||||
|
provider_ids.resize(provider_names.size());
|
||||||
|
for (size_t i = 0; i < provider_ids.size(); ++i) provider_ids[i] = i;
|
||||||
|
display_available = cuda_available = randr_available = true;
|
||||||
|
resources_available = info_available = true;
|
||||||
|
randr_minor = 4;
|
||||||
|
display_opens = display_closes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CheckSelection(bool offload, const char* scenario) {
|
||||||
|
crossdesk::ConfigureLinuxCudaGraphics();
|
||||||
|
const char* prime = std::getenv("__NV_PRIME_RENDER_OFFLOAD");
|
||||||
|
const char* vendor = std::getenv("__GLX_VENDOR_LIBRARY_NAME");
|
||||||
|
const bool matches = offload
|
||||||
|
? prime && std::string(prime) == "1" && vendor && std::string(vendor) == "nvidia"
|
||||||
|
: !prime && !vendor;
|
||||||
|
if (!matches || display_opens != display_closes) {
|
||||||
|
std::cerr << "Incorrect graphics selection or leaked X display: " << scenario << '\n';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Link-time substitutes for the external XRandR/CUDA queries, not for the
|
||||||
|
// selection logic under test. No real display or CUDA context is initialized.
|
||||||
|
extern "C" {
|
||||||
|
Display* XOpenDisplay(const char*) {
|
||||||
|
if (!display_available) return nullptr;
|
||||||
|
++display_opens;
|
||||||
|
return reinterpret_cast<Display*>(&display_opens);
|
||||||
|
}
|
||||||
|
int XCloseDisplay(Display*) { ++display_closes; return 0; }
|
||||||
|
Window XDefaultRootWindow(Display*) { return 1; }
|
||||||
|
Status XRRQueryVersion(Display*, int* major, int* minor) {
|
||||||
|
*major = 1;
|
||||||
|
*minor = randr_minor;
|
||||||
|
return randr_available;
|
||||||
|
}
|
||||||
|
XRRScreenResources* XRRGetScreenResourcesCurrent(Display*, Window) {
|
||||||
|
return resources_available ? new XRRScreenResources{} : nullptr;
|
||||||
|
}
|
||||||
|
void XRRFreeScreenResources(XRRScreenResources* value) { delete value; }
|
||||||
|
XRRProviderResources* XRRGetProviderResources(Display*, Window) {
|
||||||
|
if (!resources_available) return nullptr;
|
||||||
|
auto* result = new XRRProviderResources{};
|
||||||
|
result->nproviders = static_cast<int>(provider_ids.size());
|
||||||
|
result->providers = provider_ids.data();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
void XRRFreeProviderResources(XRRProviderResources* value) { delete value; }
|
||||||
|
XRRProviderInfo* XRRGetProviderInfo(Display*, XRRScreenResources*, RRProvider id) {
|
||||||
|
if (!info_available) return nullptr;
|
||||||
|
auto* result = new XRRProviderInfo{};
|
||||||
|
result->name = provider_names.at(id).data();
|
||||||
|
result->nameLen = static_cast<int>(provider_names.at(id).size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
void XRRFreeProviderInfo(XRRProviderInfo* value) { delete value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
bool IsCudaDeviceAvailable() { return cuda_available; }
|
||||||
|
std::shared_ptr<spdlog::logger> get_logger() { return spdlog::default_logger(); }
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
bool ok = true;
|
||||||
|
Reset({"NVIDIA-0", "AMD Radeon Graphics @ pci:0000:05:00.0"});
|
||||||
|
ok &= CheckSelection(false, "NVIDIA primary with an AMD display sink");
|
||||||
|
Reset({"NVIDIA-0"});
|
||||||
|
ok &= CheckSelection(false, "NVIDIA-only desktop");
|
||||||
|
Reset({"modesetting", "NVIDIA-G0"});
|
||||||
|
ok &= CheckSelection(true, "integrated primary with NVIDIA offload");
|
||||||
|
Reset({"modesetting", "NVIDIA-G12"});
|
||||||
|
ok &= CheckSelection(true, "numbered offload GPU screen");
|
||||||
|
Reset({"modesetting", "NVIDIA-G", "NVIDIA-G0-extra", ""});
|
||||||
|
ok &= CheckSelection(false, "no recognized NVIDIA GPU screen");
|
||||||
|
Reset({});
|
||||||
|
ok &= CheckSelection(false, "no providers");
|
||||||
|
Reset({"NVIDIA-G0"});
|
||||||
|
cuda_available = false;
|
||||||
|
ok &= CheckSelection(false, "CUDA unavailable");
|
||||||
|
Reset({"NVIDIA-G0"});
|
||||||
|
unsetenv("DISPLAY");
|
||||||
|
ok &= CheckSelection(false, "no X display");
|
||||||
|
Reset({"NVIDIA-G0"});
|
||||||
|
display_available = false;
|
||||||
|
ok &= CheckSelection(false, "X display inaccessible");
|
||||||
|
Reset({"NVIDIA-G0"});
|
||||||
|
randr_available = false;
|
||||||
|
ok &= CheckSelection(false, "RandR unavailable");
|
||||||
|
Reset({"NVIDIA-G0"});
|
||||||
|
randr_minor = 3;
|
||||||
|
ok &= CheckSelection(false, "RandR predates provider queries");
|
||||||
|
Reset({"NVIDIA-G0"});
|
||||||
|
resources_available = false;
|
||||||
|
ok &= CheckSelection(false, "RandR resources unavailable");
|
||||||
|
Reset({"NVIDIA-G0"});
|
||||||
|
info_available = false;
|
||||||
|
ok &= CheckSelection(false, "provider info unavailable");
|
||||||
|
|
||||||
|
// Any explicit graphics selection must win, including PRIME=0.
|
||||||
|
for (const auto* name : selection_variables) {
|
||||||
|
Reset({"modesetting", "NVIDIA-G0"});
|
||||||
|
setenv(name, "0", 1);
|
||||||
|
crossdesk::ConfigureLinuxCudaGraphics();
|
||||||
|
for (const auto* other : selection_variables) {
|
||||||
|
const char* value = std::getenv(other);
|
||||||
|
const bool preserved = std::string(name) == other
|
||||||
|
? value && std::string(value) == "0" : !value;
|
||||||
|
if (!preserved) { std::cerr << "Changed explicit selection: " << name << '\n'; ok = false; }
|
||||||
|
}
|
||||||
|
if (display_opens) { std::cerr << "Probed explicitly selected graphics\n"; ok = false; }
|
||||||
|
}
|
||||||
|
return ok ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "ui/model_update.h"
|
#include "ui/model_update.h"
|
||||||
#include "ui/ui_localization.h"
|
#include "ui/ui_localization.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -330,6 +331,60 @@ bool VerifyGalleryPopups(RenderTestPlatform& platform) {
|
|||||||
return Check(!wall->get_gallery_adding(), "Add dialog Cancel must respond immediately");
|
return Check(!wall->get_gallery_adding(), "Add dialog Cancel must respond immediately");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool VerifyUpdateRecovery(RenderTestPlatform& platform) {
|
||||||
|
auto main = CreateMainWindow();
|
||||||
|
auto& screen = *platform.adapter;
|
||||||
|
const float width = main->get_minimum_window_width();
|
||||||
|
const float height = main->get_minimum_window_height();
|
||||||
|
main->window().set_size(slint::LogicalSize({width, height}));
|
||||||
|
main->set_update_available(true);
|
||||||
|
main->set_update_install_available(true);
|
||||||
|
main->set_latest_version("v2.0.0");
|
||||||
|
int install_requests = 0, manual_downloads = 0;
|
||||||
|
main->on_open_download([&] { ++install_requests; main->set_update_confirm_open(true); });
|
||||||
|
main->on_open_manual_download([&] { ++manual_downloads; });
|
||||||
|
main->show();
|
||||||
|
screen.Settle();
|
||||||
|
const float button_y = height / 2 + std::min(488.f, height - 32) / 2 - 41;
|
||||||
|
const float action_x = width / 2 + 161;
|
||||||
|
const float manual_x = action_x - 118;
|
||||||
|
const auto action_pixels = [&] {
|
||||||
|
return screen.Region(int(action_x - 40), int(button_y - 12), 80, 24);
|
||||||
|
};
|
||||||
|
const auto install_label = action_pixels();
|
||||||
|
main->set_update_install_failed(true);
|
||||||
|
main->set_update_install_status(crossdesk::ui_localization::Text(
|
||||||
|
crossdesk::localization::update_install_failed[0] + "\nDownload failed"));
|
||||||
|
screen.Settle();
|
||||||
|
if (!Check(action_pixels() != install_label,
|
||||||
|
"A failed update must visibly replace Install update with Retry update")) return false;
|
||||||
|
screen.Click(manual_x, button_y);
|
||||||
|
if (!Check(manual_downloads == 1 && install_requests == 0 && !main->get_update_confirm_open(),
|
||||||
|
"Manual download must remain reachable after failure without starting automatic installation")) return false;
|
||||||
|
screen.Click(action_x, button_y);
|
||||||
|
if (!Check(install_requests == 1 && main->get_update_confirm_open(),
|
||||||
|
"Retry must open the installation confirmation")) return false;
|
||||||
|
screen.Key(slint::platform::key_codes::Escape);
|
||||||
|
if (!Check(!main->get_update_confirm_open(), "Retry confirmation must remain cancellable")) return false;
|
||||||
|
main->set_update_install_busy(true);
|
||||||
|
screen.Settle();
|
||||||
|
screen.Click(action_x, button_y);
|
||||||
|
screen.Click(manual_x, button_y);
|
||||||
|
if (!Check(install_requests == 1 && manual_downloads == 1,
|
||||||
|
"Recovery buttons must reject repeated clicks while an update is running")) return false;
|
||||||
|
main->set_update_install_busy(false);
|
||||||
|
main->set_update_install_available(false);
|
||||||
|
main->set_update_install_status(crossdesk::ui_localization::Text(
|
||||||
|
crossdesk::localization::update_install_failed_manual[0]));
|
||||||
|
// The application's primary action falls back to manual download when the
|
||||||
|
// current installation or signed manifest no longer supports automatic updates.
|
||||||
|
main->on_open_download([&] { ++manual_downloads; });
|
||||||
|
screen.Settle();
|
||||||
|
screen.Click(action_x, button_y);
|
||||||
|
return Check(manual_downloads == 2 && !main->get_update_confirm_open(),
|
||||||
|
"An unavailable automatic retry must retain the primary manual download action");
|
||||||
|
}
|
||||||
|
|
||||||
bool VerifyServerPanel(RenderTestPlatform& platform) {
|
bool VerifyServerPanel(RenderTestPlatform& platform) {
|
||||||
auto panel = crossdesk::ui::ServerWindow::create();
|
auto panel = crossdesk::ui::ServerWindow::create();
|
||||||
auto& screen = *platform.adapter;
|
auto& screen = *platform.adapter;
|
||||||
@@ -377,5 +432,6 @@ int main() {
|
|||||||
if (!VerifyGalleryPopups(*backend)) return 3;
|
if (!VerifyGalleryPopups(*backend)) return 3;
|
||||||
if (!VerifyServerPanel(*backend)) return 4;
|
if (!VerifyServerPanel(*backend)) return 4;
|
||||||
if (!VerifyAutostartSettings(*backend)) return 5;
|
if (!VerifyAutostartSettings(*backend)) return 5;
|
||||||
|
if (!VerifyUpdateRecovery(*backend)) return 6;
|
||||||
std::cout << "Slint popup rendering and local input passed.\n";
|
std::cout << "Slint popup rendering and local input passed.\n";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,17 @@ function setup_targets()
|
|||||||
add_files("apps/desktop/tests/slint_ui_smoke_test.cpp")
|
add_files("apps/desktop/tests/slint_ui_smoke_test.cpp")
|
||||||
add_rules("crossdesk.slint_runtime")
|
add_rules("crossdesk.slint_runtime")
|
||||||
|
|
||||||
|
if is_os("linux") and is_arch("x86_64") and is_config("USE_CUDA", true) then
|
||||||
|
target("linux_cuda_graphics_test")
|
||||||
|
set_kind("binary")
|
||||||
|
set_default(false)
|
||||||
|
add_packages("spdlog")
|
||||||
|
add_includedirs("apps/desktop/src", "apps/desktop/src/log",
|
||||||
|
"deps/submodules/minirtc/src/api")
|
||||||
|
add_files("apps/desktop/tests/linux_cuda_graphics_test.cpp",
|
||||||
|
"apps/desktop/src/platform/linux/gui/cuda_graphics.cpp")
|
||||||
|
end
|
||||||
|
|
||||||
target("slint_stream_render_test")
|
target("slint_stream_render_test")
|
||||||
set_kind("binary")
|
set_kind("binary")
|
||||||
set_languages("c++20")
|
set_languages("c++20")
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
"NSLocalNetworkUsageDescription" = "CrossDesk 需要访问本地网络,以连接同一局域网内的电脑,进行远程控制和文件传输。";
|
"NSLocalNetworkUsageDescription" = "CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# CrossDesk MCP
|
||||||
|
|
||||||
|
让本机 Agent 通过 MCP 观察并操作远端 CrossDesk,同时为用户提供浏览器、终端或 MCP Apps 监看面板。控制端不需要安装 CrossDesk 桌面客户端;被控端仍需运行兼容的 CrossDesk,并连接支持 `cli` 注册的信令服务器。
|
||||||
|
|
||||||
|
此目录实现 MCP 包 `@chunkdash/crossdesk-mcp` 0.1.0,绑定原生 CLI 包 0.2.0。**源码实现和本地打包不代表这些版本已经发布到 npm。** 发布后的安装示例需要以实际可用版本为准。
|
||||||
|
|
||||||
|
## 从源码运行
|
||||||
|
|
||||||
|
仓库根目录构建原生程序(已有工作区可保留原有桌面构建选项):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh apps/cli/scripts/prepare-deps.sh
|
||||||
|
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
|
||||||
|
xmake build crossdesk-cli
|
||||||
|
cd apps/mcp
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Node.js 要求 22 或以上。在 Agent 宿主中配置本地 stdio 进程,例如 macOS arm64 源码构建:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"crossdesk": {
|
||||||
|
"command": "node",
|
||||||
|
"args": [
|
||||||
|
"/absolute/path/crossdesk/apps/mcp/dist/main.js",
|
||||||
|
"serve", "--stdio", "--panel=auto",
|
||||||
|
"--native-binary", "/absolute/path/crossdesk/build/macosx/arm64/release/crossdesk-cli"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 和 Linux 使用各自构建产物路径;`--native-binary` 必须指向可信原生可执行程序,不接受 shell 命令。也可以通过 `CROSSDESK_NATIVE_BINARY` 显式指定开发产物。未指定时只从包内的固定版本平台依赖解析,不搜索 PATH。服务启动不连接远端、不打开浏览器;连接时才检查 `doctor` 协议并启动原生子进程。
|
||||||
|
|
||||||
|
发布包的配置形式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"crossdesk": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@chunkdash/crossdesk-mcp@<已发布版本>", "serve", "--stdio", "--panel=auto"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
平台依赖覆盖 macOS arm64/x64、glibc Linux arm64/x64 和 Windows x64。安装时保留 optional dependencies;不需要另装全局 CLI。原生运行要求沿用 [CLI 分发文档](../cli/docs/npm-distribution.md)。`npm ci` 仅安装此源码目录的开发依赖,开发运行需使用 `--native-binary`;生成的发布包才包含固定平台依赖。
|
||||||
|
|
||||||
|
## 用户连接流程
|
||||||
|
|
||||||
|
1. 对 Agent 说“打开 CrossDesk”。`crossdesk_open` 创建 `view_id`,返回面板选择和等待用户状态。
|
||||||
|
2. 在面板选择已保存设备,或输入 9 位设备 ID;密码填写在面板里。可选择仅查看或允许 Agent 控制。
|
||||||
|
3. “保存设备”“记住密码”“允许以后自动连接”分别控制设备簿、系统凭据库、持久权限。后两项要求保存设备,默认均关闭。保存密码不自动授予控制权限。
|
||||||
|
4. 有待执行的远程任务时,Agent 在打开面板后立即调用 `crossdesk_get_session({view_id, wait_ms: 25000})` 等待连接。连接就绪后,工具直接返回会话 ID 和权限状态,Agent 继续任务;用户无需回到对话发送连接确认,也无需点击继续按钮。浏览器和 Apps 面板使用同一流程。
|
||||||
|
5. Agent 获取独立截图,用截图的 `observation_id` 和图像坐标提交动作。面板预览不消耗模型截图序列。
|
||||||
|
|
||||||
|
`crossdesk_open` 立即返回,让 Apps 宿主有机会渲染面板;等待连接在后续 `crossdesk_get_session` 调用中完成。按 `view_id` 查询默认最多等待 25 秒,`wait_ms: 0` 则立即查询;未就绪时返回 `wait_timed_out: true`,Agent 在原任务仍待执行时继续等待。按 `session_id` 查询仍立即返回。取消等待只取消本次查询,不断开用户正在建立或使用的连接。用户主动断开后返回 `closed`,Agent 停止等待,不自动重连。
|
||||||
|
|
||||||
|
面板发起的连接失败后,按 `view_id` 等待也会返回 `failed` 和错误码,避免 Agent 一直停留在等待用户状态。断开正在建立的连接会取消整个连接尝试。多个面板和 Agent 同时查询状态时共用一次原生查询,单次查询取消或暂时繁忙不会断开会话。`crossdesk_connect` 在连接成功但面板初始化失败时仍返回有效的 `session_id`,并附加 `panel_error`。
|
||||||
|
|
||||||
|
自动继续依赖宿主保持当前 Agent 任务及工具调用流程。它不通过网页伪造用户消息,也不承诺从已经结束的对话中主动唤醒 Agent;此类唤醒由宿主负责。
|
||||||
|
|
||||||
|
人工暂停由原生层锁定,模型不能通过 `set_control(true)` 恢复;只有当前授权面板的恢复按钮可以恢复。仅查看连接不能升级为控制连接,需重新连接并授权。关闭/隐藏面板仅停止预览,远程会话保留;断开按钮或 `crossdesk_disconnect` 才结束会话。
|
||||||
|
|
||||||
|
同一 MCP 进程默认只保留一个活动远程会话。相同配置目录中的进程用设备锁避免同时连接同一目标;它不协调另一个系统用户、另一份配置目录或独立桌面客户端。
|
||||||
|
|
||||||
|
## 面板策略
|
||||||
|
|
||||||
|
`--panel` 优先于设备配置中的 `panel`,默认 `auto`。
|
||||||
|
|
||||||
|
| 模式 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `auto` | 有本机 GUI 时打开浏览器;无 GUI 且检测到终端环境时提供 CLI 画面入口;两者均无则使用 `off` |
|
||||||
|
| `apps` | 只使用 MCP Apps;能力不匹配返回 `PANEL_UNSUPPORTED` |
|
||||||
|
| `browser` | 只使用本地浏览器,不附加 Apps 工具资源元数据 |
|
||||||
|
| `terminal` | 提供独立终端 `view` 命令,共用当前 MCP 会话,不启动网页服务 |
|
||||||
|
| `off` | 不启动网页服务,保留截图和操作工具,凭据通过独立 `setup` 配置 |
|
||||||
|
|
||||||
|
`auto` 不再优先选择 MCP Apps;需要宿主内嵌面板时显式使用 `--panel=apps`。Apps 使用 `extensions["io.modelcontextprotocol/ui"].mimeTypes` 中的 `text/html;profile=mcp-app` 判断能力。旧协议读取 initialize 能力;2026-07-28 协议读取当前请求的能力,不按宿主产品名猜测支持情况。Apps 的卡片、侧栏或窗口形式由宿主决定。
|
||||||
|
|
||||||
|
Linux 根据 `DISPLAY` / `WAYLAND_DISPLAY` 判断 GUI;macOS 检查当前用户的桌面登录会话,SSH 会话不自动打开远端 Mac 的浏览器;Windows 排除 SSH 和 Services 会话。终端判断包含 TTY、`SSH_TTY` 和非 `dumb` 的 `TERM`,因为 MCP 的 stdin/stdout 通常是管道。环境变量被宿主清除或识别不适用时,可显式设置 `--panel=terminal` 或 `--panel=browser`。
|
||||||
|
|
||||||
|
终端模式的 `crossdesk_open` / `crossdesk_connect` 返回 `terminal_command`。用户在同一主机的另一个交互终端执行(源码运行时将 `crossdesk-mcp` 替换为 `node /absolute/path/apps/mcp/dist/main.js`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crossdesk-mcp view --view <view_id> --config-dir <相同的配置目录>
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令提供设备选择、隐藏密码输入和本次控制授权;已建立会话则直接显示画面。终端通过受限配置目录中的运行描述符和认证本地 IPC 复用 MCP 的原生连接,不启动第二个远程连接,也不占用 Agent 的终端或 MCP stdio。Agent 应展示命令供用户执行,再通过 `crossdesk_get_session` 等待;不能在工具输出捕获通道中运行 `view`。无交互终端时 `view` 返回 `VIEW_REQUIRES_TERMINAL`。
|
||||||
|
|
||||||
|
终端画面采用原生 CLI 的 ANSI 四分块渲染器,目标 8 FPS,最多 160 列 × 60 行,根据终端环境使用 8/256/真彩色。默认字符宽高比 0.5,可用 `--cell-aspect`(0.2–2)调整。此入口暂不使用 Kitty/Sixel。`p` 暂停 Agent、`r` 恢复已授权控制、`d` 断开、`q` / Ctrl+C 仅关闭画面并恢复终端;关闭画面后会话保留。暂停仍由原生层锁定,Agent 不能自行恢复。
|
||||||
|
|
||||||
|
浏览器/Apps 与终端均绘制 Agent 最后成功发送的鼠标位置,支持移动、点击、滚动和拖拽;坐标来自原生层对截图缩放及裁剪的映射。暂停或关闭控制时隐藏,切换显示器或图像尺寸变化时清除。该指针是 Agent 的操作位置提示,不代表远端输入执行 ACK,也不跟踪远端用户自己的鼠标。它不写入模型截图或源视频。
|
||||||
|
|
||||||
|
Apps 资源为 `ui://crossdesk/viewer.html`,工具只在允许使用 Apps 且当前请求支持时暴露 UI 元数据和面板专用接口。页面回报 `crossdesk_ui_ready` 后才标记就绪;10 秒未回报显示 `not_confirmed`,不自动弹第二个浏览器。用户明确要求回退时调用 `crossdesk_open_browser`;该工具不附带 Apps 元数据,`apps`/`off` 策略拒绝覆盖。
|
||||||
|
|
||||||
|
浏览器面板只监听随机端口的 `127.0.0.1`,与 MCP 服务同一进程。它不是 MCP Streamable HTTP,也不向局域网提供 MCP 接入。MCP、CLI、用户浏览器应在同一控制端电脑上运行;SSH 容器/远程宿主的浏览器转发不属于此本地接入模式。
|
||||||
|
|
||||||
|
页面标题为 CrossDesk MCP,仅保留正式应用标志,使用文字按钮和简洁状态提示。布局填满浏览器或宿主面板的可用宽高,远程画面保持原比例自动缩放;连接设置在高度不足时独立滚动。连接后侧栏显示设备与授权信息,暂停、恢复和断开按钮位于画面工具栏。「放大画面」收起侧栏,按 Esc 还原;窄屏默认显示连接表单,连接后显示画面,可用「连接信息」展开设备详情。界面资源均可离线使用,保存设备与持久授权仍由用户分别选择。
|
||||||
|
|
||||||
|
首次打开使用 URL fragment 中的短期一次性 bootstrap,页面立即清除 fragment,并换取 HttpOnly/SameSite cookie 和 CSRF 令牌。模型只收到无特权的网页地址。复制无凭据地址到另一浏览器不能获得会话控制权。
|
||||||
|
|
||||||
|
无法自动打开浏览器时,在自己的终端运行工具结果 `pairing_command` 中的命令和参数。例如:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crossdesk-mcp setup --open-panel --view <view_id> --config-dir <相同的配置目录>
|
||||||
|
```
|
||||||
|
|
||||||
|
通过源码运行时用 `node /absolute/path/apps/mcp/dist/main.js` 替换 `crossdesk-mcp`。配对通过本用户配置目录中的受限运行描述符和本地 IPC 完成,不允许匿名 HTTP 请求签发控制凭证。`setup` 使用用户终端的桌面环境打开浏览器;仍无法打开时,只在该交互终端显示 60 秒有效的一次性地址,不将它返回 MCP 或发送到聊天。面板最多保留 32 个视图;再次打开时会清理超过 30 分钟未使用的视图。浏览器会话最长 12 小时,失效后重新打开。
|
||||||
|
|
||||||
|
## 无面板配置与凭据
|
||||||
|
|
||||||
|
在用户自己的交互终端执行(不能在宿主 stdio 通道中交互输入密码):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crossdesk-mcp setup
|
||||||
|
```
|
||||||
|
|
||||||
|
配置默认存放于 `~/.config/crossdesk/mcp/`,可用 `--config-dir` 或 `CROSSDESK_MCP_CONFIG_DIR` 覆盖。`devices.json` 包含设备、面板/视频选项及凭据引用;密码保存到 macOS Keychain、Windows 凭据库或 Linux Secret Service。凭据库不可用就返回明确错误,没有明文文件降级。面板的临时连接仍可使用用户现场输入的密码。持久保存失败会保留已建立连接,并在面板显示错误。
|
||||||
|
|
||||||
|
也可在 `setup` 中指定外部密码环境变量名称,服务读取该变量作为凭据提供器。示例配置(没有密码值):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"panel": "off",
|
||||||
|
"server": "api.crossdesk.cn",
|
||||||
|
"port": 9099,
|
||||||
|
"devices": [{
|
||||||
|
"alias": "work-pc",
|
||||||
|
"device_id": "123456789",
|
||||||
|
"server": "api.crossdesk.cn",
|
||||||
|
"port": 9099,
|
||||||
|
"authorization": "control",
|
||||||
|
"credential": "environment",
|
||||||
|
"environment_variable": "MY_CROSSDESK_PASSWORD"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
授权范围和可用凭据缺一不可。模型不能提供任意 `device_id` 绕过面板确认;模型侧 `crossdesk_connect` 仅接受已授权设备别名。不要把密码发给 Agent,也不要把包含密码的命令行写进宿主配置。
|
||||||
|
|
||||||
|
Apps 依赖宿主遵守 app-only 工具可见性和 `_meta` 的模型不可见约定;服务另检查每个视图的随机 UI 能力令牌和 generation。app-only 标记本身不是可信身份凭据。能够读取同用户进程/文件或执行任意本机代码的 Agent 不受此边界隔离。
|
||||||
|
|
||||||
|
## 模型工具
|
||||||
|
|
||||||
|
| 工具 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `crossdesk_open` | 打开设备选择/连接面板,返回 `view_id` |
|
||||||
|
| `crossdesk_open_browser` | 用户显式请求的浏览器入口/回退 |
|
||||||
|
| `crossdesk_list_devices` | 本机设备簿和授权摘要,不提供云发现 |
|
||||||
|
| `crossdesk_connect` | 通过别名连接已经授权且有凭据的设备 |
|
||||||
|
| `crossdesk_get_session` | `session_id`、`view_id` 严格二选一;按 `view_id` 可等待连接,`wait_ms` 范围 0–25000 |
|
||||||
|
| `crossdesk_screenshot` | 返回 PNG image content 和观测元数据 |
|
||||||
|
| `crossdesk_action` | 结构化输入,可附带动作之后的截图 |
|
||||||
|
| `crossdesk_set_control` | 在已授权范围内启停 Agent 输入 |
|
||||||
|
| `crossdesk_select_display` | 切换显示器,使旧观测失效 |
|
||||||
|
| `crossdesk_disconnect` | 断开并释放输入状态、子进程和设备锁 |
|
||||||
|
|
||||||
|
动作包括 `move`、`click`、`double_click`、`scroll`、`drag`、`key_press`、`hotkey`、`type_text`。不提供 shell、任意键按下保持、剪贴板或文件操作。按键使用 Windows VK 对应的标准名称:`A`–`Z`、`0`–`9`、`F1`–`F12`、`CTRL`、`ALT`、`SHIFT`、`META`、`ENTER`、`ESC`、方向键等;应用快捷键仍需根据远端系统选择。
|
||||||
|
|
||||||
|
鼠标动作和 `key_press` 支持可选的 `modifiers: ["CTRL", "ALT", "SHIFT", "META"]`(选择需要的键,不能重复),用于组合点击、选择、拖拽和快捷键。修饰键仅在本次动作期间保持,结束、取消或人工暂停时释放。`drag` 可指定 `button: "left" | "right" | "middle"`,默认左键。标点键支持 `SEMICOLON`、`EQUAL`、`COMMA`、`MINUS`、`PERIOD`、`SLASH`、`BACKQUOTE`、`BRACKET_LEFT`、`BACKSLASH`、`BRACKET_RIGHT`、`QUOTE`;这些是 VK 键位名称,实际字符取决于远端键盘布局,输入文字应使用 `type_text`。例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"session_id":"…","operation_id":"select-item-1","observation_id":"…","action":{"type":"click","x":120,"y":80,"modifiers":["SHIFT"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
截图默认最长边 1280,最大 2048;允许指定源帧像素区域。返回 `source_size`、`source_rect`、`image_size`、`generation`、`geometry_epoch`、`frame_id`、`frame_age_ms` 和 `observation_id`。动作坐标使用返回图像坐标,不使用浏览器 CSS 像素。观测保留最多 32 个,30 秒过期;切换显示器、连接代际或图像几何变化立即失效。截图使用不超过 2 秒的帧,PNG 不超过 2 MiB,必要时继续缩小输出并返回真实尺寸。
|
||||||
|
|
||||||
|
`timeout_ms` 限制等待可用帧的时间,PNG 编码独立执行;`0` 表示只使用当前已就绪的新鲜帧,无帧返回 `FRAME_TIMEOUT`,编码器繁忙返回 `BUSY`。截图按到达顺序处理,并优先于预览。视频超过 2 秒未更新时拒绝新的键鼠输入,恢复新鲜画面后可继续;已提交操作的去重查询仍可读取原结果。
|
||||||
|
|
||||||
|
`operation_id` 在当前原生会话中缓存最近 128 个操作,至少在未被容量淘汰的 5 分钟窗口内支持同参数查询;不同参数复用 ID 返回冲突。`submitted` 仅说明本地发送完成,远端没有执行 ACK。超时、断网、取消或发送失败可能返回 `outcome_unknown`/`side_effect_possible`,禁止自动重放。`observe_after` 默认开启,后续图像只证明取得更新帧,不证明操作执行成功。
|
||||||
|
|
||||||
|
## 实时视频与按需截图
|
||||||
|
|
||||||
|
实时视频提供两条路径,默认 `browser`,两条路径均由原生 CLI 的本机 WebSocket 直接发送给浏览器。Node 只处理会话、授权票据和 MCP 工具,不读取或转发实时视频字节;不再创建视频管道,不经过 MCP stdio 或模型上下文,不逐帧转 PNG、不重新编码视频。
|
||||||
|
|
||||||
|
| `video.decode_path` | 画面路径 |
|
||||||
|
| --- | --- |
|
||||||
|
| `browser` | MiniRTC 输出原始 H.264 Annex B → 浏览器 WebCodecs 解码 → canvas |
|
||||||
|
| `minirtc` | MiniRTC 解码 → 紧凑 NV12 原始帧 → 浏览器 VideoFrame / canvas;浏览器不再解码 H.264 |
|
||||||
|
|
||||||
|
`video.decoder` 默认 `auto`:优先使用可用硬件解码器,不支持时使用软件。`software` 禁用本程序的硬件选择与硬件回退。浏览器路径使用 WebCodecs 的 `prefer-hardware` / `prefer-software`;这些值是浏览器 API 的偏好,浏览器可以忽略,不能据此宣称已确认物理硬件或软件后端。原生路径由 MiniRTC 的解码器工厂选择,本机 macOS 使用 VideoToolbox 或 OpenH264;其他平台使用 MiniRTC 已支持的后端。状态中的 `native_video_decoder` 返回 MiniRTC 报告的后端名称(统计可用前为空),不代表浏览器后端。
|
||||||
|
|
||||||
|
在 `~/.config/crossdesk/mcp/devices.json` 的根对象中加入:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"video": {
|
||||||
|
"decode_path": "minirtc",
|
||||||
|
"decoder": "auto"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
也可在 Agent 宿主的 MCP 启动参数中配置,命令行优先于文件:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crossdesk-mcp serve --stdio --video-path browser --video-decoder auto
|
||||||
|
crossdesk-mcp serve --stdio --video-path minirtc --video-decoder software
|
||||||
|
```
|
||||||
|
|
||||||
|
修改后重启 MCP,再建立连接。选项适用于整个 MCP 进程,两条路径均将解码选项传给原生端;浏览器路径中的原生解码用于截图缓存。截图工具或动作后的截图请求才触发 PNG 编码。这里配置的是控制端解码器,不更改远端编码器。原有配置文件无需新增字段即可继续使用。
|
||||||
|
|
||||||
|
Agent 模式默认请求高帧率档位,分辨率上限 1920 × 1080;实际分辨率和帧率由远端能力、网络和本机解码性能决定。面板显示实际尺寸与呈现 FPS。浏览器路径需要 WebCodecs H.264 解码能力;MiniRTC 路径仅需浏览器支持从 NV12 创建 VideoFrame 并绘制。能力不足时明确报错,不自动退回 PNG 轮询。NV12 不压缩,1080p / 30 FPS 约需 93 MB/s 本机传输,4K / 30 FPS 约需 373 MB/s;它不增加远端到控制端的 H.264 网络码率。移除 Node 转发没有消除 NV12 自身的本机传输与浏览器像素处理开销;这不是 GPU 零拷贝。
|
||||||
|
|
||||||
|
视频 WebSocket 在原生 CLI 的独立 I/O 线程中运行,只监听随机 `127.0.0.1` 端口,与 Node 面板 HTTP 端口不同。浏览器面板通过 cookie/CSRF 授权获取视频票据,Apps 通过 app-only `crossdesk_video_open` 和视图令牌获取。票据 15 秒内一次性有效,绑定视图、会话和代际,通过 WebSocket 第一条消息提交,不写入 URL、日志或模型可见工具结果。WebSocket 只提供画面、收帧 ACK 和关键帧恢复请求,不能执行远端输入操作。最多 8 个活动观看者。H.264 丢帧后等待关键帧恢复;NV12 每帧独立,每个观看者最多一帧未确认,原生发送队列只保留最新画面;H.264 最多 8 帧未确认并受字节阈值限制。10 秒未确认最早一帧则关闭该视频通道,不阻塞其他观看者或 MCP 控制。视图撤销、显示器/连接代际变化、断开和进程退出会关闭对应视频连接;暂停 Agent 控制仍继续播放。页面隐藏或卸载时释放视频订阅,恢复可见时重新订阅。
|
||||||
|
|
||||||
|
浏览器页面和 MCP Apps 资源的 CSP 允许原生服务使用的 `ws://127.0.0.1:*`(仅动态端口),HTTP 控制仍限制到 Node 面板的确切地址。原生服务另外校验确切 Host、Origin 和一次性票据。宿主仍可能限制 loopback、WebSocket、WebCodecs 或 iframe 权限;视频失败时提供重试和浏览器入口,`--panel=apps` 的严格策略仍禁止浏览器回退。浏览器和 Apps 共用同一播放器,具体宿主需要独立验收。
|
||||||
|
|
||||||
|
## 打包
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
npm run pack:npm -- --out-dir /absolute/path/artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本生成可发布 tarball,包含面板、运行代码、源代码、许可和固定版本的五个平台可选依赖。CI 的 `MCP package` 工作流仅构建并上传 `mcp-package`,不发布 npm。原生包继续由 CLI 工作流构建,`doctor` 必须同时声明自动化协议和 `automation_video_protocols: ["h264-annexb/1", "nv12/1"]` 和 `automation_video_decoders: ["auto", "software"]`,以及 `automation_video_transport: "loopback-websocket/1"`、`automation_terminal_protocol: "ansi/1"`;先发布对应 0.2.0 平台包,再发布 MCP tarball。尚未进行真实跨平台发布/远端验收时,不应把构建成功等同于各宿主和设备上的联调完成。
|
||||||
|
|
||||||
|
从发布 tarball 重建 TypeScript/面板时,将包中的 `build-package.json` 复制为 `package.json`,运行 `npm ci && npm run build`。原生源码和依赖来源随平台包提供,详见 [源码来源](../cli/docs/source-provenance.md)。
|
||||||
|
|
||||||
|
## 本地审阅验证(2026-11-03)
|
||||||
|
|
||||||
|
动作参数校验和输入步骤生成集中在 `apps/cli/automation_protocol.*`,主循环负责调度、取消和权限检查。截图按色度网格对齐后仅转换所需区域,PNG 在容量受限的缓冲区中单次压缩;原生帧仅在没有其他读取者时复用内存。Node 按行累计响应字节,在完整响应到达时解码,避免反复扫描和复制大截图。
|
||||||
|
|
||||||
|
本机 macOS arm64 完成 TypeScript/面板构建、原生 CLI 构建、现有 wire/keyboard-state 测试,以及工程外的 MCP、会话和原生动作回归检查。ASan/UBSan 检查覆盖动作调度和输入清理;140 组随机图像覆盖完整帧、奇偶裁剪边界、缩放及容量回退,优化前后 PNG 字节一致。
|
||||||
|
|
||||||
|
使用 1920 × 1080 合成 NV12、最长边 1280、每项 6 次的 `-O2` 本地基准:完整画面编码约 22.5 → 11.5 ms,321 × 241 裁剪约 2.1 → 0.9 ms。数字仅衡量图像转换与编码,不包含网络、解码、远端执行或宿主渲染;未连接真实远端或完成 Windows/Linux 验收。额外测试代码、脚本、数据和产物均存放于工程外系统临时目录。
|
||||||
Generated
+977
@@ -0,0 +1,977 @@
|
|||||||
|
{
|
||||||
|
"name": "@chunkdash/crossdesk-mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@chunkdash/crossdesk-mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/client": "2.4.0",
|
||||||
|
"@modelcontextprotocol/core": "2.4.0",
|
||||||
|
"@modelcontextprotocol/ext-apps": "2.0.4",
|
||||||
|
"@modelcontextprotocol/server": "2.4.0",
|
||||||
|
"@napi-rs/keyring": "2.1.2",
|
||||||
|
"zod": "4.6.7"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"crossdesk-mcp": "dist/main.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.19.15",
|
||||||
|
"esbuild": "0.28.2",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/client": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/core": "2.4.0",
|
||||||
|
"cross-spawn": "^7.0.5",
|
||||||
|
"eventsource": "^3.0.2",
|
||||||
|
"eventsource-parser": "^3.0.8",
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"pkce-challenge": "^5.0.0",
|
||||||
|
"zod": "^4.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/core": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/ext-apps": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-B+PcbuGiaf0e5I3uy4iO5tS3Abo+5w8JvZFzHMzQdq9yVwP836zJkw2+KCGOQ0r4TM1fuQi4JdnbuWjkS4aBvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"examples/*"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@modelcontextprotocol/client": "^2.0.0",
|
||||||
|
"@modelcontextprotocol/core": "^2.0.0",
|
||||||
|
"@modelcontextprotocol/server": "^2.0.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"zod": "^4.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@modelcontextprotocol/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/server": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/core": "2.4.0",
|
||||||
|
"zod": "^4.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-9i1SR6tYeQ3o1beLeVMxhN6Fq513m9TZlTs+46+4JgvSNkaFoW9S4WrpV5MjzE+0CzVoC+dZ4RfZDIgW6+afnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/keyring-darwin-arm64": "2.1.2",
|
||||||
|
"@napi-rs/keyring-darwin-x64": "2.1.2",
|
||||||
|
"@napi-rs/keyring-freebsd-x64": "2.1.2",
|
||||||
|
"@napi-rs/keyring-linux-arm-gnueabihf": "2.1.2",
|
||||||
|
"@napi-rs/keyring-linux-arm64-gnu": "2.1.2",
|
||||||
|
"@napi-rs/keyring-linux-arm64-musl": "2.1.2",
|
||||||
|
"@napi-rs/keyring-linux-riscv64-gnu": "2.1.2",
|
||||||
|
"@napi-rs/keyring-linux-x64-gnu": "2.1.2",
|
||||||
|
"@napi-rs/keyring-linux-x64-musl": "2.1.2",
|
||||||
|
"@napi-rs/keyring-win32-arm64-msvc": "2.1.2",
|
||||||
|
"@napi-rs/keyring-win32-ia32-msvc": "2.1.2",
|
||||||
|
"@napi-rs/keyring-win32-x64-msvc": "2.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-darwin-arm64": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-vsInUWUzWS6/WzR/TFNwmdtmwKWjnnR8TOpvZ/pjjxDAkJB/waz37PWzp2b8AFcw1aCcq2aQ7k2vvbLQy8pTrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-darwin-x64": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-wQihYroaQ3r++R6u5c9Gj0TCw1l1F53LCb5aLOXfVvT4DsnDnpe7p7VGYo4/33bLe0iqVevtAj+ET3yKCtOlJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-freebsd-x64": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Ve5ha7MbJsq+4TYyXYiTy4IeZOnYxkBB05uH2+nIrEKBkHmyfHttF814EfWAfoJa63KqBhYRXdYJo0o1WVL2sg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-zxijTnVnTApEyJMjgnLdVQL1g1DAudku/5brrWNDRWj4J0q0nFhUZEtzUA+nY2FvgDjl4s+2YCZYXSTH4TFMUg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-arm64-gnu": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-PM+SAcl/yGzgGbeeCsKrnlG4T5DBIDjmDqtKm5an1bJz0o0gwSzR3BMiXf04JenkcqascZpdcIReoEvLj0cvCw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-arm64-musl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-+ERS9oyInPCALN957cViBDbYJc724/Q+rZ8ZAz4rOHj4B4v0VR4OAWThdYZgZqD54sZVV0MdyX24qva97hYoAQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-pGADtqeT3U1SaFZGjkVsmZVjeNOQx0fr77S+NWszXjZboyaUOLlGKgqG6ssAWzcwxv8uPhKc9Ij17ujNZhSqLw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-x64-gnu": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Fj5TmTnl2oriuCT5DDEYlhgxYes20uGfgOg10LK9PyPVsI8/PQ1m8I4rq/4A5hkrMtY7wvFb70YmX/yPHDYWvQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-x64-musl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-C+KhAeU6x2A43FV3dOmMvUraJb8zsC9VZfsunjZ1cgtuAb208TALaUuAY0jThOphXqhyhBJOs2xTCQhOrRkJIw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-win32-arm64-msvc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-kBq80ZZfjXoa5wHG9kgAVhYYjaPOLgHh5ngt7r1ftwFXPXDPXKH7oM2r2j2Q64cs0pVtmodVtn5Q9e6xCwzQUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-win32-ia32-msvc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-bhgKvVbWlZ6iR/tmvb2ht6/o0OzarUX9W7hjg+G+eVdGjR1prwtQ9lYo5ZESdn/+zyF1MIFvHkOCGja9YqDcww==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-win32-x64-msvc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-yQtFdfuwqM0ezKlYO6r2n44in2LGzd4MC679Ddhvojip3t1hn+q/4I+gJU+cIJQVd5FAZSKNrK4tGfew3UTpAQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.2",
|
||||||
|
"@esbuild/android-arm": "0.28.2",
|
||||||
|
"@esbuild/android-arm64": "0.28.2",
|
||||||
|
"@esbuild/android-x64": "0.28.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.2",
|
||||||
|
"@esbuild/darwin-x64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.2",
|
||||||
|
"@esbuild/linux-arm": "0.28.2",
|
||||||
|
"@esbuild/linux-arm64": "0.28.2",
|
||||||
|
"@esbuild/linux-ia32": "0.28.2",
|
||||||
|
"@esbuild/linux-loong64": "0.28.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.2",
|
||||||
|
"@esbuild/linux-s390x": "0.28.2",
|
||||||
|
"@esbuild/linux-x64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.2",
|
||||||
|
"@esbuild/sunos-x64": "0.28.2",
|
||||||
|
"@esbuild/win32-arm64": "0.28.2",
|
||||||
|
"@esbuild/win32-ia32": "0.28.2",
|
||||||
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventsource-parser": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource-parser": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz",
|
||||||
|
"integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pkce-challenge": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||||
|
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@chunkdash/crossdesk-mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Local CrossDesk MCP tools and remote desktop panels",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
|
"author": "DI JUNKUN",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/kunkundi/crossdesk.git",
|
||||||
|
"directory": "apps/mcp"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"crossdesk-mcp": "dist/main.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && tsc -p tsconfig.ui.json && node scripts/build-ui.mjs",
|
||||||
|
"check": "tsc --noEmit && tsc -p tsconfig.ui.json",
|
||||||
|
"pack:npm": "node scripts/pack.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/client": "2.4.0",
|
||||||
|
"@modelcontextprotocol/core": "2.4.0",
|
||||||
|
"@modelcontextprotocol/ext-apps": "2.0.4",
|
||||||
|
"@modelcontextprotocol/server": "2.4.0",
|
||||||
|
"@napi-rs/keyring": "2.1.2",
|
||||||
|
"zod": "4.6.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.19.15",
|
||||||
|
"esbuild": "0.28.2",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { build } from "esbuild";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
// Unmodified apps/desktop/resources/linux/crossdesk_128x128.png.
|
||||||
|
// Keep a local copy so the npm source package rebuilds without desktop assets.
|
||||||
|
const logo = "data:image/png;base64," +
|
||||||
|
(await readFile(resolve(root, "ui/assets/crossdesk.png"))).toString("base64");
|
||||||
|
await mkdir(resolve(root, "dist"), { recursive: true });
|
||||||
|
for (const mode of ["apps", "browser"]) {
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [resolve(root, "ui/viewer.ts")],
|
||||||
|
bundle: true,
|
||||||
|
minify: true,
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2022",
|
||||||
|
outfile: "viewer.js",
|
||||||
|
write: false,
|
||||||
|
define: {
|
||||||
|
CROSSDESK_APPS: String(mode === "apps"),
|
||||||
|
CROSSDESK_LOGO: JSON.stringify(logo),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const script = result.outputFiles
|
||||||
|
.find((f) => f.path.endsWith(".js"))
|
||||||
|
.text.replaceAll("</script", "<\\/script");
|
||||||
|
const css = result.outputFiles.find((f) => f.path.endsWith(".css")).text;
|
||||||
|
const nonce = mode === "browser" ? ' nonce="__CROSSDESK_NONCE__"' : "";
|
||||||
|
await writeFile(
|
||||||
|
resolve(root, `dist/viewer-${mode}.html`),
|
||||||
|
`<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#ffffff"><link rel="icon" type="image/png" href="${logo}"><title>CrossDesk MCP</title><style${nonce}>${css}</style></head><body><div id="app"></div><script${nonce}>${script}</script></body></html>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
writeFile,
|
||||||
|
mkdir,
|
||||||
|
cp,
|
||||||
|
chmod,
|
||||||
|
rm,
|
||||||
|
access,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { dirname, resolve, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const repository = resolve(root, "../..");
|
||||||
|
const { values } = parseArgs({ options: { "out-dir": { type: "string" } } });
|
||||||
|
const output = resolve(values["out-dir"] ?? join(repository, "build/npm-mcp"));
|
||||||
|
const project = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
||||||
|
const cli = JSON.parse(
|
||||||
|
await readFile(join(root, "../cli/package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const platforms = JSON.parse(
|
||||||
|
await readFile(join(root, "../cli/npm/platforms.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const native = await import(new URL("../dist/native.js", import.meta.url));
|
||||||
|
if (native.NATIVE_VERSION !== cli.version)
|
||||||
|
throw new Error("MCP and CLI native versions must match.");
|
||||||
|
for (const file of ["main.js", "viewer-browser.html", "viewer-apps.html"])
|
||||||
|
await access(join(root, "dist", file));
|
||||||
|
const staging = await mkdtemp(join(tmpdir(), "crossdesk-mcp-pack-"));
|
||||||
|
try {
|
||||||
|
for (const entry of [
|
||||||
|
"dist",
|
||||||
|
"src",
|
||||||
|
"ui",
|
||||||
|
"scripts",
|
||||||
|
"tsconfig.json",
|
||||||
|
"tsconfig.ui.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"README.md",
|
||||||
|
])
|
||||||
|
await cp(join(root, entry), join(staging, entry), { recursive: true });
|
||||||
|
await cp(join(repository, "LICENSE"), join(staging, "LICENSE"));
|
||||||
|
await writeFile(
|
||||||
|
join(staging, "build-package.json"),
|
||||||
|
JSON.stringify(project, null, 2) + "\n",
|
||||||
|
);
|
||||||
|
const manifest = {
|
||||||
|
...project,
|
||||||
|
private: false,
|
||||||
|
files: [
|
||||||
|
"dist",
|
||||||
|
"src",
|
||||||
|
"ui",
|
||||||
|
"scripts/build-ui.mjs",
|
||||||
|
"tsconfig.json",
|
||||||
|
"tsconfig.ui.json",
|
||||||
|
"build-package.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE",
|
||||||
|
],
|
||||||
|
optionalDependencies: Object.fromEntries(
|
||||||
|
Object.values(platforms).map((p) => [p.packageName, cli.version]),
|
||||||
|
),
|
||||||
|
publishConfig: { access: "public" },
|
||||||
|
};
|
||||||
|
delete manifest.scripts;
|
||||||
|
delete manifest.devDependencies;
|
||||||
|
await writeFile(
|
||||||
|
join(staging, "package.json"),
|
||||||
|
JSON.stringify(manifest, null, 2) + "\n",
|
||||||
|
);
|
||||||
|
await chmod(join(staging, "dist/main.js"), 0o755);
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
const filename = `${project.name.replace(/^@/, "").replace("/", "-")}-${project.version}.tgz`;
|
||||||
|
try {
|
||||||
|
await access(join(output, filename));
|
||||||
|
throw new Error(`Output already exists: ${filename}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
const args = [
|
||||||
|
"pack",
|
||||||
|
"--json",
|
||||||
|
"--ignore-scripts",
|
||||||
|
"--pack-destination",
|
||||||
|
output,
|
||||||
|
];
|
||||||
|
if (process.platform === "win32" && !process.env.npm_execpath)
|
||||||
|
throw new Error("On Windows use npm run pack:npm.");
|
||||||
|
const result = execFileSync(
|
||||||
|
process.env.npm_execpath ? process.execPath : "npm",
|
||||||
|
process.env.npm_execpath ? [process.env.npm_execpath, ...args] : args,
|
||||||
|
{ cwd: staging, encoding: "utf8" },
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
const packages = Array.isArray(parsed) ? parsed : Object.values(parsed);
|
||||||
|
if (packages.length !== 1 || packages[0].filename !== filename)
|
||||||
|
throw new Error("Unexpected package archive.");
|
||||||
|
process.stdout.write(join(output, filename) + "\n");
|
||||||
|
} finally {
|
||||||
|
await rm(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import {
|
||||||
|
createServer,
|
||||||
|
type IncomingMessage,
|
||||||
|
type ServerResponse,
|
||||||
|
type Server,
|
||||||
|
} from "node:http";
|
||||||
|
import {
|
||||||
|
createServer as createIPC,
|
||||||
|
createConnection,
|
||||||
|
type Server as IPCServer,
|
||||||
|
} from "node:net";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { readFile, writeFile, unlink, chmod, readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Failure, token, equal, failure, type Data } from "./common.js";
|
||||||
|
import { Sessions, type View } from "./sessions.js";
|
||||||
|
import { uiAction } from "./ui-actions.js";
|
||||||
|
import { displayEnvironment } from "./environment.js";
|
||||||
|
|
||||||
|
const uiFile = fileURLToPath(new URL("./viewer-browser.html", import.meta.url));
|
||||||
|
interface BrowserSession {
|
||||||
|
csrf: string;
|
||||||
|
views: Set<string>;
|
||||||
|
expires: number;
|
||||||
|
}
|
||||||
|
export async function openURL(url: string): Promise<boolean> {
|
||||||
|
if (!displayEnvironment().gui) return false;
|
||||||
|
const command =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? "open"
|
||||||
|
: process.platform === "win32"
|
||||||
|
? "rundll32.exe"
|
||||||
|
: "xdg-open";
|
||||||
|
const args =
|
||||||
|
process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
stdio: "ignore",
|
||||||
|
shell: false,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.unref();
|
||||||
|
resolve(true);
|
||||||
|
}, 1500);
|
||||||
|
child.once("error", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(code === 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export class BrowserPanel {
|
||||||
|
private closed = false;
|
||||||
|
private server?: Server;
|
||||||
|
private starting?: Promise<void>;
|
||||||
|
private ipc?: IPCServer;
|
||||||
|
private ipcPath?: string;
|
||||||
|
private descriptor?: string;
|
||||||
|
private origin = "";
|
||||||
|
private cookie = "crossdesk_" + token();
|
||||||
|
private grants = new Map<string, { view: string; expires: number }>();
|
||||||
|
private clients = new Map<string, BrowserSession>();
|
||||||
|
private html = "";
|
||||||
|
constructor(private sessions: Sessions) {}
|
||||||
|
async connectDomains() {
|
||||||
|
await this.start();
|
||||||
|
// Native processes own ephemeral video ports, including after reconnect.
|
||||||
|
// Restrict the wildcard to ports on the literal loopback address.
|
||||||
|
return [this.origin, "ws://127.0.0.1:*"];
|
||||||
|
}
|
||||||
|
async video(view: View) {
|
||||||
|
await this.start();
|
||||||
|
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||||
|
return this.sessions.openVideo(view, this.origin);
|
||||||
|
}
|
||||||
|
async open(view: View): Promise<Data> {
|
||||||
|
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||||
|
await this.start();
|
||||||
|
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||||
|
const opened = await openURL(this.bootstrapURL(view));
|
||||||
|
return {
|
||||||
|
...this.sessions.describe(view),
|
||||||
|
browser_opened: opened,
|
||||||
|
browser_url: this.origin,
|
||||||
|
pairing_command: {
|
||||||
|
command: "crossdesk-mcp",
|
||||||
|
args: [
|
||||||
|
"setup",
|
||||||
|
"--open-panel",
|
||||||
|
"--view",
|
||||||
|
view.id,
|
||||||
|
"--config-dir",
|
||||||
|
this.sessions.store.directory,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private bootstrapURL(view: View): string {
|
||||||
|
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||||
|
for (const [key, value] of this.grants)
|
||||||
|
if (value.expires < Date.now()) this.grants.delete(key);
|
||||||
|
if (this.grants.size >= 32) throw new Failure("VIEW_LIMIT");
|
||||||
|
const grant = token();
|
||||||
|
this.grants.set(grant, { view: view.id, expires: Date.now() + 60000 });
|
||||||
|
return `${this.origin}/#bootstrap=${grant}`;
|
||||||
|
}
|
||||||
|
private async start() {
|
||||||
|
this.starting ??= (async () => {
|
||||||
|
this.html = await readFile(uiFile, "utf8");
|
||||||
|
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||||
|
this.server = createServer((req, res) => {
|
||||||
|
void this.handle(req, res).catch(() => {
|
||||||
|
if (!res.headersSent)
|
||||||
|
this.respond(res, 500, { error: { code: "INTERNAL_ERROR" } });
|
||||||
|
else res.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// No upgrade handler or video bytes in the Node HTTP server.
|
||||||
|
this.server.requestTimeout = 45000;
|
||||||
|
this.server.headersTimeout = 10000;
|
||||||
|
this.server.maxHeadersCount = 32;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.server!.once("error", reject);
|
||||||
|
this.server!.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
this.origin = `http://127.0.0.1:${(this.server.address() as import("node:net").AddressInfo).port}`;
|
||||||
|
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||||
|
await this.startIPC();
|
||||||
|
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||||
|
})().catch(async (error) => {
|
||||||
|
await this.close(false);
|
||||||
|
this.starting = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return this.starting;
|
||||||
|
}
|
||||||
|
private respond(res: ServerResponse, code: number, value: Data) {
|
||||||
|
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
private client(req: IncomingMessage): BrowserSession | undefined {
|
||||||
|
const value = req.headers.cookie
|
||||||
|
?.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.find((s) => s.startsWith(this.cookie + "="))
|
||||||
|
?.slice(this.cookie.length + 1);
|
||||||
|
const client = value ? this.clients.get(value) : undefined;
|
||||||
|
return client && client.expires > Date.now() ? client : undefined;
|
||||||
|
}
|
||||||
|
private async body(req: IncomingMessage): Promise<Data> {
|
||||||
|
if (req.headers["content-type"] !== "application/json")
|
||||||
|
throw new Failure("CONTENT_TYPE_REQUIRED");
|
||||||
|
let bytes = 0;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of req) {
|
||||||
|
bytes += chunk.length;
|
||||||
|
if (bytes > 65536) throw new Failure("MESSAGE_TOO_LARGE");
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
if (!value || Array.isArray(value) || typeof value !== "object")
|
||||||
|
throw new Error();
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
throw new Failure("INVALID_ARGUMENT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.setHeader("Referrer-Policy", "no-referrer");
|
||||||
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
res.setHeader("X-Frame-Options", "DENY");
|
||||||
|
const authority = this.origin.slice("http://".length);
|
||||||
|
if (
|
||||||
|
req.headers.host !== authority ||
|
||||||
|
(req.headers.origin && req.headers.origin !== this.origin)
|
||||||
|
)
|
||||||
|
return this.respond(res, 403, { error: { code: "ORIGIN_DENIED" } });
|
||||||
|
if (req.url === "/" && req.method === "GET") {
|
||||||
|
const nonce = token();
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src data:; connect-src 'self' ws://127.0.0.1:*; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
||||||
|
);
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||||
|
res.end(this.html.replaceAll("__CROSSDESK_NONCE__", nonce));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/context" && req.method === "GET") {
|
||||||
|
const client = this.client(req);
|
||||||
|
if (!client)
|
||||||
|
return this.respond(res, 401, { error: { code: "PAIRING_REQUIRED" } });
|
||||||
|
for (const id of client.views)
|
||||||
|
if (!this.sessions.views.has(id)) client.views.delete(id);
|
||||||
|
if (!client.views.size)
|
||||||
|
return this.respond(res, 401, { error: { code: "PAIRING_REQUIRED" } });
|
||||||
|
return this.respond(res, 200, {
|
||||||
|
csrf: client.csrf,
|
||||||
|
views: [...client.views].map((v) =>
|
||||||
|
this.sessions.describe(this.sessions.view(v)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.method !== "POST" || req.headers.origin !== this.origin)
|
||||||
|
return this.respond(res, 403, { error: { code: "ORIGIN_DENIED" } });
|
||||||
|
try {
|
||||||
|
const body = await this.body(req);
|
||||||
|
if (req.url === "/api/bootstrap") {
|
||||||
|
const grant =
|
||||||
|
typeof body.token === "string"
|
||||||
|
? this.grants.get(body.token)
|
||||||
|
: undefined;
|
||||||
|
if (!grant || grant.expires < Date.now())
|
||||||
|
throw new Failure("PAIRING_REQUIRED");
|
||||||
|
this.grants.delete(body.token);
|
||||||
|
for (const [id, client] of this.clients)
|
||||||
|
if (client.expires < Date.now()) this.clients.delete(id);
|
||||||
|
const existing = this.client(req);
|
||||||
|
if (!existing && this.clients.size >= 32)
|
||||||
|
throw new Failure("VIEW_LIMIT");
|
||||||
|
const session = token(),
|
||||||
|
client = existing ?? {
|
||||||
|
csrf: token(),
|
||||||
|
views: new Set<string>(),
|
||||||
|
expires: 0,
|
||||||
|
};
|
||||||
|
client.views.add(grant.view);
|
||||||
|
client.expires = Date.now() + 12 * 60 * 60 * 1000;
|
||||||
|
for (const [key, previous] of this.clients)
|
||||||
|
if (previous === client) this.clients.delete(key);
|
||||||
|
this.clients.set(session, client);
|
||||||
|
res.setHeader(
|
||||||
|
"Set-Cookie",
|
||||||
|
`${this.cookie}=${session}; HttpOnly; SameSite=Strict; Path=/api`,
|
||||||
|
);
|
||||||
|
return this.respond(res, 200, {
|
||||||
|
csrf: client.csrf,
|
||||||
|
...this.sessions.describe(this.sessions.view(grant.view)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const client = this.client(req);
|
||||||
|
if (
|
||||||
|
!client ||
|
||||||
|
typeof req.headers["x-crossdesk-csrf"] !== "string" ||
|
||||||
|
!equal(client.csrf, req.headers["x-crossdesk-csrf"])
|
||||||
|
)
|
||||||
|
throw new Failure("PANEL_UNAUTHORIZED");
|
||||||
|
if (typeof body.view_id !== "string" || !client.views.has(body.view_id))
|
||||||
|
throw new Failure("VIEW_UNAUTHORIZED");
|
||||||
|
const name = req.url?.match(
|
||||||
|
/^\/api\/(ready|devices|status|connect|video_open|control)$/,
|
||||||
|
)?.[1];
|
||||||
|
if (!name) throw new Failure("UNKNOWN_ACTION");
|
||||||
|
const { view_id, ...args } = body;
|
||||||
|
const controller = new AbortController();
|
||||||
|
res.once("close", () => {
|
||||||
|
if (!res.writableEnded) controller.abort();
|
||||||
|
});
|
||||||
|
const result = name === "video_open" ? await this.video(this.sessions.view(view_id)) : await uiAction(
|
||||||
|
this.sessions,
|
||||||
|
this.sessions.view(view_id),
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
this.respond(res, 200, result);
|
||||||
|
} catch (error) {
|
||||||
|
this.respond(res, 400, { error: failure(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async startIPC() {
|
||||||
|
const secret = token();
|
||||||
|
const suffix = token().slice(0, 20);
|
||||||
|
this.ipcPath =
|
||||||
|
process.platform === "win32"
|
||||||
|
? `\\\\.\\pipe\\crossdesk-mcp-${suffix}`
|
||||||
|
: join(tmpdir(), `cd-mcp-${suffix}.sock`);
|
||||||
|
this.ipc = createIPC((socket) => {
|
||||||
|
socket.setTimeout(5000, () => socket.destroy());
|
||||||
|
let data = "",
|
||||||
|
consumed = false;
|
||||||
|
socket.on("error", () => {});
|
||||||
|
socket.on("data", (part) => {
|
||||||
|
data += part.toString("utf8");
|
||||||
|
if (data.length > 2048) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (consumed || !data.includes("\n")) return;
|
||||||
|
consumed = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(data);
|
||||||
|
if (
|
||||||
|
typeof p.secret !== "string" ||
|
||||||
|
!equal(p.secret, secret) ||
|
||||||
|
typeof p.view_id !== "string"
|
||||||
|
)
|
||||||
|
throw new Failure("PAIRING_DENIED");
|
||||||
|
const v = this.sessions.view(p.view_id);
|
||||||
|
if (
|
||||||
|
v.panel !== "browser" ||
|
||||||
|
!["auto", "browser"].includes(this.sessions.policy)
|
||||||
|
)
|
||||||
|
throw new Failure("PANEL_POLICY_DENIED");
|
||||||
|
// Only the authenticated local setup process receives this URL.
|
||||||
|
// It uses its own desktop environment to open the browser.
|
||||||
|
socket.end(
|
||||||
|
JSON.stringify({ ok: true, url: this.bootstrapURL(v) }) + "\n",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
socket.end('{"ok":false}\n');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.ipc!.once("error", reject);
|
||||||
|
this.ipc!.listen(this.ipcPath!, resolve);
|
||||||
|
});
|
||||||
|
if (process.platform !== "win32") await chmod(this.ipcPath, 0o600);
|
||||||
|
this.descriptor = join(
|
||||||
|
this.sessions.store.directory,
|
||||||
|
`run-${process.pid}-${suffix}.json`,
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
this.descriptor,
|
||||||
|
JSON.stringify({ pid: process.pid, endpoint: this.ipcPath, secret }),
|
||||||
|
{ mode: 0o600, flag: "wx" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async close(final = true) {
|
||||||
|
if (final) this.closed = true;
|
||||||
|
await this.sessions.revokeVideo();
|
||||||
|
this.grants.clear();
|
||||||
|
this.clients.clear();
|
||||||
|
this.server?.closeAllConnections();
|
||||||
|
if (this.server?.listening) this.server.close();
|
||||||
|
if (this.ipc?.listening) this.ipc.close();
|
||||||
|
if (this.descriptor) await unlink(this.descriptor).catch(() => {});
|
||||||
|
if (this.ipcPath && process.platform !== "win32")
|
||||||
|
await unlink(this.ipcPath).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function pairBrowser(
|
||||||
|
directory: string,
|
||||||
|
view: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
for (const file of await readdir(directory)) {
|
||||||
|
if (!/^run-\d+-[a-zA-Z0-9_-]+\.json$/.test(file)) continue;
|
||||||
|
try {
|
||||||
|
const descriptor = JSON.parse(
|
||||||
|
await readFile(join(directory, file), "utf8"),
|
||||||
|
);
|
||||||
|
const url = await new Promise<string | undefined>((resolve) => {
|
||||||
|
const socket = createConnection(descriptor.endpoint);
|
||||||
|
let data = "";
|
||||||
|
socket.setTimeout(5000, () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
socket.once("error", () => resolve(undefined));
|
||||||
|
socket.once("close", () => resolve(undefined));
|
||||||
|
socket.once("connect", () =>
|
||||||
|
socket.write(
|
||||||
|
JSON.stringify({ secret: descriptor.secret, view_id: view }) + "\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
socket.on("data", (part) => {
|
||||||
|
data += part;
|
||||||
|
if (data.length > 2048) socket.destroy();
|
||||||
|
});
|
||||||
|
socket.once("end", () => {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(data);
|
||||||
|
resolve(
|
||||||
|
result.ok &&
|
||||||
|
typeof result.url === "string" &&
|
||||||
|
/^http:\/\/127\.0\.0\.1:\d+\/#bootstrap=[a-zA-Z0-9_-]+$/.test(
|
||||||
|
result.url,
|
||||||
|
)
|
||||||
|
? result.url
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (url) return url;
|
||||||
|
} catch {
|
||||||
|
/* Another MCP instance may have exited. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type Data = Record<string, any>;
|
||||||
|
export const token = () => randomBytes(24).toString("base64url");
|
||||||
|
export const equal = (a: string, b: string) => {
|
||||||
|
const left = Buffer.from(a),
|
||||||
|
right = Buffer.from(b);
|
||||||
|
return left.length === right.length && timingSafeEqual(left, right);
|
||||||
|
};
|
||||||
|
export class Failure extends Error {
|
||||||
|
constructor(
|
||||||
|
public code: string,
|
||||||
|
public sideEffect = false,
|
||||||
|
public retryable = false,
|
||||||
|
) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function failure(error: unknown): Data {
|
||||||
|
const e = error instanceof Failure ? error : new Failure("INTERNAL_ERROR");
|
||||||
|
return {
|
||||||
|
code: e.code,
|
||||||
|
retryable: e.retryable,
|
||||||
|
side_effect_possible: e.sideEffect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function parse<T extends z.ZodType>(
|
||||||
|
schema: T,
|
||||||
|
value: unknown,
|
||||||
|
): z.output<T> {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (!result.success) throw new Failure("INVALID_ARGUMENT");
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
export const id = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(128)
|
||||||
|
.regex(/^[a-zA-Z0-9_-]+$/);
|
||||||
|
export const alias = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(80)
|
||||||
|
.regex(/^[\p{L}\p{N} _.-]+$/u);
|
||||||
|
export const mode = z.enum(["observe", "control"]);
|
||||||
|
export const panel = z.enum(["auto", "apps", "browser", "terminal", "off"]);
|
||||||
|
export type Panel = z.infer<typeof panel>;
|
||||||
|
export const videoOptions = z.strictObject({
|
||||||
|
decode_path: z.enum(["browser", "minirtc"]).default("browser"),
|
||||||
|
decoder: z.enum(["auto", "software"]).default("auto"),
|
||||||
|
});
|
||||||
|
export type VideoOptions = z.infer<typeof videoOptions>;
|
||||||
|
export const host = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(253)
|
||||||
|
.regex(/^[a-zA-Z0-9.:-]+$/);
|
||||||
|
export const point = z.strictObject({
|
||||||
|
x: z.number().finite().min(0).max(8192),
|
||||||
|
y: z.number().finite().min(0).max(8192),
|
||||||
|
});
|
||||||
|
const button = z.enum(["left", "right", "middle"]).optional();
|
||||||
|
const unique = (keys: string[]) => new Set(keys).size === keys.length;
|
||||||
|
const modifiers = z.array(z.enum(["CTRL", "ALT", "SHIFT", "META"])).max(4).refine(unique)
|
||||||
|
.describe("Modifiers held during this action and released afterwards, including on cancellation.").optional();
|
||||||
|
const key = z.enum([
|
||||||
|
..."ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split(""),
|
||||||
|
"BACKSPACE", "TAB", "ENTER", "SHIFT", "CTRL", "ALT", "PAUSE", "CAPS_LOCK",
|
||||||
|
"ESC", "SPACE", "PAGE_UP", "PAGE_DOWN", "END", "HOME", "LEFT", "UP", "RIGHT", "DOWN",
|
||||||
|
"INSERT", "DELETE", "META", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
|
||||||
|
"SEMICOLON", "EQUAL", "COMMA", "MINUS", "PERIOD", "SLASH", "BACKQUOTE", "BRACKET_LEFT", "BACKSLASH", "BRACKET_RIGHT", "QUOTE",
|
||||||
|
]);
|
||||||
|
export const action = z.discriminatedUnion("type", [
|
||||||
|
point.extend({ type: z.literal("move"), modifiers }),
|
||||||
|
point.extend({ type: z.literal("click"), button, modifiers }),
|
||||||
|
point.extend({ type: z.literal("double_click"), button, modifiers }),
|
||||||
|
point.extend({
|
||||||
|
type: z.literal("scroll"),
|
||||||
|
direction: z.enum(["up", "down", "left", "right"]),
|
||||||
|
ticks: z.int().min(1).max(10).optional(),
|
||||||
|
modifiers,
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
type: z.literal("drag"),
|
||||||
|
path: z.array(point).min(2).max(128),
|
||||||
|
duration_ms: z.int().min(100).max(2000).optional(),
|
||||||
|
button,
|
||||||
|
modifiers,
|
||||||
|
}),
|
||||||
|
z.strictObject({ type: z.literal("key_press"), key, modifiers })
|
||||||
|
.refine((p) => !p.modifiers?.some((modifier) => modifier === p.key)),
|
||||||
|
z.strictObject({
|
||||||
|
type: z.literal("hotkey"),
|
||||||
|
keys: z.array(key).min(2).max(6).refine(unique),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
type: z.literal("type_text"),
|
||||||
|
text: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(16384)
|
||||||
|
.refine((s) => Buffer.byteLength(s) <= 16384 && !s.includes("\0")),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
export const screenshot = z.strictObject({
|
||||||
|
session_id: id,
|
||||||
|
region: z
|
||||||
|
.strictObject({
|
||||||
|
x: z.int().min(0),
|
||||||
|
y: z.int().min(0),
|
||||||
|
width: z.int().min(1).max(8192),
|
||||||
|
height: z.int().min(1).max(8192),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
max_edge: z.int().min(64).max(2048).optional(),
|
||||||
|
after_frame_id: z.int().min(0).optional(),
|
||||||
|
generation: z.int().min(0).optional(),
|
||||||
|
timeout_ms: z.int().min(0).max(5000).optional(),
|
||||||
|
});
|
||||||
|
export const uiConnect = z.strictObject({
|
||||||
|
device_alias: alias.optional(),
|
||||||
|
device_id: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{9}$/)
|
||||||
|
.optional(),
|
||||||
|
label: alias.optional(),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.max(256)
|
||||||
|
.refine((s) => Buffer.byteLength(s) <= 256 && !/[\0\r\n]/.test(s))
|
||||||
|
.optional(),
|
||||||
|
mode: mode.default("observe"),
|
||||||
|
display_index: z.int().min(0).max(63).default(0),
|
||||||
|
server: host.optional(),
|
||||||
|
port: z.int().min(1).max(65535).optional(),
|
||||||
|
save_device: z.boolean().default(false),
|
||||||
|
remember_password: z.boolean().default(false),
|
||||||
|
persistent_authorization: z.boolean().default(false),
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { statSync } from "node:fs";
|
||||||
|
|
||||||
|
// MCP stdio is normally piped even when its host is an interactive terminal.
|
||||||
|
// Detect the desktop independently, without opening or taking over /dev/tty.
|
||||||
|
export function displayEnvironment(
|
||||||
|
platform = process.platform,
|
||||||
|
env = process.env,
|
||||||
|
tty = !!(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY),
|
||||||
|
): { gui: boolean; terminal: boolean } {
|
||||||
|
const ssh = !!(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
|
||||||
|
let gui = false;
|
||||||
|
if (platform === "linux") gui = !!(env.DISPLAY || env.WAYLAND_DISPLAY);
|
||||||
|
else if (platform === "darwin" && !ssh) {
|
||||||
|
try {
|
||||||
|
const uid = statSync("/dev/console").uid;
|
||||||
|
gui = uid !== 0 && uid === process.getuid?.();
|
||||||
|
} catch { /* No logged-in desktop session. */ }
|
||||||
|
} else if (platform === "win32") {
|
||||||
|
gui = !ssh && env.SESSIONNAME?.toLowerCase() !== "services";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
gui,
|
||||||
|
terminal: tty || !!env.SSH_TTY || (!!env.TERM && env.TERM !== "dumb"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
|
import { Writable } from "node:stream";
|
||||||
|
import {
|
||||||
|
serveStdio,
|
||||||
|
StdioServerTransport,
|
||||||
|
} from "@modelcontextprotocol/server/stdio";
|
||||||
|
import { DeviceStore, type Device } from "./store.js";
|
||||||
|
import { Sessions } from "./sessions.js";
|
||||||
|
import { BrowserPanel, pairBrowser, openURL } from "./browser.js";
|
||||||
|
import { createServer } from "./server.js";
|
||||||
|
import { TerminalPanel } from "./terminal.js";
|
||||||
|
import { terminalView } from "./terminal-view.js";
|
||||||
|
import {
|
||||||
|
panel,
|
||||||
|
parse,
|
||||||
|
Failure,
|
||||||
|
failure,
|
||||||
|
uiConnect,
|
||||||
|
videoOptions,
|
||||||
|
type Panel,
|
||||||
|
} from "./common.js";
|
||||||
|
|
||||||
|
async function setup(store: DeviceStore, view?: string) {
|
||||||
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
||||||
|
throw new Failure("SETUP_REQUIRES_TERMINAL");
|
||||||
|
if (view) {
|
||||||
|
const url = await pairBrowser(store.directory, view);
|
||||||
|
if (!url) throw new Failure("PANEL_NOT_FOUND");
|
||||||
|
if (await openURL(url)) process.stdout.write("浏览器配对入口已打开。\n");
|
||||||
|
else
|
||||||
|
process.stdout.write(
|
||||||
|
`请在本机浏览器打开下方一次性链接(60 秒有效,不要发送到聊天):\n${url}\n`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hidden = false;
|
||||||
|
const output = new Writable({
|
||||||
|
write(chunk, _encoding, done) {
|
||||||
|
if (!hidden) process.stdout.write(chunk);
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rl = createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output,
|
||||||
|
terminal: true,
|
||||||
|
historySize: 0,
|
||||||
|
});
|
||||||
|
const question = async (label: string) => (await rl.question(label)).trim();
|
||||||
|
try {
|
||||||
|
const config = await store.config();
|
||||||
|
process.stdout.write(
|
||||||
|
"CrossDesk MCP · 本地设备配置\n密码只保存到系统凭据库,不写入设备配置。\n",
|
||||||
|
);
|
||||||
|
const name = await question("设备别名:");
|
||||||
|
const previous = config.devices.find((d) => d.alias === name);
|
||||||
|
const device = previous?.device_id ?? (await question("9 位设备 ID:"));
|
||||||
|
const server =
|
||||||
|
previous?.server ??
|
||||||
|
((await question(`服务器 [${config.server}]:`)) || config.server);
|
||||||
|
const port =
|
||||||
|
previous?.port ??
|
||||||
|
Number((await question(`端口 [${config.port}]:`)) || config.port);
|
||||||
|
const permission = await question(
|
||||||
|
"允许未来自动连接?[n / observe / control]:",
|
||||||
|
);
|
||||||
|
const environment = await question(
|
||||||
|
"使用现有密码环境变量(留空使用系统凭据库):",
|
||||||
|
);
|
||||||
|
let password: string | undefined;
|
||||||
|
if (!environment) {
|
||||||
|
hidden = true;
|
||||||
|
process.stdout.write("访问密码(隐藏输入,可为空):");
|
||||||
|
try {
|
||||||
|
password = await rl.question("");
|
||||||
|
} finally {
|
||||||
|
hidden = false;
|
||||||
|
process.stdout.write("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parse(uiConnect, {
|
||||||
|
device_id: device,
|
||||||
|
label: name,
|
||||||
|
server,
|
||||||
|
port,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const d: Device = {
|
||||||
|
alias: name,
|
||||||
|
device_id: device,
|
||||||
|
server,
|
||||||
|
port,
|
||||||
|
...(permission === "observe" || permission === "control"
|
||||||
|
? { authorization: permission }
|
||||||
|
: {}),
|
||||||
|
...(environment
|
||||||
|
? { credential: "environment", environment_variable: environment }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
await store.save(d, password);
|
||||||
|
password = undefined;
|
||||||
|
process.stdout.write(
|
||||||
|
"已保存。Agent 可调用 crossdesk_list_devices;自动连接仍需有效授权和凭据。\n",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
hidden = false;
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
const { values, positionals } = parseArgs({
|
||||||
|
allowPositionals: true,
|
||||||
|
strict: true,
|
||||||
|
options: {
|
||||||
|
stdio: { type: "boolean" },
|
||||||
|
panel: { type: "string" },
|
||||||
|
"video-path": { type: "string" },
|
||||||
|
"video-decoder": { type: "string" },
|
||||||
|
"config-dir": { type: "string" },
|
||||||
|
"native-binary": { type: "string" },
|
||||||
|
"open-panel": { type: "boolean" },
|
||||||
|
view: { type: "string" },
|
||||||
|
"cell-aspect": { type: "string" },
|
||||||
|
help: { type: "boolean" },
|
||||||
|
version: { type: "boolean" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (values.version) {
|
||||||
|
process.stdout.write("0.1.0\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (values.help || !positionals.length) {
|
||||||
|
process.stdout.write(
|
||||||
|
"crossdesk-mcp serve --stdio [--panel auto|apps|browser|terminal|off] [--video-path browser|minirtc] [--video-decoder auto|software] [--config-dir directory]\ncrossdesk-mcp view --view VIEW_ID [--config-dir directory] [--cell-aspect 0.5]\ncrossdesk-mcp setup [--config-dir directory]\ncrossdesk-mcp setup --open-panel --view VIEW_ID [--config-dir directory]\nDevelopment: --native-binary /absolute/path/to/crossdesk-cli\n",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (positionals.length !== 1 || !["serve", "setup", "view"].includes(positionals[0]!))
|
||||||
|
throw new Failure("INVALID_COMMAND");
|
||||||
|
const store = new DeviceStore(
|
||||||
|
values["config-dir"] ?? process.env.CROSSDESK_MCP_CONFIG_DIR,
|
||||||
|
);
|
||||||
|
await store.initialize();
|
||||||
|
if (positionals[0] === "view") {
|
||||||
|
if (!values.view) throw new Failure("VIEW_REQUIRED");
|
||||||
|
await terminalView(store.directory, values.view, Number(values["cell-aspect"] ?? 0.5));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (positionals[0] === "setup") {
|
||||||
|
if (values["open-panel"] && !values.view)
|
||||||
|
throw new Failure("VIEW_REQUIRED");
|
||||||
|
await setup(store, values["open-panel"] ? values.view : undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!values.stdio) throw new Failure("STDIO_REQUIRED");
|
||||||
|
const config = await store.config();
|
||||||
|
const policy: Panel = parse(
|
||||||
|
panel,
|
||||||
|
values.panel ?? config.panel,
|
||||||
|
);
|
||||||
|
const sessions = new Sessions(
|
||||||
|
store,
|
||||||
|
policy,
|
||||||
|
values["native-binary"] ?? process.env.CROSSDESK_NATIVE_BINARY,
|
||||||
|
parse(videoOptions, {
|
||||||
|
decode_path: values["video-path"] ?? config.video.decode_path,
|
||||||
|
decoder: values["video-decoder"] ?? config.video.decoder,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const browser = new BrowserPanel(sessions);
|
||||||
|
const terminal = new TerminalPanel(sessions);
|
||||||
|
const handle = serveStdio(() => createServer(sessions, browser, terminal), {
|
||||||
|
transport: new StdioServerTransport(),
|
||||||
|
onerror: () => process.stderr.write("crossdesk-mcp: protocol error\n"),
|
||||||
|
});
|
||||||
|
let closing = false;
|
||||||
|
const close = async () => {
|
||||||
|
if (closing) return;
|
||||||
|
closing = true;
|
||||||
|
await terminal.close();
|
||||||
|
await browser.close();
|
||||||
|
await sessions.close();
|
||||||
|
await handle.close();
|
||||||
|
};
|
||||||
|
process.stdin.once("end", () => {
|
||||||
|
void close();
|
||||||
|
});
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const)
|
||||||
|
process.once(signal, () => {
|
||||||
|
void close().finally(() => {
|
||||||
|
process.exitCode = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
process.once("uncaughtException", () => {
|
||||||
|
process.stderr.write("crossdesk-mcp: INTERNAL_ERROR\n");
|
||||||
|
void close().finally(() => {
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`crossdesk-mcp: ${failure(error).code}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { spawn, execFile, type ChildProcess } from "node:child_process";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type { Writable } from "node:stream";
|
||||||
|
import { Failure, type Data, type VideoOptions } from "./common.js";
|
||||||
|
|
||||||
|
export const NATIVE_VERSION = "0.2.0";
|
||||||
|
const run = promisify(execFile);
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
function nativeEnvironment(): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = {};
|
||||||
|
for (const key of [
|
||||||
|
"PATH",
|
||||||
|
"HOME",
|
||||||
|
"USERPROFILE",
|
||||||
|
"SystemRoot",
|
||||||
|
"SYSTEMROOT",
|
||||||
|
"WINDIR",
|
||||||
|
"TEMP",
|
||||||
|
"TMP",
|
||||||
|
"TMPDIR",
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"SSL_CERT_FILE",
|
||||||
|
"SSL_CERT_DIR",
|
||||||
|
])
|
||||||
|
if (process.env[key] !== undefined) env[key] = process.env[key];
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
export async function nativeBinary(override?: string): Promise<string> {
|
||||||
|
let binary: string;
|
||||||
|
if (override) binary = resolve(override);
|
||||||
|
else {
|
||||||
|
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
"darwin-arm64",
|
||||||
|
"darwin-x64",
|
||||||
|
"linux-arm64",
|
||||||
|
"linux-x64",
|
||||||
|
"windows-x64",
|
||||||
|
].includes(target)
|
||||||
|
)
|
||||||
|
throw new Failure("PLATFORM_UNSUPPORTED");
|
||||||
|
if (
|
||||||
|
process.platform === "linux" &&
|
||||||
|
!(process.report.getReport() as Data).header.glibcVersionRuntime
|
||||||
|
)
|
||||||
|
throw new Failure("GLIBC_REQUIRED");
|
||||||
|
try {
|
||||||
|
const manifest = require.resolve(
|
||||||
|
`@chunkdash/crossdesk-cli-${target}/package.json`,
|
||||||
|
);
|
||||||
|
if (require(manifest).version !== NATIVE_VERSION)
|
||||||
|
throw new Failure("NATIVE_VERSION_MISMATCH");
|
||||||
|
binary = join(
|
||||||
|
dirname(manifest),
|
||||||
|
"bin",
|
||||||
|
process.platform === "win32" ? "crossdesk-cli.exe" : "crossdesk-cli",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Failure
|
||||||
|
? error
|
||||||
|
: new Failure("NATIVE_PACKAGE_MISSING");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { stdout } = await run(binary, ["doctor", "--json"], {
|
||||||
|
timeout: 5000,
|
||||||
|
maxBuffer: 65536,
|
||||||
|
windowsHide: true,
|
||||||
|
env: nativeEnvironment(),
|
||||||
|
});
|
||||||
|
const doctor = JSON.parse(stdout);
|
||||||
|
if (doctor.automation_protocol !== "crossdesk-automation/1" ||
|
||||||
|
doctor.automation_terminal_protocol !== "ansi/1" ||
|
||||||
|
doctor.automation_video_transport !== "loopback-websocket/1" ||
|
||||||
|
!["h264-annexb/1", "nv12/1"].every((p) => doctor.automation_video_protocols?.includes(p)) ||
|
||||||
|
!["auto", "software"].every((p) => doctor.automation_video_decoders?.includes(p)))
|
||||||
|
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Failure ? error : new Failure("NATIVE_START_FAILED");
|
||||||
|
}
|
||||||
|
return binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pending = {
|
||||||
|
resolve: (value: Data) => void;
|
||||||
|
reject: (error: Failure) => void;
|
||||||
|
clean: () => void;
|
||||||
|
action: boolean;
|
||||||
|
};
|
||||||
|
export class NativeClient {
|
||||||
|
private child: ChildProcess;
|
||||||
|
private pending = new Map<number, Pending>();
|
||||||
|
private sequence = 0;
|
||||||
|
private chunks: Buffer[] = [];
|
||||||
|
private bufferedBytes = 0;
|
||||||
|
private closed = false;
|
||||||
|
private exited = false;
|
||||||
|
private closing?: Promise<void>;
|
||||||
|
constructor(binary: string, server: string, port: number, logs: string,
|
||||||
|
readonly videoOptions: VideoOptions = { decode_path: "browser", decoder: "auto" }) {
|
||||||
|
this.child = spawn(
|
||||||
|
binary,
|
||||||
|
[
|
||||||
|
"agent",
|
||||||
|
"--stdio",
|
||||||
|
"--password-fd",
|
||||||
|
"3",
|
||||||
|
"--video-ws",
|
||||||
|
"--server",
|
||||||
|
server,
|
||||||
|
"--port",
|
||||||
|
String(port),
|
||||||
|
"--log-dir",
|
||||||
|
logs,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
stdio: ["pipe", "pipe", "ignore", "pipe"],
|
||||||
|
shell: false,
|
||||||
|
windowsHide: true,
|
||||||
|
env: nativeEnvironment(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.child.stdout!.on("data", (part: Buffer) => this.receive(part));
|
||||||
|
this.child.stdin!.on("error", () => this.fail());
|
||||||
|
this.child.stdio[3]!.on("error", () => this.fail());
|
||||||
|
this.child.once("error", () => this.fail());
|
||||||
|
// Process exit can precede the final stdout data. Drain replies until EOF
|
||||||
|
// or close before rejecting requests that have no response.
|
||||||
|
this.child.once("close", () => { this.exited = true; this.fail(); });
|
||||||
|
this.child.stdout!.once("end", () => this.fail());
|
||||||
|
}
|
||||||
|
async connect(
|
||||||
|
device: string,
|
||||||
|
password: string,
|
||||||
|
mode: string,
|
||||||
|
display: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Data> {
|
||||||
|
if (Buffer.byteLength(password) > 256 || /[\0\r\n]/.test(password))
|
||||||
|
throw new Failure("INVALID_CREDENTIAL");
|
||||||
|
const hello = await this.request(
|
||||||
|
"hello",
|
||||||
|
{ protocol: "crossdesk-automation/1" },
|
||||||
|
5000,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (hello.protocol !== "crossdesk-automation/1" ||
|
||||||
|
hello.terminal_protocol !== "ansi/1" ||
|
||||||
|
hello.video_transport !== "loopback-websocket/1" ||
|
||||||
|
!hello.video_protocols?.includes(this.videoOptions.decode_path === "minirtc" ? "nv12/1" : "h264-annexb/1") ||
|
||||||
|
!hello.video_decoders?.includes(this.videoOptions.decoder))
|
||||||
|
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
|
||||||
|
const secret = Buffer.from(password + "\n");
|
||||||
|
password = "";
|
||||||
|
(this.child.stdio[3] as Writable).end(secret, () => secret.fill(0));
|
||||||
|
return this.request("connect", { device, mode, display, ...this.videoOptions }, 7000, signal);
|
||||||
|
}
|
||||||
|
request(
|
||||||
|
method: string,
|
||||||
|
args: Data = {},
|
||||||
|
timeout = 7000,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Data> {
|
||||||
|
if (this.closed || (this.closing && method !== "disconnect"))
|
||||||
|
return Promise.reject(new Failure("SESSION_CLOSED"));
|
||||||
|
if (signal?.aborted) return Promise.reject(new Failure("CANCELLED"));
|
||||||
|
if (this.pending.size >= 32 || this.child.stdin!.writableLength > 262144)
|
||||||
|
return Promise.reject(new Failure("BUSY", false, true));
|
||||||
|
const id = ++this.sequence;
|
||||||
|
const line = JSON.stringify({ ...args, id, method }) + "\n";
|
||||||
|
if (Buffer.byteLength(line) > 65536)
|
||||||
|
return Promise.reject(new Failure("MESSAGE_TOO_LARGE"));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cancel = (code: string) => {
|
||||||
|
const p = this.pending.get(id);
|
||||||
|
if (!p) return;
|
||||||
|
this.pending.delete(id);
|
||||||
|
p.clean();
|
||||||
|
// Cancellation is best effort; a timed-out action is never replayed.
|
||||||
|
this.child.stdin?.write(
|
||||||
|
JSON.stringify({
|
||||||
|
id: ++this.sequence,
|
||||||
|
method: "cancel",
|
||||||
|
request_id: id,
|
||||||
|
}) + "\n",
|
||||||
|
);
|
||||||
|
reject(new Failure(code, method === "action"));
|
||||||
|
};
|
||||||
|
const abort = () => cancel("CANCELLED");
|
||||||
|
const timer = setTimeout(() => cancel("NATIVE_TIMEOUT"), timeout);
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
action: method === "action",
|
||||||
|
clean: () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.child.stdin!.write(line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private receive(part: Buffer) {
|
||||||
|
if (this.closed) return;
|
||||||
|
for (let start = 0; start < part.length;) {
|
||||||
|
const end = part.indexOf(10, start);
|
||||||
|
const chunk = part.subarray(start, end < 0 ? part.length : end);
|
||||||
|
this.bufferedBytes += chunk.length;
|
||||||
|
if (this.bufferedBytes > 4 * 1024 * 1024) {
|
||||||
|
this.fail();
|
||||||
|
this.child.kill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.chunks.push(chunk);
|
||||||
|
if (end < 0) return;
|
||||||
|
// One allocation and UTF-8 decode per response, including split UTF-8
|
||||||
|
// codepoints. Do not repeatedly scan/copy a multi-megabyte PNG response.
|
||||||
|
const line = this.chunks.length === 1 ? chunk : Buffer.concat(this.chunks, this.bufferedBytes);
|
||||||
|
this.chunks = [];
|
||||||
|
this.bufferedBytes = 0;
|
||||||
|
start = end + 1;
|
||||||
|
try {
|
||||||
|
const reply = JSON.parse(line.toString("utf8"));
|
||||||
|
if (!reply || !Number.isSafeInteger(reply.id) || reply.id < 0 || typeof reply.ok !== "boolean" ||
|
||||||
|
(reply.ok && (!reply.result || typeof reply.result !== "object" || Array.isArray(reply.result))) ||
|
||||||
|
(!reply.ok && (!reply.error || typeof reply.error.code !== "string")))
|
||||||
|
throw new Error();
|
||||||
|
const p = this.pending.get(reply.id);
|
||||||
|
if (!p) continue;
|
||||||
|
this.pending.delete(reply.id);
|
||||||
|
p.clean();
|
||||||
|
if (reply.ok) p.resolve(reply.result);
|
||||||
|
else
|
||||||
|
p.reject(
|
||||||
|
new Failure(
|
||||||
|
/^[A-Z_]+$/.test(reply.error?.code)
|
||||||
|
? reply.error.code
|
||||||
|
: "NATIVE_ERROR",
|
||||||
|
!!reply.error?.side_effect_possible,
|
||||||
|
!!reply.error?.retryable,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.fail();
|
||||||
|
this.child.kill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fail() {
|
||||||
|
this.closed = true;
|
||||||
|
this.chunks = [];
|
||||||
|
this.bufferedBytes = 0;
|
||||||
|
for (const p of this.pending.values()) {
|
||||||
|
p.clean();
|
||||||
|
p.reject(new Failure("SESSION_CLOSED", p.action));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
close(): Promise<void> {
|
||||||
|
if (this.closing) return this.closing;
|
||||||
|
this.closing = (async () => {
|
||||||
|
if (!this.closed)
|
||||||
|
await this.request("disconnect", {}, 1000).catch(() => {});
|
||||||
|
this.child.stdin?.end();
|
||||||
|
if (!this.exited && this.child.exitCode === null && this.child.signalCode === null)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const kill = setTimeout(() => {
|
||||||
|
this.child.kill("SIGKILL");
|
||||||
|
}, 2000);
|
||||||
|
// Keep the device lock until the child really exits.
|
||||||
|
this.child.once("close", () => {
|
||||||
|
clearTimeout(kill);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.fail();
|
||||||
|
})();
|
||||||
|
return this.closing;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { McpServer } from "@modelcontextprotocol/server";
|
||||||
|
import type {
|
||||||
|
CallToolResult,
|
||||||
|
ClientCapabilities,
|
||||||
|
Tool,
|
||||||
|
ServerContext,
|
||||||
|
} from "@modelcontextprotocol/server";
|
||||||
|
import {
|
||||||
|
getUiCapability,
|
||||||
|
registerAppResource,
|
||||||
|
registerAppTool,
|
||||||
|
RESOURCE_MIME_TYPE,
|
||||||
|
} from "@modelcontextprotocol/ext-apps/server";
|
||||||
|
import { Sessions, type View } from "./sessions.js";
|
||||||
|
import { BrowserPanel } from "./browser.js";
|
||||||
|
import { TerminalPanel } from "./terminal.js";
|
||||||
|
import { uiAction, uiSchemas, type UIAction } from "./ui-actions.js";
|
||||||
|
import {
|
||||||
|
id,
|
||||||
|
alias,
|
||||||
|
mode,
|
||||||
|
action,
|
||||||
|
screenshot,
|
||||||
|
Failure,
|
||||||
|
failure,
|
||||||
|
parse,
|
||||||
|
token,
|
||||||
|
type Data,
|
||||||
|
} from "./common.js";
|
||||||
|
|
||||||
|
const resource = "ui://crossdesk/viewer.html";
|
||||||
|
const uiNames: Record<UIAction, string> = {
|
||||||
|
ready: "crossdesk_ui_ready",
|
||||||
|
devices: "crossdesk_ui_list_devices",
|
||||||
|
status: "crossdesk_ui_get_session",
|
||||||
|
connect: "crossdesk_ui_connect",
|
||||||
|
video_open: "crossdesk_video_open",
|
||||||
|
control: "crossdesk_viewer_control",
|
||||||
|
};
|
||||||
|
function output(value: Data, meta?: Data): CallToolResult {
|
||||||
|
const data = { ...value };
|
||||||
|
const content: any[] = [];
|
||||||
|
if (data.mime_type === "image/png" && typeof data.data === "string") {
|
||||||
|
content.push({ type: "image", mimeType: data.mime_type, data: data.data });
|
||||||
|
delete data.data;
|
||||||
|
}
|
||||||
|
if (data.observation?.data) {
|
||||||
|
content.push({
|
||||||
|
type: "image",
|
||||||
|
mimeType: data.observation.mime_type,
|
||||||
|
data: data.observation.data,
|
||||||
|
});
|
||||||
|
data.observation = { ...data.observation };
|
||||||
|
delete data.observation.data;
|
||||||
|
}
|
||||||
|
content.unshift({ type: "text", text: JSON.stringify(data) });
|
||||||
|
return { content, structuredContent: data, ...(meta ? { _meta: meta } : {}) };
|
||||||
|
}
|
||||||
|
export function createServer(
|
||||||
|
sessions: Sessions,
|
||||||
|
browser: BrowserPanel,
|
||||||
|
terminal: TerminalPanel,
|
||||||
|
): McpServer {
|
||||||
|
const server = new McpServer(
|
||||||
|
{ name: "crossdesk-mcp", version: "0.1.0" },
|
||||||
|
{
|
||||||
|
capabilities: { tools: {}, resources: {} },
|
||||||
|
instructions:
|
||||||
|
"Remote computer control. Open a panel for the user to select and authorize a device. Never request passwords in chat. When a remote task is pending, immediately follow crossdesk_open with crossdesk_get_session(view_id, wait_ms: 25000) in the same turn. Repeat bounded waits on wait_timed_out until ready or the user cancels; do not ask the user to type a connection confirmation or click Continue. On ready, continue the requested task within current authorization. On closed/failed, stop waiting and do not automatically reconnect. Obtain a screenshot before actions and pass its observation_id; coordinates refer to that returned image. Submitted actions are not remote execution acknowledgements. Never replay actions after uncertain outcomes. Human pause can only be resumed in the user panel.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
type Definition = {
|
||||||
|
name: string;
|
||||||
|
schema: z.ZodType;
|
||||||
|
description: string;
|
||||||
|
ui?: boolean;
|
||||||
|
app?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
run: (p: Data, ctx: ServerContext) => Promise<CallToolResult>;
|
||||||
|
};
|
||||||
|
const definitions = new Map<string, Definition>();
|
||||||
|
const supported = (ctx: ServerContext) => {
|
||||||
|
const envelope = ctx.mcpReq.envelope as Data | undefined;
|
||||||
|
const caps = envelope
|
||||||
|
? envelope["io.modelcontextprotocol/clientCapabilities"]
|
||||||
|
: server.server.getClientCapabilities();
|
||||||
|
return (
|
||||||
|
getUiCapability((caps ?? {}) as ClientCapabilities)?.mimeTypes?.includes(
|
||||||
|
RESOURCE_MIME_TYPE,
|
||||||
|
) ?? false
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const apps = (ctx: ServerContext) =>
|
||||||
|
sessions.policy === "apps" && supported(ctx);
|
||||||
|
const openPanel = async (v: View) => v.panel === "browser" ? browser.open(v)
|
||||||
|
: v.panel === "terminal" ? terminal.open(v) : sessions.describe(v);
|
||||||
|
const viewResult = (v: View, result: Data) =>
|
||||||
|
output(
|
||||||
|
result,
|
||||||
|
v.panel === "apps"
|
||||||
|
? {
|
||||||
|
"crossdesk/ui": {
|
||||||
|
view_id: v.id,
|
||||||
|
view_generation: v.generation,
|
||||||
|
ui_token: v.secret,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const add = (
|
||||||
|
name: string,
|
||||||
|
schema: z.ZodType,
|
||||||
|
description: string,
|
||||||
|
run: Definition["run"],
|
||||||
|
options: Partial<Definition> = {},
|
||||||
|
) => {
|
||||||
|
const d = { name, schema, description, run, ...options };
|
||||||
|
definitions.set(name, d);
|
||||||
|
const config = {
|
||||||
|
description,
|
||||||
|
inputSchema: schema,
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: !!d.readOnly,
|
||||||
|
destructiveHint: d.name === "crossdesk_action",
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
...(d.ui || d.app
|
||||||
|
? {
|
||||||
|
_meta: {
|
||||||
|
ui: {
|
||||||
|
...(d.ui ? { resourceUri: resource } : {}),
|
||||||
|
visibility: d.app
|
||||||
|
? ["app" as const]
|
||||||
|
: ["model" as const, "app" as const],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
if (d.ui || d.app)
|
||||||
|
registerAppTool(
|
||||||
|
server,
|
||||||
|
name,
|
||||||
|
{ ...config, _meta: config._meta! },
|
||||||
|
(p, ctx) => run(p as Data, ctx),
|
||||||
|
);
|
||||||
|
else server.registerTool(name, config, (p, ctx) => run(p as Data, ctx));
|
||||||
|
};
|
||||||
|
add(
|
||||||
|
"crossdesk_open",
|
||||||
|
z.strictObject({ device_alias: alias.optional() }),
|
||||||
|
"Open CrossDesk so the user can choose a device, enter its password, and authorize this session. GUI environments use a browser; headless terminal environments return terminal_command, which you must show to the user to run in a separate terminal. Never run the terminal viewer in a captured tool-output channel. Returns immediately. To continue a pending remote task, call crossdesk_get_session with the returned view_id and wait_ms: 25000; no chat confirmation is needed.",
|
||||||
|
async (p, ctx) => {
|
||||||
|
if (p.device_alias) await sessions.store.device(p.device_alias);
|
||||||
|
const v = sessions.create(
|
||||||
|
sessions.choose(supported(ctx)),
|
||||||
|
p.device_alias,
|
||||||
|
);
|
||||||
|
return viewResult(
|
||||||
|
v,
|
||||||
|
await openPanel(v),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ ui: true },
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
"crossdesk_open_browser",
|
||||||
|
z.strictObject({ view_id: id.optional() }),
|
||||||
|
"Explicitly open the local browser panel after the user requests browser fallback. Does not create another remote connection.",
|
||||||
|
async (p) => {
|
||||||
|
if (!["auto", "browser"].includes(sessions.policy))
|
||||||
|
throw new Failure("PANEL_POLICY_DENIED");
|
||||||
|
const v = p.view_id
|
||||||
|
? sessions.view(p.view_id)
|
||||||
|
: sessions.create("browser");
|
||||||
|
if (v.panel !== "browser") {
|
||||||
|
await sessions.revokeVideo(v);
|
||||||
|
v.panel = "browser";
|
||||||
|
v.generation++;
|
||||||
|
v.secret = token();
|
||||||
|
v.ready = false;
|
||||||
|
v.created = Date.now();
|
||||||
|
}
|
||||||
|
return output(await browser.open(v));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
"crossdesk_list_devices",
|
||||||
|
z.strictObject({}),
|
||||||
|
"List the local saved device book and persistent authorization, without credentials. This is not online discovery.",
|
||||||
|
async () => output({ devices: await sessions.store.list() }),
|
||||||
|
{ readOnly: true },
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
"crossdesk_connect",
|
||||||
|
z.strictObject({
|
||||||
|
device_alias: alias,
|
||||||
|
mode: mode.default("observe"),
|
||||||
|
display_index: z.int().min(0).max(63).default(0),
|
||||||
|
}),
|
||||||
|
"Connect only a saved, previously authorized device with available credentials. AUTH_REQUIRED means the user must authorize through the panel or setup.",
|
||||||
|
async (p, ctx) => {
|
||||||
|
const selected = sessions.choose(supported(ctx));
|
||||||
|
// Reserve panel capacity before starting a connection. Once connected,
|
||||||
|
// panel setup errors must not hide the live session from the caller.
|
||||||
|
const v = sessions.create(selected, p.device_alias);
|
||||||
|
let result: Data;
|
||||||
|
try {
|
||||||
|
result = await sessions.connectSaved(
|
||||||
|
p.device_alias,
|
||||||
|
p.mode,
|
||||||
|
p.display_index,
|
||||||
|
ctx.mcpReq.signal,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
sessions.views.delete(v.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
v.session = result.session_id;
|
||||||
|
let panelResult: Data;
|
||||||
|
try { panelResult = await openPanel(v); }
|
||||||
|
catch (error) { panelResult = { ...sessions.describe(v), panel_error: failure(error) }; }
|
||||||
|
return viewResult(v, {
|
||||||
|
...result,
|
||||||
|
...panelResult,
|
||||||
|
state: result.state,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ ui: true },
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
"crossdesk_get_session",
|
||||||
|
z.strictObject({
|
||||||
|
session_id: id.optional(),
|
||||||
|
view_id: id.optional(),
|
||||||
|
wait_ms: z.int().min(0).max(25000).optional(),
|
||||||
|
}),
|
||||||
|
"Get connection, display geometry, frame age and control state. Supply exactly one of session_id or view_id. With view_id, wait for connection (default 25000 ms; 0 returns immediately). On wait_timed_out, repeat while the remote task is pending. On ready, continue without asking the user to send a message. Waiting never connects or grants control. session_id returns immediately and requires wait_ms to be 0 or omitted.",
|
||||||
|
async (p, ctx) => {
|
||||||
|
if (!!p.session_id === !!p.view_id)
|
||||||
|
throw new Failure("SELECT_ONE_SESSION");
|
||||||
|
if (p.session_id && p.wait_ms) throw new Failure("INVALID_ARGUMENT");
|
||||||
|
return output(p.view_id
|
||||||
|
? await sessions.waitForConnection(p.view_id, p.wait_ms ?? 25000, ctx.mcpReq.signal)
|
||||||
|
: await sessions.status(p.session_id, undefined, ctx.mcpReq.signal));
|
||||||
|
},
|
||||||
|
{ readOnly: true },
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
"crossdesk_screenshot",
|
||||||
|
screenshot,
|
||||||
|
"Capture the remote display as a PNG image and immutable observation metadata. Coordinates in subsequent actions use this returned image. after_frame_id requires generation.",
|
||||||
|
async (p, ctx) => output(await sessions.screenshot(p, ctx.mcpReq.signal)),
|
||||||
|
{ readOnly: true },
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
"crossdesk_action",
|
||||||
|
z.strictObject({
|
||||||
|
session_id: id,
|
||||||
|
operation_id: id,
|
||||||
|
observation_id: id,
|
||||||
|
action,
|
||||||
|
observe_after: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
"Submit one structured action against a recent observation. Reuse operation_id only to retrieve that same operation, never replay uncertain effects with a new ID. Human pause prevents control. A post-action screenshot is not an execution acknowledgement.",
|
||||||
|
async (p, ctx) => output(await sessions.action(p, ctx.mcpReq.signal)),
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
"crossdesk_set_control",
|
||||||
|
z.strictObject({ session_id: id, enabled: z.boolean() }),
|
||||||
|
"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_select_display",
|
||||||
|
z.strictObject({ session_id: id, display_index: z.int().min(0).max(63) }),
|
||||||
|
"Select a remote display. Invalidates previous observations; capture a new screenshot before any further input.",
|
||||||
|
async (p) => output(await sessions.display(p.session_id, p.display_index)),
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
"crossdesk_disconnect",
|
||||||
|
z.strictObject({ session_id: id }),
|
||||||
|
"Disconnect the remote session, release input state and stop associated previews.",
|
||||||
|
async (p) => output(await sessions.disconnect(p.session_id)),
|
||||||
|
);
|
||||||
|
for (const [name, schema] of Object.entries(uiSchemas)) {
|
||||||
|
add(
|
||||||
|
uiNames[name as UIAction],
|
||||||
|
schema.extend({
|
||||||
|
view_id: id,
|
||||||
|
view_generation: z.int().min(1),
|
||||||
|
ui_token: id,
|
||||||
|
}),
|
||||||
|
`CrossDesk panel only: ${name}.`,
|
||||||
|
async (p, ctx) => {
|
||||||
|
if (!apps(ctx)) throw new Failure("PANEL_UNSUPPORTED");
|
||||||
|
const { view_id, view_generation, ui_token, ...args } = p;
|
||||||
|
const v = sessions.authenticate(view_id, view_generation, ui_token);
|
||||||
|
if (v.panel !== "apps") throw new Failure("VIEW_UNAUTHORIZED");
|
||||||
|
// Video grants stay in the authenticated app-only response.
|
||||||
|
const result = name === "video_open" ? await browser.video(v) : await uiAction(
|
||||||
|
sessions,
|
||||||
|
v,
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
ctx.mcpReq.signal,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: name === "video_open" ? "Video viewer connection" : JSON.stringify(result),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
structuredContent: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ app: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
registerAppResource(
|
||||||
|
server,
|
||||||
|
"CrossDesk viewer",
|
||||||
|
resource,
|
||||||
|
{ mimeType: RESOURCE_MIME_TYPE },
|
||||||
|
async (uri, ctx) => {
|
||||||
|
if (!apps(ctx)) throw new Failure("PANEL_UNSUPPORTED");
|
||||||
|
return {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
uri: uri.href,
|
||||||
|
mimeType: RESOURCE_MIME_TYPE,
|
||||||
|
text: await readFile(
|
||||||
|
new URL("./viewer-apps.html", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
_meta: {
|
||||||
|
ui: {
|
||||||
|
prefersBorder: true,
|
||||||
|
csp: { connectDomains: await browser.connectDomains(), resourceDomains: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Request-scoped projection avoids global tool mutations when modern clients
|
||||||
|
// send concurrent calls with different capability envelopes.
|
||||||
|
server.server.setRequestHandler("tools/list", async (_req, ctx) => ({
|
||||||
|
tools: [...definitions.values()]
|
||||||
|
.filter((d) => !d.app || apps(ctx))
|
||||||
|
.map((d) => {
|
||||||
|
const inputSchema = z.toJSONSchema(d.schema, {
|
||||||
|
io: "input",
|
||||||
|
}) as Tool["inputSchema"];
|
||||||
|
if (d.name === "crossdesk_get_session")
|
||||||
|
inputSchema.oneOf = [
|
||||||
|
{ required: ["session_id"] },
|
||||||
|
{ required: ["view_id"] },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
name: d.name,
|
||||||
|
description: d.description,
|
||||||
|
inputSchema,
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: !!d.readOnly,
|
||||||
|
destructiveHint: d.name === "crossdesk_action",
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
...((d.ui || d.app) && apps(ctx)
|
||||||
|
? {
|
||||||
|
_meta: {
|
||||||
|
ui: {
|
||||||
|
...(d.ui ? { resourceUri: resource } : {}),
|
||||||
|
visibility: d.app ? ["app"] : ["model", "app"],
|
||||||
|
},
|
||||||
|
...(d.ui ? { "ui/resourceUri": resource } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
server.server.setRequestHandler("tools/call", async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const d = definitions.get(req.params.name);
|
||||||
|
if (!d || (d.app && !apps(ctx))) throw new Failure("UNKNOWN_TOOL");
|
||||||
|
if (Buffer.byteLength(JSON.stringify(req.params.arguments ?? {})) > 65536)
|
||||||
|
throw new Failure("MESSAGE_TOO_LARGE");
|
||||||
|
const p = parse(d.schema, req.params.arguments ?? {}) as Data;
|
||||||
|
return await d.run(p, ctx);
|
||||||
|
} catch (error) {
|
||||||
|
const value = { error: failure(error) };
|
||||||
|
return { ...output(value), isError: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.server.setRequestHandler("resources/list", async (_req, ctx) => ({
|
||||||
|
resources: apps(ctx)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "CrossDesk viewer",
|
||||||
|
uri: resource,
|
||||||
|
mimeType: RESOURCE_MIME_TYPE,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
import {
|
||||||
|
Failure,
|
||||||
|
failure,
|
||||||
|
token,
|
||||||
|
equal,
|
||||||
|
parse,
|
||||||
|
uiConnect,
|
||||||
|
type Data,
|
||||||
|
type Panel,
|
||||||
|
type VideoOptions,
|
||||||
|
} from "./common.js";
|
||||||
|
import { DeviceStore, type Device } from "./store.js";
|
||||||
|
import { nativeBinary, NativeClient } from "./native.js";
|
||||||
|
import { displayEnvironment } from "./environment.js";
|
||||||
|
|
||||||
|
export interface View {
|
||||||
|
id: string;
|
||||||
|
generation: number;
|
||||||
|
secret: string;
|
||||||
|
panel: Exclude<Panel, "auto">;
|
||||||
|
created: number;
|
||||||
|
ready: boolean;
|
||||||
|
lastSeen: number;
|
||||||
|
alias?: string;
|
||||||
|
session?: string;
|
||||||
|
disconnected?: boolean;
|
||||||
|
connectionError?: Data;
|
||||||
|
}
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
device: Device;
|
||||||
|
native: NativeClient;
|
||||||
|
status: Data;
|
||||||
|
release: () => Promise<void>;
|
||||||
|
connecting: boolean;
|
||||||
|
statusRequest?: Promise<Data>;
|
||||||
|
closing?: Promise<void>;
|
||||||
|
}
|
||||||
|
export class Sessions {
|
||||||
|
readonly views = new Map<string, View>();
|
||||||
|
private current?: Session;
|
||||||
|
private connecting?: AbortController;
|
||||||
|
private connectionDone?: Promise<void>;
|
||||||
|
private closing = false;
|
||||||
|
private binary?: Promise<string>;
|
||||||
|
constructor(
|
||||||
|
readonly store: DeviceStore,
|
||||||
|
readonly policy: Panel,
|
||||||
|
private nativePath?: string,
|
||||||
|
private videoOptions?: VideoOptions,
|
||||||
|
) {}
|
||||||
|
choose(apps: boolean): View["panel"] {
|
||||||
|
if (this.policy === "apps" && !apps) throw new Failure("PANEL_UNSUPPORTED");
|
||||||
|
if (this.policy !== "auto") return this.policy;
|
||||||
|
const environment = displayEnvironment();
|
||||||
|
return environment.gui ? "browser" : environment.terminal ? "terminal" : "off";
|
||||||
|
}
|
||||||
|
create(panel: View["panel"], alias?: string): View {
|
||||||
|
for (const [id, view] of this.views)
|
||||||
|
if (Date.now() - view.lastSeen > 30 * 60 * 1000) {
|
||||||
|
void this.revokeVideo(view);
|
||||||
|
this.views.delete(id);
|
||||||
|
}
|
||||||
|
if (this.views.size >= 32) throw new Failure("VIEW_LIMIT");
|
||||||
|
const view: View = {
|
||||||
|
id: token(),
|
||||||
|
generation: 1,
|
||||||
|
secret: token(),
|
||||||
|
panel,
|
||||||
|
created: Date.now(),
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
ready: false,
|
||||||
|
alias,
|
||||||
|
session: this.current?.id,
|
||||||
|
};
|
||||||
|
this.views.set(view.id, view);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
view(id: string): View {
|
||||||
|
const v = this.views.get(id);
|
||||||
|
if (!v) throw new Failure("VIEW_NOT_FOUND");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
authenticate(id: string, generation: number, secret: string): View {
|
||||||
|
const v = this.view(id);
|
||||||
|
if (v.generation !== generation || !equal(v.secret, secret))
|
||||||
|
throw new Failure("VIEW_UNAUTHORIZED");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
describe(v: View): Data {
|
||||||
|
return {
|
||||||
|
view_id: v.id,
|
||||||
|
view_generation: v.generation,
|
||||||
|
panel: v.panel,
|
||||||
|
panel_status: v.ready
|
||||||
|
? "ready"
|
||||||
|
: Date.now() - v.created > 10000
|
||||||
|
? "not_confirmed"
|
||||||
|
: "waiting",
|
||||||
|
session_id: v.session ?? null,
|
||||||
|
state: v.session ? "session_available" : v.disconnected ? "closed" : v.connectionError ? "failed" : "awaiting_user",
|
||||||
|
...(v.connectionError ? { error: v.connectionError } : {}),
|
||||||
|
device_alias: v.alias ?? null,
|
||||||
|
next_step: v.session
|
||||||
|
? "Call crossdesk_get_session with view_id."
|
||||||
|
: v.disconnected || v.connectionError
|
||||||
|
? "The session was disconnected. Do not automatically reconnect."
|
||||||
|
: v.panel === "off"
|
||||||
|
? "Use setup to configure credentials and authorization, then crossdesk_connect with the saved device alias. Never send passwords in chat."
|
||||||
|
: "The user selects and authorizes a device in the panel. To continue a pending remote task, immediately call crossdesk_get_session with this view_id and wait_ms: 25000; repeat on wait_timed_out. Do not ask the user to send a connection confirmation message.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private session(id: string): Session {
|
||||||
|
if (!this.current || this.current.id !== id)
|
||||||
|
throw new Failure("SESSION_NOT_FOUND");
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
async status(sessionId?: string, viewId?: string, signal?: AbortSignal): Promise<Data> {
|
||||||
|
const view = viewId ? this.view(viewId) : undefined;
|
||||||
|
const id = sessionId ?? view?.session;
|
||||||
|
if (!id && view) return this.describe(view);
|
||||||
|
const s = this.session(id!);
|
||||||
|
// A panel connection may still be performing the native hello handshake.
|
||||||
|
// Reading native status here would race initialization and could close it.
|
||||||
|
if (s.connecting) return {
|
||||||
|
...(view ? this.describe(view) : {}),
|
||||||
|
session_id: s.id,
|
||||||
|
device_alias: s.device.alias,
|
||||||
|
state: "connecting",
|
||||||
|
control: false,
|
||||||
|
};
|
||||||
|
// Concurrent panel/model polls share one native read. A cancelled waiter
|
||||||
|
// must not cancel another caller's status request or disconnect the session.
|
||||||
|
if (signal?.aborted) throw new Failure("CANCELLED");
|
||||||
|
if (!["closed", "failed"].includes(s.status.state)) {
|
||||||
|
s.statusRequest ??= s.native.request("status", {}, 5000)
|
||||||
|
.then((status) => (s.status = status))
|
||||||
|
.catch((error) => {
|
||||||
|
if (!(error instanceof Failure) || error.code !== "SESSION_CLOSED") throw error;
|
||||||
|
return (s.status = { state: "closed", control: false });
|
||||||
|
})
|
||||||
|
.finally(() => { s.statusRequest = undefined; });
|
||||||
|
await this.waitStatus(s.statusRequest, signal);
|
||||||
|
}
|
||||||
|
// The user may have disconnected or replaced the session during the read.
|
||||||
|
if (this.current !== s || (view && view.session !== id))
|
||||||
|
return view ? this.describe(view) : { session_id: id, state: "closed", control: false };
|
||||||
|
if (["closed", "failed"].includes(s.status.state)) {
|
||||||
|
await this.dispose(s);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...s.status,
|
||||||
|
session_id: s.id,
|
||||||
|
device_alias: s.device.alias,
|
||||||
|
...(view ? this.describe(view) : {}),
|
||||||
|
state: s.status.state,
|
||||||
|
...(s.status.state === "ready" ? {
|
||||||
|
next_step: "Connection ready. Continue the user's pending task within the current authorization. Obtain a screenshot before input; respect human_paused and control. No user confirmation message is needed.",
|
||||||
|
} : ["closed", "failed", "disconnected"].includes(s.status.state) ? {
|
||||||
|
next_step: "The connection ended. Stop waiting and do not automatically reconnect or replay actions.",
|
||||||
|
} : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private async waitStatus(request: Promise<Data>, signal?: AbortSignal) {
|
||||||
|
if (!signal) return request;
|
||||||
|
let abort: () => void = () => {};
|
||||||
|
try {
|
||||||
|
return await Promise.race([request, new Promise<never>((_, reject) => {
|
||||||
|
abort = () => reject(new Failure("CANCELLED"));
|
||||||
|
signal.addEventListener("abort", abort, { once: true });
|
||||||
|
if (signal.aborted) abort();
|
||||||
|
})]);
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener("abort", abort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private dispose(s: Session): Promise<void> {
|
||||||
|
return s.closing ??= s.native.close().finally(() => s.release());
|
||||||
|
}
|
||||||
|
async waitForConnection(viewId: string, waitMs: number, signal?: AbortSignal): Promise<Data> {
|
||||||
|
const deadline = Date.now() + waitMs;
|
||||||
|
let result = this.describe(this.view(viewId));
|
||||||
|
const timedOut = () => ({
|
||||||
|
...result,
|
||||||
|
wait_timed_out: true,
|
||||||
|
next_step: "Still waiting for the user to connect in the panel. Call crossdesk_get_session again with the same view_id and wait_ms: 25000 while the remote task is pending. Do not ask the user to type a connection confirmation.",
|
||||||
|
});
|
||||||
|
const timeout = new AbortController();
|
||||||
|
const timer = waitMs > 0 ? setTimeout(() => timeout.abort(), waitMs) : undefined;
|
||||||
|
const waitSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
|
||||||
|
const view = this.view(viewId);
|
||||||
|
view.lastSeen = Date.now();
|
||||||
|
result = await this.status(undefined, viewId, waitSignal);
|
||||||
|
if (waitMs === 0 || view.panel === "off" ||
|
||||||
|
["ready", "closed", "failed", "disconnected"].includes(result.state))
|
||||||
|
return result;
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) return timedOut();
|
||||||
|
await delay(Math.min(250, remaining), undefined, { signal: waitSignal });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
|
||||||
|
if (timeout.signal.aborted) return timedOut();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async connectSaved(
|
||||||
|
name: string,
|
||||||
|
mode: string,
|
||||||
|
display: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Data> {
|
||||||
|
const d = await this.store.device(name);
|
||||||
|
if (
|
||||||
|
!d.authorization ||
|
||||||
|
(mode === "control" && d.authorization !== "control")
|
||||||
|
)
|
||||||
|
throw new Failure("AUTH_REQUIRED");
|
||||||
|
return this.connect(
|
||||||
|
d,
|
||||||
|
await this.store.password(d),
|
||||||
|
mode,
|
||||||
|
display,
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async connectUI(v: View, args: unknown, signal?: AbortSignal): Promise<Data> {
|
||||||
|
const p = parse(uiConnect, args);
|
||||||
|
if (!!p.device_alias === !!p.device_id)
|
||||||
|
throw new Failure("SELECT_ONE_DEVICE");
|
||||||
|
const config = await this.store.config();
|
||||||
|
const d: Device = p.device_alias
|
||||||
|
? { ...(await this.store.device(p.device_alias)) }
|
||||||
|
: {
|
||||||
|
alias: p.label ?? p.device_id!,
|
||||||
|
device_id: p.device_id!,
|
||||||
|
server: p.server ?? config.server,
|
||||||
|
port: p.port ?? config.port,
|
||||||
|
};
|
||||||
|
if (p.device_alias && (p.server || p.port || p.label))
|
||||||
|
throw new Failure("INVALID_ARGUMENT");
|
||||||
|
if ((p.remember_password || p.persistent_authorization) && !p.save_device)
|
||||||
|
throw new Failure("SAVE_DEVICE_REQUIRED");
|
||||||
|
let password = p.password ?? (await this.store.password(d));
|
||||||
|
p.password = undefined;
|
||||||
|
// Persist only after successful authentication, avoiding poisoned saved passwords.
|
||||||
|
const result = await this.connect(
|
||||||
|
d,
|
||||||
|
password,
|
||||||
|
p.mode,
|
||||||
|
p.display_index,
|
||||||
|
v,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (p.save_device) {
|
||||||
|
d.authorization = p.persistent_authorization ? p.mode : undefined;
|
||||||
|
try {
|
||||||
|
await this.store.save(d, p.remember_password ? password : undefined);
|
||||||
|
} catch (error) {
|
||||||
|
result.persistence_error =
|
||||||
|
error instanceof Failure ? error.code : "CONFIG_WRITE_FAILED";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
password = "";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
private async connect(
|
||||||
|
d: Device,
|
||||||
|
password: string,
|
||||||
|
mode: string,
|
||||||
|
display: number,
|
||||||
|
view?: View,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Data> {
|
||||||
|
if (this.closing) throw new Failure("SESSION_CLOSED");
|
||||||
|
if (this.connecting) throw new Failure("BUSY");
|
||||||
|
// Reserve the single connection slot before the first await.
|
||||||
|
const controller = this.connecting = new AbortController();
|
||||||
|
signal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
||||||
|
let complete!: () => void;
|
||||||
|
this.connectionDone = new Promise<void>((resolve) => { complete = resolve; });
|
||||||
|
let release: (() => Promise<void>) | undefined;
|
||||||
|
let native: NativeClient | undefined;
|
||||||
|
let session: Session | undefined;
|
||||||
|
try {
|
||||||
|
if (this.current && !["closed", "failed", "disconnected"].includes(
|
||||||
|
(await this.status(this.current.id, undefined, signal)).state,
|
||||||
|
)) throw new Failure("SESSION_LIMIT");
|
||||||
|
if (this.current) await this.disconnect(this.current.id);
|
||||||
|
if (view) {
|
||||||
|
view.disconnected = false;
|
||||||
|
view.connectionError = undefined;
|
||||||
|
}
|
||||||
|
release = await this.store.lock(
|
||||||
|
`device:${d.server}:${d.port}:${d.device_id}`,
|
||||||
|
);
|
||||||
|
this.binary ??= nativeBinary(this.nativePath).catch((e) => {
|
||||||
|
this.binary = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
const binary = await this.binary;
|
||||||
|
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
|
||||||
|
native = new NativeClient(
|
||||||
|
binary,
|
||||||
|
d.server,
|
||||||
|
d.port,
|
||||||
|
join(this.store.directory, "logs"),
|
||||||
|
this.videoOptions ?? (await this.store.config()).video,
|
||||||
|
);
|
||||||
|
const s: Session = session = {
|
||||||
|
id: token(),
|
||||||
|
device: d,
|
||||||
|
native,
|
||||||
|
status: {},
|
||||||
|
release,
|
||||||
|
connecting: true,
|
||||||
|
};
|
||||||
|
this.current = s;
|
||||||
|
if (view) {
|
||||||
|
view.session = s.id;
|
||||||
|
view.alias = d.alias;
|
||||||
|
view.disconnected = false;
|
||||||
|
}
|
||||||
|
s.status = await native.connect(
|
||||||
|
d.device_id,
|
||||||
|
password,
|
||||||
|
mode,
|
||||||
|
display,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
password = "";
|
||||||
|
const deadline = Date.now() + 35000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
|
||||||
|
s.status = await native.request("status", {}, 5000, signal);
|
||||||
|
if (this.current !== s || signal.aborted) throw new Failure("CANCELLED");
|
||||||
|
if (s.status.state === "ready" && typeof s.status.frame_age_ms === "number" && s.status.frame_age_ms <= 2000) {
|
||||||
|
s.connecting = false;
|
||||||
|
return { ...s.status, session_id: s.id, device_alias: s.device.alias };
|
||||||
|
}
|
||||||
|
if (["failed", "closed", "disconnected"].includes(s.status.state))
|
||||||
|
throw new Failure(
|
||||||
|
s.status.error === "incorrect_password"
|
||||||
|
? "AUTH_FAILED"
|
||||||
|
: "CONNECTION_FAILED",
|
||||||
|
);
|
||||||
|
await delay(100, undefined, { signal });
|
||||||
|
}
|
||||||
|
throw new Failure("CONNECTION_TIMEOUT");
|
||||||
|
} catch (error) {
|
||||||
|
if (session) await this.dispose(session);
|
||||||
|
else {
|
||||||
|
await native?.close();
|
||||||
|
await release?.();
|
||||||
|
}
|
||||||
|
if (this.current?.native === native) this.current = undefined;
|
||||||
|
const reason = signal.aborted ? new Failure("CANCELLED") : error;
|
||||||
|
if (view && (!view.session || view.session === session?.id)) {
|
||||||
|
view.session = undefined;
|
||||||
|
if (!view.disconnected) view.connectionError = failure(reason);
|
||||||
|
}
|
||||||
|
throw reason;
|
||||||
|
} finally {
|
||||||
|
password = "";
|
||||||
|
this.connecting = undefined;
|
||||||
|
this.connectionDone = undefined;
|
||||||
|
complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async screenshot(p: Data, signal?: AbortSignal): Promise<Data> {
|
||||||
|
const { session_id, ...args } = p;
|
||||||
|
if (args.after_frame_id !== undefined && args.generation === undefined)
|
||||||
|
throw new Failure("GENERATION_REQUIRED");
|
||||||
|
return this.session(session_id).native.request(
|
||||||
|
"screenshot",
|
||||||
|
args,
|
||||||
|
(args.timeout_ms ?? 2000) + 3000,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async action(p: Data, signal?: AbortSignal): Promise<Data> {
|
||||||
|
const { session_id, observe_after = true, ...args } = p;
|
||||||
|
const s = this.session(session_id);
|
||||||
|
const result = await s.native.request("action", args, 5000, signal);
|
||||||
|
if (observe_after && result.status === "submitted") {
|
||||||
|
try {
|
||||||
|
result.observation = await this.screenshot(
|
||||||
|
{
|
||||||
|
session_id,
|
||||||
|
generation: result.generation,
|
||||||
|
after_frame_id: result.after_frame_id,
|
||||||
|
timeout_ms: 2000,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
result.observation_error =
|
||||||
|
error instanceof Failure ? error.code : "OBSERVATION_FAILED";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async control(id: string, enabled: boolean) {
|
||||||
|
return this.session(id).native.request("control", { enabled });
|
||||||
|
}
|
||||||
|
async display(id: string, index: number) {
|
||||||
|
return this.session(id).native.request("display", { index });
|
||||||
|
}
|
||||||
|
async disconnect(id: string) {
|
||||||
|
const s = this.session(id);
|
||||||
|
if (s.connecting) this.connecting?.abort();
|
||||||
|
for (const v of this.views.values())
|
||||||
|
if (v.session === id) {
|
||||||
|
v.session = undefined;
|
||||||
|
v.disconnected = true;
|
||||||
|
v.connectionError = undefined;
|
||||||
|
}
|
||||||
|
await this.dispose(s);
|
||||||
|
if (this.current === s) this.current = undefined;
|
||||||
|
return { session_id: id, state: "closed" };
|
||||||
|
}
|
||||||
|
async viewerControl(v: View, operation: string) {
|
||||||
|
if (!v.session) throw new Failure("NOT_READY");
|
||||||
|
if (operation === "disconnect") return this.disconnect(v.session);
|
||||||
|
return this.session(v.session).native.request("viewer_control", {
|
||||||
|
operation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async terminalFrame(v: View, args: Data, signal?: AbortSignal) {
|
||||||
|
if (v.panel !== "terminal" || !v.session || this.connecting)
|
||||||
|
throw new Failure("NOT_READY");
|
||||||
|
const s = this.session(v.session);
|
||||||
|
return s.native.request("terminal_frame", args, 5000, signal);
|
||||||
|
}
|
||||||
|
async openVideo(v: View, origin: string): Promise<Data> {
|
||||||
|
if (!v.session || this.views.get(v.id) !== v) throw new Failure("NOT_READY");
|
||||||
|
const s = this.session(v.session);
|
||||||
|
if (this.closing || this.connecting || ["closed", "failed"].includes(s.status.state))
|
||||||
|
throw new Failure("NOT_READY");
|
||||||
|
const generation = v.generation, session = v.session;
|
||||||
|
const ticket = token();
|
||||||
|
const result = await s.native.request("video_open", {
|
||||||
|
ticket, view: v.id, view_generation: generation, session, origin, apps: v.panel === "apps",
|
||||||
|
});
|
||||||
|
if (this.current !== s || this.views.get(v.id) !== v ||
|
||||||
|
v.generation !== generation || v.session !== session) {
|
||||||
|
await s.native.request("video_revoke", { view: v.id }).catch(() => {});
|
||||||
|
throw new Failure("SESSION_CHANGED");
|
||||||
|
}
|
||||||
|
if (result.transport !== "loopback-websocket/1" ||
|
||||||
|
typeof result.url !== "string" || !/^ws:\/\/127\.0\.0\.1:\d+\/api\/video$/.test(result.url) ||
|
||||||
|
result.protocol !== (s.native.videoOptions.decode_path === "minirtc" ? "nv12/1" : "h264-annexb/1") ||
|
||||||
|
result.decoder !== s.native.videoOptions.decoder)
|
||||||
|
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
|
||||||
|
return { ...result, ticket };
|
||||||
|
}
|
||||||
|
async revokeVideo(view?: View) {
|
||||||
|
const s = this.current;
|
||||||
|
if (!s || (view && view.session !== s.id)) return;
|
||||||
|
try {
|
||||||
|
await s.native.request("video_revoke", view ? { view: view.id } : {});
|
||||||
|
} catch {
|
||||||
|
// Losing the control channel must not leave an authorized video socket
|
||||||
|
// alive after its view was removed or replaced.
|
||||||
|
if (this.current === s) await this.disconnect(s.id);
|
||||||
|
else await s.native.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
this.closing = true;
|
||||||
|
this.connecting?.abort();
|
||||||
|
const pending = this.connectionDone;
|
||||||
|
if (this.current) await this.disconnect(this.current.id);
|
||||||
|
await pending;
|
||||||
|
this.views.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
writeFile,
|
||||||
|
rename,
|
||||||
|
unlink,
|
||||||
|
open,
|
||||||
|
chmod,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
alias,
|
||||||
|
host,
|
||||||
|
mode,
|
||||||
|
panel,
|
||||||
|
videoOptions,
|
||||||
|
Failure,
|
||||||
|
parse,
|
||||||
|
token,
|
||||||
|
type Data,
|
||||||
|
} from "./common.js";
|
||||||
|
|
||||||
|
const deviceSchema = z.strictObject({
|
||||||
|
alias,
|
||||||
|
device_id: z.string().regex(/^\d{9}$/),
|
||||||
|
server: host,
|
||||||
|
port: z.int().min(1).max(65535),
|
||||||
|
authorization: mode.optional(),
|
||||||
|
credential: z.enum(["keyring", "environment"]).optional(),
|
||||||
|
environment_variable: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[A-Z][A-Z0-9_]{0,127}$/)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
export type Device = z.infer<typeof deviceSchema>;
|
||||||
|
const configSchema = z.strictObject({
|
||||||
|
version: z.literal(1),
|
||||||
|
panel: panel.default("auto"),
|
||||||
|
video: videoOptions.prefault({}),
|
||||||
|
server: host.default("api.crossdesk.cn"),
|
||||||
|
port: z.int().min(1).max(65535).default(9099),
|
||||||
|
devices: z.array(deviceSchema).max(256),
|
||||||
|
});
|
||||||
|
export type Config = z.infer<typeof configSchema>;
|
||||||
|
export class DeviceStore {
|
||||||
|
readonly directory: string;
|
||||||
|
readonly file: string;
|
||||||
|
constructor(directory?: string) {
|
||||||
|
this.directory = resolve(
|
||||||
|
directory ?? join(homedir(), ".config", "crossdesk", "mcp"),
|
||||||
|
);
|
||||||
|
this.file = join(this.directory, "devices.json");
|
||||||
|
}
|
||||||
|
async initialize() {
|
||||||
|
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||||
|
if (process.platform !== "win32") await chmod(this.directory, 0o700);
|
||||||
|
await mkdir(join(this.directory, "logs"), { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
async config(): Promise<Config> {
|
||||||
|
try {
|
||||||
|
return parse(configSchema, JSON.parse(await readFile(this.file, "utf8")));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||||
|
return configSchema.parse({ version: 1, devices: [] });
|
||||||
|
throw new Failure("CONFIG_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async list(): Promise<Data[]> {
|
||||||
|
return (await this.config()).devices.map((d) => ({
|
||||||
|
device_alias: d.alias,
|
||||||
|
device_id: d.device_id,
|
||||||
|
server: d.server,
|
||||||
|
port: d.port,
|
||||||
|
authorization: d.authorization ?? "none",
|
||||||
|
credential_provider: d.credential ?? "none",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
async device(name: string): Promise<Device> {
|
||||||
|
const d = (await this.config()).devices.find((d) => d.alias === name);
|
||||||
|
if (!d) throw new Failure("DEVICE_NOT_FOUND");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
private account(d: Device) {
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(JSON.stringify([this.directory, d.server, d.port, d.device_id]))
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
private async entry(d: Device) {
|
||||||
|
try {
|
||||||
|
const { AsyncEntry } = await import("@napi-rs/keyring");
|
||||||
|
return new AsyncEntry("crossdesk-mcp", this.account(d), {
|
||||||
|
linux: { store: "secret-service" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async password(d: Device): Promise<string> {
|
||||||
|
if (d.credential === "environment") {
|
||||||
|
const secret =
|
||||||
|
d.environment_variable && process.env[d.environment_variable];
|
||||||
|
if (secret !== undefined) return secret;
|
||||||
|
} else if (d.credential === "keyring") {
|
||||||
|
try {
|
||||||
|
const secret = await (await this.entry(d)).getPassword();
|
||||||
|
if (secret !== null) return secret;
|
||||||
|
} catch {
|
||||||
|
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Failure("AUTH_REQUIRED");
|
||||||
|
}
|
||||||
|
async save(d: Device, password?: string) {
|
||||||
|
parse(deviceSchema, d);
|
||||||
|
const lock = await this.lock("config");
|
||||||
|
try {
|
||||||
|
const config = await this.config();
|
||||||
|
const previous = config.devices.find((item) => item.alias === d.alias);
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
(previous.device_id !== d.device_id ||
|
||||||
|
previous.server !== d.server ||
|
||||||
|
previous.port !== d.port)
|
||||||
|
)
|
||||||
|
throw new Failure("DEVICE_ALIAS_CONFLICT");
|
||||||
|
if (password !== undefined) {
|
||||||
|
try {
|
||||||
|
await (await this.entry(d)).setPassword(password);
|
||||||
|
d.credential = "keyring";
|
||||||
|
delete d.environment_variable;
|
||||||
|
} catch {
|
||||||
|
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.devices = [
|
||||||
|
...config.devices.filter((item) => item.alias !== d.alias),
|
||||||
|
d,
|
||||||
|
];
|
||||||
|
parse(configSchema, config);
|
||||||
|
const temporary = `${this.file}.${token()}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", {
|
||||||
|
mode: 0o600,
|
||||||
|
flag: "wx",
|
||||||
|
});
|
||||||
|
await rename(temporary, this.file);
|
||||||
|
} finally {
|
||||||
|
await unlink(temporary).catch(() => {});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await lock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async lock(name: string): Promise<() => Promise<void>> {
|
||||||
|
const path = join(
|
||||||
|
this.directory,
|
||||||
|
`lock-${createHash("sha256").update(name).digest("hex").slice(0, 24)}`,
|
||||||
|
);
|
||||||
|
for (let attempt = 0; attempt < 2; ++attempt) {
|
||||||
|
try {
|
||||||
|
const handle = await open(path, "wx", 0o600);
|
||||||
|
await handle.writeFile(String(process.pid));
|
||||||
|
await handle.close();
|
||||||
|
let released = false;
|
||||||
|
return async () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
await unlink(path).catch(() => {});
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST")
|
||||||
|
throw new Failure("CONFIG_WRITE_FAILED");
|
||||||
|
const pid = Number(await readFile(path, "utf8").catch(() => "0"));
|
||||||
|
if (!Number.isSafeInteger(pid) || pid < 1)
|
||||||
|
throw new Failure("DEVICE_BUSY");
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
throw new Failure("DEVICE_BUSY");
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as NodeJS.ErrnoException).code !== "ESRCH")
|
||||||
|
throw new Failure("DEVICE_BUSY");
|
||||||
|
}
|
||||||
|
await unlink(path).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Failure("DEVICE_BUSY");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
|
import { emitKeypressEvents } from "node:readline";
|
||||||
|
import { Writable } from "node:stream";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
import { Failure, failure, type Data } from "./common.js";
|
||||||
|
import { TerminalClient } from "./terminal.js";
|
||||||
|
|
||||||
|
async function connect(client: TerminalClient) {
|
||||||
|
let hidden = false;
|
||||||
|
const output = new Writable({ write(chunk, _encoding, done) {
|
||||||
|
if (!hidden) process.stdout.write(chunk);
|
||||||
|
done();
|
||||||
|
} });
|
||||||
|
const rl = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
|
||||||
|
const ask = async (label: string) => (await rl.question(label)).trim();
|
||||||
|
try {
|
||||||
|
const { devices, defaults } = await client.call("devices");
|
||||||
|
const status = await client.call("status");
|
||||||
|
process.stdout.write("CrossDesk MCP · 终端画面\n");
|
||||||
|
for (const d of devices)
|
||||||
|
process.stdout.write(`${d.device_alias} (${d.device_id})\n`);
|
||||||
|
const selected = (await ask(`设备别名或 9 位 ID${status.device_alias ? ` [${status.device_alias}]` : ""}:`)) || status.device_alias;
|
||||||
|
const device = devices.find((d: Data) => d.device_alias === selected);
|
||||||
|
const args: Data = device ? { device_alias: selected } : {
|
||||||
|
device_id: selected,
|
||||||
|
server: (await ask(`服务器 [${defaults.server}]:`)) || defaults.server,
|
||||||
|
port: Number((await ask(`端口 [${defaults.port}]:`)) || defaults.port),
|
||||||
|
};
|
||||||
|
args.mode = (await ask("允许 Agent 操作?[y/N]:")).toLowerCase() === "y" ? "control" : "observe";
|
||||||
|
args.display_index = Number((await ask("显示器索引 [0]:")) || "0");
|
||||||
|
const saved = device?.credential_provider !== "none" && !!device;
|
||||||
|
if (!saved || (await ask("使用已保存凭据?[Y/n]:")).toLowerCase() === "n") {
|
||||||
|
process.stdout.write("访问密码(隐藏输入,可为空):");
|
||||||
|
hidden = true;
|
||||||
|
try { args.password = await rl.question(""); }
|
||||||
|
finally { hidden = false; process.stdout.write("\n"); }
|
||||||
|
}
|
||||||
|
process.stdout.write("正在连接…\n");
|
||||||
|
try { await client.call("connect", args); }
|
||||||
|
finally { args.password = undefined; }
|
||||||
|
} finally { rl.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminalView(directory: string, view: string, cellAspect = 0.5) {
|
||||||
|
if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.TERM === "dumb")
|
||||||
|
throw new Failure("VIEW_REQUIRES_TERMINAL");
|
||||||
|
if (!Number.isFinite(cellAspect) || cellAspect < 0.2 || cellAspect > 2)
|
||||||
|
throw new Failure("INVALID_ARGUMENT");
|
||||||
|
const client = await TerminalClient.attach(directory, view);
|
||||||
|
let raw = false, stopped = false, controls = false, message = "";
|
||||||
|
const previousRaw = process.stdin.isRaw;
|
||||||
|
const stop = () => { stopped = true; };
|
||||||
|
const keypress = (_text: string, key: { name?: string; ctrl?: boolean; meta?: boolean; sequence?: string }) => {
|
||||||
|
if (key.name === "q" || (key.ctrl && key.name === "c")) { stop(); return; }
|
||||||
|
if (controls || key.ctrl || key.meta || !["p", "r", "d"].includes(key.sequence ?? "")) return;
|
||||||
|
controls = true;
|
||||||
|
void client.call("control", { operation: key.sequence === "p" ? "pause" : key.sequence === "r" ? "resume" : "disconnect" })
|
||||||
|
.then(() => { message = ""; if (key.sequence === "d") stopped = true; })
|
||||||
|
.catch((error) => { message = failure(error).code; })
|
||||||
|
.finally(() => { controls = false; });
|
||||||
|
};
|
||||||
|
const write = (value: string) => new Promise<void>((resolve, reject) => {
|
||||||
|
process.stdout.write(value, (error) => error ? reject(error) : resolve());
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
let status = await client.call("status");
|
||||||
|
if (!status.session_id) await connect(client);
|
||||||
|
process.stdin.setRawMode(true);
|
||||||
|
raw = true;
|
||||||
|
emitKeypressEvents(process.stdin);
|
||||||
|
process.stdin.on("keypress", keypress);
|
||||||
|
process.stdin.resume();
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, stop);
|
||||||
|
await write("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[2J");
|
||||||
|
let statusAt = 0, size = "";
|
||||||
|
const colors = /truecolor|24bit/i.test(process.env.COLORTERM ?? "") ? 24
|
||||||
|
: /256color/i.test(process.env.TERM ?? "") ? 256 : 8;
|
||||||
|
while (!stopped) {
|
||||||
|
const started = Date.now();
|
||||||
|
if (started - statusAt >= 500) {
|
||||||
|
status = await client.call("status", {}, 7000);
|
||||||
|
statusAt = Date.now();
|
||||||
|
if (["closed", "failed", "disconnected"].includes(status.state)) break;
|
||||||
|
}
|
||||||
|
// Leave a spare column so terminals with unusual wrap behavior stay safe.
|
||||||
|
const columns = Math.min(160, Math.max(1, (process.stdout.columns || 80) - 1));
|
||||||
|
const rows = Math.min(60, Math.max(1, (process.stdout.rows || 24) - 2));
|
||||||
|
const nextSize = `${columns}:${rows}`;
|
||||||
|
let screen = nextSize !== size ? "\x1b[2J" : "";
|
||||||
|
size = nextSize;
|
||||||
|
try {
|
||||||
|
const frame = await client.call("frame", { columns, rows, colors, cell_aspect: cellAspect }, 7000);
|
||||||
|
if (frame.columns !== columns || frame.rows !== rows || !Array.isArray(frame.lines) || frame.lines.length !== rows)
|
||||||
|
throw new Failure("INVALID_FRAME");
|
||||||
|
// Only renderer-generated SGR sequences and single-width block glyphs.
|
||||||
|
for (const [index, line] of frame.lines.entries()) {
|
||||||
|
if (typeof line !== "string" || !/^(?:\x1b\[[0-9;]*m|[ \u2580-\u259f])*$/u.test(line))
|
||||||
|
throw new Failure("INVALID_FRAME");
|
||||||
|
screen += `\x1b[${index + 1};1H${line}\x1b[0m\x1b[K`;
|
||||||
|
}
|
||||||
|
const cursor = frame.cursor;
|
||||||
|
if (cursor && Number.isInteger(cursor.column) && Number.isInteger(cursor.row) &&
|
||||||
|
cursor.column >= 0 && cursor.column < columns && cursor.row >= 0 && cursor.row < rows)
|
||||||
|
screen += `\x1b[${cursor.row + 1};${cursor.column + 1}H\x1b[97;44m↖\x1b[0m`;
|
||||||
|
} catch (error) {
|
||||||
|
if (!["NOT_READY", "BUSY", "FRAME_TIMEOUT", "STALE_OBSERVATION"].includes(failure(error).code)) throw error;
|
||||||
|
}
|
||||||
|
const label = status.human_paused ? "PAUSED" : status.control ? "AGENT CONTROL" : "OBSERVE";
|
||||||
|
const bar = `${label} | p pause / r resume / d disconnect / q close view${message ? ` | ${message}` : ""}`;
|
||||||
|
// No remote strings or credentials are interpolated into terminal control output.
|
||||||
|
screen += `\x1b[${rows + 1};1H\x1b[0m${bar.replace(/[^\x20-\x7e]/g, "").slice(0, columns)}\x1b[K`;
|
||||||
|
await write(screen);
|
||||||
|
await delay(Math.max(0, 125 - (Date.now() - started)));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
process.stdin.off("keypress", keypress);
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.off(signal, stop);
|
||||||
|
if (raw) {
|
||||||
|
process.stdin.setRawMode(previousRaw);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdout.write("\x1b[0m\x1b[?7h\x1b[?25h\x1b[?1049l");
|
||||||
|
}
|
||||||
|
await client.call("detach", {}, 1000).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { createServer, createConnection, type Server, type Socket } from "node:net";
|
||||||
|
import { chmod, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Failure, equal, failure, id, parse, token, type Data } from "./common.js";
|
||||||
|
import { Sessions, type View } from "./sessions.js";
|
||||||
|
import { uiAction } from "./ui-actions.js";
|
||||||
|
|
||||||
|
const frameSchema = z.strictObject({
|
||||||
|
columns: z.int().min(1).max(160),
|
||||||
|
rows: z.int().min(1).max(60),
|
||||||
|
colors: z.union([z.literal(8), z.literal(24), z.literal(256)]),
|
||||||
|
cell_aspect: z.number().finite().min(0.2).max(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A private local channel, separate from MCP stdio and browser HTTP. Only the
|
||||||
|
// user's terminal receives rendered frames and connection credentials.
|
||||||
|
export class TerminalPanel {
|
||||||
|
private server?: Server;
|
||||||
|
private starting?: Promise<void>;
|
||||||
|
private endpoint?: string;
|
||||||
|
private descriptor?: string;
|
||||||
|
private closed = false;
|
||||||
|
private sockets = new Set<Socket>();
|
||||||
|
private frames = new Set<string>();
|
||||||
|
private frameTimes = new Map<string, number>();
|
||||||
|
constructor(private sessions: Sessions) {}
|
||||||
|
|
||||||
|
async open(view: View): Promise<Data> {
|
||||||
|
await this.start();
|
||||||
|
for (const id of this.frameTimes.keys())
|
||||||
|
if (!this.sessions.views.has(id)) this.frameTimes.delete(id);
|
||||||
|
return {
|
||||||
|
...this.sessions.describe(view),
|
||||||
|
terminal_command: {
|
||||||
|
command: "crossdesk-mcp",
|
||||||
|
args: ["view", "--view", view.id, "--config-dir", this.sessions.store.directory],
|
||||||
|
},
|
||||||
|
next_step: "Show terminal_command to the user to run in a separate interactive terminal on this host. It displays the existing MCP session and accepts credentials locally. Never run it inside MCP stdio or capture its video into chat. Then wait with crossdesk_get_session(view_id, wait_ms: 25000).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async start() {
|
||||||
|
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||||
|
this.starting ??= (async () => {
|
||||||
|
const secret = token(), suffix = token().slice(0, 20);
|
||||||
|
this.endpoint = process.platform === "win32"
|
||||||
|
? `\\\\.\\pipe\\crossdesk-terminal-${suffix}`
|
||||||
|
: join(tmpdir(), `cd-term-${suffix}.sock`);
|
||||||
|
this.server = createServer((socket) => {
|
||||||
|
this.sockets.add(socket);
|
||||||
|
const controller = new AbortController();
|
||||||
|
socket.on("error", () => {});
|
||||||
|
socket.once("close", () => {
|
||||||
|
this.sockets.delete(socket);
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
|
socket.setTimeout(45000, () => socket.destroy());
|
||||||
|
let buffer = Buffer.alloc(0), consumed = false;
|
||||||
|
socket.on("data", (chunk: Buffer) => {
|
||||||
|
if (consumed) return socket.destroy();
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
if (buffer.length > 65536) return socket.destroy();
|
||||||
|
if (!buffer.includes(10)) return;
|
||||||
|
consumed = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(buffer.toString("utf8"));
|
||||||
|
buffer = Buffer.alloc(0);
|
||||||
|
if (typeof p.secret !== "string" || !equal(secret, p.secret))
|
||||||
|
throw new Failure("PANEL_UNAUTHORIZED");
|
||||||
|
const view = this.sessions.view(parse(id, p.view_id));
|
||||||
|
if (view.panel !== "terminal" || !["auto", "terminal"].includes(this.sessions.policy))
|
||||||
|
throw new Failure("PANEL_POLICY_DENIED");
|
||||||
|
view.lastSeen = Date.now();
|
||||||
|
let result: Data;
|
||||||
|
if (p.method === "frame") {
|
||||||
|
const args = parse(frameSchema, p.args);
|
||||||
|
if (this.frames.has(view.id) || Date.now() - (this.frameTimes.get(view.id) ?? 0) < 100)
|
||||||
|
throw new Failure("BUSY", false, true);
|
||||||
|
this.frames.add(view.id);
|
||||||
|
this.frameTimes.set(view.id, Date.now());
|
||||||
|
try { result = await this.sessions.terminalFrame(view, args, controller.signal); }
|
||||||
|
finally { this.frames.delete(view.id); }
|
||||||
|
} else if (p.method === "detach") {
|
||||||
|
view.ready = false;
|
||||||
|
result = this.sessions.describe(view);
|
||||||
|
} else {
|
||||||
|
result = await uiAction(this.sessions, view, p.method, p.args, controller.signal);
|
||||||
|
}
|
||||||
|
socket.end(JSON.stringify({ result }) + "\n");
|
||||||
|
} catch (error) {
|
||||||
|
socket.end(JSON.stringify({ error: failure(error) }) + "\n");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.server!.once("error", reject);
|
||||||
|
this.server!.listen(this.endpoint!, resolve);
|
||||||
|
});
|
||||||
|
if (process.platform !== "win32") await chmod(this.endpoint, 0o600);
|
||||||
|
this.descriptor = join(this.sessions.store.directory, `terminal-${process.pid}-${suffix}.json`);
|
||||||
|
await writeFile(this.descriptor, JSON.stringify({ endpoint: this.endpoint, secret }), { mode: 0o600, flag: "wx" });
|
||||||
|
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||||
|
})().catch(async (error) => {
|
||||||
|
await this.cleanup();
|
||||||
|
this.starting = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return this.starting;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanup() {
|
||||||
|
for (const socket of this.sockets) socket.destroy();
|
||||||
|
if (this.server?.listening) this.server.close();
|
||||||
|
if (this.descriptor) await unlink(this.descriptor).catch(() => {});
|
||||||
|
if (this.endpoint && process.platform !== "win32") await unlink(this.endpoint).catch(() => {});
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
this.closed = true;
|
||||||
|
await this.starting?.catch(() => {});
|
||||||
|
await this.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TerminalClient {
|
||||||
|
private constructor(private endpoint: string, private secret: string, private view: string) {}
|
||||||
|
static async attach(directory: string, view: string) {
|
||||||
|
parse(id, view);
|
||||||
|
for (const file of await readdir(directory)) {
|
||||||
|
if (!/^terminal-\d+-[a-zA-Z0-9_-]+\.json$/.test(file)) continue;
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(await readFile(join(directory, file), "utf8"));
|
||||||
|
const client = new TerminalClient(d.endpoint, d.secret, view);
|
||||||
|
await client.call("ready", {}, 2000);
|
||||||
|
return client;
|
||||||
|
} catch { /* An unrelated or exited MCP instance. */ }
|
||||||
|
}
|
||||||
|
throw new Failure("PANEL_NOT_FOUND");
|
||||||
|
}
|
||||||
|
call(method: string, args: Data = {}, timeout = 45000): Promise<Data> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = createConnection(this.endpoint);
|
||||||
|
let buffer = Buffer.alloc(0), settled = false;
|
||||||
|
const finish = (error?: Failure, result?: Data) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.destroy();
|
||||||
|
if (error) reject(error); else resolve(result!);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish(new Failure("PANEL_TIMEOUT")), timeout);
|
||||||
|
socket.on("error", () => finish(new Failure("PANEL_CLOSED")));
|
||||||
|
socket.once("close", () => finish(new Failure("PANEL_CLOSED")));
|
||||||
|
socket.once("connect", () => socket.write(JSON.stringify({ secret: this.secret, view_id: this.view, method, args }) + "\n"));
|
||||||
|
socket.on("data", (chunk: Buffer) => {
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
if (buffer.length > 2 * 1024 * 1024) return finish(new Failure("MESSAGE_TOO_LARGE"));
|
||||||
|
if (!buffer.includes(10)) return;
|
||||||
|
try {
|
||||||
|
const reply = JSON.parse(buffer.toString("utf8"));
|
||||||
|
if (reply.error) finish(new Failure(reply.error.code));
|
||||||
|
else if (reply.result && typeof reply.result === "object") finish(undefined, reply.result);
|
||||||
|
else finish(new Failure("INVALID_RESPONSE"));
|
||||||
|
} catch { finish(new Failure("INVALID_RESPONSE")); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { parse, uiConnect, Failure, type Data } from "./common.js";
|
||||||
|
import { Sessions, type View } from "./sessions.js";
|
||||||
|
|
||||||
|
export const uiSchemas = {
|
||||||
|
ready: z.strictObject({}),
|
||||||
|
devices: z.strictObject({}),
|
||||||
|
status: z.strictObject({}),
|
||||||
|
connect: uiConnect,
|
||||||
|
video_open: z.strictObject({}),
|
||||||
|
control: z.strictObject({
|
||||||
|
operation: z.enum(["pause", "resume", "disconnect"]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export type UIAction = keyof typeof uiSchemas;
|
||||||
|
export async function uiAction(
|
||||||
|
sessions: Sessions,
|
||||||
|
view: View,
|
||||||
|
name: string,
|
||||||
|
args: unknown,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Data> {
|
||||||
|
if (!Object.hasOwn(uiSchemas, name)) throw new Failure("UNKNOWN_ACTION");
|
||||||
|
const p = parse(uiSchemas[name as UIAction], args) as Data;
|
||||||
|
view.lastSeen = Date.now();
|
||||||
|
switch (name) {
|
||||||
|
case "ready":
|
||||||
|
view.ready = true;
|
||||||
|
return sessions.describe(view);
|
||||||
|
case "devices": {
|
||||||
|
const config = await sessions.store.config();
|
||||||
|
return {
|
||||||
|
devices: await sessions.store.list(),
|
||||||
|
defaults: { server: config.server, port: config.port },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "status":
|
||||||
|
return sessions.status(undefined, view.id, signal);
|
||||||
|
case "connect":
|
||||||
|
return sessions.connectUI(view, p, signal);
|
||||||
|
case "control":
|
||||||
|
return sessions.viewerControl(view, p.operation);
|
||||||
|
default:
|
||||||
|
throw new Failure("UNKNOWN_ACTION");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["ui/**/*.ts"]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,301 @@
|
|||||||
|
type FrameInfo = { width: number; height: number; fps: number; frames: number };
|
||||||
|
type Cursor = { type: "cursor"; visible: boolean; x: number; y: number;
|
||||||
|
generation: number; geometry_epoch: number; width: number; height: number };
|
||||||
|
function parameters(data: Uint8Array) {
|
||||||
|
let codec = "", signature = "";
|
||||||
|
for (let i = 0; i + 4 < data.length; i++) {
|
||||||
|
if (data[i] || data[i + 1]) continue;
|
||||||
|
const prefix = data[i + 2] === 1 ? 3 : data[i + 2] === 0 && data[i + 3] === 1 ? 4 : 0;
|
||||||
|
if (!prefix) continue;
|
||||||
|
const start = i + prefix, type = data[start]! & 31;
|
||||||
|
if (type !== 7 && type !== 8) continue;
|
||||||
|
let end = start + 1;
|
||||||
|
while (end + 3 < data.length && !(data[end] === 0 && data[end + 1] === 0 &&
|
||||||
|
(data[end + 2] === 1 || (data[end + 2] === 0 && data[end + 3] === 1)))) end++;
|
||||||
|
if (end + 3 >= data.length) end = data.length;
|
||||||
|
if (end - start > 65536) throw new Error("VIDEO_INVALID");
|
||||||
|
const hex = [...data.subarray(start, end)].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||||
|
signature += hex;
|
||||||
|
if (type === 7 && end >= start + 4) codec = "avc1." + hex.slice(2, 8);
|
||||||
|
i = end - 1;
|
||||||
|
}
|
||||||
|
return { codec, signature };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VideoPlayer {
|
||||||
|
private socket?: WebSocket;
|
||||||
|
private decoder?: VideoDecoder;
|
||||||
|
private stopped = false;
|
||||||
|
private failed = false;
|
||||||
|
private waitingKey = true;
|
||||||
|
private generation = -1;
|
||||||
|
private sequence = -1;
|
||||||
|
private signature = "";
|
||||||
|
private queue: ArrayBuffer[] = [];
|
||||||
|
private queuedBytes = 0;
|
||||||
|
private pumping = false;
|
||||||
|
private keyRequested = 0;
|
||||||
|
private firstFrame?: ReturnType<typeof setTimeout>;
|
||||||
|
private latest?: VideoFrame;
|
||||||
|
private drawing = 0;
|
||||||
|
private count = 0;
|
||||||
|
private samples = 0;
|
||||||
|
private sampledAt = performance.now();
|
||||||
|
private fps = 0;
|
||||||
|
private raw = false;
|
||||||
|
private software = false;
|
||||||
|
private automatic = true;
|
||||||
|
private cursor?: Cursor;
|
||||||
|
private cursorDrawn = "";
|
||||||
|
private resize?: ResizeObserver;
|
||||||
|
constructor(private canvas: HTMLCanvasElement,
|
||||||
|
private onFrame: (info: FrameInfo) => void,
|
||||||
|
private onError: (error: Error) => void,
|
||||||
|
private overlay?: HTMLCanvasElement) {
|
||||||
|
if (overlay) {
|
||||||
|
this.resize = new ResizeObserver(() => this.drawCursor());
|
||||||
|
this.resize.observe(overlay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start(url: string, ticket: string, protocol = "h264-annexb/1", decoder = "auto") {
|
||||||
|
if (!["h264-annexb/1", "nv12/1"].includes(protocol) ||
|
||||||
|
!["auto", "software"].includes(decoder)) throw new Error("VIDEO_INVALID");
|
||||||
|
this.raw = protocol === "nv12/1";
|
||||||
|
this.automatic = decoder === "auto";
|
||||||
|
this.software = !this.automatic;
|
||||||
|
if (typeof VideoFrame === "undefined" || (!this.raw && typeof VideoDecoder === "undefined"))
|
||||||
|
throw new Error("VIDEO_UNSUPPORTED");
|
||||||
|
if (!/^ws:\/\/127\.0\.0\.1:\d+\/api\/video$/.test(url)) throw new Error("VIDEO_INVALID");
|
||||||
|
const socket = this.socket = new WebSocket(url);
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socket.onopen = () => socket.send(JSON.stringify({ ticket }));
|
||||||
|
socket.onerror = () => this.fail(new Error("VIDEO_UNAVAILABLE"));
|
||||||
|
socket.onclose = () => { if (!this.stopped) this.fail(new Error("VIDEO_UNAVAILABLE")); };
|
||||||
|
this.firstFrame = setTimeout(() => this.fail(new Error("VIDEO_UNAVAILABLE")), 10000);
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (this.stopped) return;
|
||||||
|
if (typeof event.data === "string") {
|
||||||
|
try {
|
||||||
|
if (event.data.length > 1024) throw new Error();
|
||||||
|
const p = JSON.parse(event.data) as Cursor;
|
||||||
|
if (p.type !== "cursor" || typeof p.visible !== "boolean" ||
|
||||||
|
!Number.isFinite(p.x) || !Number.isFinite(p.y) || p.x < 0 || p.x > 1 || p.y < 0 || p.y > 1 ||
|
||||||
|
!Number.isSafeInteger(p.generation) || p.generation < 0 ||
|
||||||
|
!Number.isSafeInteger(p.geometry_epoch) || p.geometry_epoch < 0 ||
|
||||||
|
!Number.isInteger(p.width) || !Number.isInteger(p.height) ||
|
||||||
|
p.width < 0 || p.width > 8192 || p.height < 0 || p.height > 8192)
|
||||||
|
throw new Error();
|
||||||
|
this.cursor = p;
|
||||||
|
this.drawCursor();
|
||||||
|
} catch { this.fail(new Error("VIDEO_INVALID")); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) return;
|
||||||
|
const data = event.data;
|
||||||
|
if (data.byteLength < (this.raw ? 38 : 25) ||
|
||||||
|
data.byteLength > (this.raw ? 16777216 * 3 / 2 + 32 : 4 * 1024 * 1024 + 24))
|
||||||
|
return this.fail(new Error("VIDEO_INVALID"));
|
||||||
|
const header = new DataView(data);
|
||||||
|
if (header.getUint32(0) !== (this.raw ? 0x43444e31 : 0x43445631))
|
||||||
|
return this.fail(new Error("VIDEO_INVALID"));
|
||||||
|
// Receipt, not presentation: bounds native writes even when the decoder
|
||||||
|
// or the canvas is slow. This message goes directly to the native CLI.
|
||||||
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({
|
||||||
|
type: "ack", generation: header.getUint32(4), sequence: header.getUint32(8),
|
||||||
|
}));
|
||||||
|
if (this.raw || this.queue.length >= 8 || this.queuedBytes + data.byteLength > 8 * 1024 * 1024) {
|
||||||
|
this.queue = [];
|
||||||
|
this.queuedBytes = 0;
|
||||||
|
this.waitingKey = true;
|
||||||
|
this.requestKey();
|
||||||
|
}
|
||||||
|
this.queue.push(data);
|
||||||
|
this.queuedBytes += data.byteLength;
|
||||||
|
void this.pump();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private requestKey() {
|
||||||
|
if (this.raw) return;
|
||||||
|
if (Date.now() - this.keyRequested < 1000 || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||||
|
this.keyRequested = Date.now();
|
||||||
|
this.socket.send(JSON.stringify({ type: "keyframe" }));
|
||||||
|
}
|
||||||
|
private async pump() {
|
||||||
|
if (this.pumping) return;
|
||||||
|
this.pumping = true;
|
||||||
|
try {
|
||||||
|
while (!this.stopped && this.queue.length) {
|
||||||
|
const bytes = this.queue.shift()!;
|
||||||
|
this.queuedBytes -= bytes.byteLength;
|
||||||
|
const packet = new DataView(bytes);
|
||||||
|
if (packet.getUint32(0) !== (this.raw ? 0x43444e31 : 0x43445631) || packet.getUint32(20) > 1)
|
||||||
|
throw new Error("VIDEO_INVALID");
|
||||||
|
const generation = packet.getUint32(4), sequence = packet.getUint32(8);
|
||||||
|
const key = packet.getUint32(20) === 1;
|
||||||
|
if (generation < this.generation) continue;
|
||||||
|
const timestamp = Number(packet.getBigUint64(12));
|
||||||
|
if (!Number.isSafeInteger(timestamp)) throw new Error("VIDEO_INVALID");
|
||||||
|
if (this.raw) {
|
||||||
|
const width = packet.getUint32(24), height = packet.getUint32(28);
|
||||||
|
if (!key || !width || !height || (width & 1) || (height & 1) ||
|
||||||
|
width > 8192 || height > 8192 || width * height > 16777216 ||
|
||||||
|
bytes.byteLength !== 32 + width * height * 3 / 2)
|
||||||
|
throw new Error("VIDEO_INVALID");
|
||||||
|
if (generation === this.generation && sequence <= this.sequence) continue;
|
||||||
|
this.generation = generation;
|
||||||
|
this.sequence = sequence;
|
||||||
|
// Match the native NV12 screenshot conversion's limited-range BT.601.
|
||||||
|
this.present(new VideoFrame(new Uint8Array(bytes, 32), {
|
||||||
|
format: "NV12", codedWidth: width, codedHeight: height, timestamp,
|
||||||
|
colorSpace: { matrix: "smpte170m", primaries: "smpte170m", transfer: "smpte170m", fullRange: false },
|
||||||
|
transfer: [bytes],
|
||||||
|
} as VideoFrameBufferInit & { transfer: ArrayBuffer[] }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (generation !== this.generation || (this.sequence >= 0 && sequence !== this.sequence + 1))
|
||||||
|
this.waitingKey = true;
|
||||||
|
this.generation = generation;
|
||||||
|
this.sequence = sequence;
|
||||||
|
if ((this.decoder?.decodeQueueSize ?? 0) >= 4) this.waitingKey = true;
|
||||||
|
if (this.waitingKey && !key) { this.requestKey(); continue; }
|
||||||
|
const data = new Uint8Array(bytes, 24);
|
||||||
|
if (key) {
|
||||||
|
const { codec, signature } = parameters(data);
|
||||||
|
if (!codec) { this.waitingKey = true; this.requestKey(); continue; }
|
||||||
|
if (!this.decoder || this.waitingKey || signature !== this.signature) {
|
||||||
|
const config: VideoDecoderConfig = { codec, optimizeForLatency: true,
|
||||||
|
hardwareAcceleration: this.software ? "prefer-software" : "prefer-hardware" };
|
||||||
|
if (!(await VideoDecoder.isConfigSupported(config)).supported) {
|
||||||
|
if (this.software) throw new Error("VIDEO_UNSUPPORTED");
|
||||||
|
this.software = true;
|
||||||
|
config.hardwareAcceleration = "prefer-software";
|
||||||
|
if (!(await VideoDecoder.isConfigSupported(config)).supported) throw new Error("VIDEO_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
if (this.stopped) break;
|
||||||
|
if (this.decoder && this.decoder.state !== "closed") this.decoder.close();
|
||||||
|
const decoder = new VideoDecoder({
|
||||||
|
output: (frame) => {
|
||||||
|
if (this.stopped || this.decoder !== decoder) { frame.close(); return; }
|
||||||
|
this.present(frame);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
if (this.stopped || this.decoder !== decoder) return;
|
||||||
|
this.decoder = undefined;
|
||||||
|
if (this.software || !this.automatic) {
|
||||||
|
this.fail(new Error("VIDEO_UNAVAILABLE"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.software = true;
|
||||||
|
this.waitingKey = true;
|
||||||
|
this.requestKey();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.decoder = decoder;
|
||||||
|
decoder.configure(config);
|
||||||
|
this.signature = signature;
|
||||||
|
}
|
||||||
|
this.waitingKey = false;
|
||||||
|
}
|
||||||
|
this.decoder?.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp, data }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.fail(error instanceof Error ? error : new Error("VIDEO_INVALID"));
|
||||||
|
} finally { this.pumping = false; }
|
||||||
|
}
|
||||||
|
private present(frame: VideoFrame) {
|
||||||
|
this.latest?.close();
|
||||||
|
this.latest = frame;
|
||||||
|
if (!this.drawing) this.drawing = requestAnimationFrame(() => this.draw());
|
||||||
|
}
|
||||||
|
private draw() {
|
||||||
|
this.drawing = 0;
|
||||||
|
const frame = this.latest;
|
||||||
|
this.latest = undefined;
|
||||||
|
if (!frame) return;
|
||||||
|
try {
|
||||||
|
if (this.stopped) return;
|
||||||
|
const width = frame.displayWidth, height = frame.displayHeight;
|
||||||
|
if (width > 8192 || height > 8192 || width * height > 16777216) throw new Error("VIDEO_INVALID");
|
||||||
|
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||||||
|
this.canvas.width = width;
|
||||||
|
this.canvas.height = height;
|
||||||
|
}
|
||||||
|
this.canvas.getContext("2d", { alpha: false })!.drawImage(frame, 0, 0);
|
||||||
|
clearTimeout(this.firstFrame);
|
||||||
|
this.count++;
|
||||||
|
this.drawCursor();
|
||||||
|
this.samples++;
|
||||||
|
const elapsed = performance.now() - this.sampledAt;
|
||||||
|
if (elapsed >= 1000) {
|
||||||
|
this.fps = this.samples * 1000 / elapsed;
|
||||||
|
this.samples = 0;
|
||||||
|
this.sampledAt = performance.now();
|
||||||
|
}
|
||||||
|
this.canvas.dataset.frames = String(this.count);
|
||||||
|
this.onFrame({ width, height, frames: this.count, fps: this.fps });
|
||||||
|
} catch { this.fail(new Error("VIDEO_INVALID")); }
|
||||||
|
finally { frame.close(); }
|
||||||
|
}
|
||||||
|
private drawCursor() {
|
||||||
|
const overlay = this.overlay;
|
||||||
|
if (!overlay) return;
|
||||||
|
if (overlay.width !== this.canvas.width) overlay.width = this.canvas.width;
|
||||||
|
if (overlay.height !== this.canvas.height) overlay.height = this.canvas.height;
|
||||||
|
const p = this.cursor;
|
||||||
|
const state = JSON.stringify([this.stopped, !!this.count, this.generation,
|
||||||
|
overlay.width, overlay.height, overlay.clientWidth, overlay.clientHeight, p]);
|
||||||
|
if (state === this.cursorDrawn) return;
|
||||||
|
this.cursorDrawn = state;
|
||||||
|
const context = overlay.getContext("2d")!;
|
||||||
|
context.clearRect(0, 0, overlay.width, overlay.height);
|
||||||
|
if (this.stopped || !this.count || !p?.visible || p.generation !== this.generation ||
|
||||||
|
p.width !== overlay.width || p.height !== overlay.height) return;
|
||||||
|
// Match the video canvas's object-fit: contain, including letterboxing.
|
||||||
|
const scale = Math.min(overlay.clientWidth / overlay.width, overlay.clientHeight / overlay.height);
|
||||||
|
if (!scale) return;
|
||||||
|
context.save();
|
||||||
|
context.translate(Math.min(overlay.width - 1, p.x * overlay.width), Math.min(overlay.height - 1, p.y * overlay.height));
|
||||||
|
context.scale(1 / scale, 1 / scale);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(0, 0);
|
||||||
|
context.lineTo(0, 22);
|
||||||
|
context.lineTo(6, 17);
|
||||||
|
context.lineTo(10, 26);
|
||||||
|
context.lineTo(14, 24);
|
||||||
|
context.lineTo(10, 15);
|
||||||
|
context.lineTo(18, 15);
|
||||||
|
context.closePath();
|
||||||
|
context.fillStyle = "#3269e8";
|
||||||
|
context.strokeStyle = "#fff";
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.shadowColor = "#0009";
|
||||||
|
context.shadowBlur = 3;
|
||||||
|
context.fill();
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
private fail(error: Error) {
|
||||||
|
if (this.failed || this.stopped) return;
|
||||||
|
this.failed = true;
|
||||||
|
this.stop();
|
||||||
|
this.onError(error);
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this.stopped = true;
|
||||||
|
this.cursor = undefined;
|
||||||
|
this.resize?.disconnect();
|
||||||
|
this.drawCursor();
|
||||||
|
clearTimeout(this.firstFrame);
|
||||||
|
this.socket?.close();
|
||||||
|
if (this.decoder && this.decoder.state !== "closed") this.decoder.close();
|
||||||
|
this.decoder = undefined;
|
||||||
|
cancelAnimationFrame(this.drawing);
|
||||||
|
this.drawing = 0;
|
||||||
|
this.latest?.close();
|
||||||
|
this.latest = undefined;
|
||||||
|
this.queue = [];
|
||||||
|
this.queuedBytes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
:root {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #292d33;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
color-scheme: light;
|
||||||
|
--accent: #3269b7;
|
||||||
|
--muted: #777d85;
|
||||||
|
--line: #e4e6e9;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; }
|
||||||
|
body { overflow: hidden; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
button, summary, input, select { -webkit-tap-highlight-color: transparent; }
|
||||||
|
:focus-visible { outline: 2px solid #7194c6; outline-offset: 2px; }
|
||||||
|
#app { width: 100%; height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
|
||||||
|
.app-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; padding: 12px 20px; border-bottom: 1px solid var(--line); flex: none; }
|
||||||
|
.brand, .header-right { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||||
|
.brand-logo { display: block; width: 28px; height: 28px; }
|
||||||
|
h1 { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
|
||||||
|
h2 { margin: 0; font-size: 13px; font-weight: 600; }
|
||||||
|
.connection { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||||
|
.connection[data-state="online"] { color: #397557; }
|
||||||
|
.connection[data-state="connecting"] { color: #8a682d; }
|
||||||
|
.connection[data-state="offline"] { color: #a15149; }
|
||||||
|
#details-toggle { display: none; }
|
||||||
|
main { display: grid; grid-template-columns: 270px minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||||
|
aside { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 22px 20px; border-right: 1px solid var(--line); }
|
||||||
|
#sidebar-title { margin-bottom: 20px; }
|
||||||
|
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||||
|
label { display: block; margin: 16px 0 7px; font-size: 12px; }
|
||||||
|
fieldset > label:first-child { margin-top: 0; }
|
||||||
|
.optional { color: var(--muted); margin-left: 4px; font-size: 11px; }
|
||||||
|
input:not([type="checkbox"]), select { display: block; width: 100%; height: 36px; min-width: 0; border: 1px solid #d7dade; border-radius: 4px; padding: 7px 9px; background: #fff; color: inherit; }
|
||||||
|
input:not([type="checkbox"]):focus, select:focus { border-color: var(--accent); outline: 1px solid var(--accent); }
|
||||||
|
input:disabled, select:disabled { background: #f5f5f5; color: #999; }
|
||||||
|
input::placeholder { color: #92969c; }
|
||||||
|
#device-id, #session-device-id { font-variant-numeric: tabular-nums; }
|
||||||
|
.password-field { position: relative; }
|
||||||
|
.password-field input { padding-right: 48px; }
|
||||||
|
.password-field button { position: absolute; right: 2px; top: 2px; height: 32px; min-height: 32px; padding: 0 8px; border: 0; background: transparent; color: var(--muted); }
|
||||||
|
.field-hint, .privacy, .session-note { color: var(--muted); font-size: 11px; line-height: 1.7; margin: 7px 0 0; }
|
||||||
|
.preferences { border-top: 1px solid var(--line); margin-top: 20px; padding-top: 7px; }
|
||||||
|
details { font-size: 12px; }
|
||||||
|
summary { padding: 9px 0; cursor: pointer; color: #666d76; }
|
||||||
|
summary::marker { font-size: 10px; }
|
||||||
|
.checks { padding-bottom: 10px; }
|
||||||
|
.checks label { display: flex; align-items: flex-start; gap: 8px; margin-top: 13px; font-size: 12px; line-height: 1.5; cursor: pointer; }
|
||||||
|
.checks label:has(input:disabled) { color: #999; cursor: default; }
|
||||||
|
.checks small { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; }
|
||||||
|
input[type="checkbox"] { width: 14px; height: 14px; flex: none; margin: 2px 0 0; accent-color: var(--accent); }
|
||||||
|
.row { display: flex; gap: 12px; }
|
||||||
|
.row > div { flex: 1; min-width: 0; }
|
||||||
|
button { display: inline-flex; align-items: center; justify-content: center; min-height: 32px; padding: 6px 10px; border: 1px solid #d9dce0; border-radius: 4px; background: #fff; color: #4d535c; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
||||||
|
button:not(:disabled):hover { background: #f3f4f5; border-color: #bfc4cb; }
|
||||||
|
button:not(:disabled):active { background: #e9ebee; }
|
||||||
|
button:disabled { cursor: default; color: #a3a7ad; background: #fafafa; border-color: #e7e9eb; }
|
||||||
|
.primary { width: 100%; min-height: 36px; background: var(--accent); color: #fff; border-color: var(--accent); margin-top: 20px; }
|
||||||
|
.primary:not(:disabled):hover { background: #285b9f; border-color: #285b9f; }
|
||||||
|
.privacy { margin-top: 12px; }
|
||||||
|
#error { flex: none; max-height: 25%; overflow: auto; padding: 10px 20px; background: #fff5f2; border-bottom: 1px solid #edddd6; color: #964a37; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
.device-card strong { display: block; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }
|
||||||
|
.device-card > span { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; }
|
||||||
|
.session-details { margin: 20px 0 0; font-size: 12px; }
|
||||||
|
.session-details > div { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid #eee; }
|
||||||
|
.session-details dt { color: var(--muted); flex: none; }
|
||||||
|
.session-details dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
|
||||||
|
#session-agent[data-paused="true"] { color: #956e2d; }
|
||||||
|
.session-note { margin-top: 18px; }
|
||||||
|
.workspace { display: flex; flex-direction: column; gap: 10px; min-width: 0; min-height: 0; overflow: auto; padding: 16px; background: #f6f7f8; }
|
||||||
|
.viewer-card { display: flex; flex-direction: column; flex: 1; min-height: 160px; min-width: 0; background: #fff; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; }
|
||||||
|
.viewer-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; padding: 9px 12px; flex: none; }
|
||||||
|
.viewer-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
|
||||||
|
.viewer-title h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#video-state { color: var(--muted); font-size: 11px; white-space: nowrap; flex: none; }
|
||||||
|
#video-state[data-state="stale"] { color: #956e2d; }
|
||||||
|
.viewer-controls { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.danger:not(:disabled) { color: #9a4e47; }
|
||||||
|
.danger:not(:disabled):hover { border-color: #d6b4ae; background: #fff6f4; }
|
||||||
|
/* The screen takes the remaining panel height; object-fit preserves the remote aspect ratio. */
|
||||||
|
.screen { position: relative; flex: 1; min-height: 60px; width: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; isolation: isolate; border-radius: 8px; background: #17191c; }
|
||||||
|
.screen canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
#agent-cursor { pointer-events: none; z-index: 4; }
|
||||||
|
/* Overlay the control indicator so toggling control never resizes the video. */
|
||||||
|
.control-halo { display: none; }
|
||||||
|
#app[data-controlling="true"] .screen::before,
|
||||||
|
#app[data-controlling="true"] .screen::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; }
|
||||||
|
#app[data-controlling="true"] .screen::before { z-index: 1; box-shadow: inset 0 0 10px #3976ff40, inset 0 0 3px #8ccaff70; }
|
||||||
|
#app[data-controlling="true"] .control-halo {
|
||||||
|
--halo-spread: 14px;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Fade the light inward at the edges, leaving the desktop itself clear. */
|
||||||
|
mask-image:
|
||||||
|
linear-gradient(to bottom, #000, #0007 4px, transparent var(--halo-spread), transparent calc(100% - var(--halo-spread)), #0007 calc(100% - 4px), #000),
|
||||||
|
linear-gradient(to right, #000, #0007 4px, transparent var(--halo-spread), transparent calc(100% - var(--halo-spread)), #0007 calc(100% - 4px), #000);
|
||||||
|
}
|
||||||
|
.control-halo::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -22%;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 32% 38% at 24% 26%, #d5f3ff 0%, #76c4ff 20%, #427bff80 50%, transparent 100%),
|
||||||
|
radial-gradient(ellipse 38% 40% at 78% 75%, #c4d7ff 0%, #637cff 24%, #436bff70 60%, transparent 100%),
|
||||||
|
linear-gradient(125deg, #3c8aff70, #687dff50 55%, #4ab5ff80);
|
||||||
|
filter: blur(8px);
|
||||||
|
opacity: .9;
|
||||||
|
/* Move a cached light layer rather than repainting an animated gradient. */
|
||||||
|
animation: control-halo-drift 9s ease-in-out infinite alternate;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
#app[data-controlling="true"] .screen::after {
|
||||||
|
z-index: 3;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(125deg, #dbf6ffb0, #5e98ff70 30%, #c2e9ffc0 53%, #7e9dff80 75%, #b0e6ffb0);
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
}
|
||||||
|
@keyframes control-halo-drift {
|
||||||
|
0% { transform: translate3d(-7%, -5%, 0) scale(1); opacity: .7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { transform: translate3d(7%, 5%, 0) scale(1.05); opacity: .85; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.control-halo::before { animation: none; will-change: auto; }
|
||||||
|
}
|
||||||
|
#empty { text-align: center; padding: 16px; max-width: 100%; color: #b4b7bd; }
|
||||||
|
#empty h3 { font-size: 14px; font-weight: 400; margin: 0 0 8px; }
|
||||||
|
#empty p { color: #7e848e; font-size: 12px; line-height: 1.7; margin: 0; }
|
||||||
|
.stale { position: absolute; left: 12px; bottom: 12px; padding: 7px 10px; background: #25272cdd; color: #e2c495; border-radius: 3px; font-size: 11px; }
|
||||||
|
.telemetry { display: flex; align-items: center; flex-wrap: wrap; gap: 7px 18px; padding: 9px 12px; flex: none; color: var(--muted); font-size: 11px; }
|
||||||
|
.stream-metrics { display: flex; gap: 14px; font-variant-numeric: tabular-nums; }
|
||||||
|
#permission { margin-left: auto; }
|
||||||
|
#permission[data-state="paused"] { color: #956e2d; }
|
||||||
|
.panel-status { flex: none; font-size: 12px; line-height: 1.6; color: var(--muted); margin: 0; }
|
||||||
|
.video-options { display: flex; flex-wrap: wrap; gap: 8px; flex: none; }
|
||||||
|
.video-options:not(:has(button:not([hidden]))) { display: none; }
|
||||||
|
.focus-mode main { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.focus-mode .app-header { display: none; }
|
||||||
|
.focus-mode aside, .focus-mode #details-toggle { display: none !important; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.app-header { padding: 10px 14px; }
|
||||||
|
main { display: flex; flex-direction: column; }
|
||||||
|
aside { flex: 1; border-right: 0; padding: 20px; }
|
||||||
|
#app:not([data-connected="true"]) .workspace { display: none; }
|
||||||
|
#app[data-connected="true"] aside { display: none; flex: none; max-height: 40%; border-bottom: 1px solid var(--line); }
|
||||||
|
#app[data-connected="true"].details-open aside { display: block; }
|
||||||
|
#app[data-connected="true"] #details-toggle { display: inline-flex; }
|
||||||
|
#app[data-connected="true"] .workspace { flex: 1; }
|
||||||
|
.workspace { padding: 10px; }
|
||||||
|
#sidebar-title { margin-bottom: 16px; }
|
||||||
|
.session-details { margin-top: 12px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#app[data-controlling="true"] .control-halo { --halo-spread: 10px; }
|
||||||
|
.app-header { gap: 8px; padding: 10px 12px; }
|
||||||
|
.brand, .header-right { gap: 7px; }
|
||||||
|
.brand-logo { width: 24px; height: 24px; }
|
||||||
|
h1 { font-size: 15px; }
|
||||||
|
.connection { font-size: 11px; }
|
||||||
|
#details-toggle { padding: 5px 7px; min-height: 28px; font-size: 11px; }
|
||||||
|
.workspace { padding: 8px; gap: 8px; }
|
||||||
|
.viewer-toolbar { padding: 9px; }
|
||||||
|
.viewer-title, .viewer-controls { width: 100%; flex: auto; }
|
||||||
|
.viewer-controls button { flex: 1; min-height: 36px; }
|
||||||
|
.telemetry { padding: 8px 9px; gap: 6px 10px; }
|
||||||
|
#session-info { display: none; }
|
||||||
|
.stream-metrics { gap: 10px; }
|
||||||
|
#permission { font-size: 10px; }
|
||||||
|
}
|
||||||
|
@media (max-height: 460px) and (min-width: 481px) {
|
||||||
|
.app-header { padding-top: 6px; padding-bottom: 6px; }
|
||||||
|
.workspace { padding: 8px; gap: 6px; }
|
||||||
|
.viewer-toolbar { padding-top: 5px; padding-bottom: 5px; }
|
||||||
|
.telemetry { padding-top: 6px; padding-bottom: 6px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import type { App } from "@modelcontextprotocol/ext-apps";
|
||||||
|
import "./viewer.css";
|
||||||
|
import { VideoPlayer } from "./video-player.js";
|
||||||
|
|
||||||
|
declare const CROSSDESK_APPS: boolean;
|
||||||
|
declare const CROSSDESK_LOGO: string;
|
||||||
|
type Data = Record<string, any>;
|
||||||
|
type Invoke = (name: string, args?: Data) => Promise<Data>;
|
||||||
|
const root = document.querySelector<HTMLDivElement>("#app")!;
|
||||||
|
root.innerHTML = `
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="brand"><img class="brand-logo" src="${CROSSDESK_LOGO}" alt="" width="28" height="28"><h1>CrossDesk MCP</h1></div>
|
||||||
|
<div class="header-right"><button id="details-toggle" aria-expanded="false" aria-controls="sidebar">连接信息</button><span class="connection" id="connection" role="status"><span id="connection-label">等待连接</span></span></div>
|
||||||
|
</header>
|
||||||
|
<div id="error" role="alert" hidden></div>
|
||||||
|
<main>
|
||||||
|
<aside id="sidebar" aria-labelledby="sidebar-title">
|
||||||
|
<h2 id="sidebar-title">连接设备</h2>
|
||||||
|
<div id="connect-form" role="form" aria-label="连接远程电脑">
|
||||||
|
<fieldset id="connection-fields">
|
||||||
|
<label for="device">设备</label><select id="device"><option value="">连接新设备</option></select>
|
||||||
|
<div id="new-device">
|
||||||
|
<label for="device-id">设备 ID</label><input id="device-id" inputmode="numeric" autocomplete="off" placeholder="输入 9 位设备 ID" maxlength="11">
|
||||||
|
</div>
|
||||||
|
<label for="password">访问密码</label><div class="password-field"><input id="password" type="password" autocomplete="off" maxlength="256" placeholder="输入远端访问密码"><button type="button" id="password-toggle" aria-label="显示密码" aria-pressed="false">显示</button></div>
|
||||||
|
<label for="mode">授权范围</label><select id="mode" aria-describedby="mode-description"><option value="observe">仅查看画面</option><option value="control">允许 Agent 操作</option></select>
|
||||||
|
<p class="field-hint" id="mode-description">Agent 可以查看屏幕,无法操作键鼠。</p>
|
||||||
|
<details class="preferences"><summary>保存与授权</summary>
|
||||||
|
<div id="new-device-label"><label for="label">设备备注 <span class="optional">可选</span></label><input id="label" placeholder="例如:工作电脑" maxlength="80"></div>
|
||||||
|
<div class="checks">
|
||||||
|
<label><input id="save" type="checkbox"><span>保存设备</span></label>
|
||||||
|
<label><input id="remember" type="checkbox" disabled><span>记住密码<small>保存在本机系统凭据库</small></span></label>
|
||||||
|
<label><input id="authorize" type="checkbox" disabled><span>允许下次自动连接<small>沿用本次授权范围</small></span></label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details id="advanced"><summary>高级设置</summary>
|
||||||
|
<label for="server">服务器</label><input id="server" value="api.crossdesk.cn" autocomplete="off">
|
||||||
|
<div class="row"><div><label for="port">端口</label><input id="port" type="number" min="1" max="65535" value="9099"></div><div><label for="display">显示器索引</label><input id="display" type="number" min="0" max="63" value="0"></div></div>
|
||||||
|
</details>
|
||||||
|
</fieldset>
|
||||||
|
<button id="connect" type="button" class="primary" disabled><span id="connect-label">连接</span></button>
|
||||||
|
<p class="privacy">密码不会发送到 Agent 对话中。</p>
|
||||||
|
</div>
|
||||||
|
<div id="session-card" hidden>
|
||||||
|
<div class="device-card"><strong id="session-device">远程电脑</strong><span id="session-platform">已连接</span></div>
|
||||||
|
<dl class="session-details"><div id="session-device-id-row" hidden><dt>设备 ID</dt><dd id="session-device-id"></dd></div><div><dt>访问权限</dt><dd id="session-access"></dd></div><div><dt>显示器</dt><dd id="session-display"></dd></div><div><dt>Agent 状态</dt><dd id="session-agent"></dd></div></dl>
|
||||||
|
<p class="session-note">关闭页面不会断开连接。</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section class="workspace" aria-label="远程画面与会话控制">
|
||||||
|
<div class="viewer-card">
|
||||||
|
<div class="viewer-toolbar">
|
||||||
|
<div class="viewer-title"><h2 id="device-name">远程画面</h2><span id="video-state">待连接</span></div>
|
||||||
|
<div class="viewer-controls"><button id="pause" disabled><span id="pause-label">暂停控制</span></button><button id="disconnect" class="danger" disabled>断开连接</button><button id="focus-view" aria-label="放大画面" aria-pressed="false" disabled>放大画面</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="screen" id="screen">
|
||||||
|
<canvas id="frame" role="img" aria-label="远程桌面实时画面" hidden></canvas>
|
||||||
|
<canvas id="agent-cursor" aria-hidden="true"></canvas>
|
||||||
|
<div class="control-halo" aria-hidden="true"></div>
|
||||||
|
<div id="empty"><h3 id="empty-title">未连接设备</h3><p id="empty-description">选择设备并输入访问密码。</p></div>
|
||||||
|
<div class="stale" id="stale" role="status" hidden>画面暂未更新</div>
|
||||||
|
</div>
|
||||||
|
<div class="telemetry"><div class="stream-metrics"><span id="resolution">—</span><span id="fps">—</span></div><span id="session-info">等待设备连接</span><span id="permission">等待授权</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="video-options"><button id="video-retry" hidden>重新加载画面</button><button id="video-browser" hidden>在浏览器中观看</button></div>
|
||||||
|
<p id="panel-status" class="panel-status" role="status" hidden></p>
|
||||||
|
</section>
|
||||||
|
</main>`;
|
||||||
|
const element = <T extends HTMLElement = HTMLElement>(id: string) =>
|
||||||
|
document.getElementById(id) as T;
|
||||||
|
const input = (id: string) => element<HTMLInputElement>(id);
|
||||||
|
const select = (id: string) => element<HTMLSelectElement>(id);
|
||||||
|
let invoke: Invoke;
|
||||||
|
let app: App | undefined;
|
||||||
|
let context: Data | undefined;
|
||||||
|
let status: Data = {};
|
||||||
|
let savedDevices: Data[] = [];
|
||||||
|
let controlBusy = false;
|
||||||
|
let disconnectedByUser = false;
|
||||||
|
let frameSize = "", frameRate = "";
|
||||||
|
let player: VideoPlayer | undefined;
|
||||||
|
let videoStarting = false, videoFailed = false, videoEpoch = 0;
|
||||||
|
let videoSession: string | undefined;
|
||||||
|
let videoGeneration: number | undefined;
|
||||||
|
let imageAt = 0;
|
||||||
|
let busy = false,
|
||||||
|
polling = false,
|
||||||
|
started = false;
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
VIDEO_UNSUPPORTED: "当前浏览器不支持此视频格式,请使用支持 WebCodecs 的浏览器。",
|
||||||
|
VIDEO_UNAVAILABLE: "视频通道暂不可用。可重新加载画面;内嵌面板受限时请在浏览器中观看。",
|
||||||
|
VIDEO_INVALID: "视频格式发生变化,请重新加载画面。",
|
||||||
|
VIDEO_LIMIT: "观看面板数量已达上限,请关闭其他面板。",
|
||||||
|
AUTH_FAILED: "访问密码错误,请重新输入。",
|
||||||
|
AUTH_REQUIRED: "请输入访问密码,或在本机 setup 中配置凭据。",
|
||||||
|
CONNECTION_FAILED: "连接失败,请检查设备是否在线及服务器设置。",
|
||||||
|
CONNECTION_TIMEOUT: "等待远端画面超时,请检查远端状态后重试。",
|
||||||
|
CONNECTION_LOST: "远程连接已中断。请检查远端状态并重新连接。",
|
||||||
|
SESSION_LIMIT: "已有活动连接,请先断开当前电脑。",
|
||||||
|
DEVICE_BUSY: "另一实例正在使用此设备。",
|
||||||
|
CREDENTIAL_STORE_UNAVAILABLE: "系统凭据库不可用,密码未保存。",
|
||||||
|
PAIRING_REQUIRED:
|
||||||
|
"请在终端运行 Agent 工具结果中的 setup --open-panel 配对命令,再打开面板。",
|
||||||
|
PANEL_UNAUTHORIZED: "面板授权已失效,请重新打开。",
|
||||||
|
VIEW_UNAUTHORIZED: "此面板已失效,请使用最新打开的面板。",
|
||||||
|
CONTROL_DENIED: "当前会话没有控制权限,或已被人工暂停。",
|
||||||
|
NATIVE_PACKAGE_MISSING:
|
||||||
|
"缺少对应平台的 CLI 运行组件,请检查包安装是否启用了 optional dependencies。",
|
||||||
|
INVALID_ARGUMENT: "请检查设备 ID、服务器和输入内容。",
|
||||||
|
DEVICE_ALIAS_CONFLICT: "这个备注已属于另一台设备,请换一个备注。",
|
||||||
|
};
|
||||||
|
function text(id: string, value: string) {
|
||||||
|
if (element(id).textContent !== value) element(id).textContent = value;
|
||||||
|
}
|
||||||
|
function platformName(platform?: string) {
|
||||||
|
return ({ macos: "macOS", windows: "Windows", linux: "Linux", android: "Android", ios: "iOS" } as Record<string, string>)[platform ?? ""] ?? "远程电脑";
|
||||||
|
}
|
||||||
|
function resetMetrics() {
|
||||||
|
imageAt = 0;
|
||||||
|
frameSize = frameRate = "";
|
||||||
|
text("resolution", "—");
|
||||||
|
text("fps", "—");
|
||||||
|
element<HTMLButtonElement>("focus-view").disabled = true;
|
||||||
|
}
|
||||||
|
function setFocus(focused: boolean) {
|
||||||
|
root.classList.toggle("focus-mode", focused);
|
||||||
|
const button = element<HTMLButtonElement>("focus-view");
|
||||||
|
const label = focused ? "还原布局" : "放大画面";
|
||||||
|
button.textContent = label;
|
||||||
|
if (focused) setDetails(false);
|
||||||
|
button.setAttribute("aria-pressed", String(focused));
|
||||||
|
button.setAttribute("aria-label", label);
|
||||||
|
button.title = label;
|
||||||
|
}
|
||||||
|
function setDetails(open: boolean) {
|
||||||
|
root.classList.toggle("details-open", open);
|
||||||
|
element("details-toggle").setAttribute("aria-expanded", String(open));
|
||||||
|
text("details-toggle", open ? "收起信息" : "连接信息");
|
||||||
|
}
|
||||||
|
element("details-toggle").onclick = () => setDetails(!root.classList.contains("details-open"));
|
||||||
|
element("focus-view").onclick = () => setFocus(!root.classList.contains("focus-mode"));
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && root.classList.contains("focus-mode")) setFocus(false);
|
||||||
|
});
|
||||||
|
function hidePassword() {
|
||||||
|
input("password").type = "password";
|
||||||
|
text("password-toggle", "显示");
|
||||||
|
element("password-toggle").setAttribute("aria-label", "显示密码");
|
||||||
|
element("password-toggle").setAttribute("aria-pressed", "false");
|
||||||
|
}
|
||||||
|
element("password-toggle").onclick = () => {
|
||||||
|
if (input("password").type === "text") return hidePassword();
|
||||||
|
input("password").type = "text";
|
||||||
|
text("password-toggle", "隐藏");
|
||||||
|
element("password-toggle").setAttribute("aria-label", "隐藏密码");
|
||||||
|
element("password-toggle").setAttribute("aria-pressed", "true");
|
||||||
|
};
|
||||||
|
select("mode").onchange = () => {
|
||||||
|
text("mode-description", select("mode").value === "control"
|
||||||
|
? "Agent 可以操作键鼠,你可以随时暂停。"
|
||||||
|
: "Agent 可以查看屏幕,无法操作键鼠。");
|
||||||
|
};
|
||||||
|
function showError(error: unknown) {
|
||||||
|
const code = error instanceof Error ? error.message : "INTERNAL_ERROR";
|
||||||
|
element("error").textContent =
|
||||||
|
messages[code] ??
|
||||||
|
`操作未完成(${/^[A-Z_]+$/.test(code) ? code : "CONNECTION_ERROR"})`;
|
||||||
|
element("error").hidden = false;
|
||||||
|
}
|
||||||
|
function check(result: Data): Data {
|
||||||
|
if (result.error) throw new Error(result.error.code ?? "INTERNAL_ERROR");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async function devices() {
|
||||||
|
const result = await invoke("devices");
|
||||||
|
if (!started && result.defaults) {
|
||||||
|
input("server").value = result.defaults.server;
|
||||||
|
input("port").value = String(result.defaults.port);
|
||||||
|
}
|
||||||
|
const chosen = select("device").value || context?.device_alias || "";
|
||||||
|
select("device").replaceChildren(new Option("连接新设备", ""));
|
||||||
|
savedDevices = result.devices ?? [];
|
||||||
|
for (const d of savedDevices)
|
||||||
|
select("device").add(
|
||||||
|
new Option(`${d.device_alias} · ${d.device_id}`, d.device_alias),
|
||||||
|
);
|
||||||
|
select("device").value = [...select("device").options].some(
|
||||||
|
(o) => o.value === chosen,
|
||||||
|
)
|
||||||
|
? chosen
|
||||||
|
: "";
|
||||||
|
deviceChanged();
|
||||||
|
}
|
||||||
|
function deviceChanged() {
|
||||||
|
const saved = !!select("device").value;
|
||||||
|
element("new-device").hidden = saved;
|
||||||
|
element("new-device-label").hidden = saved;
|
||||||
|
input("server").disabled = input("port").disabled = saved;
|
||||||
|
input("password").placeholder = saved
|
||||||
|
? "留空使用已保存的密码"
|
||||||
|
: "输入远端访问密码";
|
||||||
|
}
|
||||||
|
select("device").onchange = deviceChanged;
|
||||||
|
input("save").onchange = () => {
|
||||||
|
for (const name of ["remember", "authorize"]) {
|
||||||
|
input(name).disabled = !input("save").checked;
|
||||||
|
if (!input("save").checked) input(name).checked = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function renderStatus(s: Data) {
|
||||||
|
const connected = s.state === "ready" || s.state === "waiting_frame";
|
||||||
|
const lost = ["closed", "failed"].includes(s.state);
|
||||||
|
if (lost && !disconnectedByUser && ["ready", "waiting_frame"].includes(status.state)) showError(new Error("CONNECTION_LOST"));
|
||||||
|
const changedSession = s.session_id !== status.session_id;
|
||||||
|
status = s;
|
||||||
|
const connecting = busy || s.state === "waiting_frame";
|
||||||
|
root.dataset.connected = String(connected);
|
||||||
|
root.dataset.connecting = String(connecting);
|
||||||
|
root.dataset.controlling = String(connected && s.control === true && s.control_allowed === true && !s.human_paused);
|
||||||
|
element("connection").dataset.state = connected ? "online" : connecting ? "connecting" : lost && !disconnectedByUser ? "offline" : "idle";
|
||||||
|
text("connection-label", connected ? "已连接" : connecting ? "正在连接" : lost ? "未连接" : "等待连接");
|
||||||
|
element("connect-form").hidden = connected;
|
||||||
|
element("session-card").hidden = !connected;
|
||||||
|
text("sidebar-title", connected ? "连接信息" : "连接设备");
|
||||||
|
text("device-name", connected ? s.device_alias || "远程电脑" : "远程画面");
|
||||||
|
if (!connected || changedSession) setDetails(false);
|
||||||
|
text("permission", connected ? s.human_paused ? "控制已暂停" : s.control_allowed ? "允许 Agent 操作" : "仅查看画面" : "等待授权");
|
||||||
|
element("permission").dataset.state = !connected ? "idle" : s.human_paused ? "paused" : "active";
|
||||||
|
const platform = platformName(s.platform);
|
||||||
|
text("session-info", connected ? `${platform} · 显示器 ${(s.display ?? 0) + 1}` : "等待设备连接");
|
||||||
|
text("session-device", s.device_alias || "远程电脑");
|
||||||
|
text("session-platform", platform);
|
||||||
|
const device = savedDevices.find((d) => d.device_alias === s.device_alias);
|
||||||
|
element("session-device-id-row").hidden = !device;
|
||||||
|
text("session-device-id", device ? String(device.device_id).replace(/(\d{3})(?=\d)/g, "$1 ") : "");
|
||||||
|
text("session-access", s.control_allowed ? "查看与操作" : "仅查看");
|
||||||
|
text("session-display", `显示器 ${(s.display ?? 0) + 1}`);
|
||||||
|
text("session-agent", s.human_paused ? "已暂停" : s.control_allowed ? "允许操作" : "仅可观察");
|
||||||
|
element("session-agent").dataset.paused = String(!!s.human_paused);
|
||||||
|
element<HTMLButtonElement>("pause").disabled = !connected || !s.control_allowed || controlBusy;
|
||||||
|
const pauseLabel = s.human_paused ? "恢复控制" : "暂停控制";
|
||||||
|
text("pause-label", pauseLabel);
|
||||||
|
element<HTMLButtonElement>("disconnect").disabled = !s.session_id || controlBusy || busy;
|
||||||
|
element<HTMLButtonElement>("connect").disabled = busy || connected || !invoke;
|
||||||
|
element<HTMLFieldSetElement>("connection-fields").disabled = busy;
|
||||||
|
element("connect").setAttribute("aria-busy", String(busy));
|
||||||
|
text("connect-label", busy ? "正在连接…" : "连接");
|
||||||
|
if (!connected) {
|
||||||
|
element("frame").hidden = true;
|
||||||
|
element("empty").hidden = false;
|
||||||
|
element("stale").hidden = true;
|
||||||
|
resetMetrics();
|
||||||
|
if (root.classList.contains("focus-mode")) setFocus(false);
|
||||||
|
} else if (changedSession) {
|
||||||
|
resetMetrics();
|
||||||
|
element("frame").hidden = true;
|
||||||
|
element("empty").hidden = false;
|
||||||
|
}
|
||||||
|
if (!imageAt && !videoFailed) {
|
||||||
|
text("empty-title", connecting || connected ? "正在建立远程连接" : lost ? "会话已结束" : "未连接设备");
|
||||||
|
text("empty-description", connecting || connected ? "正在等待远端画面,请稍候。" : lost ? "请重新连接设备。" : "选择设备并输入访问密码。");
|
||||||
|
text("video-state", connecting || connected ? "等待画面" : "待连接");
|
||||||
|
element("video-state").dataset.state = "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function connect() {
|
||||||
|
if (!invoke || busy || ["ready", "waiting_frame"].includes(status.state)) return;
|
||||||
|
element("error").hidden = true;
|
||||||
|
disconnectedByUser = false;
|
||||||
|
busy = true;
|
||||||
|
renderStatus(status);
|
||||||
|
const saved = select("device").value;
|
||||||
|
let args: Data = {
|
||||||
|
mode: select("mode").value,
|
||||||
|
display_index: Number(input("display").value),
|
||||||
|
save_device: input("save").checked,
|
||||||
|
remember_password: input("remember").checked,
|
||||||
|
persistent_authorization: input("authorize").checked,
|
||||||
|
...(saved
|
||||||
|
? { device_alias: saved }
|
||||||
|
: {
|
||||||
|
device_id: input("device-id").value.replace(/\s/g, ""),
|
||||||
|
server: input("server").value,
|
||||||
|
port: Number(input("port").value),
|
||||||
|
...(input("label").value ? { label: input("label").value } : {}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
if (!saved || input("password").value)
|
||||||
|
args.password = input("password").value;
|
||||||
|
input("password").value = "";
|
||||||
|
hidePassword();
|
||||||
|
try {
|
||||||
|
const result = await invoke("connect", args);
|
||||||
|
args = {};
|
||||||
|
renderStatus(result);
|
||||||
|
if (result.persistence_error)
|
||||||
|
showError(new Error(result.persistence_error));
|
||||||
|
await devices();
|
||||||
|
} catch (error) {
|
||||||
|
args = {};
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
renderStatus(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element("connect").onclick = () => {
|
||||||
|
void connect();
|
||||||
|
};
|
||||||
|
element("connect-form").addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" && event.target instanceof HTMLInputElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
void connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
element("pause").onclick = async () => {
|
||||||
|
if (controlBusy) return;
|
||||||
|
controlBusy = true;
|
||||||
|
renderStatus(status);
|
||||||
|
try {
|
||||||
|
await invoke("control", {
|
||||||
|
operation: status.human_paused ? "resume" : "pause",
|
||||||
|
});
|
||||||
|
renderStatus(await invoke("status"));
|
||||||
|
} catch (e) {
|
||||||
|
showError(e);
|
||||||
|
} finally {
|
||||||
|
controlBusy = false;
|
||||||
|
renderStatus(status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
element("disconnect").onclick = async () => {
|
||||||
|
if (controlBusy) return;
|
||||||
|
controlBusy = true;
|
||||||
|
disconnectedByUser = true;
|
||||||
|
renderStatus(status);
|
||||||
|
try {
|
||||||
|
await closePreview();
|
||||||
|
await invoke("control", { operation: "disconnect" });
|
||||||
|
element("error").hidden = true;
|
||||||
|
resetMetrics();
|
||||||
|
renderStatus(await invoke("status"));
|
||||||
|
} catch (e) {
|
||||||
|
disconnectedByUser = false;
|
||||||
|
showError(e);
|
||||||
|
} finally {
|
||||||
|
controlBusy = false;
|
||||||
|
renderStatus(status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
async function closePreview() {
|
||||||
|
++videoEpoch;
|
||||||
|
player?.stop();
|
||||||
|
player = undefined;
|
||||||
|
videoSession = undefined;
|
||||||
|
videoGeneration = undefined;
|
||||||
|
videoStarting = videoFailed = false;
|
||||||
|
element("video-retry").hidden = true;
|
||||||
|
element("video-browser").hidden = true;
|
||||||
|
}
|
||||||
|
async function startPreview() {
|
||||||
|
if (player || videoStarting || videoFailed || document.hidden) return;
|
||||||
|
const epoch = ++videoEpoch;
|
||||||
|
videoSession = status.session_id;
|
||||||
|
videoGeneration = status.generation;
|
||||||
|
videoStarting = true;
|
||||||
|
const failed = (error: Error) => {
|
||||||
|
if (epoch !== videoEpoch) return;
|
||||||
|
videoFailed = true;
|
||||||
|
showError(error);
|
||||||
|
element("video-retry").hidden = false;
|
||||||
|
element("video-browser").hidden = !CROSSDESK_APPS;
|
||||||
|
element("stale").hidden = !imageAt;
|
||||||
|
text("video-state", "画面不可用");
|
||||||
|
element("video-state").dataset.state = "stale";
|
||||||
|
if (!imageAt) {
|
||||||
|
text("empty-title", "暂时无法显示画面");
|
||||||
|
text("empty-description", "会话仍然保留。请尝试重新加载画面。");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const grant = await invoke("video_open");
|
||||||
|
if (epoch !== videoEpoch || document.hidden) return;
|
||||||
|
player = new VideoPlayer(element<HTMLCanvasElement>("frame"), (info) => {
|
||||||
|
if (epoch !== videoEpoch) return;
|
||||||
|
imageAt = Date.now();
|
||||||
|
element("frame").hidden = false;
|
||||||
|
element("empty").hidden = true;
|
||||||
|
element("stale").hidden = true;
|
||||||
|
const size = `${info.width} × ${info.height}`;
|
||||||
|
const rate = info.fps > 0 ? `${info.fps.toFixed(1)} FPS` : "测量中";
|
||||||
|
if (size !== frameSize) { frameSize = size; text("resolution", size); }
|
||||||
|
if (rate !== frameRate) { frameRate = rate; text("fps", rate); }
|
||||||
|
element<HTMLButtonElement>("focus-view").disabled = false;
|
||||||
|
text("video-state", "实时");
|
||||||
|
element("video-state").dataset.state = "live";
|
||||||
|
}, failed, element<HTMLCanvasElement>("agent-cursor"));
|
||||||
|
player.start(grant.url, grant.ticket, grant.protocol, grant.decoder);
|
||||||
|
} catch (error) {
|
||||||
|
failed(error instanceof Error ? error : new Error("VIDEO_UNAVAILABLE"));
|
||||||
|
} finally {
|
||||||
|
if (epoch === videoEpoch) videoStarting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element("video-retry").onclick = async () => {
|
||||||
|
await closePreview();
|
||||||
|
element("error").hidden = true;
|
||||||
|
await startPreview();
|
||||||
|
};
|
||||||
|
element("video-browser").onclick = async () => {
|
||||||
|
try {
|
||||||
|
const result = await app!.callServerTool({name: "crossdesk_open_browser", arguments: {view_id: context!.view_id}});
|
||||||
|
if (result.isError) throw new Error("VIDEO_UNAVAILABLE");
|
||||||
|
started = false;
|
||||||
|
await closePreview();
|
||||||
|
element("error").hidden = true;
|
||||||
|
for (const name of ["connect", "pause", "disconnect"])
|
||||||
|
element<HTMLButtonElement>(name).disabled = true;
|
||||||
|
element("panel-status").hidden = false;
|
||||||
|
element("panel-status").textContent = "浏览器观看面板已打开。";
|
||||||
|
} catch (error) { showError(error); }
|
||||||
|
};
|
||||||
|
async function poll() {
|
||||||
|
if (polling || !invoke || !started || document.hidden || busy || controlBusy) return;
|
||||||
|
polling = true;
|
||||||
|
try {
|
||||||
|
renderStatus(await invoke("status"));
|
||||||
|
if (videoSession && (videoSession !== status.session_id ||
|
||||||
|
videoGeneration !== status.generation)) {
|
||||||
|
if (videoFailed) element("error").hidden = true;
|
||||||
|
await closePreview();
|
||||||
|
}
|
||||||
|
if (status.state !== "ready" || document.hidden) {
|
||||||
|
await closePreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startPreview();
|
||||||
|
const stale = !!imageAt && Date.now() - imageAt > 2000;
|
||||||
|
element("stale").hidden = !stale;
|
||||||
|
if (stale) {
|
||||||
|
text("video-state", "等待画面");
|
||||||
|
element("video-state").dataset.state = "stale";
|
||||||
|
text("fps", "—");
|
||||||
|
frameRate = "";
|
||||||
|
}
|
||||||
|
} catch (error) { showError(error); }
|
||||||
|
finally { polling = false; }
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) void closePreview();
|
||||||
|
else void poll();
|
||||||
|
});
|
||||||
|
window.addEventListener("pagehide", () => {
|
||||||
|
void closePreview();
|
||||||
|
});
|
||||||
|
setInterval(() => {
|
||||||
|
void poll();
|
||||||
|
}, 1000);
|
||||||
|
async function ready(next: Data) {
|
||||||
|
if (
|
||||||
|
started &&
|
||||||
|
context?.view_id === next.view_id &&
|
||||||
|
context?.view_generation === next.view_generation
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (started) await closePreview();
|
||||||
|
context = next;
|
||||||
|
element("panel-status").hidden = true;
|
||||||
|
started = false;
|
||||||
|
await invoke("ready");
|
||||||
|
await devices();
|
||||||
|
started = true;
|
||||||
|
element<HTMLButtonElement>("connect").disabled = false;
|
||||||
|
await poll();
|
||||||
|
}
|
||||||
|
async function start() {
|
||||||
|
if (CROSSDESK_APPS) {
|
||||||
|
const { App } = await import("@modelcontextprotocol/ext-apps");
|
||||||
|
app = new App({ name: "CrossDesk MCP", version: "0.1.0" }, {});
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
ready: "crossdesk_ui_ready",
|
||||||
|
devices: "crossdesk_ui_list_devices",
|
||||||
|
status: "crossdesk_ui_get_session",
|
||||||
|
connect: "crossdesk_ui_connect",
|
||||||
|
video_open: "crossdesk_video_open",
|
||||||
|
control: "crossdesk_viewer_control",
|
||||||
|
};
|
||||||
|
let connected = false,
|
||||||
|
initial: Data | undefined;
|
||||||
|
invoke = async (name, args = {}) => {
|
||||||
|
const result = await app!.callServerTool({
|
||||||
|
name: names[name]!,
|
||||||
|
arguments: {
|
||||||
|
...args,
|
||||||
|
view_id: context!.view_id,
|
||||||
|
view_generation: context!.view_generation,
|
||||||
|
ui_token: context!.ui_token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data =
|
||||||
|
result.structuredContent ??
|
||||||
|
JSON.parse((result.content?.[0] as Data)?.text ?? "{}");
|
||||||
|
return check(data);
|
||||||
|
};
|
||||||
|
app.ontoolresult = (result) => {
|
||||||
|
const meta = result._meta?.["crossdesk/ui"] as Data | undefined;
|
||||||
|
if (!meta) return;
|
||||||
|
initial = { ...(result.structuredContent ?? {}), ...meta };
|
||||||
|
if (connected) void ready(initial).catch(showError);
|
||||||
|
};
|
||||||
|
app.onteardown = async () => {
|
||||||
|
await closePreview();
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
await app.connect();
|
||||||
|
connected = true;
|
||||||
|
if (initial) await ready(initial);
|
||||||
|
} else {
|
||||||
|
let csrf = "";
|
||||||
|
const request = async (path: string, body?: Data) =>
|
||||||
|
check(
|
||||||
|
await (
|
||||||
|
await fetch("/api/" + path, {
|
||||||
|
method: body ? "POST" : "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: body
|
||||||
|
? { "Content-Type": "application/json", "X-CrossDesk-CSRF": csrf }
|
||||||
|
: {},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
).json(),
|
||||||
|
);
|
||||||
|
const bootstrap = new URLSearchParams(location.hash.slice(1)).get(
|
||||||
|
"bootstrap",
|
||||||
|
);
|
||||||
|
history.replaceState(null, "", location.pathname);
|
||||||
|
const result = bootstrap
|
||||||
|
? await request("bootstrap", { token: bootstrap })
|
||||||
|
: await request("context");
|
||||||
|
csrf = result.csrf;
|
||||||
|
invoke = (name, args = {}) =>
|
||||||
|
request(name, { ...args, view_id: context!.view_id });
|
||||||
|
await ready(bootstrap ? result : result.views.at(-1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void start().catch(showError);
|
||||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: faa4adc1e3...9ef130b445
@@ -19,6 +19,11 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
struct EncodedVideoFrame {
|
||||||
|
std::vector<uint8_t> data;
|
||||||
|
uint64_t generation = 0, sequence = 0, timestamp_us = 0;
|
||||||
|
bool key = false;
|
||||||
|
};
|
||||||
struct DecodedFrame {
|
struct DecodedFrame {
|
||||||
unsigned width = 0, height = 0;
|
unsigned width = 0, height = 0;
|
||||||
uint64_t generation = 0, frame_id = 0;
|
uint64_t generation = 0, frame_id = 0;
|
||||||
@@ -46,6 +51,7 @@ struct SessionOptions {
|
|||||||
int port = 9099, display = 0, fps = 8;
|
int port = 9099, display = 0, fps = 8;
|
||||||
int preview_width = 1920, preview_height = 1080;
|
int preview_width = 1920, preview_height = 1080;
|
||||||
int quality = 2, max_bitrate_bps = 6000000;
|
int quality = 2, max_bitrate_bps = 6000000;
|
||||||
|
bool hardware_acceleration = false;
|
||||||
std::chrono::seconds timeout{30};
|
std::chrono::seconds timeout{30};
|
||||||
};
|
};
|
||||||
// All public methods except the destructor run on one owner thread. Call Tick
|
// All public methods except the destructor run on one owner thread. Call Tick
|
||||||
@@ -59,15 +65,19 @@ public:
|
|||||||
void Tick();
|
void Tick();
|
||||||
void Disconnect();
|
void Disconnect();
|
||||||
bool SelectDisplay(int index);
|
bool SelectDisplay(int index);
|
||||||
|
void SetVideoOutput(bool enabled, bool request_keyframe = false);
|
||||||
|
std::optional<EncodedVideoFrame> TakeEncodedVideo();
|
||||||
void SetPreview(int fps, int width, int height, int quality = -1,
|
void SetPreview(int fps, int width, int height, int quality = -1,
|
||||||
int max_bitrate_bps = -1);
|
int max_bitrate_bps = -1);
|
||||||
void SetControl(bool enabled);
|
void SetControl(bool enabled);
|
||||||
|
void ReleaseInput();
|
||||||
bool Text(const std::string &utf8);
|
bool Text(const std::string &utf8);
|
||||||
bool Key(int key, bool down);
|
bool Key(int key, bool down);
|
||||||
bool Pointer(float x, float y, int flag, int wheel = 0);
|
bool Pointer(float x, float y, int flag, int wheel = 0);
|
||||||
std::optional<DecodedFrame> TakeFrame(DecodedFrame *recycle = nullptr);
|
std::optional<DecodedFrame> TakeFrame(DecodedFrame *recycle = nullptr);
|
||||||
nlohmann::json Status() const;
|
nlohmann::json Status() const;
|
||||||
bool Connected() const { return state_ == "connected"; }
|
bool Connected() const { return state_ == "connected"; }
|
||||||
|
bool ControlEnabled() const { return control_; }
|
||||||
bool PreviewAccepted() const { return preview_accepted_; }
|
bool PreviewAccepted() const { return preview_accepted_; }
|
||||||
bool SupportsPreviewFps15() const { return fps15_; }
|
bool SupportsPreviewFps15() const { return fps15_; }
|
||||||
bool Finished() const { return state_ == "failed" || state_ == "closed"; }
|
bool Finished() const { return state_ == "failed" || state_ == "closed"; }
|
||||||
@@ -84,13 +94,14 @@ private:
|
|||||||
Params Parameters();
|
Params Parameters();
|
||||||
bool Send(const char *stream, const std::string &payload);
|
bool Send(const char *stream, const std::string &payload);
|
||||||
void Preview();
|
void Preview();
|
||||||
void ReleaseInput();
|
|
||||||
static void Signal(SignalStatus, const char *, size_t, void *);
|
static void Signal(SignalStatus, const char *, size_t, void *);
|
||||||
static void Connection(ConnectionStatus, const char *, size_t, void *);
|
static void Connection(ConnectionStatus, const char *, size_t, void *);
|
||||||
static void Data(const char *, size_t, const char *, size_t, const char *,
|
static void Data(const char *, size_t, const char *, size_t, const char *,
|
||||||
size_t, void *);
|
size_t, void *);
|
||||||
static void Video(const MiniRtcVideoFrame *, const char *, size_t,
|
static void Video(const MiniRtcVideoFrame *, const char *, size_t,
|
||||||
const char *, size_t, void *);
|
const char *, size_t, void *);
|
||||||
|
static int EncodedVideo(const uint8_t *, size_t, int64_t, const char *,
|
||||||
|
size_t, const char *, size_t, void *);
|
||||||
static void Network(const char *, size_t, TraversalMode,
|
static void Network(const char *, size_t, TraversalMode,
|
||||||
const MiniRtcNetTrafficStats *, const char *, size_t,
|
const MiniRtcNetTrafficStats *, const char *, size_t,
|
||||||
void *);
|
void *);
|
||||||
@@ -102,6 +113,7 @@ private:
|
|||||||
bool control_ = false, preview_supported_ = false, resolution_ = false;
|
bool control_ = false, preview_supported_ = false, resolution_ = false;
|
||||||
bool fps15_ = false, bandwidth_ = false, text_supported_ = false;
|
bool fps15_ = false, bandwidth_ = false, text_supported_ = false;
|
||||||
bool preview_accepted_ = false;
|
bool preview_accepted_ = false;
|
||||||
|
HostPlatform host_platform_ = HostPlatform::Unknown;
|
||||||
std::vector<HostDisplay> displays_;
|
std::vector<HostDisplay> displays_;
|
||||||
std::set<int> keys_, buttons_;
|
std::set<int> keys_, buttons_;
|
||||||
float pointer_x_ = .5f, pointer_y_ = .5f;
|
float pointer_x_ = .5f, pointer_y_ = .5f;
|
||||||
@@ -114,6 +126,12 @@ private:
|
|||||||
MiniRtcNetTrafficStats network_{};
|
MiniRtcNetTrafficStats network_{};
|
||||||
TraversalMode traversal_ = UnknownMode;
|
TraversalMode traversal_ = UnknownMode;
|
||||||
LatestFrame frames_;
|
LatestFrame frames_;
|
||||||
|
bool video_output_ = false, video_wait_key_ = true, video_request_key_ = false;
|
||||||
|
size_t video_bytes_ = 0;
|
||||||
|
uint64_t video_generation_ = 0, video_sequence_ = 0;
|
||||||
|
std::deque<EncodedVideoFrame> video_frames_;
|
||||||
|
std::vector<uint8_t> video_sps_, video_pps_;
|
||||||
|
std::chrono::steady_clock::time_point video_key_requested_{};
|
||||||
};
|
};
|
||||||
nlohmann::json ParseBoundedJson(const std::string &text);
|
nlohmann::json ParseBoundedJson(const std::string &text);
|
||||||
std::string SafeText(const std::string &text, size_t limit = 160);
|
std::string SafeText(const std::string &text, size_t limit = 160);
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ class Session {
|
|||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
directory_ = std::move(directory);
|
directory_ = std::move(directory);
|
||||||
}
|
}
|
||||||
|
// A browser destination belongs to one wire ID, never to the whole peer.
|
||||||
|
// Keep reservations until consumed: other queued files may take hours.
|
||||||
|
bool ReserveDirectory(uint32_t id, const std::filesystem::path& directory) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (closed_ || !id || incoming_.count(id) || cancelled_incoming_.count(id) ||
|
||||||
|
destinations_.size() >= 8192)
|
||||||
|
return false;
|
||||||
|
return destinations_.emplace(id, directory).second;
|
||||||
|
}
|
||||||
bool Start(const std::filesystem::path& path, const std::string& name,
|
bool Start(const std::filesystem::path& path, const std::string& name,
|
||||||
Clock::time_point now = Clock::now(), uint32_t id = 0) {
|
Clock::time_point now = Clock::now(), uint32_t id = 0) {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
@@ -240,6 +249,7 @@ class Session {
|
|||||||
(it != incoming_.end() && h.total_size != it->second.total))
|
(it != incoming_.end() && h.total_size != it->second.total))
|
||||||
return ack;
|
return ack;
|
||||||
cancelled_incoming_[h.file_id] = now;
|
cancelled_incoming_[h.file_id] = now;
|
||||||
|
destinations_.erase(h.file_id);
|
||||||
if (it != incoming_.end()) {
|
if (it != incoming_.end()) {
|
||||||
NotifyReceive(h.file_id, it->second, ReceiveStatus::Cancelled);
|
NotifyReceive(h.file_id, it->second, ReceiveStatus::Cancelled);
|
||||||
Notify(it->second.name, -1, false, "");
|
Notify(it->second.name, -1, false, "");
|
||||||
@@ -253,6 +263,7 @@ class Session {
|
|||||||
if (cancelled_incoming_.count(h.file_id))
|
if (cancelled_incoming_.count(h.file_id))
|
||||||
return {kFileAckMagic, h.file_id, 0, h.total_size, 2};
|
return {kFileAckMagic, h.file_id, 0, h.total_size, 2};
|
||||||
auto reject = [&]() {
|
auto reject = [&]() {
|
||||||
|
destinations_.erase(h.file_id);
|
||||||
if (it != incoming_.end()) {
|
if (it != incoming_.end()) {
|
||||||
NotifyReceive(h.file_id, it->second, ReceiveStatus::Failed);
|
NotifyReceive(h.file_id, it->second, ReceiveStatus::Failed);
|
||||||
Notify(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it);
|
Notify(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it);
|
||||||
@@ -275,6 +286,10 @@ class Session {
|
|||||||
Incoming file;
|
Incoming file;
|
||||||
file.name = SafeName(chunk.file_name); file.total = h.total_size;
|
file.name = SafeName(chunk.file_name); file.total = h.total_size;
|
||||||
file.directory = directory_.empty() ? std::filesystem::path(".") : directory_;
|
file.directory = directory_.empty() ? std::filesystem::path(".") : directory_;
|
||||||
|
if (const auto destination = destinations_.find(h.file_id); destination != destinations_.end()) {
|
||||||
|
file.directory = destination->second;
|
||||||
|
destinations_.erase(destination);
|
||||||
|
}
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::create_directories(file.directory, ec);
|
std::filesystem::create_directories(file.directory, ec);
|
||||||
if (ec || (directory_.empty() && options_.receive_subdirectory)) return reject();
|
if (ec || (directory_.empty() && options_.receive_subdirectory)) return reject();
|
||||||
@@ -435,6 +450,7 @@ class Session {
|
|||||||
for (auto& entry : incoming_) Remove(entry.second);
|
for (auto& entry : incoming_) Remove(entry.second);
|
||||||
incoming_.clear();
|
incoming_.clear();
|
||||||
cancelled_incoming_.clear();
|
cancelled_incoming_.clear();
|
||||||
|
destinations_.clear();
|
||||||
}
|
}
|
||||||
const std::chrono::seconds timeout_{30};
|
const std::chrono::seconds timeout_{30};
|
||||||
mutable std::mutex mutex_;
|
mutable std::mutex mutex_;
|
||||||
@@ -447,6 +463,7 @@ class Session {
|
|||||||
std::filesystem::path directory_;
|
std::filesystem::path directory_;
|
||||||
std::optional<Outgoing> outgoing_;
|
std::optional<Outgoing> outgoing_;
|
||||||
std::map<uint32_t, Incoming> incoming_;
|
std::map<uint32_t, Incoming> incoming_;
|
||||||
|
std::map<uint32_t, std::filesystem::path> destinations_;
|
||||||
std::map<uint32_t, Clock::time_point> cancelled_incoming_;
|
std::map<uint32_t, Clock::time_point> cancelled_incoming_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-11-02
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _FRAME_IMAGE_H_
|
||||||
|
#define _FRAME_IMAGE_H_
|
||||||
|
|
||||||
|
#include <controller_session.h>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
struct ImageRegion {
|
||||||
|
int x = 0, y = 0, width = 0, height = 0;
|
||||||
|
};
|
||||||
|
struct FrameImage {
|
||||||
|
int width = 0, height = 0;
|
||||||
|
ImageRegion region;
|
||||||
|
std::vector<uint8_t> png;
|
||||||
|
};
|
||||||
|
// Pure bounded conversion; safe on an encoding worker, never calls the session.
|
||||||
|
FrameImage EncodeFrameImage(const DecodedFrame &frame, ImageRegion region,
|
||||||
|
int max_edge, size_t max_bytes);
|
||||||
|
std::string ImageBase64(const std::vector<uint8_t> &bytes);
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -77,7 +77,7 @@ Params ControllerSession::Parameters() {
|
|||||||
std::snprintf(p.log_path, sizeof(p.log_path), "%s",
|
std::snprintf(p.log_path, sizeof(p.log_path), "%s",
|
||||||
options_.log_directory.c_str());
|
options_.log_directory.c_str());
|
||||||
p.signal_server_port = options_.port;
|
p.signal_server_port = options_.port;
|
||||||
p.hardware_acceleration = false;
|
p.hardware_acceleration = options_.hardware_acceleration;
|
||||||
p.native_video_output = true;
|
p.native_video_output = true;
|
||||||
p.av1_encoding = false;
|
p.av1_encoding = false;
|
||||||
p.turn_mode = TurnAutoUdpTcp;
|
p.turn_mode = TurnAutoUdpTcp;
|
||||||
@@ -92,6 +92,7 @@ Params ControllerSession::Parameters() {
|
|||||||
p.on_net_status_report = Network;
|
p.on_net_status_report = Network;
|
||||||
p.on_receive_data_buffer = Data;
|
p.on_receive_data_buffer = Data;
|
||||||
p.on_receive_video_frame = Video;
|
p.on_receive_video_frame = Video;
|
||||||
|
p.on_receive_encoded_video = EncodedVideo;
|
||||||
// No audio callback, clipboard receiver, file writer or incoming input
|
// No audio callback, clipboard receiver, file writer or incoming input
|
||||||
// handler.
|
// handler.
|
||||||
p.user_id = "cli";
|
p.user_id = "cli";
|
||||||
@@ -118,6 +119,7 @@ void ControllerSession::Connect(SessionOptions options, std::string device,
|
|||||||
state_ = "registering";
|
state_ = "registering";
|
||||||
error_.clear();
|
error_.clear();
|
||||||
displays_.clear();
|
displays_.clear();
|
||||||
|
host_platform_ = HostPlatform::Unknown;
|
||||||
preview_supported_ = resolution_ = fps15_ = bandwidth_ = text_supported_ =
|
preview_supported_ = resolution_ = fps15_ = bandwidth_ = text_supported_ =
|
||||||
false;
|
false;
|
||||||
preview_request_ = preview_ack_ = 0;
|
preview_request_ = preview_ack_ = 0;
|
||||||
@@ -238,6 +240,7 @@ void ControllerSession::Tick() {
|
|||||||
if (!a.from_json(e.data))
|
if (!a.from_json(e.data))
|
||||||
continue;
|
continue;
|
||||||
if (a.type == host_infomation) {
|
if (a.type == host_infomation) {
|
||||||
|
host_platform_ = a.i.platform;
|
||||||
displays_.clear();
|
displays_.clear();
|
||||||
for (size_t i = 0; i < std::min(a.i.display_num, size_t(64)); ++i)
|
for (size_t i = 0; i < std::min(a.i.display_num, size_t(64)); ++i)
|
||||||
displays_.push_back({SafeText(a.i.display_list[i]), a.i.left[i],
|
displays_.push_back({SafeText(a.i.display_list[i]), a.i.left[i],
|
||||||
@@ -285,6 +288,8 @@ void ControllerSession::Fail(const char *reason) {
|
|||||||
state_ = "failed";
|
state_ = "failed";
|
||||||
control_ = false;
|
control_ = false;
|
||||||
ClosePeers();
|
ClosePeers();
|
||||||
|
keys_.clear();
|
||||||
|
buttons_.clear();
|
||||||
}
|
}
|
||||||
void ControllerSession::Disconnect() {
|
void ControllerSession::Disconnect() {
|
||||||
if (state_ == "closed" && !control_)
|
if (state_ == "closed" && !control_)
|
||||||
@@ -292,6 +297,8 @@ void ControllerSession::Disconnect() {
|
|||||||
ReleaseInput();
|
ReleaseInput();
|
||||||
control_ = false;
|
control_ = false;
|
||||||
ClosePeers();
|
ClosePeers();
|
||||||
|
keys_.clear();
|
||||||
|
buttons_.clear();
|
||||||
state_ = "closed";
|
state_ = "closed";
|
||||||
}
|
}
|
||||||
bool ControllerSession::SelectDisplay(int index) {
|
bool ControllerSession::SelectDisplay(int index) {
|
||||||
@@ -364,30 +371,34 @@ void ControllerSession::SetControl(bool enabled) {
|
|||||||
control_ = enabled;
|
control_ = enabled;
|
||||||
}
|
}
|
||||||
void ControllerSession::ReleaseInput() {
|
void ControllerSession::ReleaseInput() {
|
||||||
auto keys = keys_;
|
// Release buttons before modifiers, so interrupted modified drags do not
|
||||||
for (int k : keys)
|
// turn into an unmodified drop. Failed releases remain tracked for cleanup.
|
||||||
Key(k, false);
|
|
||||||
auto buttons = buttons_;
|
auto buttons = buttons_;
|
||||||
for (int b : buttons)
|
for (int b : buttons)
|
||||||
Pointer(pointer_x_, pointer_y_, b + 1);
|
Pointer(pointer_x_, pointer_y_, b + 1);
|
||||||
keys_.clear();
|
auto keys = keys_;
|
||||||
buttons_.clear();
|
for (auto it = keys.rbegin(); it != keys.rend(); ++it)
|
||||||
|
Key(*it, false);
|
||||||
}
|
}
|
||||||
bool ControllerSession::Key(int key, bool down) {
|
bool ControllerSession::Key(int key, bool down) {
|
||||||
if (!control_ || !Connected() || key < 0 || key > 255)
|
if ((!control_ && (down || !keys_.count(key))) || !Connected() || key < 0 ||
|
||||||
|
key > 255)
|
||||||
return false;
|
return false;
|
||||||
RemoteAction a{};
|
RemoteAction a{};
|
||||||
a.type = keyboard;
|
a.type = keyboard;
|
||||||
a.k = {size_t(key), 0, false, down ? key_down : key_up};
|
a.k = {size_t(key), 0, false, down ? key_down : key_up};
|
||||||
if (down)
|
if (down)
|
||||||
keys_.insert(key);
|
keys_.insert(key);
|
||||||
else
|
const bool sent = Send(kKeyboardStream, a.to_json());
|
||||||
|
if (sent && !down)
|
||||||
keys_.erase(key);
|
keys_.erase(key);
|
||||||
return Send(kKeyboardStream, a.to_json());
|
return sent;
|
||||||
}
|
}
|
||||||
bool ControllerSession::Pointer(float x, float y, int flag, int wheel) {
|
bool ControllerSession::Pointer(float x, float y, int flag, int wheel) {
|
||||||
if (!control_ || !Connected() || !std::isfinite(x) || !std::isfinite(y) ||
|
const bool releasing =
|
||||||
x < 0 || y < 0 || x > 1 || y > 1 || flag < 0 || flag > 8 ||
|
flag >= 2 && flag <= 6 && !(flag & 1) && buttons_.count(flag - 1);
|
||||||
|
if ((!control_ && !releasing) || !Connected() || !std::isfinite(x) ||
|
||||||
|
!std::isfinite(y) || x < 0 || y < 0 || x > 1 || y > 1 || flag < 0 || flag > 8 ||
|
||||||
std::abs(int64_t(wheel)) > 1200)
|
std::abs(int64_t(wheel)) > 1200)
|
||||||
return false;
|
return false;
|
||||||
RemoteAction a{};
|
RemoteAction a{};
|
||||||
@@ -395,13 +406,12 @@ bool ControllerSession::Pointer(float x, float y, int flag, int wheel) {
|
|||||||
a.m = {x, y, wheel, static_cast<MouseFlag>(flag)};
|
a.m = {x, y, wheel, static_cast<MouseFlag>(flag)};
|
||||||
pointer_x_ = x;
|
pointer_x_ = x;
|
||||||
pointer_y_ = y;
|
pointer_y_ = y;
|
||||||
if (flag >= 1 && flag <= 6) {
|
if (flag >= 1 && flag <= 6 && (flag & 1))
|
||||||
if (flag & 1)
|
|
||||||
buttons_.insert(flag);
|
buttons_.insert(flag);
|
||||||
else
|
const bool sent = Send(kMouseStream, a.to_json());
|
||||||
|
if (sent && flag >= 2 && flag <= 6 && !(flag & 1))
|
||||||
buttons_.erase(flag - 1);
|
buttons_.erase(flag - 1);
|
||||||
}
|
return sent;
|
||||||
return Send(kMouseStream, a.to_json());
|
|
||||||
}
|
}
|
||||||
bool ControllerSession::Text(const std::string &text) {
|
bool ControllerSession::Text(const std::string &text) {
|
||||||
if (!control_ || !Connected() || !text_supported_ || text.empty() ||
|
if (!control_ || !Connected() || !text_supported_ || text.empty() ||
|
||||||
@@ -422,7 +432,9 @@ std::optional<DecodedFrame> ControllerSession::TakeFrame(DecodedFrame *recycle)
|
|||||||
json ControllerSession::Status() const {
|
json ControllerSession::Status() const {
|
||||||
json ds = json::array();
|
json ds = json::array();
|
||||||
for (size_t i = 0; i < displays_.size(); ++i)
|
for (size_t i = 0; i < displays_.size(); ++i)
|
||||||
ds.push_back({{"index", i}, {"name", displays_[i].name}});
|
ds.push_back({{"index", i}, {"name", displays_[i].name},
|
||||||
|
{"left", displays_[i].left}, {"top", displays_[i].top},
|
||||||
|
{"right", displays_[i].right}, {"bottom", displays_[i].bottom}});
|
||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
return {{"state", state_},
|
return {{"state", state_},
|
||||||
{"client_id", client_id_},
|
{"client_id", client_id_},
|
||||||
@@ -430,6 +442,7 @@ json ControllerSession::Status() const {
|
|||||||
{"generation", generation_},
|
{"generation", generation_},
|
||||||
{"control", control_},
|
{"control", control_},
|
||||||
{"displays", ds},
|
{"displays", ds},
|
||||||
|
{"platform", HostPlatformName(host_platform_)},
|
||||||
{"display", options_.display},
|
{"display", options_.display},
|
||||||
{"text_input", text_supported_},
|
{"text_input", text_supported_},
|
||||||
{"preview_supported", preview_supported_},
|
{"preview_supported", preview_supported_},
|
||||||
@@ -440,7 +453,19 @@ json ControllerSession::Status() const {
|
|||||||
{"preview_height", options_.preview_height},
|
{"preview_height", options_.preview_height},
|
||||||
{"preview_quality", options_.quality},
|
{"preview_quality", options_.quality},
|
||||||
{"preview_bitrate_bps", options_.max_bitrate_bps},
|
{"preview_bitrate_bps", options_.max_bitrate_bps},
|
||||||
|
{"native_video_decoder", SafeText(std::string(
|
||||||
|
network_.video_decoder,
|
||||||
|
std::find(network_.video_decoder,
|
||||||
|
network_.video_decoder + sizeof(network_.video_decoder), '\0')))},
|
||||||
{"frames", frames_.Stats()},
|
{"frames", frames_.Stats()},
|
||||||
|
{"video_output",
|
||||||
|
{{"enabled", video_output_},
|
||||||
|
{"received", video_sequence_},
|
||||||
|
{"queued", video_frames_.size()},
|
||||||
|
{"queued_bytes", video_bytes_},
|
||||||
|
{"waiting_keyframe", video_wait_key_},
|
||||||
|
{"has_sps", !video_sps_.empty()},
|
||||||
|
{"has_pps", !video_pps_.empty()}}},
|
||||||
{"network",
|
{"network",
|
||||||
{{"video_bps", network_.video_inbound_stats.bitrate},
|
{{"video_bps", network_.video_inbound_stats.bitrate},
|
||||||
{"rtt_ms", network_.rtt_ms},
|
{"rtt_ms", network_.rtt_ms},
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#include "controller_session.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <display_stream_id.h>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
constexpr size_t kMaxAccessUnit = 4 * 1024 * 1024;
|
||||||
|
// NAL spans include their Annex B start code, so cached parameter sets can be
|
||||||
|
// prepended to an IDR without converting or re-encoding the original stream.
|
||||||
|
size_t StartCode(const uint8_t *data, size_t size, size_t at) {
|
||||||
|
if (at + 3 <= size && !data[at] && !data[at + 1]) {
|
||||||
|
if (data[at + 2] == 1)
|
||||||
|
return 3;
|
||||||
|
if (at + 4 <= size && !data[at + 2] && data[at + 3] == 1)
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
void ControllerSession::SetVideoOutput(bool enabled, bool request_keyframe) {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
if (video_output_ != enabled || request_keyframe) {
|
||||||
|
video_frames_.clear();
|
||||||
|
video_bytes_ = 0;
|
||||||
|
video_wait_key_ = true;
|
||||||
|
video_request_key_ = enabled;
|
||||||
|
}
|
||||||
|
video_output_ = enabled;
|
||||||
|
}
|
||||||
|
std::optional<EncodedVideoFrame> ControllerSession::TakeEncodedVideo() {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
if (video_frames_.empty())
|
||||||
|
return {};
|
||||||
|
auto frame = std::move(video_frames_.front());
|
||||||
|
video_frames_.pop_front();
|
||||||
|
video_bytes_ -= frame.data.size();
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
int ControllerSession::EncodedVideo(const uint8_t *data, size_t size, int64_t,
|
||||||
|
const char *, size_t, const char *stream,
|
||||||
|
size_t stream_size, void *context) {
|
||||||
|
auto &s = *static_cast<ControllerSession *>(context);
|
||||||
|
try {
|
||||||
|
std::lock_guard lock(s.mutex_);
|
||||||
|
if (s.stopping_ || !s.video_output_ || !data || !size ||
|
||||||
|
size > kMaxAccessUnit || !stream || stream_size >= 64 ||
|
||||||
|
std::string(stream, stream_size) !=
|
||||||
|
MakeDisplayStreamId(s.callback_display_))
|
||||||
|
return 0;
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
if (s.video_generation_ != s.callback_generation_) {
|
||||||
|
s.video_generation_ = s.callback_generation_;
|
||||||
|
s.video_frames_.clear();
|
||||||
|
s.video_bytes_ = 0;
|
||||||
|
s.video_sps_.clear();
|
||||||
|
s.video_pps_.clear();
|
||||||
|
s.video_wait_key_ = s.video_request_key_ = true;
|
||||||
|
}
|
||||||
|
bool key = false;
|
||||||
|
for (size_t at = 0; at + 3 < size;) {
|
||||||
|
const auto prefix = StartCode(data, size, at);
|
||||||
|
if (!prefix) {
|
||||||
|
++at;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto body = at + prefix;
|
||||||
|
if (body >= size)
|
||||||
|
break;
|
||||||
|
size_t end = body + 1;
|
||||||
|
while (end < size && !StartCode(data, size, end))
|
||||||
|
++end;
|
||||||
|
const auto type = data[body] & 31;
|
||||||
|
key |= type == 5;
|
||||||
|
if ((type == 7 || type == 8) && end - at <= 65536) {
|
||||||
|
auto ¶meter = type == 7 ? s.video_sps_ : s.video_pps_;
|
||||||
|
const std::vector<uint8_t> next(data + at, data + end);
|
||||||
|
if (parameter != next) {
|
||||||
|
parameter = next;
|
||||||
|
s.video_wait_key_ = s.video_request_key_ = true;
|
||||||
|
s.video_frames_.clear();
|
||||||
|
s.video_bytes_ = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
at = end;
|
||||||
|
}
|
||||||
|
++s.video_sequence_;
|
||||||
|
const auto output_size =
|
||||||
|
size + (key ? s.video_sps_.size() + s.video_pps_.size() : 0);
|
||||||
|
if (s.video_frames_.size() >= 60 ||
|
||||||
|
s.video_bytes_ + output_size > 8 * 1024 * 1024) {
|
||||||
|
s.video_frames_.clear();
|
||||||
|
s.video_bytes_ = 0;
|
||||||
|
s.video_wait_key_ = s.video_request_key_ = true;
|
||||||
|
}
|
||||||
|
if (key && !s.video_sps_.empty() && !s.video_pps_.empty()) {
|
||||||
|
s.video_wait_key_ = s.video_request_key_ = false;
|
||||||
|
}
|
||||||
|
if (!s.video_wait_key_) {
|
||||||
|
EncodedVideoFrame frame;
|
||||||
|
frame.key = key;
|
||||||
|
frame.generation = s.callback_generation_;
|
||||||
|
frame.sequence = s.video_sequence_;
|
||||||
|
frame.timestamp_us =
|
||||||
|
std::chrono::duration_cast<std::chrono::microseconds>(
|
||||||
|
now.time_since_epoch())
|
||||||
|
.count();
|
||||||
|
if (key) {
|
||||||
|
frame.data = s.video_sps_;
|
||||||
|
frame.data.insert(frame.data.end(), s.video_pps_.begin(),
|
||||||
|
s.video_pps_.end());
|
||||||
|
}
|
||||||
|
frame.data.insert(frame.data.end(), data, data + size);
|
||||||
|
if (frame.data.size() <= kMaxAccessUnit) {
|
||||||
|
s.video_bytes_ += frame.data.size();
|
||||||
|
s.video_frames_.push_back(std::move(frame));
|
||||||
|
} else {
|
||||||
|
s.video_wait_key_ = s.video_request_key_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.video_request_key_ &&
|
||||||
|
now - s.video_key_requested_ >= std::chrono::seconds(1)) {
|
||||||
|
s.video_key_requested_ = now;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
} catch (...) { /* The receive callback must not unwind into MiniRTC. */
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#include "frame_image.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <libyuv.h>
|
||||||
|
#include <png.h>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
FrameImage EncodeFrameImage(const DecodedFrame &f, ImageRegion r, int max_edge,
|
||||||
|
size_t max_bytes) {
|
||||||
|
if (!f.width || !f.height || f.width > 8192 || f.height > 8192 ||
|
||||||
|
(f.width & 1) || (f.height & 1) ||
|
||||||
|
uint64_t(f.width) * f.height > 16777216 ||
|
||||||
|
f.nv12.size() != size_t(f.width) * f.height * 3 / 2 || max_edge < 64 ||
|
||||||
|
max_edge > 2048 || max_bytes < 4096 || max_bytes > 2 * 1024 * 1024)
|
||||||
|
throw std::invalid_argument("INVALID_IMAGE_REQUEST");
|
||||||
|
if (!r.width && !r.height)
|
||||||
|
r = {0, 0, int(f.width), int(f.height)};
|
||||||
|
if (r.x < 0 || r.y < 0 || r.width < 1 || r.height < 1 ||
|
||||||
|
r.width > int(f.width) || r.height > int(f.height) ||
|
||||||
|
r.x > int(f.width) - r.width || r.y > int(f.height) - r.height)
|
||||||
|
throw std::invalid_argument("INVALID_REGION");
|
||||||
|
// Align the NV12 crop to its chroma grid, then offset the RGB crop by at
|
||||||
|
// most one pixel. Small observations do not need a full-frame RGB buffer.
|
||||||
|
const int left = r.x & ~1, top = r.y & ~1;
|
||||||
|
const int width = r.width + r.x - left, height = r.height + r.y - top;
|
||||||
|
std::vector<uint8_t> argb(size_t(width) * height * 4);
|
||||||
|
if (libyuv::NV12ToARGB(f.nv12.data() + size_t(top) * f.width + left, f.width,
|
||||||
|
f.nv12.data() + size_t(f.width) * f.height +
|
||||||
|
size_t(top / 2) * f.width + left,
|
||||||
|
f.width, argb.data(), width * 4, width, height))
|
||||||
|
throw std::runtime_error("IMAGE_CONVERSION_FAILED");
|
||||||
|
FrameImage result;
|
||||||
|
result.region = r;
|
||||||
|
for (int edge = max_edge;; edge = std::max(32, edge * 3 / 4)) {
|
||||||
|
const double scale =
|
||||||
|
std::min(1.0, double(edge) / std::max(r.width, r.height));
|
||||||
|
result.width = std::max(1, int(r.width * scale));
|
||||||
|
result.height = std::max(1, int(r.height * scale));
|
||||||
|
std::vector<uint8_t> scaled(size_t(result.width) * result.height * 4);
|
||||||
|
std::vector<uint8_t> rgb(size_t(result.width) * result.height * 3);
|
||||||
|
if (libyuv::ARGBScale(argb.data() + (size_t(r.y - top) * width + r.x - left) * 4,
|
||||||
|
width * 4, r.width, r.height, scaled.data(),
|
||||||
|
result.width * 4, result.width, result.height,
|
||||||
|
libyuv::kFilterBox) ||
|
||||||
|
libyuv::ARGBToRAW(scaled.data(), result.width * 4, rgb.data(),
|
||||||
|
result.width * 3, result.width, result.height))
|
||||||
|
throw std::runtime_error("IMAGE_CONVERSION_FAILED");
|
||||||
|
png_image image{};
|
||||||
|
image.version = PNG_IMAGE_VERSION;
|
||||||
|
image.width = result.width;
|
||||||
|
image.height = result.height;
|
||||||
|
image.format = PNG_FORMAT_RGB;
|
||||||
|
// libpng reports the required size on buffer exhaustion. Compress once
|
||||||
|
// into the bounded output, instead of compressing once to measure and
|
||||||
|
// again to write every successful screenshot.
|
||||||
|
const auto capacity =
|
||||||
|
std::min(png_alloc_size_t(max_bytes), PNG_IMAGE_PNG_SIZE_MAX(image));
|
||||||
|
result.png.resize(capacity);
|
||||||
|
png_alloc_size_t size = capacity;
|
||||||
|
const auto ok = png_image_write_to_memory(
|
||||||
|
&image, result.png.data(), &size, 0, rgb.data(), 0, nullptr);
|
||||||
|
png_image_free(&image);
|
||||||
|
if (ok) {
|
||||||
|
result.png.resize(size);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (size <= max_bytes)
|
||||||
|
throw std::runtime_error("IMAGE_ENCODING_FAILED");
|
||||||
|
if (edge == 32)
|
||||||
|
throw std::runtime_error("IMAGE_TOO_LARGE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string ImageBase64(const std::vector<uint8_t> &data) {
|
||||||
|
constexpr char alphabet[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
std::string out;
|
||||||
|
out.reserve((data.size() + 2) / 3 * 4);
|
||||||
|
for (size_t i = 0; i < data.size(); i += 3) {
|
||||||
|
const uint32_t n = (uint32_t(data[i]) << 16) |
|
||||||
|
(i + 1 < data.size() ? uint32_t(data[i + 1]) << 8 : 0) |
|
||||||
|
(i + 2 < data.size() ? data[i + 2] : 0);
|
||||||
|
out += alphabet[n >> 18];
|
||||||
|
out += alphabet[(n >> 12) & 63];
|
||||||
|
out += i + 1 < data.size() ? alphabet[(n >> 6) & 63] : '=';
|
||||||
|
out += i + 2 < data.size() ? alphabet[n & 63] : '=';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -145,6 +145,8 @@ struct HostInfo {
|
|||||||
bool supports_deskhub_resolution;
|
bool supports_deskhub_resolution;
|
||||||
bool supports_deskhub_fps15;
|
bool supports_deskhub_fps15;
|
||||||
bool supports_deskhub_bandwidth;
|
bool supports_deskhub_bandwidth;
|
||||||
|
// Optional; legacy peers keep the original single-file transfer UI.
|
||||||
|
bool supports_file_browser;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ServiceStatus {
|
struct ServiceStatus {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ void ResetHostInfo(HostInfo& info) {
|
|||||||
info.supports_deskhub_resolution = false;
|
info.supports_deskhub_resolution = false;
|
||||||
info.supports_deskhub_fps15 = false;
|
info.supports_deskhub_fps15 = false;
|
||||||
info.supports_deskhub_bandwidth = false;
|
info.supports_deskhub_bandwidth = false;
|
||||||
|
info.supports_file_browser = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void CopyHostName(HostInfo& info, const std::string& host_name) {
|
void CopyHostName(HostInfo& info, const std::string& host_name) {
|
||||||
@@ -251,7 +252,8 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
|
|||||||
{"supports_deskhub_preview", action.i.supports_deskhub_preview},
|
{"supports_deskhub_preview", action.i.supports_deskhub_preview},
|
||||||
{"supports_deskhub_resolution", action.i.supports_deskhub_resolution},
|
{"supports_deskhub_resolution", action.i.supports_deskhub_resolution},
|
||||||
{"supports_deskhub_fps15", action.i.supports_deskhub_fps15},
|
{"supports_deskhub_fps15", action.i.supports_deskhub_fps15},
|
||||||
{"supports_deskhub_bandwidth", action.i.supports_deskhub_bandwidth}};
|
{"supports_deskhub_bandwidth", action.i.supports_deskhub_bandwidth},
|
||||||
|
{"supports_file_browser", action.i.supports_file_browser}};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ControlType::invalid:
|
case ControlType::invalid:
|
||||||
@@ -476,6 +478,8 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
|||||||
host_info_object.value("supports_deskhub_fps15", false);
|
host_info_object.value("supports_deskhub_fps15", false);
|
||||||
output.i.supports_deskhub_bandwidth =
|
output.i.supports_deskhub_bandwidth =
|
||||||
host_info_object.value("supports_deskhub_bandwidth", false);
|
host_info_object.value("supports_deskhub_bandwidth", false);
|
||||||
|
output.i.supports_file_browser =
|
||||||
|
host_info_object.value("supports_file_browser", false);
|
||||||
const std::string host_name =
|
const std::string host_name =
|
||||||
host_info_object.at("host_name").get<std::string>();
|
host_info_object.at("host_name").get<std::string>();
|
||||||
CopyHostName(output.i, host_name);
|
CopyHostName(output.i, host_name);
|
||||||
|
|||||||
@@ -10571,6 +10571,78 @@
|
|||||||
"fr": "Impossible de copier les informations d’association. Réessayez.",
|
"fr": "Impossible de copier les informations d’association. Réessayez.",
|
||||||
"es": "No se pudo copiar la información de emparejamiento. Inténtelo de nuevo.",
|
"es": "No se pudo copiar la información de emparejamiento. Inténtelo de nuevo.",
|
||||||
"pt-BR": "Não foi possível copiar as informações de pareamento. Tente novamente."
|
"pt-BR": "Não foi possível copiar as informações de pareamento. Tente novamente."
|
||||||
|
},
|
||||||
|
"重试更新": {
|
||||||
|
"zh-Hans": "重试更新",
|
||||||
|
"en": "Retry update",
|
||||||
|
"ru": "Повторить",
|
||||||
|
"zh-Hant": "重試更新",
|
||||||
|
"ja": "再試行",
|
||||||
|
"ko": "다시 시도",
|
||||||
|
"de": "Erneut versuchen",
|
||||||
|
"fr": "Réessayer",
|
||||||
|
"es": "Reintentar",
|
||||||
|
"pt-BR": "Tentar novamente"
|
||||||
|
},
|
||||||
|
"更新未完成,请手动下载安装。": {
|
||||||
|
"zh-Hans": "更新未完成,请手动下载安装。",
|
||||||
|
"en": "Update did not complete. Download and install manually.",
|
||||||
|
"ru": "Обновление не завершено. Скачайте и установите вручную.",
|
||||||
|
"zh-Hant": "更新未完成,請手動下載安裝。",
|
||||||
|
"ja": "アップデートが完了しませんでした。手動でダウンロードしてインストールしてください。",
|
||||||
|
"ko": "업데이트가 완료되지 않았습니다. 수동으로 다운로드하여 설치하세요.",
|
||||||
|
"de": "Das Update wurde nicht abgeschlossen. Laden Sie es herunter und installieren Sie es manuell.",
|
||||||
|
"fr": "La mise à jour n'a pas été effectuée. Téléchargez-la et installez-la manuellement.",
|
||||||
|
"es": "La actualización no se completó. Descárguela e instálela manualmente.",
|
||||||
|
"pt-BR": "A atualização não foi concluída. Baixe e instale manualmente."
|
||||||
|
},
|
||||||
|
"发送到远端": {
|
||||||
|
"zh-Hans": "发送到远端",
|
||||||
|
"en": "Send to remote",
|
||||||
|
"ru": "Отправить на удалённый ПК",
|
||||||
|
"zh-Hant": "傳送到遠端",
|
||||||
|
"ja": "リモートへ送信",
|
||||||
|
"ko": "원격으로 보내기",
|
||||||
|
"de": "An Remote-PC senden",
|
||||||
|
"fr": "Envoyer au PC distant",
|
||||||
|
"es": "Enviar al equipo remoto",
|
||||||
|
"pt-BR": "Enviar ao computador remoto"
|
||||||
|
},
|
||||||
|
"发送到本机": {
|
||||||
|
"zh-Hans": "发送到本机",
|
||||||
|
"en": "Send to local",
|
||||||
|
"ru": "Отправить на этот ПК",
|
||||||
|
"zh-Hant": "傳送到本機",
|
||||||
|
"ja": "この端末へ送信",
|
||||||
|
"ko": "이 기기로 보내기",
|
||||||
|
"de": "An diesen PC senden",
|
||||||
|
"fr": "Envoyer à ce PC",
|
||||||
|
"es": "Enviar a este equipo",
|
||||||
|
"pt-BR": "Enviar a este computador"
|
||||||
|
},
|
||||||
|
"清除已完成": {
|
||||||
|
"zh-Hans": "清除已完成",
|
||||||
|
"en": "Clear completed",
|
||||||
|
"ru": "Убрать завершённые",
|
||||||
|
"zh-Hant": "清除已完成",
|
||||||
|
"ja": "完了済みをクリア",
|
||||||
|
"ko": "완료 항목 지우기",
|
||||||
|
"de": "Abgeschlossene entfernen",
|
||||||
|
"fr": "Effacer les tâches terminées",
|
||||||
|
"es": "Limpiar completadas",
|
||||||
|
"pt-BR": "Limpar concluídas"
|
||||||
|
},
|
||||||
|
"打开目录": {
|
||||||
|
"zh-Hans": "打开目录",
|
||||||
|
"en": "Open folder",
|
||||||
|
"ru": "Открыть папку",
|
||||||
|
"zh-Hant": "開啟目錄",
|
||||||
|
"ja": "フォルダーを開く",
|
||||||
|
"ko": "폴더 열기",
|
||||||
|
"de": "Ordner öffnen",
|
||||||
|
"fr": "Ouvrir le dossier",
|
||||||
|
"es": "Abrir carpeta",
|
||||||
|
"pt-BR": "Abrir pasta"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"desktop": {
|
"desktop": {
|
||||||
@@ -10784,10 +10856,12 @@
|
|||||||
"access_website": "访问官网: ",
|
"access_website": "访问官网: ",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"install_update": "安装更新",
|
"install_update": "安装更新",
|
||||||
|
"retry_update": "重试更新",
|
||||||
"manual_download": "手动下载",
|
"manual_download": "手动下载",
|
||||||
"install_update_confirm": "下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?",
|
"install_update_confirm": "下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?",
|
||||||
"update_preparing": "正在准备更新…",
|
"update_preparing": "正在准备更新…",
|
||||||
"update_install_failed": "更新未完成,可重试或手动安装。",
|
"update_install_failed": "更新未完成,可重试或手动安装。",
|
||||||
|
"update_install_failed_manual": "更新未完成,请手动下载安装。",
|
||||||
"connection_alias": "修改名称",
|
"connection_alias": "修改名称",
|
||||||
"delete_connection": "删除连接",
|
"delete_connection": "删除连接",
|
||||||
"connect_to_this_connection": "发起连接",
|
"connect_to_this_connection": "发起连接",
|
||||||
@@ -11574,7 +11648,13 @@
|
|||||||
"暂无可信设备",
|
"暂无可信设备",
|
||||||
"请核对控制端指纹。",
|
"请核对控制端指纹。",
|
||||||
"配对信息已复制。",
|
"配对信息已复制。",
|
||||||
"无法复制配对信息,请重试。"
|
"无法复制配对信息,请重试。",
|
||||||
|
"重试更新",
|
||||||
|
"更新未完成,请手动下载安装。",
|
||||||
|
"发送到远端",
|
||||||
|
"发送到本机",
|
||||||
|
"清除已完成",
|
||||||
|
"打开目录"
|
||||||
],
|
],
|
||||||
"android": [
|
"android": [
|
||||||
" · 已完成",
|
" · 已完成",
|
||||||
|
|||||||
Reference in New Issue
Block a user