[feat] integrate CrossDesk CLI with terminal rendering and npm packaging

This commit is contained in:
dijunkun
2026-10-30 10:42:23 +08:00
parent 2f57d0aba3
commit 699d867956
34 changed files with 3811 additions and 18 deletions
+33
View File
@@ -0,0 +1,33 @@
# CrossDesk CLI
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty 图形后端,以及 Pi 自定义 TUI 扩展。
分发目标为 macOS、Linux 和 Windows 的原生终端客户端,包含 x64 / arm64。npm 包发布后可统一使用:
```sh
npx crossdesk-cli@latest doctor --json
npx crossdesk-cli@latest view 123456789
```
**当前尚未发布到 npm,也尚未完成三端实机验收。** 已加入平台包生成、自动选包入口和六个平台的 CI 构建配置;运行要求、打包与本地安装见 [npm 分发](docs/npm-distribution.md)。
在 CrossDesk 仓库根目录执行以下命令构建 CLI:
```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
xmake run crossdesk-cli doctor --json
xmake run crossdesk-cli view 123456789 --quality high --zoom native
```
最后一个设备 ID 是格式示例,使用时替换为你有权访问的设备。密码通过隐藏提示输入。只读时点击目标或按 `1` 进入原始细节模式;`+/-` 或滚轮缩放,方向键或 `hjkl` 平移,`0` 回到完整桌面;Ctrl+] 退出,Ctrl+T 切换控制。ANSI 全屏缩略图仍受字符网格限制,默认请求 high 画质,阅读小字建议使用细节模式;支持 Kitty 协议的终端可使用 `--renderer kitty` 获得像素画面。
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
- [私有桥接协议](docs/bridge-protocol.md)
- [实际验证记录](docs/validation.md)
- [源码来源和依赖修补](docs/source-provenance.md)
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。
+35
View File
@@ -0,0 +1,35 @@
# Bridge v1(CrossDesk 私有协议,不是 MCP)
启动:`crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099] [--quality low|balanced|high|ultra]`。
通过参数数组启动,不经 shell。stdin/stdout 为 UTF-8 NDJSON,每行一个完整 JSON。stdout 使用保留的描述符,底层 stdout 重定向到 stderr。宿主应消费/隔离 stderr,不能展示为视频行或模型消息。bridge 不要求 TTY。
每条请求含整数 `id` 和字符串 `method`。响应含同一 `id`、布尔 `ok`,成功时还含 `version:1`、完整 `status`;失败含固定 `error`(无效请求、版本或操作不可用等),不回显原输入/凭据。无有效 id 的错误为 null。事件没有 id。先 hello,再发送其他操作。
| method | 字段 | 行为 |
|---|---|---|
| hello | version:1 | 协议版本握手 |
| viewport | columns:1..240, rows:1..100, colors:8/24/256, utf8:boolean, fps:1..30;可选 detail:quadrant/half, quality:low/balanced/high/ultra, zoom:1..32, native:boolean | 设置画面行数(不含状态栏)、本地目标与预览档位;默认 quadrant/high/1、native=false,保留未显式指定的 detail/quality/zoom/native;不探测宿主 TTY |
| connect | device:9位数字字符串, display:0..63(默认0) | 从专用 password fd 读取至换行/EOF(最多256字节、等待5秒),开始异步注册/连接;ok不是远端已连接 |
| status | 无 | 返回当前状态 |
| control | enabled:boolean | 显式启停控制;关闭时释放输入 |
| input | data:string,最多16KiB | 终端原始按键/UTF-8/bracketed paste/SGR鼠标;只读时不发送到远端,1/鼠标点击进入细节,+/-/滚轮缩放,方向键/hjkl 平移,0 全景;本地 Ctrl+] 退出,Ctrl+T 切换控制 |
| display | index:0..63 | 选择已广告的零起始显示器,提升 generation |
| ack | frame_id、generation:非负整数 | 确认宿主消费帧;匹配当前等待帧才开放下一帧 |
| disconnect | 无 | 释放输入、结束当前媒体会话 |
| exit | 无 | 断开并结束进程 |
凭据不能放在 connect JSON 中。专用描述符可以由 Node `spawn` 的第四条 pipe 提供。成功读取后会话不会保留用户密码供上层访问;MiniRTC 内部为现有 Join/重连流程持有必要凭据直到销毁 Peer。下一次 connect 必须有可读的凭据来源;Pi 关闭视图后直接结束进程。
帧事件:
```json
{"event":"frame","frame_id":42,"decoded_frame_id":38,"generation":4,"source_width":1280,"source_height":720,"zoom":2,"native":false,"source_rect":[320,180,640,360],"detail":"quadrant","columns":80,"rows":23,"lines":["..."],"queue_ms":3.2}
```
实际 lines 数为 rows,仅包含本地生成的 SGR 颜色序列、空格和 U+2580~U+259F 块字符,不含远端名称或任意终端指令。`frame_id` 是进程递增的输出序号,静止画面重绘仍产生新序号;`decoded_frame_id` 标识源解码帧,同源重绘可以相同。`source_width/height` 为实际解码尺寸,source_rect 为实际源裁剪矩形 [x,y,width,height]。native=true 表示原始细节模式,zoom 为当前模式下的倍率;generation 在关闭、重新连接和显示器切换时变化。viewport 变化可能与已写出的旧尺寸帧交错,宿主应丢弃不匹配尺寸/旧 generation 的帧并释放对应 ACK。宿主返回的 ACK 表示消费/布局,不表示屏幕物理显示完成。
状态事件每秒最多一次:`{"event":"state","status":{...},"output_bytes":123,"submitted_frames":3,"elapsed_seconds":1.5}`。status 含 registering/connecting/connected/failed/closed/idle、client_id(当前或刚结束会话的 `cli-xxxxxxxxx`,注册前为空)、error、control、generation、显示器、文本/预览能力、预览确认状态、请求的 preview_width/height/quality/bitrate_bps、帧计数与网络统计。接收/提交 FPS 由相应计数的增量除以时间增量计算。
边界:输入每条及当前解析缓冲上限64KiB、嵌套32层;响应队列64条;输出一条在途消息上限2MiB;1MiB/s;最多一条待 ACK 帧;一个最新待渲染 NV12 加一个最近画面的重绘缓存。协议错误明确响应;超限缓冲后结束。EOF 清理会话,完整的已收请求按序处理,不完整末尾报错;最多两秒排空输出后退出。宿主应持续消费响应,不能无限发送而不读取。失去连接后的释放只能尽力发送。
没有 `screenshot`、`audio`、文件、剪贴板、任意远端动作或 shell 命令方法;未知方法报错。模型接入者必须另行取得截图/自动操作授权,不能把持续帧事件附加到聊天记录。
+82
View File
@@ -0,0 +1,82 @@
# npm 分发与平台兼容性
## 使用方式
发布后的统一入口如下;本仓库当前尚未向 npm 发布,不能把这些命令当成已经上线的服务。
```sh
npx crossdesk-cli@latest doctor --json
npx crossdesk-cli@latest view 123456789
npm install -g crossdesk-cli
crossdesk-cli view 123456789
```
需要 Node.js 22 或以上和 npm。启动器根据 Node 的 `process.platform` / `process.arch` 选择同版本的平台包,直接启动原生 C++ 程序,继承终端、工作目录、环境变量和命令参数。安装过程不下载外部可执行文件、不执行 postinstall、不在用户机器编译。不要使用 `--omit=optional`,否则对应平台包不会安装。
## 平台与终端
| 平台 | 原生包 | 终端适配与运行边界 |
| --- | --- | --- |
| macOS Apple Silicon | `crossdesk-cli-darwin-arm64` | POSIX TTY;具体最低系统版本写入包内 `build-info.json` |
| macOS Intel | `crossdesk-cli-darwin-x64` | POSIX TTY;最低系统版本同上 |
| Linux x64 | `crossdesk-cli-linux-x64` | glibc、libstdc++、zlib;所需 GLIBC / GLIBCXX 版本写入 `build-info.json` |
| Linux arm64 | `crossdesk-cli-linux-arm64` | 同上 |
| Windows x64 | `crossdesk-cli-win32-x64` | Windows 10 或以上,原生 Console / ConPTY 与 VT 输出 |
| Windows arm64 | `crossdesk-cli-win32-arm64` | Windows 11 ARM,原生 Console / ConPTY |
这些是构建与分发目标,不代表均已实机通过。当前完整构建与 npm 运行验证只覆盖 macOS arm64;CLI 主程序与平台 I/O 已通过 Windows / Linux x64、arm64 的交叉编译检查。其他平台的完整 MiniRTC 链接、实际视频连接、键鼠、终端恢复仍需对应机器验收。
普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty 图形协议是可选增强。macOS Terminal、iTerm2、Linux 常用终端、Windows Terminal 和 VS Code 集成终端属于目标场景。SSH 应分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。不支持 `TERM=dumb` 或把交互观看输出重定向到文件;非交互宿主使用 `bridge --stdio`。Alpine / musl 当前没有预编译包。Windows 独立 mintty 窗口可能不提供 Console / ConPTY,应使用 Windows Terminal 承载 Git Bash。
CI 的 macOS 构建配置指定 `--target_minver=13.0`,但最终必须检查实际产物及依赖。当前开发机的本地二进制最低要求为 **macOS 27.0**,不应将这个本地产物宣称为旧 macOS 通用版。
## 生成包
先按 [构建说明](terminal-client.md) 构建 release,再执行:
```sh
npm --prefix apps/cli run pack:npm
```
脚本读取 `apps/cli/package.json` 与 `apps/cli/xmake.lua` 的版本,检查二者一致;确认源码中的 MiniRTC 日志脱敏补丁已应用,再检查当前平台二进制格式、CPU、动态链接依赖,并运行离线 `doctor --json`。macOS 只接受系统动态库,Linux 只接受列明的基础系统运行库,Windows 拒绝未随产品处理的 DLL 和动态 MSVC 运行库。Windows 在含 `dumpbin.exe` 的 Visual Studio 开发者环境中运行打包。脚本预先检查两个输出文件是否存在,并在临时目录中完成两个包后再写入输出目录;写入失败时清理本次已写入的包,已有同名产物不会被覆盖。
默认从 `build/<platform>/<arch>/release/` 读取原生程序,将两个 `.tgz` 写入 `build/npm/`。可指定路径:
```sh
npm --prefix apps/cli run pack:npm -- --binary /absolute/path/crossdesk-cli --out-dir /absolute/path/output
```
每台构建机生成当前平台的原生包与相同版本的通用启动包。`apps/cli/npm/platforms.json` 集中定义六个平台;启动包通过精确版本的 `optionalDependencies` 让 npm 仅安装匹配系统和 CPU 的原生包。原生包包含二进制、运行要求、SHA-256,以及当前控制端和已应用修补的 MiniRTC 源码快照。外部 Xmake 依赖的许可证、对应源码和重链接材料需要随正式版本一并提供,参见 [源码来源](source-provenance.md)。源码快照已含 MiniRTC,解包后无需运行 `prepare-deps.sh`;在 `source/` 目录按上述构建说明显式关闭桌面、开启 CLI,再构建 `crossdesk-cli`。源码包仅包含 CLI 及其依赖,不包含桌面或移动应用。
`apps/cli/package.json` 的 `private: true` 表示源码目录不是发布产物。只发布打包脚本生成的 tarball;生成的包不带 private 标记。
## 本地安装验证
以 macOS arm64 为例,用生成包的绝对路径,在工程外的临时目录执行:
```sh
npx --yes --offline \
--package /absolute/path/crossdesk-cli-darwin-arm64-0.1.0.tgz \
--package /absolute/path/crossdesk-cli-0.1.0.tgz \
-- crossdesk-cli doctor --json
```
也可先 `npm install --ignore-scripts` 安装这两个本地 tarball,再使用安装目录中的 `crossdesk-cli`。验证交互观看需要真实 TTY 与授权的被控端,doctor 不会建立远程连接。
Pi 等宿主应直接启动原生程序以可靠控制进程和专用密码描述符。全局安装后,用 `crossdesk-cli --print-binary-path` 获取路径,再将该路径设为 `CROSSDESK_CLI`;不要把整条 npx 命令放进此变量。
## CI 与发布
`.github/workflows/build-cli.yml` 配置六个平台的原生 release 构建、doctor 和打包,并上传 tarball;该工作流不执行 npm publish。工作流随 CLI 整合到 CrossDesk;本次本地整合未触发远程 CI。
正式发布时,先发布全部对应版本的原生包,再发布通用 `crossdesk-cli` 包。例如:
```sh
npm publish crossdesk-cli-darwin-arm64-0.1.0.tgz --access public
# 同样发布其余五个平台的原生包后,再发布入口包。
npm publish crossdesk-cli-0.1.0.tgz --access public
```
发布需要目标 npm 包的权限;当前名称只是配置值,查询时注册表返回未找到,不等于已经获得名称所有权。每次版本发布同步更新 `apps/cli/package.json` 和 `apps/cli/xmake.lua`。缺失平台包时启动器明确报错,不下载别的平台程序或回退为源码编译。
依据:[npm bin / optionalDependencies / os / cpu](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/)、[npx](https://docs.npmjs.com/cli/v11/commands/npx/)、[GitHub runner 平台](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)、[Windows Console 模式](https://learn.microsoft.com/en-us/windows/console/getconsolemode)。
+15
View File
@@ -0,0 +1,15 @@
# 源码来源
CLI 从独立开发目录整合到 CrossDesk,入口、打包脚本、npm 启动器和 Pi 扩展位于 `apps/cli`,会话和终端渲染实现分别位于 `libs/controller`、`libs/terminal`。
- CrossDesk 仓库:`https://github.com/kunkundi/crossdesk.git`。独立 CLI 的协议代码原取自 `becd1c0437f6c4fad757052c70db1eba71cf312f`;整合时目标仓库基于 `2f57d0ab`,两边 wire 源文件一致,直接复用现有 `libs/wire`。
- MiniRTC:`https://github.com/kunkundi/minirtc.git`,固定提交 `b9ee1fa7fe038fdf771f5dec1b1d561d78273f15`。该提交基于整合前的 `cdcd0d8fb0e9b688f3bf6416fe52b634cf20d9ca`,仅增加下述四处凭据日志脱敏修改。
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
`deps/patches/minirtc-credential-logs.patch` 是 CLI 所需的 MiniRTC 修补:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 验证固定版本并幂等应用补丁,CLI 构建检查补丁已存在。共享子模块中的脱敏同样适用于桌面程序;补丁没有改变鉴权、TLS、TURN 或媒体协议。
npm 原生包携带 CLI、共享库、构建配置和已修补 MiniRTC 的源码快照,不复制桌面、移动端、测试或本机构建缓存。源码包保留仓库目录结构,在 `source/` 中配置 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 后构建。
LICENSE 沿用 CrossDesk GPL-3.0;MiniRTC 子模块保留其 LGPL-3.0 和第三方声明。外部 Xmake 依赖的许可证、对应源码及重链接材料仍需随正式版本提供。
+127
View File
@@ -0,0 +1,127 @@
# 终端客户端
## 构建与安装
要求 Git、Xmake >= 3.1.1、C++17 编译器、系统 zlib 头文件/链接库,以及 MiniRTC 包构建使用的系统工具。macOS SDK 已提供 zlib;Linux 需相应开发包(如 zlib1g-dev);Windows 使用 Visual Studio C++ 构建工具,zlib 由 Xmake 获取并静态链接,运行库使用 MT。依赖需要可访问 Xmake 包仓库。Linux 使用 GCC/Clang;macOS 使用 Xcode 命令行工具。无需安装 Slint、SDL、X11、PulseAudio 或桌面采集 SDK。
```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
xmake run crossdesk-cli doctor --json
```
`prepare-deps.sh` 固定 MiniRTC commit 并应用凭据日志脱敏补丁,可重复执行。构建会检查补丁,缺失时失败。不要直接升级子模块。仓库默认开启桌面构建、关闭 CLI。上述参数只构建 CLI;需要同时构建桌面和 CLI 时,将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。恢复默认桌面配置使用 `xmake f -y --CROSSDESK_BUILD_DESKTOP=y --CROSSDESK_BUILD_CLI=n`。所有命令均从仓库根目录执行。
构建产物在 `build/<platform>/<arch>/<mode>/crossdesk-cli`。可把对应目录加入 PATH 或复制可执行文件到自己的 bin 目录。Pi 需要直接启动该可执行文件,不能使用会输出构建日志的 `xmake run` 作为桥接程序。
macOS 的 MiniRTC 依赖仍链接 VideoToolbox/CoreMedia 和 GLib 声明的 AppKit 等系统框架;CLI 不创建 GUI、采集器或音频设备。硬件解码参数显式关闭。Linux 无需 DISPLAY/WAYLAND_DISPLAY;本轮尚未在 Linux 主机实际构建验证。
Windows 产物为 `build/windows/<arch>/release/crossdesk-cli.exe`;先在 Git Bash 中执行 `sh apps/cli/scripts/prepare-deps.sh`,然后可在开发者 PowerShell 中执行上述 Xmake 命令。Windows 使用 Console / ConPTY 的原生键盘、鼠标事件和 VT 输出,保存并恢复控制台模式与代码页;无需 WSL。Windows Terminal、现代 PowerShell / CMD 和 VS Code 的 ConPTY 终端属于目标运行环境,仍需实机验收。Git Bash 的独立 mintty 窗口没有原生控制台时,应从 Windows Terminal 的 Git Bash 配置启动。
预编译 npm 包的用户无需构建工具,见 [npm 分发](npm-distribution.md)。
## 连接与身份
```sh
crossdesk-cli view 123456789
crossdesk-cli view 123456789 --renderer ansi --fps 8 --quality high --zoom native
crossdesk-cli view 123456789 --renderer kitty --quality ultra --zoom native --display 0
crossdesk-cli view 123456789 --control
crossdesk-cli view 123456789 --server your-server.example --port 9099
```
设备 ID 必须是 9 位数字,以上数字仅示例。默认信令地址沿用当前 CrossDesk:`api.crossdesk.cn:9099`。密码从本地终端隐藏输入(POSIX `/dev/tty`、Windows `CONIN$` / `CONOUT$`),不提供密码命令行参数。CLI 不读取、不修改桌面配置。每次连接直接用 `cli` 请求服务端分配临时 `cli-xxxxxxxxx` ID(后缀为 9 位数字),注册、Join 和媒体使用同一个 Peer。CLI 仅作为控制端,不申请普通数字身份或 `C-` 身份,也不保存本机密码或重连凭据。每次启动或重新连接都会申请新的 ID;断开后服务端按 Web 临时身份的规则清理注册记录。
需先部署支持 `cli` 注册的新版 crossdesk-server;旧服务端会登录失败,本客户端不会回退为普通设备身份。桥接 `status.client_id` 返回服务端分配的 ID。
流程为创建主控 Peer、注册流 → 以 `cli` 注册 → SignalConnected 返回 `cli-xxxxxxxxx` → Join → Connected → 现有 HostInformation 握手 → 显示器选择、能力协商 → 视频。Init 返回 0 不是远端连接成功。连接总超时默认 30 秒;信令断开后结束本次会话,用户重新连接,不自动循环重试密码。
保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。
## ANSI、Kitty 与 SSH
ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝 view,不输出全屏转义序列。
画面按字符单元比例等比缩放,留黑边及状态栏,默认假定字符宽高比 0.5;TTY 提供像素尺寸时使用其比例。每次只输出变化行,合并连续颜色设置;缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认预留两行及一列,避免自动换行。
ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `1` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。
auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没有有效 Kitty 回应就用 ANSI。显式 kitty 模式探测失败时报错。tmux/screen 中 auto 使用 ANSI,显式 kitty 报不支持;不修改用户终端配置。
Kitty 通过终端字节流发送 zlib 压缩的内联 RGB/Base64(每块至多 4096 字符),不使用远端临时图片文件。图像固定 ID=31、placement=1,更新同一图像,退出删除。依据终端报告的像素尺寸生成画面(未知时按 10×20 像素字符估算),上限 1920×1080。保留黑边和宽高比;原始细节模式按像素视口裁取源区域,避免整桌面缩小。没有可靠终端像素尺寸时使用估算值,因此不承诺显示设备的物理像素一一对应。压缩后超出单次输出预算时逐级降低尺寸。高熵视频压缩率较低,同一输出预算下实际帧率可能低于目标。RGB 压缩无损,不代表上游视频编码无损。
```sh
ssh -t your-server
crossdesk-cli view 123456789 --renderer ansi --fps 8
```
用户本地终端负责显示 SSH 字节流。ANSI、Kitty over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。
## 预览与背压
`--fps 1..30` 是本地输出目标。对端声明 PreviewSubscription 时,映射到合法 1/8/15 FPS(15 需要对应能力);普通编码设置仍使用合法 30 FPS。分辨率上限使用协商到的 v2,视频预算只在 bandwidth 能力存在时发送。`--quality low|balanced|high|ultra` 对应如下请求:
| 档位 | 分辨率上限 | 质量枚举 | 视频预算 |
|---|---|---|---|
| low | 640×360 | 0 | 1Mbps |
| balanced | 1280×720 | 1 | 2.5Mbps |
| high(默认) | 1920×1080 | 2 | 6Mbps |
| ultra | 3840×2160 | 2 | 12Mbps |
这些是请求上限,实际视频尺寸与码率由被控端决定,ultra 不会把低分辨率源变成真实 4K。低带宽链路可显式选择 balanced/low。窗口缩小不会再把远端预览降到字符网格尺寸;选定清晰度用于后续局部放大。旧端继续基础观看并本地限帧缩放,不发送未声明的扩展。预览 request_id/ack/accepted 可从桥接状态检查;未确认或被拒绝不冒充成功,也不阻止基础视频。
媒体回调仅同步复制借用的 NV12 数据,校验尺寸、容量、native 描述符与 stride;不保留借用指针。上限单帧 16,777,216 像素、每边 8192、偶数尺寸。最多一个待转换帧,替换前先完成校验与复制,无效帧不会挤掉已有画面;播放器另保留最近转换的一帧用于静止桌面的本地放大/重绘,并在接收新帧时回收上一帧缓冲区供后续复制使用。只丢弃解码后图像。显示器切换/关闭提升 generation,旧帧失效。
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),以 1MiB/s 令牌预算分块非阻塞写入;不能开始新画面时保留最新解码帧。ANSI 差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;view 结束日志另给平均提交 FPS。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。
## 输入与退出
默认只读、静音。`--control` 或 Ctrl+T 明确启用输入;Ctrl+T 再次关闭并释放已记录按键/鼠标。Ctrl+] 始终退出;控制模式下 q 是远端文本。Ctrl+T 和 Ctrl+] 是保留本地组合,目前不可重绑定。
只读时,`1` 或在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍),滚轮以鼠标指向为中心调整倍率。方向键或 `h/j/k/l` 向左/下/上/右平移,`0` 恢复完整画面。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;调整视图时先 Ctrl+T 切回只读。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。
支持 SGR 鼠标移动、左右中键、拖动和滚轮;黑边不发送新点击,黑边处松开仍释放旧按钮。输入坐标映射到所选显示器,`--display` 是 HostInformation 列表的零起始索引,视频流为 Display1、Display2……
支持 UTF-8 文本与 bracketed paste(远端须声明 text_input),Enter、Tab、Backspace、Esc、方向、Home/End、Delete、PageUp/Down、F1–F4、Ctrl/Alt+英文字母。不支持的远端文本能力会提示,不把粘贴执行为本地 shell 命令。普通终端采用立即按下/释放的 tap 策略;不承诺持续按键、所有组合键、IME 或完整键盘布局。Pi 使用宿主 SDK 解码增强按键后转为这组基础输入。
失焦报告、关闭控制、切换显示器、退出和断开都尝试释放已记录输入;网络已中断时不能保证远端收到释放。正常退出在普通执行路径恢复终端模式、鼠标报告、光标、自动换行和备用屏幕。POSIX 同样处理 SIGINT/SIGTERM/SIGHUP;Windows 处理控制台 Ctrl+C / Ctrl+Break,恢复控制台模式与代码页。中止时最多等待两秒排空当前画面,随后发送取消/终止转义序列并恢复;输出连接彻底损坏或 SIGKILL 时无法保证恢复,可在 shell 执行 `reset`。
只读是本客户端行为限制,不是服务端强制的独立只读授权。没有文件/剪贴板/远端命令接收处理器。
## Pi 扩展
依据已下载的 `@earendil-works/pi-coding-agent` 与 `pi-tui` **1.4.0** SDK 实现和类型检查。旧的 `@mariozechner` 包未验证,缺少 ctx.mode 的宿主会降级。
```sh
export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli"
pi -e ./apps/cli/plugins/pi/index.ts
```
在 Pi 输入 `/crossdesk` 或 `/crossdesk 123456789`。先输入 ID,再在自定义遮蔽组件输入密码。扩展使用独立 fd 3 写密码,随后关闭;不写配置、聊天、工具结果或模型上下文。键盘 Ctrl+] 关闭,Ctrl+T 开关控制。插件默认请求 high 预览并显示 ANSI 象限字符行,支持同样的只读细节/放大/平移键,颜色深度依据终端环境回退,由 Pi 自身布局和刷新;不直接占用 TTY,不发送 Kitty 图形或自行打开鼠标报告。
需要全局发现时,把整个 `apps/cli/plugins/pi` 目录复制到 `~/.pi/agent/extensions/crossdesk/` 后重启 Pi 或执行 `/reload`。卸载时先关闭视图,删除该目录并 `/reload`;使用 `-e` 时从启动参数移除即可。无需修改包中的密码或设备配置。默认连接官方信令;自建信令当前通过独立 view 命令使用,插件尚无服务器设置界面。
仅在 `ctx.mode === "tui"`、hasUI 且进程双向 TTY 时打开组件。RPC/print/json 返回独立播放器提示,不声称内嵌视频。UI 关闭/会话退出先关闭 bridge stdin,超时 SIGTERM,再超时 SIGKILL。桥接异常会显示在仍可 Ctrl+] 退出的组件内。SDK 类型和模拟 UI 生命周期已验证,真实 Pi TUI 视觉刷新、增强按键与鼠标传递未实机验收。真实设备的 bridge 与独立 view 验证见 [验证记录](validation.md)。
官方依据:[扩展文档](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)、[TUI 文档](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/tui.md)、[Kitty 图形协议](https://sw.kovidgoyal.net/kitty/graphics-protocol/)。
## 故障排查与边界
- `incorrect_password`:重新启动并输入正确密码,不自动重复尝试。
- `device_not_found` / `device_offline_or_unavailable`:核对 ID、服务端和被控端在线状态。
- `signaling_login_failed` / `signaling_disconnected`:检查服务器、网络、代理、防火墙;确认服务端已支持 `cli` 注册。
- `cli_identity_not_supported`:服务端未返回有效的 `cli-` 加 9 位数字的身份,需升级或检查服务端。
- `tls_certificate_error`:检查证书链、主机名、机器时钟和系统 CA,不关闭验证。
- `connection_timeout` / `connection_failed`:检查 NAT/TURN 与被控端。TURN 凭据只取服务端下发值。
- `display_not_available`:换成远端声明的索引;单会话一次只显示一个屏幕。
- `media_initialization_failed`:检查编译依赖和本地媒体运行环境。
- 只读下按键无动作是预期;远端未声明文本协议时文本输入不可用。
- 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。view 的摘要在 client.log;底层诊断默认关闭且敏感 Join 日志已修补。目录不保存密码;使用完可删除。错误退出信息在恢复终端后显示。
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty 或证明远端可连接。
第一轮无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker、IP 直连、MCP 或自动截图。其他宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端或给出播放器命令。
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
+40
View File
@@ -0,0 +1,40 @@
# CLI 验证记录
## 本地仓库整合(2026-10-30)
环境为 macOS arm64、Xcode SDK 27.0、Xmake 3.1.1、C++17,关闭 CUDA。CLI 源码整合到 CrossDesk 的 `apps/cli`,共享现有 wire 与 MiniRTC;未新增或修改工程内测试。
- CLI 单独配置和 release 构建通过,目标为 `crossdesk-cli`;`doctor --json` 返回有效 JSON,动态链接依赖仅为系统库及框架,无 Slint / SDL。
- 同时启用桌面与 CLI 时,debug 配置下 `crossdesk`、`crossdesk-cli` 均编译通过。
- 原有仓库结构、wire 和键盘状态测试通过,未修改测试源码。
- 工程外桥接检查通过:JSON 协议、版本校验、分片、EOF、消息上限、慢消费者、本地信令失败、凭据描述符和非 TTY 拒绝。
- 工程外 POSIX PTY 检查通过:隐藏密码、无 DISPLAY/WAYLAND_DISPLAY、原始模式恢复、Kitty 探测超时和 tmux 下明确拒绝 Kitty。
- macOS arm64 原生 npm 包和通用启动包生成成功;离线安装、启动器 `doctor` 和原生程序路径解析通过。该本地产物最低要求为 macOS 27.0。
- 从原生包解出源码后,在没有 Git 元数据、没有桌面应用源码的目录中重新配置、构建 CLI 并运行 `doctor` 成功。
- 验证后恢复整合前的桌面 debug 构建配置,CLI 默认关闭;新命令行二进制保留在 `build/macosx/arm64/release/crossdesk-cli`。
## 优化回归(2026-10-30)
- Release 构建、实际二进制 bridge/PTY 回归通过。ASan/UBSan 检查覆盖原有会话和渲染行为,以及无效帧保留、NV12 缓冲复用、预览参数去重、ACK 状态、重复断开和重连网络统计复位。
- 生产 bridge 配合工程外假传输验证静止画面重绘、细节模式、质量切换、窗口变化、无效配置原子性和帧 ACK 背压,通过。
- ANSI 渲染前后对比覆盖 8/24/256 色、UTF-8/ASCII、half/quadrant、全景/细节模式,输出校验值一致。在本机 240×100 网格连续执行 200 次差分生成,静止网格约 447.4 ms → 3.6 ms,单行变化约 422.8 ms → 9.5 ms。该结果只衡量差分文本生成,不包含视频解码、图像缩放和终端显示时间。
- npm 真实打包、离线安装及源码包重建通过;工程外故障注入确认已有产物保留、第二个包生成失败时无半套输出、缺失脱敏补丁时拒绝打包。
新增回归代码、基准、假传输和验证产物均位于工程外临时目录,未向工程或子模块添加测试。此次完整构建与运行验证覆盖 macOS arm64,未重新进行 Linux/Windows 实机验证。
## 独立开发阶段的验证范围
以下为迁入前的验证记录摘要,不表示本次重新连接了远程设备:
- macOS arm64 的 debug/release 构建、CLI bridge 的 NDJSON、POSIX PTY 隐藏密码与终端恢复均已验证。
- 工程外的 ASan/UBSan 检查覆盖会话状态、只读限制、输入释放、帧内存所有权、渲染、缩放和平移、输出背压和帧 ACK。
- Pi 1.4.0 SDK 类型检查与模拟组件生命周期已验证;真实 Pi TUI 视觉效果未验收。
- 早期身份协议版本曾连接真实设备进行只读 ANSI 观看和 Kitty 协议数据验证;Kitty 数据解压检查不等于 Kitty 应用或 SSH 实机显示验收。
- 改为 `cli-` 临时身份后,正式 CLI 与本地 WSS 服务端验证了临时注册、Join、身份不可复用及退出清理;线上服务尚未在该轮部署,早期画面验证不能代替新身份协议的线上联调。
- npm 的 macOS arm64 本地生成及安装已验证;Linux / Windows 当时仅完成 CLI 主程序和平台 I/O 的交叉编译检查,没有完整 MiniRTC 链接和实机连接结果。
## 实测边界
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。六个平台 CI 配置与 npm 包名均不代表已发布或已验收。
CLI 需要支持 `cli` 临时注册的服务端。`doctor --json` 仅报告本机环境,不会联网登录或证明远端可连接。
+730
View File
@@ -0,0 +1,730 @@
#include "terminal_io.h"
#include <algorithm>
#include <cerrno>
#include <clocale>
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <terminal_renderer.h>
using namespace crossdesk;
using namespace crossdesk::terminal;
using namespace crossdesk::cli;
using nlohmann::json;
using Clock = std::chrono::steady_clock;
namespace {
std::string Env(const char *name) {
const auto *p = std::getenv(name);
return p ? p : "";
}
struct Descriptor {
int value;
explicit Descriptor(int v) : value(v) {}
~Descriptor() {
if (value >= 0)
Close(value);
}
};
class Output {
public:
explicit Output(int fd)
: fd_(fd), nonblocking_(fd, true), last_(Clock::now()) {}
bool Idle() const { return pending_.empty(); }
void Put(std::string text) {
if (!Idle() || text.size() > 2 * 1024 * 1024)
throw std::runtime_error("output budget exceeded");
pending_ = std::move(text);
offset_ = 0;
}
bool Pump() {
auto now = Clock::now();
credit_ = std::min(
1048576.0,
credit_ + std::chrono::duration<double>(now - last_).count() * 1048576);
last_ = now;
if (Idle())
return false;
auto size =
std::min({pending_.size() - offset_, size_t(16384), size_t(credit_)});
if (!size)
return false;
auto n = Write(fd_, pending_.data() + offset_, size);
if (n < 0 && errno != EAGAIN && errno != EINTR)
throw std::runtime_error("output closed");
if (n > 0) {
offset_ += n;
credit_ -= n;
bytes += n;
}
if (offset_ == pending_.size()) {
pending_.clear();
return true;
}
return false;
}
void Wait(int input_fd, int timeout_ms = 5) const {
// Wake for writable output instead of adding 5ms after each short PTY
// write. When rate-limited, wait for input/time to replenish the budget.
WaitIo(input_fd, !Idle() && credit_ >= 1 ? fd_ : -1, timeout_ms);
}
uint64_t bytes = 0;
private:
int fd_;
NonBlocking nonblocking_;
std::string pending_;
size_t offset_ = 0;
double credit_ = 1048576;
Clock::time_point last_;
};
struct Tty {
int out;
RawTerminal terminal;
Tty(int input, int output) : out(output), terminal(input, output) {
const char start[] = "\033[?1049h\033[?25l\033[?7l\033[?1003h\033[?"
"1006h\033[?1004h\033[?2004h\033[2J";
Write(out, start, sizeof(start) - 1);
}
~Tty() {
// Cancel any incomplete string/control sequence before restoration.
std::string end = "\030\033\\" + std::string(kKittyDelete) +
"\033[0m\033[?1003l\033[?1006l\033[?1004l\033[?2004l\033["
"?7h\033[?25h\033[?1049l";
Write(out, end.data(), end.size());
}
};
Viewport Size() {
auto ws = GetTerminalSize(0);
Viewport v;
v.columns = std::clamp(int(ws.columns) - 1, 1, 240);
v.rows = std::clamp(int(ws.rows) - 2, 1, 100);
if (ws.columns && ws.rows) {
v.pixel_width = int(ws.pixel_width) * v.columns / ws.columns;
v.pixel_height = int(ws.pixel_height) * v.rows / ws.rows;
}
if (ws.pixel_width && ws.pixel_height && ws.columns && ws.rows)
v.cell_aspect = std::clamp(double(ws.pixel_width) * ws.rows /
(double(ws.pixel_height) * ws.columns),
.2, 2.0);
auto locale = Env("LC_ALL");
if (locale.empty())
locale = Env("LC_CTYPE");
if (locale.empty())
locale = Env("LANG");
v.utf8 = locale.find("UTF-8") != std::string::npos ||
locale.find("utf8") != std::string::npos ||
locale.find("utf-8") != std::string::npos;
auto color = Env("COLORTERM");
v.colors = color == "truecolor" || color == "24bit" ? 24
: Env("TERM").find("256color") != std::string::npos ? 256
: 8;
#ifdef _WIN32
v.utf8 = true;
v.colors = 24;
#endif
return v;
}
std::string SecretFd(int fd) {
if (fd < 3 || fd > 1024)
throw std::runtime_error("password_fd must be a dedicated descriptor >= 3");
std::string secret;
auto deadline = Clock::now() + std::chrono::seconds(5);
while (Clock::now() < deadline && !interrupted) {
if (!WaitReadable(fd, 20))
continue;
char c;
auto n = Read(fd, &c, 1);
if (n == 0)
return secret;
if (n < 0) {
if (errno == EINTR)
continue;
throw std::runtime_error("credential read failed");
}
if (c == '\n')
return secret;
if (c == '\0' || secret.size() >= 256)
throw std::runtime_error("invalid credentials");
secret += c;
}
throw std::runtime_error("credential channel timeout");
}
int Integer(const json &j, const char *key, int lo, int hi, int fallback) {
if (!j.contains(key))
return fallback;
if (!j[key].is_number_integer())
throw std::invalid_argument(std::string(key) + " must be an integer");
auto n = j[key].get<int64_t>();
if (n < lo || n > hi)
throw std::invalid_argument(std::string(key) + " out of range");
return int(n);
}
bool Boolean(const json &j, const char *key, bool fallback) {
if (!j.contains(key))
return fallback;
if (!j[key].is_boolean())
throw std::invalid_argument(std::string(key) + " must be boolean");
return j[key];
}
void Quality(SessionOptions &options, const std::string &name) {
if (name == "low") {
options.quality = 0;
options.preview_width = 640;
options.preview_height = 360;
options.max_bitrate_bps = 1000000;
} else if (name == "balanced") {
options.quality = 1;
options.preview_width = 1280;
options.preview_height = 720;
options.max_bitrate_bps = 2500000;
} else if (name == "high") {
options.quality = 2;
options.preview_width = 1920;
options.preview_height = 1080;
options.max_bitrate_bps = 6000000;
} else if (name == "ultra") {
options.quality = 2;
options.preview_width = 3840;
options.preview_height = 2160;
options.max_bitrate_bps = 12000000;
} else
throw std::invalid_argument("quality must be low, balanced, high or ultra");
}
AnsiDetail Detail(const std::string &name) {
if (name == "half")
return AnsiDetail::Half;
if (name == "quadrant")
return AnsiDetail::Quadrant;
throw std::invalid_argument("ansi-detail must be half or quadrant");
}
int View(SessionOptions options, const std::string &device,
const std::string &renderer, bool control, int fd, AnsiDetail detail,
double zoom, bool native) {
if (!IsTty(0) || !IsTty(fd) || Env("TERM") == "dumb")
throw std::runtime_error("view needs an interactive ANSI terminal; use "
"bridge --stdio for a host");
auto password = Password();
if (interrupted)
return 130;
Tty tty(0, fd);
Output output(fd);
bool kitty = false;
if (renderer != "ansi" && Env("TMUX").empty() && Env("STY").empty()) {
output.Put(std::string(kKittyQuery) + "\033[c");
std::string reply;
auto until = Clock::now() + std::chrono::milliseconds(250);
while (Clock::now() < until && !interrupted) {
output.Pump();
WaitReadable(0, 10);
char b[1024];
auto n = Read(0, b, sizeof(b));
if (n > 0 && reply.size() < 8192)
reply.append(b, n);
}
kitty = KittyReply(reply);
}
if (renderer == "kitty" && !kitty)
throw std::runtime_error(
"Kitty probe failed or multiplexer active; use --renderer ansi");
ControllerSession s;
Input input;
input.control = control;
input.zoom = zoom;
input.native = native;
s.Connect(options, device, std::move(password));
s.SetControl(control);
std::optional<Grid> displayed, pending;
bool pending_frame = false;
Viewport last_view = Size();
std::optional<DecodedFrame> last_frame;
uint64_t view_revision = input.view_revision;
bool redraw = true;
uint64_t submitted = 0;
const auto started = Clock::now();
double queue_ms = 0;
auto next = Clock::now(), status_tick = next;
while (!interrupted && !input.exit) {
s.Tick();
char bytes[8192];
auto n = Read(0, bytes, sizeof(bytes));
input.Feed(n > 0 ? std::string(bytes, n) : "", s,
displayed ? &*displayed : nullptr);
if (output.Pump() && pending_frame) {
displayed = std::move(pending);
pending.reset();
pending_frame = false;
++submitted;
}
if (s.Finished())
break;
auto now = Clock::now();
auto v = Size();
v.detail = detail;
v.native = input.native;
v.pixel_mode = kitty;
v.zoom = input.zoom;
v.center_x = input.center_x;
v.center_y = input.center_y;
if (input.view_revision != view_revision) {
view_revision = input.view_revision;
redraw = true;
}
if (output.Idle() &&
(v.columns != last_view.columns || v.rows != last_view.rows ||
v.pixel_width != last_view.pixel_width ||
v.pixel_height != last_view.pixel_height)) {
output.Put("\033[2J");
displayed.reset();
last_view = v;
redraw = true;
}
if (output.Idle() && now >= status_tick) {
auto st = s.Status();
const auto last_state = st["state"].get<std::string>();
auto line = (input.error.empty() ? last_state : input.error) + " " +
(input.control ? "CONTROL" : "READ ONLY") + " " +
(kitty ? "KITTY"
: detail == AnsiDetail::Quadrant ? "QUAD"
: "HALF") +
(input.native ? " native x" : " fit x") +
std::to_string(int(input.zoom)) +
(last_frame ? " " + std::to_string(last_frame->width) + "x" +
std::to_string(last_frame->height)
: "") +
" | Ctrl+] exit Ctrl+T control";
output.Put("\033[" + std::to_string(v.rows + 1) + ";1H\033[0m\033[K" +
line.substr(0, v.columns) + "\033[" +
std::to_string(v.rows + 2) + ";1H\033[K" +
std::string("Read-only: click/1 detail, wheel/+/- zoom, "
"arrows pan, 0 fit | out=" +
std::to_string(submitted) +
" bytes=" + std::to_string(output.bytes))
.substr(0, v.columns));
status_tick = now + std::chrono::seconds(1);
}
if (output.Idle() && now >= next) {
next = now + std::chrono::milliseconds(1000 / options.fps);
bool fresh = false;
if (auto frame = s.TakeFrame(last_frame ? &*last_frame : nullptr);
frame && frame->generation == s.Generation()) {
last_frame = std::move(frame);
fresh = true;
}
if (last_frame && last_frame->generation != s.Generation())
last_frame.reset();
if (last_frame && (fresh || redraw)) {
if (fresh)
queue_ms = std::chrono::duration<double, std::milli>(
now - last_frame->queued)
.count();
pending = kitty ? Layout(*last_frame, v) : Convert(*last_frame, v);
output.Put(kitty ? Kitty(*last_frame, v)
: Difference(*pending, displayed));
redraw = false;
pending_frame = true;
if (output.Idle()) {
displayed = std::move(pending);
pending.reset();
pending_frame = false;
}
}
}
output.Wait(0);
}
auto final = s.Status();
s.Disconnect();
auto until = Clock::now() + std::chrono::seconds(2);
while (!output.Idle() && Clock::now() < until) {
output.Pump();
output.Wait(-1);
}
// These diagnostics go to the private file, never through the active screen.
std::cerr
<< json{{"session", final},
{"submitted", submitted},
{"output_bytes", output.bytes},
{"last_queue_ms", queue_ms},
{"elapsed_seconds",
std::chrono::duration<double>(Clock::now() - started).count()},
{"submitted_fps",
submitted / std::max(.001, std::chrono::duration<double>(
Clock::now() - started)
.count())}}
.dump()
<< "\n";
if (final["state"] == "failed")
throw std::runtime_error(final["error"].get<std::string>());
return interrupted ? 130 : 0;
}
int Bridge(int fd, SessionOptions options, int password_fd) {
Output output(fd);
ControllerSession s;
Input input;
Viewport viewport;
viewport.detail = AnsiDetail::Quadrant;
NonBlocking input_mode(0);
std::deque<json> replies;
std::string buffer;
bool hello = false, quit = false, eof = false, writing_frame = false;
uint64_t submitted_frames = 0;
const auto started = Clock::now();
uint64_t awaiting = 0;
std::optional<Grid> current, pending_grid;
std::optional<DecodedFrame> last_frame;
uint64_t frame_sequence = 0, view_revision = 0;
bool redraw = true;
auto next = Clock::now(), status_tick = next;
auto respond = [&](json j) {
if (replies.size() >= 64)
throw std::runtime_error("bridge response queue exceeded");
replies.push_back(std::move(j));
};
auto quit_deadline = Clock::time_point::max();
while (!interrupted) {
s.Tick();
input.Feed("", s, current ? &*current : nullptr);
if (output.Pump() && writing_frame) {
++submitted_frames;
writing_frame = false;
}
if (!quit) {
char bytes[8192];
auto n = Read(0, bytes, sizeof(bytes));
if (n > 0) {
buffer.append(bytes, n);
if (buffer.size() > 65536) {
respond(
{{"id", nullptr}, {"ok", false}, {"error", "message_too_large"}});
quit = true;
}
} else if (n == 0) {
eof = true;
} else if (errno != EAGAIN && errno != EINTR) {
quit = true;
}
for (int count = 0; !quit && count < 16; ++count) {
auto end = buffer.find('\n');
if (end == std::string::npos)
break;
auto raw = buffer.substr(0, end);
buffer.erase(0, end + 1);
auto j = ParseBoundedJson(raw);
json id = nullptr;
try {
if (!j.is_object())
throw std::invalid_argument("invalid_json");
if (!j.contains("id") || !j["id"].is_number_integer())
throw std::invalid_argument("integer_request_id_required");
id = j["id"];
std::string method = j.at("method").get<std::string>();
if (method == "hello") {
if (Integer(j, "version", 1, 1, 0) != 1)
throw std::invalid_argument("unsupported_version");
hello = true;
} else if (!hello)
throw std::invalid_argument("hello_required");
else if (method == "viewport") {
auto next_view = viewport;
auto next_options = options;
next_view.columns = Integer(j, "columns", 1, 240, 80);
next_view.rows = Integer(j, "rows", 1, 100, 23);
next_view.colors = Integer(j, "colors", 8, 256, 24);
if (next_view.colors != 8 && next_view.colors != 24 &&
next_view.colors != 256)
throw std::invalid_argument("unsupported_colors");
next_view.utf8 = Boolean(j, "utf8", true);
if (j.contains("detail"))
next_view.detail = Detail(j.at("detail").get<std::string>());
next_options.fps = Integer(j, "fps", 1, 30, 8);
if (j.contains("quality"))
Quality(next_options, j.at("quality").get<std::string>());
int next_zoom = Integer(j, "zoom", 1, 32, int(input.zoom));
bool next_native = Boolean(j, "native", input.native);
s.SetPreview(next_options.fps, next_options.preview_width,
next_options.preview_height, next_options.quality,
next_options.max_bitrate_bps);
viewport = next_view;
options = next_options;
input.zoom = next_zoom;
input.native = next_native;
redraw = true;
current.reset();
awaiting = 0;
} else if (method == "connect") {
if (s.Connected() ||
(!s.Finished() && s.Status()["state"] != "idle"))
throw std::invalid_argument("session_active");
options.display = Integer(j, "display", 0, 63, 0);
auto password = SecretFd(password_fd);
s.Connect(options, j.at("device").get<std::string>(),
std::move(password));
input.control = false;
input.center_x = input.center_y = .5;
last_frame.reset();
redraw = true;
current.reset();
awaiting = 0;
} else if (method == "control") {
input.control = Boolean(j, "enabled", false);
s.SetControl(input.control);
} else if (method == "input") {
auto text = j.at("data").get<std::string>();
if (text.size() > 16384)
throw std::invalid_argument("input_too_large");
input.Feed(text, s, current ? &*current : nullptr);
if (input.exit)
quit = true;
if (!input.error.empty())
throw std::invalid_argument(input.error);
} else if (method == "ack") {
if (!j.at("frame_id").is_number_unsigned() ||
!j.at("generation").is_number_unsigned())
throw std::invalid_argument("invalid_frame_ack");
if (j["generation"] == s.Generation() &&
j["frame_id"] == awaiting) {
current = std::move(pending_grid);
pending_grid.reset();
awaiting = 0;
}
} else if (method == "display") {
if (!s.SelectDisplay(Integer(j, "index", 0, 63, 0)))
throw std::invalid_argument("display_unavailable");
current.reset();
awaiting = 0;
} else if (method == "disconnect") {
s.Disconnect();
last_frame.reset();
current.reset();
awaiting = 0;
} else if (method == "exit") {
quit = true;
} else if (method != "status")
throw std::invalid_argument("unsupported_method");
respond({{"id", id},
{"ok", true},
{"version", 1},
{"status", s.Status()}});
} catch (const std::exception &) {
respond({{"id", id},
{"ok", false},
{"error", "invalid_request_or_unavailable_operation"}});
}
}
}
if (eof && buffer.find('\n') == std::string::npos) {
if (!buffer.empty()) {
respond(
{{"id", nullptr}, {"ok", false}, {"error", "incomplete_message"}});
buffer.clear();
}
quit = true;
}
auto now = Clock::now();
if (quit && quit_deadline == Clock::time_point::max()) {
s.Disconnect();
quit_deadline = now + std::chrono::seconds(2);
}
if (output.Idle()) {
if (!replies.empty()) {
output.Put(replies.front().dump() + "\n");
replies.pop_front();
} else if (quit)
break;
else if (hello && now >= status_tick) {
output.Put(json{{"event", "state"},
{"status", s.Status()},
{"output_bytes", output.bytes},
{"submitted_frames", submitted_frames},
{"elapsed_seconds",
std::chrono::duration<double>(now - started).count()}}
.dump() +
"\n");
status_tick = now + std::chrono::seconds(1);
} else if (hello && !awaiting && now >= next) {
next = now + std::chrono::milliseconds(1000 / options.fps);
bool fresh = false;
if (auto f = s.TakeFrame(last_frame ? &*last_frame : nullptr);
f && f->generation == s.Generation()) {
last_frame = std::move(f);
fresh = true;
}
if (last_frame && last_frame->generation != s.Generation())
last_frame.reset();
if (input.view_revision != view_revision) {
view_revision = input.view_revision;
redraw = true;
}
if (last_frame && (fresh || redraw)) {
viewport.zoom = input.zoom;
viewport.native = input.native;
viewport.center_x = input.center_x;
viewport.center_y = input.center_y;
pending_grid = Convert(*last_frame, viewport);
redraw = false;
writing_frame = true;
awaiting = ++frame_sequence;
output.Put(
json{{"event", "frame"},
{"frame_id", awaiting},
{"decoded_frame_id", last_frame->frame_id},
{"generation", last_frame->generation},
{"columns", viewport.columns},
{"rows", viewport.rows},
{"source_width", last_frame->width},
{"source_height", last_frame->height},
{"zoom", viewport.zoom},
{"native", viewport.native},
{"source_rect",
{pending_grid->source.x, pending_grid->source.y,
pending_grid->source.width, pending_grid->source.height}},
{"detail", viewport.detail == AnsiDetail::Quadrant
? "quadrant"
: "half"},
{"lines", Lines(*pending_grid)},
{"queue_ms", fresh
? std::chrono::duration<double, std::milli>(
now - last_frame->queued)
.count()
: 0}}
.dump() +
"\n");
}
}
}
if (now >= quit_deadline)
break;
output.Wait(eof ? -1 : 0);
}
s.Disconnect();
return interrupted ? 130 : 0;
}
const char *help = R"(crossdesk-cli doctor [--json]
crossdesk-cli view <9-digit-device-id> [--renderer auto|ansi|kitty] [--fps 1..30]
[--display 0..63] [--control] [--server host] [--port 9099]
[--quality low|balanced|high|ultra] [--ansi-detail quadrant|half] [--zoom 1..32|native]
crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099]
view: read-only and silent by default; hidden password prompt on the local terminal.
Ctrl+] closes; Ctrl+T toggles control. Read-only: click/1 native detail, wheel/+/- zoom, arrows/hjkl pan, 0 fit.
Default ANSI detail: quadrant. Default quality: high (1920x1080); ultra requests up to 3840x2160.
Display index is zero-based. ANSI cannot reproduce full desktop text at fit scale.
--fps controls local output; negotiated previews use supported 1/8/15 FPS.
bridge: private NDJSON v1, credentials on a separate inherited descriptor.
No IP direct connection, audio playback, clipboard or file transfer support.
)";
} // namespace
int main(int argc, char **argv) {
try {
std::setlocale(LC_ALL, "");
InitializeIo();
if (argc < 2 || std::string(argv[1]) == "--help") {
std::cout << help;
return 0;
}
std::string command = argv[1];
if (command == "doctor") {
if (argc > 3 || (argc == 3 && std::string(argv[2]) != "--json"))
throw std::invalid_argument("doctor accepts only --json");
auto v = Size();
json d = {{"version", 1},
{"platform", Platform()},
{"stdin_tty", IsTty(0)},
{"stdout_tty", IsTty(1)},
{"term", SafeText(Env("TERM"))},
{"utf8", v.utf8},
{"colors", v.colors},
{"multiplexer", !Env("TMUX").empty() || !Env("STY").empty()},
{"software_decode", true},
{"kitty", "probe_on_view"},
{"audio_playback", false},
{"bridge_version", 1}};
std::cout << d.dump(argc == 3 ? -1 : 2) << "\n";
return 0;
}
if (command != "view" && command != "bridge")
throw std::invalid_argument("unknown command; use --help");
SessionOptions options;
std::string renderer = "auto", device;
AnsiDetail detail = AnsiDetail::Quadrant;
double zoom = 1;
bool control = false, stdio = false, native = false;
int secret_fd = 3;
int i = 2;
if (command == "view") {
if (argc <= 2)
throw std::invalid_argument("device ID required");
device = argv[i++];
}
auto number = [](const std::string &s, int lo, int hi) {
size_t used = 0;
int n = std::stoi(s, &used);
if (used != s.size() || n < lo || n > hi)
throw std::invalid_argument("numeric option out of range");
return n;
};
for (; i < argc; ++i) {
std::string a = argv[i];
if (a == "--control" && command == "view")
control = true;
else if (a == "--stdio" && command == "bridge")
stdio = true;
else {
if (i + 1 >= argc)
throw std::invalid_argument("option value missing");
std::string value = argv[++i];
if (a == "--server")
options.host = value;
else if (a == "--port")
options.port = number(value, 1, 65535);
else if (a == "--fps" && command == "view")
options.fps = number(value, 1, 30);
else if (a == "--display" && command == "view")
options.display = number(value, 0, 63);
else if (a == "--renderer" && command == "view")
renderer = value;
else if (a == "--quality")
Quality(options, value);
else if (a == "--ansi-detail" && command == "view")
detail = Detail(value);
else if (a == "--zoom" && command == "view") {
native = value == "native";
zoom = native ? 1 : number(value, 1, 32);
} else if (a == "--password-fd" && command == "bridge")
secret_fd = number(value, 3, 1024);
else
throw std::invalid_argument("unsupported option");
}
}
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty")
throw std::invalid_argument("unknown renderer");
if (command == "bridge" && !stdio)
throw std::invalid_argument("bridge requires --stdio");
Descriptor output(Duplicate(1));
if (output.value < 0)
throw std::runtime_error("cannot preserve output channel");
// All library stdout writes go to diagnostics. Only Output uses the saved
// fd.
options.log_directory = Logs();
if (command == "view") {
Descriptor logs(OpenLog(options.log_directory + "/client.log"));
if (logs.value < 0 || Redirect(logs.value, 2) < 0)
throw std::runtime_error("cannot open diagnostic log");
}
if (Redirect(2, 1) < 0)
throw std::runtime_error("cannot isolate protocol output");
try {
return command == "view" ? View(options, device, renderer, control,
output.value, detail, zoom, native)
: Bridge(output.value, options, secret_fd);
} catch (const std::exception &e) {
if (command == "view") {
std::string msg = "crossdesk-cli: " + SafeText(e.what()) + "\r\n";
Write(output.value, msg.data(), msg.size());
}
throw;
}
} catch (const std::exception &e) {
std::cerr << "crossdesk-cli: " << SafeText(e.what()) << "\n";
return 1;
}
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { spawn } = require('node:child_process');
const platforms = require('./platforms.json');
const { version } = require('../package.json');
function main() {
const target = `${process.platform}-${process.arch}`;
if (!platforms[target]) {
throw new Error(`No crossdesk-cli build for ${target}. Supported targets: ${Object.keys(platforms).join(', ')}.`);
}
if (process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime) {
throw new Error('This release requires glibc Linux. Alpine/musl needs a separate native build.');
}
const packageName = `crossdesk-cli-${target}`;
let manifestPath;
try {
manifestPath = require.resolve(`${packageName}/package.json`);
} catch {
throw new Error(`Missing ${packageName}@${version}. Install with optional dependencies enabled; this platform's package must be published before crossdesk-cli.`);
}
if (require(manifestPath).version !== version) {
throw new Error(`Expected ${packageName}@${version}; reinstall matching package versions.`);
}
const binary = path.join(path.dirname(manifestPath), 'bin', process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli');
fs.accessSync(binary, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
const args = process.argv.slice(2);
// Hosts such as Pi should spawn the native path directly, retaining descriptor
// inheritance and control of the process lifetime without an npm intermediary.
if (args.length === 1 && args[0] === '--print-binary-path') {
process.stdout.write(`${binary}\n`);
return;
}
const stdio = ['inherit', 'inherit', 'inherit'];
if (args[0] === 'bridge') {
const index = args.lastIndexOf('--password-fd');
const fd = index === -1 ? 3 : Number(args[index + 1]);
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
try {
const stat = fs.fstatSync(fd);
if (stat.isFIFO() || stat.isSocket() || stat.isFile()) {
while (stdio.length <= fd) stdio.push('ignore');
stdio[fd] = fd;
}
} catch { /* The native bridge reports a missing credential descriptor. */ }
}
}
const child = spawn(binary, args, { stdio, shell: false });
const handlers = new Map();
if (process.platform !== 'win32') {
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
const handler = () => { if (child.exitCode === null) child.kill(signal); };
handlers.set(signal, handler);
process.on(signal, handler);
}
} else {
// Console Ctrl+C is delivered to both processes. Do not terminate the native
// child before it has restored console modes.
const handler = () => {};
handlers.set('SIGINT', handler);
process.on('SIGINT', handler);
}
const cleanup = () => {
for (const [signal, handler] of handlers) process.removeListener(signal, handler);
};
child.once('error', (error) => {
cleanup();
process.stderr.write(`crossdesk-cli: cannot start native program (${error.code || 'unknown error'}).\n`);
process.exitCode = 1;
});
child.once('exit', (code, signal) => {
cleanup();
if (signal && process.platform !== 'win32') process.kill(process.pid, signal);
else process.exitCode = code ?? 1;
});
}
try {
main();
} catch (error) {
process.stderr.write(`crossdesk-cli: ${error.message}\n`);
process.exitCode = 1;
}
+8
View File
@@ -0,0 +1,8 @@
{
"darwin-arm64": { "os": "darwin", "cpu": "arm64", "xmakePlatform": "macosx", "xmakeArch": "arm64" },
"darwin-x64": { "os": "darwin", "cpu": "x64", "xmakePlatform": "macosx", "xmakeArch": "x86_64" },
"linux-arm64": { "os": "linux", "cpu": "arm64", "libc": "glibc", "xmakePlatform": "linux", "xmakeArch": "arm64" },
"linux-x64": { "os": "linux", "cpu": "x64", "libc": "glibc", "xmakePlatform": "linux", "xmakeArch": "x86_64" },
"win32-arm64": { "os": "win32", "cpu": "arm64", "xmakePlatform": "windows", "xmakeArch": "arm64" },
"win32-x64": { "os": "win32", "cpu": "x64", "xmakePlatform": "windows", "xmakeArch": "x64" }
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "crossdesk-cli",
"version": "0.1.0",
"private": true,
"description": "CrossDesk remote desktop controller for the terminal",
"license": "GPL-3.0-only",
"author": "DI JUNKUN",
"engines": { "node": ">=22" },
"scripts": {
"pack:npm": "node scripts/pack-npm.cjs"
}
}
+172
View File
@@ -0,0 +1,172 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { truncateToWidth, parseKey, decodeKittyPrintable, isKeyRelease } from "@earendil-works/pi-tui";
import { spawn, type ChildProcess } from "node:child_process";
import type { Writable } from "node:stream";
const clean = (s: unknown) => String(s ?? "").replace(/[^\x20-\x7e]/g, "?").slice(0, 180);
// Normalize enhanced key reports with the host's actual keyboard decoder.
function legacyInput(data: string): string {
if (isKeyRelease(data)) return "";
const key = parseKey(data);
if (key === "ctrl+]") return "\x1d";
if (key?.match(/^ctrl\+[a-z]$/)) return String.fromCharCode(key.charCodeAt(5) - 96);
if (key?.match(/^alt\+[a-z]$/)) return "\x1b" + key[4];
const keys: Record<string, string> = { enter: "\r", escape: "\x1b", backspace: "\x7f", tab: "\t",
up: "\x1b[A", down: "\x1b[B", right: "\x1b[C", left: "\x1b[D", home: "\x1b[H", end: "\x1b[F",
delete: "\x1b[3~", pageUp: "\x1b[5~", pageDown: "\x1b[6~" };
return (key && keys[key]) || decodeKittyPrintable(data) || data;
}
export default function crossdesk(pi: ExtensionAPI) {
let stop: (() => void) | undefined;
pi.on("session_shutdown", async () => stop?.());
pi.registerCommand("crossdesk", {
description: "Open a human-only remote desktop view (Ctrl+] closes)",
handler: async (args, ctx) => {
if (ctx.mode !== "tui" || !ctx.hasUI || !process.stdin.isTTY || !process.stdout.isTTY) {
ctx.ui.notify("CrossDesk embedded view requires Pi interactive TUI. Run crossdesk-cli view <device-id> in a terminal.", "warning");
return;
}
if (stop) { ctx.ui.notify("A CrossDesk view is already open", "warning"); return; }
const device = (args.trim() || await ctx.ui.input("CrossDesk device ID", "9 digits"))?.trim();
if (!device) return;
if (!/^\d{9}$/.test(device)) { ctx.ui.notify("Expected a 9-digit device ID", "error"); return; }
// A custom masked prompt keeps credentials out of the editor, history,
// extension messages, model tools and package configuration.
let password = await ctx.ui.custom<string | undefined>((tui, _theme, _keys, done) => {
let secret = "";
return {
render: (width: number) => [truncateToWidth("Remote password (Enter connects, Esc cancels): " + "*".repeat([...secret].length), width)],
invalidate() {},
handleInput(data: string) {
data = legacyInput(data);
if (!data) return;
if (data === "\x1b" || data === "\x1d") { secret = ""; done(undefined); }
else if (data === "\r" || data === "\n") { const value = secret; secret = ""; done(value); }
else if (data === "\x7f" || data === "\b") secret = [...secret].slice(0, -1).join("");
else {
const text = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
if (!/[\x00-\x1f\x7f-\x9f]/.test(text) && Buffer.byteLength(secret + text) <= 256) secret += text;
}
tui.requestRender();
},
};
});
if (password === undefined) return;
let child: ChildProcess | undefined;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (!child) return;
const owned = child; child = undefined;
owned.stdin?.end(); // EOF asks the bridge to disconnect and release input.
killTimer = setTimeout(() => {
owned.kill("SIGTERM");
const force = setTimeout(() => owned.kill("SIGKILL"), 2000); force.unref();
owned.once("exit", () => clearTimeout(force));
}, 2000);
killTimer.unref();
owned.once("exit", () => { if (killTimer) clearTimeout(killTimer); });
};
try {
await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
let closed = false, status = "starting", error = "", lines: string[] = [], buffer = "";
let columns = 0, rows = 0, request = 0, ready = false, generation = 0;
let frame: { frame_id: number; generation: number } | undefined;
let helloId = 0, zoom = 1, native = false;
const colorHint = process.env.COLORTERM || "";
const colors = /^(truecolor|24bit)$/.test(colorHint) ? 24 : process.env.TERM?.includes("256color") ? 256 : 8;
const finish = () => { if (closed) return; closed = true; cleanup(); stop = undefined; lines = []; done(); };
stop = finish;
const fail = (reason: string) => { error = clean(reason); cleanup(); tui.requestRender(); };
child = spawn(process.env.CROSSDESK_CLI || "crossdesk-cli", ["bridge", "--stdio", "--password-fd", "3"], {
shell: false, stdio: ["pipe", "pipe", "pipe", "pipe"],
});
const processChild = child;
const send = (method: string, fields: Record<string, unknown> = {}) => {
if (closed || !child?.stdin || child.stdin.destroyed) return 0;
if (child.stdin.writableLength > 65536) { fail("Bridge is not consuming requests"); return 0; }
const id = ++request;
child.stdin.write(JSON.stringify({ id, method, ...fields }) + "\n"); return id;
};
child.stdin?.on("error", () => fail("Bridge input closed"));
const secrets = child.stdio[3] as Writable;
secrets.on("error", () => fail("Credential channel closed"));
secrets.end(password + "\n"); password = undefined;
child.stderr?.resume(); // Never insert native diagnostics in the TUI or chat.
child.on("error", () => fail("Cannot start crossdesk-cli; check CROSSDESK_CLI or PATH"));
child.on("exit", (code) => { if (!closed) { error ||= `Bridge exited (${code ?? "signal"})`; tui.requestRender(); } });
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (data: string) => {
if (closed) return;
buffer += data;
if (buffer.length > 2 * 1024 * 1024) { fail("Bridge message exceeds 2 MiB"); return; }
for (;;) {
const end = buffer.indexOf("\n"); if (end < 0) break;
const raw = buffer.slice(0, end); buffer = buffer.slice(end + 1);
try {
const message = JSON.parse(raw);
if (message.id === helloId && message.ok && message.version === 1 && !ready) {
ready = true;
send("viewport", { columns: columns || 80, rows: rows || 20, colors, utf8: true, fps: 8, detail: "quadrant", quality: "high" });
send("connect", { device });
}
if (message.ok === false) error = clean(message.error);
if (message.status) {
if (Number.isSafeInteger(message.status.generation)) generation = message.status.generation;
status = clean(message.status.state) + (message.status.control ? " CONTROL" : " READ ONLY");
if (message.status.error) error = clean(message.status.error);
if (message.status.state !== "connected") lines = [];
}
if (message.event === "frame") {
if (!Number.isSafeInteger(message.frame_id) || !Number.isSafeInteger(message.generation) ||
!Array.isArray(message.lines) || message.lines.length > 100)
throw new Error("Invalid frame");
if (message.columns !== columns || message.rows !== rows || message.generation < generation) {
send("ack", {frame_id: message.frame_id, generation: message.generation});
continue; // A frame already in flight may precede the resize response.
}
for (const line of message.lines) {
// Only our SGR colors, spaces and block glyphs may reach Pi.
if (typeof line !== "string" || line.length > 20000 || /[^ \u2580-\u259f]/u.test(line.replace(/\x1b\[[0-9;]*m/g, "")))
throw new Error("Invalid terminal line");
}
if (Number.isFinite(message.zoom) && message.zoom >= 1 && message.zoom <= 32) zoom = message.zoom;
native = message.native === true;
lines = message.lines; frame = { frame_id: message.frame_id, generation: message.generation };
}
tui.requestRender();
} catch { fail("Invalid bridge protocol message"); }
}
});
helloId = send("hello", { version: 1 });
const watchdog = setTimeout(() => { if (!ready) fail("Bridge handshake timeout"); }, 5000);
processChild.once("exit", () => clearTimeout(watchdog));
return {
render(width: number) {
const w = Math.max(1, Math.min(240, width - 1));
const h = Math.max(1, Math.min(100, tui.terminal.rows - 5));
if (w !== columns || h !== rows) {
columns = w; rows = h; lines = []; frame = undefined;
if (ready) send("viewport", { columns, rows, colors, utf8: true, fps: 8, detail: "quadrant", quality: "high" });
}
const ack = frame; frame = undefined;
if (ack) queueMicrotask(() => send("ack", ack)); // Host consumed the frame; not a physical display timestamp.
return [truncateToWidth(`CrossDesk ${device} | ${status} ${native ? "detail" : "fit"} x${zoom} | Ctrl+] close, Ctrl+T control`, width),
...lines.slice(0, h).map(line => truncateToWidth(line, width)),
truncateToWidth(error || (lines.length ? "Read-only: 1 detail, +/- zoom, arrows/hjkl pan, 0 fit" : "Waiting for remote video…"), width)];
},
handleInput(data: string) {
data = legacyInput(data);
if (!data) return;
if (data === "\x1d") { clearTimeout(watchdog); finish(); }
else if (ready && Buffer.byteLength(data) <= 16384) send("input", { data });
},
invalidate() {},
dispose() { clearTimeout(watchdog); cleanup(); },
};
});
} finally { password = undefined; cleanup(); stop = undefined; }
},
});
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "crossdesk-pi",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Human-only CrossDesk terminal view for Pi",
"keywords": ["pi-package"],
"pi": { "extensions": ["./index.ts"] },
"peerDependencies": {
"@earendil-works/pi-coding-agent": "^1.4.0",
"@earendil-works/pi-tui": "^1.4.0"
}
}
+183
View File
@@ -0,0 +1,183 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const cliRoot = path.resolve(__dirname, '..');
const root = path.resolve(cliRoot, '../..');
const project = require('../package.json');
const platforms = require('../npm/platforms.json');
function run(command, args, options = {}) {
return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...options });
}
function copy(from, to) {
fs.cpSync(path.join(root, from), to, {
recursive: true,
filter: (entry) => !['.git', '.xmake', '.vscode', 'build', 'out', 'node_modules', 'tests', 'test'].includes(path.basename(entry)),
});
}
function json(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function inspect(binary, target) {
const data = fs.readFileSync(binary);
const info = { target, sha256: crypto.createHash('sha256').update(data).digest('hex') };
if (process.platform === 'darwin') {
const cpu = process.arch === 'arm64' ? 0x100000c : 0x1000007;
if (data.length < 32 || data.readUInt32LE(0) !== 0xfeedfacf ||
data.readUInt32LE(4) !== cpu || data.readUInt32LE(12) !== 2) {
throw new Error('Expected a Mach-O executable matching the current CPU.');
}
info.dependencies = run('otool', ['-L', binary]).split('\n').slice(1)
.map((line) => line.trim().split(' (')[0]).filter(Boolean);
for (const dependency of info.dependencies) {
if (!dependency.startsWith('/usr/lib/') && !dependency.startsWith('/System/Library/'))
throw new Error(`Non-system macOS library must be bundled before packaging: ${dependency}`);
}
const commands = run('otool', ['-l', binary]);
info.minimumOS = commands.match(/\bminos\s+([\d.]+)/)?.[1] ||
commands.match(/LC_VERSION_MIN_MACOSX[\s\S]*?\bversion\s+([\d.]+)/)?.[1];
if (!info.minimumOS) throw new Error('Cannot determine macOS deployment target.');
run('codesign', ['--verify', binary]);
} else if (process.platform === 'linux') {
const machine = process.arch === 'arm64' ? 183 : 62;
if (data.length < 64 || data.subarray(0, 4).toString('hex') !== '7f454c46' ||
data[4] !== 2 || data[5] !== 1 || data.readUInt16LE(18) !== machine)
throw new Error('Expected a 64-bit ELF executable matching the current CPU.');
if (!process.report.getReport().header.glibcVersionRuntime)
throw new Error('The Linux npm packages currently target glibc, not musl.');
info.dependencies = run('ldd', [binary]).trim().split('\n').map((line) => line.trim());
const system = /^(linux-vdso|ld-linux|lib(c|m|dl|rt|pthread|gcc_s|stdc\+\+|z|resolv)\.)/;
for (const dependency of info.dependencies) {
const name = path.basename(dependency.split(/\s/)[0]);
if (dependency.includes('not found') || !system.test(name))
throw new Error(`Unbundled Linux dependency: ${dependency}`);
}
const symbols = run('objdump', ['-T', binary]);
info.glibc = [...symbols.matchAll(/\bGLIBC_([\d.]+)/g)].map((match) => match[1])
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1);
info.glibcxx = [...symbols.matchAll(/\bGLIBCXX_([\d.]+)/g)].map((match) => match[1])
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1);
} else {
const offset = data.length >= 64 ? data.readUInt32LE(60) : data.length;
const machine = process.arch === 'arm64' ? 0xaa64 : 0x8664;
if (data.toString('ascii', 0, 2) !== 'MZ' || offset + 6 > data.length ||
data.readUInt32LE(offset) !== 0x4550 || data.readUInt16LE(offset + 4) !== machine)
throw new Error('Expected a Windows PE executable matching the current CPU.');
info.dependencies = [...run('dumpbin.exe', ['/DEPENDENTS', binary])
.matchAll(/^\s+([\w.-]+\.dll)\s*$/gim)].map((match) => match[1]);
if (!info.dependencies.length) throw new Error('Cannot inspect Windows DLL dependencies.');
for (const dependency of info.dependencies) {
if (/^(msvcp|vcruntime|concrt)/i.test(dependency) ||
(!/^(api-ms-|ext-ms-)/i.test(dependency) &&
!fs.existsSync(path.join(process.env.SystemRoot, 'System32', dependency))))
throw new Error(`Unbundled Windows runtime: ${dependency}. Build with static runtimes (MT).`);
}
info.minimumOS = process.arch === 'arm64' ? 'Windows 11' : 'Windows 10 (VT console)';
}
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.');
return info;
}
function pack(directory, output) {
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
const expected = `${manifest.name}-${manifest.version}.tgz`;
if (fs.existsSync(path.join(output, expected))) throw new Error(`Output already exists: ${expected}`);
const args = ['pack', '--json', '--ignore-scripts', '--pack-destination', output];
if (process.platform === 'win32' && !process.env.npm_execpath)
throw new Error('On Windows, run this script through npm run pack:npm.');
const result = process.env.npm_execpath
? run(process.execPath, [process.env.npm_execpath, ...args], { cwd: directory })
: run('npm', args, { cwd: directory });
const [{ filename, size }] = JSON.parse(result);
return { filename, size };
}
let staging;
const created = [];
try {
const args = process.argv.slice(2);
let output = path.join(root, 'build', 'npm');
let binary;
for (let i = 0; i < args.length; i += 2) {
if (!args[i + 1]) throw new Error(`Missing value for ${args[i]}`);
if (args[i] === '--out-dir') output = path.resolve(args[i + 1]);
else if (args[i] === '--binary') binary = path.resolve(args[i + 1]);
else throw new Error(`Unknown option: ${args[i]}`);
}
const target = `${process.platform}-${process.arch}`;
const platform = platforms[target];
if (!platform) throw new Error(`No packaging target for ${target}`);
const version = fs.readFileSync(path.join(cliRoot, 'xmake.lua'), 'utf8').match(/set_version\("([^"]+)"\)/)?.[1];
if (version !== project.version) throw new Error('apps/cli/package.json and apps/cli/xmake.lua versions must match.');
for (const name of [project.name, `${project.name}-${target}`]) {
const filename = `${name}-${version}.tgz`;
if (fs.existsSync(path.join(output, filename))) throw new Error(`Output already exists: ${filename}`);
}
run('git', ['-C', path.join(root, 'deps/submodules/minirtc'), 'apply', '--reverse', '--check',
path.join(root, 'deps/patches/minirtc-credential-logs.patch')]);
const executable = process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli';
binary ||= path.join(root, 'build', platform.xmakePlatform, platform.xmakeArch, 'release', executable);
const info = inspect(binary, target);
staging = fs.mkdtempSync(path.join(os.tmpdir(), 'crossdesk-npm-'));
fs.mkdirSync(output, { recursive: true });
const native = path.join(staging, 'native');
fs.mkdirSync(path.join(native, 'bin'), { recursive: true });
fs.copyFileSync(binary, path.join(native, 'bin', executable));
fs.chmodSync(path.join(native, 'bin', executable), 0o755);
copy('LICENSE', path.join(native, 'LICENSE'));
// Include the controller and patched MiniRTC sources used by this checkout.
// External Xmake dependency source/licenses still belong to the release's
// dependency bundle; see source/apps/cli/docs/source-provenance.md.
// Keep desktop build definitions for Xmake's initial option discovery;
// desktop application sources and resources are intentionally not bundled.
for (const entry of ['apps/cli', 'apps/desktop/xmake',
'libs/controller/include/controller_session.h',
'libs/controller/src', 'libs/terminal', 'libs/wire/include',
'libs/wire/src', 'deps/submodules/minirtc',
'deps/submodules/xmake.lua', 'deps/thirdparty', 'deps/patches', 'xmake.lua',
'LICENSE', '.gitmodules'])
copy(entry, path.join(native, 'source', entry));
json(path.join(native, 'build-info.json'), { version, ...info });
fs.writeFileSync(path.join(native, 'README.md'), `# ${project.name}-${target}\n\nNative package for crossdesk-cli ${version}.\n\nRuntime requirements and linked libraries: [build-info.json](build-info.json).\n\nController and patched MiniRTC sources are included in source/. From source/, configure with xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release, then run xmake build crossdesk-cli. External dependency notices and corresponding sources must accompany the product release; see source/apps/cli/docs/source-provenance.md.\n`);
json(path.join(native, 'package.json'), {
name: `${project.name}-${target}`, version, description: `${project.description} (${target})`,
license: project.license, os: [platform.os], cpu: [platform.cpu],
...(platform.libc ? { libc: [platform.libc] } : {}),
files: ['bin', 'build-info.json', 'source', 'LICENSE', 'README.md'],
});
const launcher = path.join(staging, 'launcher');
fs.mkdirSync(launcher);
for (const entry of ['npm', 'docs', 'README.md']) copy(`apps/cli/${entry}`, path.join(launcher, entry));
copy('LICENSE', path.join(launcher, 'LICENSE'));
fs.chmodSync(path.join(launcher, 'npm', 'cli.cjs'), 0o755);
json(path.join(launcher, 'package.json'), {
name: project.name, version, description: project.description, license: project.license,
author: project.author, engines: project.engines,
bin: { 'crossdesk-cli': 'npm/cli.cjs' },
os: ['darwin', 'linux', 'win32'], cpu: ['arm64', 'x64'],
files: ['npm', 'docs', 'README.md', 'LICENSE'],
optionalDependencies: Object.fromEntries(Object.keys(platforms).map((key) => [`${project.name}-${key}`, version])),
});
// Finish both archives before publishing either one to the output directory.
const archives = path.join(staging, 'archives');
fs.mkdirSync(archives);
const packages = [pack(native, archives), pack(launcher, archives)];
for (const { filename } of packages) {
const destination = path.join(output, filename);
fs.copyFileSync(path.join(archives, filename), destination, fs.constants.COPYFILE_EXCL);
created.push(destination);
}
for (const { filename, size } of packages)
console.log(`${path.join(output, filename)} (${(size / 1024 / 1024).toFixed(2)} MiB)`);
console.log(`Native requirements: ${JSON.stringify(info)}`);
} catch (error) {
for (const file of created) fs.rmSync(file, { force: true });
console.error(`pack:npm: ${error.message}`);
process.exitCode = 1;
} finally {
if (staging) fs.rmSync(staging, { recursive: true, force: true });
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../../.."
git submodule update --init --recursive
actual=$(git -C deps/submodules/minirtc rev-parse HEAD)
[ "$actual" = b9ee1fa7fe038fdf771f5dec1b1d561d78273f15 ] || { echo 'Unexpected MiniRTC revision' >&2; exit 1; }
patch_file="$(pwd)/deps/patches/minirtc-credential-logs.patch"
if git -C deps/submodules/minirtc apply --reverse --check "$patch_file" 2>/dev/null; then
exit 0
fi
git -C deps/submodules/minirtc apply --check "$patch_file"
git -C deps/submodules/minirtc apply "$patch_file"
+62
View File
@@ -0,0 +1,62 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-30
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _TERMINAL_IO_H_
#define _TERMINAL_IO_H_
#include <atomic>
#include <csignal>
#include <cstddef>
#include <memory>
#include <string>
namespace crossdesk::cli {
static_assert(std::atomic<int>::is_always_lock_free);
extern std::atomic<int> interrupted;
void InitializeIo();
const char *Platform();
bool IsTty(int fd);
std::ptrdiff_t Read(int fd, void *buffer, size_t size);
std::ptrdiff_t Write(int fd, const void *buffer, size_t size);
void Close(int fd);
int Duplicate(int fd);
int Redirect(int from, int to);
int OpenLog(const std::string &path);
bool WaitReadable(int fd, int timeout_ms);
void WaitIo(int input_fd, int output_fd, int timeout_ms);
std::string Password();
std::string Logs();
struct TerminalSize {
int columns = 80, rows = 25, pixel_width = 0, pixel_height = 0;
};
TerminalSize GetTerminalSize(int fd);
class NonBlocking {
public:
explicit NonBlocking(int fd, bool output = false);
~NonBlocking();
NonBlocking(const NonBlocking &) = delete;
NonBlocking &operator=(const NonBlocking &) = delete;
private:
struct State;
std::unique_ptr<State> state_;
};
class RawTerminal {
public:
RawTerminal(int input, int output);
~RawTerminal();
RawTerminal(const RawTerminal &) = delete;
RawTerminal &operator=(const RawTerminal &) = delete;
private:
struct State;
std::unique_ptr<State> state_;
};
} // namespace crossdesk::cli
#endif
+162
View File
@@ -0,0 +1,162 @@
#ifndef _WIN32
#include "terminal_io.h"
#include <cerrno>
#include <fcntl.h>
#include <poll.h>
#include <stdexcept>
#include <sys/ioctl.h>
#include <sys/select.h>
#include <termios.h>
#include <unistd.h>
namespace crossdesk::cli {
std::atomic<int> interrupted{0};
namespace {
void Signal(int value) { interrupted.store(value, std::memory_order_relaxed); }
} // namespace
void InitializeIo() {
struct sigaction action{};
action.sa_handler = Signal;
sigemptyset(&action.sa_mask);
for (int sig : {SIGINT, SIGTERM, SIGHUP})
sigaction(sig, &action, nullptr);
std::signal(SIGPIPE, SIG_IGN);
}
const char *Platform() {
#ifdef __APPLE__
return "macos";
#else
return "linux";
#endif
}
bool IsTty(int fd) { return isatty(fd); }
std::ptrdiff_t Read(int fd, void *buffer, size_t size) {
return read(fd, buffer, size);
}
std::ptrdiff_t Write(int fd, const void *buffer, size_t size) {
return write(fd, buffer, size);
}
void Close(int fd) { close(fd); }
int Duplicate(int fd) { return dup(fd); }
int Redirect(int from, int to) { return dup2(from, to); }
int OpenLog(const std::string &path) {
return open(path.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0600);
}
bool WaitReadable(int fd, int timeout_ms) {
pollfd p{fd, POLLIN, 0};
int result = poll(&p, 1, timeout_ms);
if (result < 0 && errno != EINTR)
throw std::runtime_error("cannot poll input descriptor");
if (p.revents & POLLNVAL) {
// Darwin's /dev/tty may reject poll even though the descriptor is valid.
// select supports that controlling-terminal device without a blocking read.
if (fd < 0 || fd >= FD_SETSIZE || fcntl(fd, F_GETFL) < 0)
throw std::runtime_error("input descriptor unavailable");
fd_set readers;
FD_ZERO(&readers);
FD_SET(fd, &readers);
timeval timeout{timeout_ms / 1000, (timeout_ms % 1000) * 1000};
result = select(fd + 1, &readers, nullptr, nullptr, &timeout);
if (result < 0 && errno != EINTR)
throw std::runtime_error("cannot wait for terminal input");
return result > 0;
}
return result > 0 && (p.revents & (POLLIN | POLLHUP | POLLERR));
}
void WaitIo(int input_fd, int output_fd, int timeout_ms) {
pollfd fds[2] = {{input_fd, POLLIN, 0}, {output_fd, POLLOUT, 0}};
poll(fds, 2, timeout_ms);
}
TerminalSize GetTerminalSize(int fd) {
winsize ws{};
TerminalSize size;
if (ioctl(fd, TIOCGWINSZ, &ws) == 0 && ws.ws_col && ws.ws_row) {
size.columns = ws.ws_col;
size.rows = ws.ws_row;
size.pixel_width = ws.ws_xpixel;
size.pixel_height = ws.ws_ypixel;
}
return size;
}
struct NonBlocking::State {
int fd, flags;
};
NonBlocking::NonBlocking(int fd, bool) : state_(std::make_unique<State>()) {
state_->fd = fd;
state_->flags = fcntl(fd, F_GETFL);
if (state_->flags < 0 || fcntl(fd, F_SETFL, state_->flags | O_NONBLOCK) < 0)
throw std::runtime_error("cannot configure nonblocking descriptor");
}
NonBlocking::~NonBlocking() { fcntl(state_->fd, F_SETFL, state_->flags); }
struct RawTerminal::State {
int fd, flags;
termios saved;
};
RawTerminal::RawTerminal(int input, int) : state_(std::make_unique<State>()) {
state_->fd = input;
state_->flags = fcntl(input, F_GETFL);
if (state_->flags < 0 || tcgetattr(input, &state_->saved) != 0)
throw std::runtime_error("cannot read terminal attributes");
termios mode = state_->saved;
cfmakeraw(&mode);
if (tcsetattr(input, TCSAFLUSH, &mode) != 0)
throw std::runtime_error("cannot enter terminal raw mode");
if (fcntl(input, F_SETFL, state_->flags | O_NONBLOCK) < 0) {
tcsetattr(input, TCSAFLUSH, &state_->saved);
throw std::runtime_error("cannot configure terminal input");
}
}
RawTerminal::~RawTerminal() {
tcsetattr(state_->fd, TCSAFLUSH, &state_->saved);
fcntl(state_->fd, F_SETFL, state_->flags);
}
std::string Password() {
int fd = open("/dev/tty", O_RDWR | O_NOCTTY);
if (fd < 0)
throw std::runtime_error("password requires an interactive TTY");
struct Restore {
int fd;
termios mode{};
bool active = false;
~Restore() {
if (active) {
tcsetattr(fd, TCSAFLUSH, &mode);
write(fd, "\n", 1);
}
close(fd);
}
} restore{fd};
if (tcgetattr(fd, &restore.mode))
throw std::runtime_error("cannot read password TTY");
auto mode = restore.mode;
mode.c_lflag &= ~ECHO;
if (tcsetattr(fd, TCSAFLUSH, &mode))
throw std::runtime_error("cannot disable password echo");
restore.active = true;
const char prompt[] = "Remote password: ";
write(fd, prompt, sizeof(prompt) - 1);
std::string result;
while (!interrupted) {
if (!WaitReadable(fd, 100))
continue;
char c;
auto n = read(fd, &c, 1);
if (n < 0 && errno == EINTR)
continue;
if (n <= 0 || result.size() > 256)
break;
if (c == '\n' || c == '\r')
return result;
result += c;
}
throw std::runtime_error("password entry cancelled or too long");
}
std::string Logs() {
char path[] = "/tmp/crossdesk-cli-XXXXXX";
char *dir = mkdtemp(path);
if (!dir)
throw std::runtime_error("cannot create private log directory");
return dir;
}
} // namespace crossdesk::cli
#endif
+477
View File
@@ -0,0 +1,477 @@
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include "terminal_io.h"
#include <windows.h>
#include <algorithm>
#include <cerrno>
#include <chrono>
#include <fcntl.h>
#include <io.h>
#include <random>
#include <sddl.h>
#include <stdexcept>
#include <sys/stat.h>
#include <thread>
namespace crossdesk::cli {
std::atomic<int> interrupted{0};
namespace {
int console_output = -1;
std::string input_bytes;
wchar_t high_surrogate = 0;
DWORD mouse_buttons = 0;
HANDLE Handle(int fd) {
#ifdef _MSC_VER
// A missing optional credential fd should be an error response, not a CRT
// invalid-parameter process termination. Keep the handler thread-local.
auto previous = _set_thread_local_invalid_parameter_handler(
[](const wchar_t *, const wchar_t *, const wchar_t *, unsigned,
uintptr_t) {});
auto value = _get_osfhandle(fd);
_set_thread_local_invalid_parameter_handler(previous);
return reinterpret_cast<HANDLE>(value);
#else
return reinterpret_cast<HANDLE>(_get_osfhandle(fd));
#endif
}
void Signal(int value) { interrupted.store(value, std::memory_order_relaxed); }
BOOL WINAPI ConsoleSignal(DWORD value) {
if (value == CTRL_C_EVENT || value == CTRL_BREAK_EVENT) {
interrupted.store(SIGINT, std::memory_order_relaxed);
return TRUE;
}
return FALSE;
}
std::string Utf8(const std::wstring &value) {
if (value.empty())
return {};
int size =
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(),
int(value.size()), nullptr, 0, nullptr, nullptr);
if (!size)
throw std::runtime_error("invalid Unicode input");
std::string result(size, '\0');
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(),
int(value.size()), result.data(), size, nullptr, nullptr);
return result;
}
std::wstring Wide(const std::string &value) {
int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
int(value.size()), nullptr, 0);
if (!size)
throw std::runtime_error("invalid Unicode path");
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
int(value.size()), result.data(), size);
return result;
}
void Character(wchar_t c) {
if (c >= 0xd800 && c <= 0xdbff) {
high_surrogate = c;
return;
}
if (c >= 0xdc00 && c <= 0xdfff) {
if (high_surrogate)
input_bytes += Utf8(std::wstring{high_surrogate, c});
} else if (c) {
input_bytes += Utf8(std::wstring(1, c));
}
high_surrogate = 0;
}
void Key(const KEY_EVENT_RECORD &key) {
if (!key.bKeyDown)
return;
const char *sequence = nullptr;
switch (key.wVirtualKeyCode) {
case VK_UP:
sequence = "\033[A";
break;
case VK_DOWN:
sequence = "\033[B";
break;
case VK_RIGHT:
sequence = "\033[C";
break;
case VK_LEFT:
sequence = "\033[D";
break;
case VK_HOME:
sequence = "\033[H";
break;
case VK_END:
sequence = "\033[F";
break;
case VK_DELETE:
sequence = "\033[3~";
break;
case VK_PRIOR:
sequence = "\033[5~";
break;
case VK_NEXT:
sequence = "\033[6~";
break;
case VK_F1:
sequence = "\033OP";
break;
case VK_F2:
sequence = "\033OQ";
break;
case VK_F3:
sequence = "\033OR";
break;
case VK_F4:
sequence = "\033OS";
break;
}
for (int i = 0; i < std::min<int>(key.wRepeatCount, 64); ++i) {
if (sequence)
input_bytes += sequence;
else {
// Right Alt + Ctrl is AltGr, not an Alt shortcut.
bool alt = key.dwControlKeyState & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED);
bool ctrl =
key.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED);
if (alt && !ctrl && key.uChar.UnicodeChar)
input_bytes += '\033';
Character(key.uChar.UnicodeChar);
}
}
}
void Mouse(const MOUSE_EVENT_RECORD &mouse) {
CONSOLE_SCREEN_BUFFER_INFO info{};
if (console_output < 0 ||
!GetConsoleScreenBufferInfo(Handle(console_output), &info))
return;
int x = mouse.dwMousePosition.X - info.srWindow.Left + 1;
int y = mouse.dwMousePosition.Y - info.srWindow.Top + 1;
if (x < 1 || y < 1)
return;
auto emit = [&](int button, bool release = false) {
input_bytes += "\033[<" + std::to_string(button) + ";" + std::to_string(x) +
";" + std::to_string(y) + (release ? "m" : "M");
};
DWORD buttons = mouse.dwButtonState & 0xffff;
const DWORD masks[] = {FROM_LEFT_1ST_BUTTON_PRESSED,
FROM_LEFT_2ND_BUTTON_PRESSED,
RIGHTMOST_BUTTON_PRESSED};
for (int i = 0; i < 3; ++i)
if ((buttons & masks[i]) != (mouse_buttons & masks[i]))
emit(i, !(buttons & masks[i]));
if (mouse.dwEventFlags & (MOUSE_WHEELED | MOUSE_HWHEELED)) {
int delta = static_cast<SHORT>(HIWORD(mouse.dwButtonState));
emit(64 + ((mouse.dwEventFlags & MOUSE_HWHEELED) ? 2 : 0) + (delta < 0));
} else if (mouse.dwEventFlags & MOUSE_MOVED) {
emit(32 + ((buttons & masks[0]) ? 0
: (buttons & masks[1]) ? 1
: (buttons & masks[2]) ? 2
: 3));
}
mouse_buttons = buttons;
}
bool Ready(int fd) {
if (fd < 0)
return false;
HANDLE handle = Handle(fd);
if (handle == INVALID_HANDLE_VALUE)
throw std::runtime_error("input descriptor unavailable");
DWORD mode = 0;
if (GetConsoleMode(handle, &mode)) {
DWORD count = 0;
return !input_bytes.empty() ||
(GetNumberOfConsoleInputEvents(handle, &count) && count);
}
if (GetFileType(handle) == FILE_TYPE_PIPE) {
DWORD available = 0;
if (PeekNamedPipe(handle, nullptr, 0, nullptr, &available, nullptr))
return available != 0;
if (GetLastError() == ERROR_BROKEN_PIPE)
return true;
throw std::runtime_error("cannot inspect input pipe");
}
return true;
}
} // namespace
void InitializeIo() {
// Preserve LF-delimited JSON and terminal escape bytes through CRT streams.
for (int fd : {0, 1, 2})
_setmode(fd, _O_BINARY);
std::signal(SIGINT, Signal);
std::signal(SIGTERM, Signal);
SetConsoleCtrlHandler(ConsoleSignal, TRUE);
}
const char *Platform() { return "windows"; }
bool IsTty(int fd) {
DWORD mode;
return GetConsoleMode(Handle(fd), &mode) != 0;
}
std::ptrdiff_t Read(int fd, void *buffer, size_t size) {
if (!size)
return 0;
HANDLE handle = Handle(fd);
DWORD mode;
if (GetConsoleMode(handle, &mode)) {
if (input_bytes.empty()) {
DWORD count = 0, read = 0;
if (!GetNumberOfConsoleInputEvents(handle, &count)) {
errno = EIO;
return -1;
}
INPUT_RECORD records[128];
if (count && ReadConsoleInputW(handle, records,
std::min<DWORD>(count, 128), &read))
for (DWORD i = 0; i < read; ++i) {
if (records[i].EventType == KEY_EVENT)
Key(records[i].Event.KeyEvent);
else if (records[i].EventType == MOUSE_EVENT)
Mouse(records[i].Event.MouseEvent);
else if (records[i].EventType == FOCUS_EVENT &&
!records[i].Event.FocusEvent.bSetFocus)
input_bytes += "\033[O";
}
}
if (input_bytes.empty()) {
errno = EAGAIN;
return -1;
}
size_t count = std::min(size, input_bytes.size());
std::copy_n(input_bytes.data(), count, static_cast<char *>(buffer));
input_bytes.erase(0, count);
return count;
}
if (GetFileType(handle) == FILE_TYPE_PIPE) {
DWORD available = 0;
if (!PeekNamedPipe(handle, nullptr, 0, nullptr, &available, nullptr)) {
if (GetLastError() == ERROR_BROKEN_PIPE)
return 0;
errno = EIO;
return -1;
}
if (!available) {
errno = EAGAIN;
return -1;
}
size = std::min<size_t>(size, available);
}
DWORD count = 0;
if (!ReadFile(handle, buffer, DWORD(std::min<size_t>(size, 16384)), &count,
nullptr)) {
if (GetLastError() == ERROR_BROKEN_PIPE)
return 0;
errno = EIO;
return -1;
}
return count;
}
std::ptrdiff_t Write(int fd, const void *buffer, size_t size) {
DWORD count = 0;
if (!WriteFile(Handle(fd), buffer, DWORD(std::min<size_t>(size, 16384)),
&count, nullptr)) {
errno = GetLastError() == ERROR_NO_DATA ? EPIPE : EIO;
return -1;
}
if (!count && size) {
errno = EAGAIN;
return -1;
}
return count;
}
void Close(int fd) { _close(fd); }
int Duplicate(int fd) { return _dup(fd); }
int Redirect(int from, int to) { return _dup2(from, to); }
int OpenLog(const std::string &path) {
return _wopen(Wide(path).c_str(),
_O_CREAT | _O_WRONLY | _O_APPEND | _O_BINARY,
_S_IREAD | _S_IWRITE);
}
bool WaitReadable(int fd, int timeout_ms) {
auto until =
std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
do {
if (Ready(fd))
return true;
if (interrupted)
return false;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
} while (std::chrono::steady_clock::now() < until);
return false;
}
void WaitIo(int input_fd, int, int timeout_ms) {
WaitReadable(input_fd, timeout_ms);
}
TerminalSize GetTerminalSize(int) {
TerminalSize size;
HANDLE handle = console_output >= 0
? Handle(console_output)
: CreateFileW(L"CONOUT$", GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
OPEN_EXISTING, 0, nullptr);
CONSOLE_SCREEN_BUFFER_INFO info{};
if (GetConsoleScreenBufferInfo(handle, &info)) {
size.columns = info.srWindow.Right - info.srWindow.Left + 1;
size.rows = info.srWindow.Bottom - info.srWindow.Top + 1;
}
if (console_output < 0 && handle != INVALID_HANDLE_VALUE)
CloseHandle(handle);
return size;
}
struct NonBlocking::State {
HANDLE handle;
DWORD mode = 0;
bool restore = false;
};
NonBlocking::NonBlocking(int fd, bool output)
: state_(std::make_unique<State>()) {
state_->handle = Handle(fd);
// Reads use PeekNamedPipe; output pipes additionally require bounded writes.
if (output && GetFileType(state_->handle) == FILE_TYPE_PIPE) {
DWORD flags = 0;
if (!GetNamedPipeHandleStateW(state_->handle, &state_->mode, nullptr,
nullptr, nullptr, nullptr, 0))
throw std::runtime_error("cannot read pipe mode");
flags = state_->mode | PIPE_NOWAIT;
if (!SetNamedPipeHandleState(state_->handle, &flags, nullptr, nullptr))
throw std::runtime_error("cannot configure nonblocking pipe");
state_->restore = true;
}
}
NonBlocking::~NonBlocking() {
if (state_->restore)
SetNamedPipeHandleState(state_->handle, &state_->mode, nullptr, nullptr);
}
struct RawTerminal::State {
HANDLE input, output;
DWORD input_mode, output_mode;
UINT input_cp, output_cp;
State(HANDLE in, HANDLE out, DWORD in_mode, DWORD out_mode)
: input(in), output(out), input_mode(in_mode), output_mode(out_mode),
input_cp(GetConsoleCP()), output_cp(GetConsoleOutputCP()) {}
State(const State &) = delete;
State &operator=(const State &) = delete;
~State() {
SetConsoleMode(input, input_mode);
SetConsoleMode(output, output_mode);
SetConsoleCP(input_cp);
SetConsoleOutputCP(output_cp);
console_output = -1;
input_bytes.clear();
high_surrogate = 0;
mouse_buttons = 0;
}
};
RawTerminal::RawTerminal(int input, int output) {
DWORD in_mode = 0, out_mode = 0;
if (!GetConsoleMode(Handle(input), &in_mode) ||
!GetConsoleMode(Handle(output), &out_mode))
throw std::runtime_error(
"view requires a Windows console or ConPTY terminal");
state_ = std::make_unique<State>(Handle(input), Handle(output), in_mode, out_mode);
DWORD mode = (in_mode & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT |
ENABLE_PROCESSED_INPUT | ENABLE_QUICK_EDIT_MODE |
ENABLE_VIRTUAL_TERMINAL_INPUT)) |
ENABLE_EXTENDED_FLAGS | ENABLE_WINDOW_INPUT | ENABLE_MOUSE_INPUT;
if (!SetConsoleMode(Handle(input), mode) ||
!SetConsoleMode(Handle(output), out_mode | ENABLE_PROCESSED_OUTPUT |
ENABLE_VIRTUAL_TERMINAL_PROCESSING) ||
!SetConsoleCP(CP_UTF8) || !SetConsoleOutputCP(CP_UTF8))
throw std::runtime_error(
"terminal requires Windows 10 or newer with VT support");
console_output = output;
}
RawTerminal::~RawTerminal() = default;
std::string Password() {
HANDLE input = CreateFileW(L"CONIN$", GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
OPEN_EXISTING, 0, nullptr);
HANDLE output =
CreateFileW(L"CONOUT$", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr, OPEN_EXISTING, 0, nullptr);
struct Restore {
HANDLE input, output;
DWORD mode = 0;
bool active = false;
~Restore() {
if (active) {
SetConsoleMode(input, mode);
DWORD count;
WriteConsoleW(output, L"\r\n", 2, &count, nullptr);
}
if (input != INVALID_HANDLE_VALUE)
CloseHandle(input);
if (output != INVALID_HANDLE_VALUE)
CloseHandle(output);
}
} restore{input, output};
if (input == INVALID_HANDLE_VALUE || output == INVALID_HANDLE_VALUE ||
!GetConsoleMode(input, &restore.mode))
throw std::runtime_error("password requires an interactive console");
if (!SetConsoleMode(
input, (restore.mode & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT)) |
ENABLE_PROCESSED_INPUT))
throw std::runtime_error("cannot disable password echo");
restore.active = true;
DWORD count;
WriteConsoleW(output, L"Remote password: ", 17, &count, nullptr);
std::wstring value;
while (!interrupted) {
if (WaitForSingleObject(input, 100) != WAIT_OBJECT_0)
continue;
INPUT_RECORD record{};
if (!ReadConsoleInputW(input, &record, 1, &count))
break;
if (!count || record.EventType != KEY_EVENT ||
!record.Event.KeyEvent.bKeyDown)
continue;
wchar_t c = record.Event.KeyEvent.uChar.UnicodeChar;
if (c == L'\r') {
auto result = Utf8(value);
if (result.size() <= 256)
return result;
break;
}
if (c == L'\b') {
if (!value.empty()) {
wchar_t last = value.back();
value.pop_back();
if (last >= 0xdc00 && last <= 0xdfff && !value.empty())
value.pop_back();
}
} else if (c >= 32) {
value += c;
if (value.size() > 256)
break;
}
}
throw std::runtime_error("password entry cancelled or too long");
}
std::string Logs() {
wchar_t root[MAX_PATH + 1];
DWORD size = GetTempPathW(MAX_PATH + 1, root);
if (!size || size > MAX_PATH)
throw std::runtime_error("cannot locate temporary directory");
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;OW)", SDDL_REVISION_1, &descriptor,
nullptr))
throw std::runtime_error("cannot secure log directory");
struct Release {
PSECURITY_DESCRIPTOR p;
~Release() { LocalFree(p); }
} release{descriptor};
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), descriptor, FALSE};
std::random_device random;
for (int attempt = 0; attempt < 10; ++attempt) {
std::wstring path = std::wstring(root) + L"crossdesk-cli-" +
std::to_wstring(random()) + L"-" +
std::to_wstring(random());
if (CreateDirectoryW(path.c_str(), &security))
return Utf8(path);
if (GetLastError() != ERROR_ALREADY_EXISTS)
break;
}
throw std::runtime_error("cannot create private log directory");
}
} // namespace crossdesk::cli
#endif
+49
View File
@@ -0,0 +1,49 @@
local source_dir = path.absolute("../..", os.scriptdir())
local controller_dir = path.join(source_dir, "libs/controller")
local terminal_dir = path.join(source_dir, "libs/terminal")
local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc")
local credential_patch = path.join(source_dir, "deps/patches/minirtc-credential-logs.patch")
add_requires("spdlog 1.14.1", {system = false})
if is_plat("windows") then
set_runtimes("MT")
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
end
target("crossdesk_controller")
set_kind("static")
add_deps("minirtc", "crossdesk_wire")
add_packages("spdlog")
add_includedirs(path.join(controller_dir, "include"),
path.join(minirtc_dir, "src/api"), {public = true})
add_includedirs(path.join(minirtc_dir, "src/log"))
add_files(path.join(controller_dir, "src/*.cpp"))
before_build(function ()
-- Require the same credential redaction in checkouts and source bundles.
os.vrunv("git", {"-C", minirtc_dir, "apply", "--reverse", "--check", credential_patch})
end)
target_end()
target("crossdesk_terminal")
set_kind("static")
add_deps("crossdesk_controller")
add_includedirs(path.join(terminal_dir, "include"), {public = true})
add_files(path.join(terminal_dir, "src/*.cpp"))
if is_plat("windows") then
add_packages("zlib", {public = true})
else
add_syslinks("z", {public = true})
end
target_end()
target("crossdesk-cli")
set_kind("binary")
set_version("0.1.0")
add_deps("crossdesk_terminal")
add_files("main.cpp")
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
if is_plat("windows") then
add_syslinks("advapi32")
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
end
target_end()