mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-02 03:52:43 +08:00
[feat] integrate CrossDesk CLI with terminal rendering and npm packaging
This commit is contained in:
@@ -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 服务。
|
||||
@@ -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 命令方法;未知方法报错。模型接入者必须另行取得截图/自动操作授权,不能把持续帧事件附加到聊天记录。
|
||||
@@ -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)。
|
||||
@@ -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 依赖的许可证、对应源码及重链接材料仍需随正式版本提供。
|
||||
@@ -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 时打开独立终端或给出播放器命令。
|
||||
|
||||
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
|
||||
@@ -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` 仅报告本机环境,不会联网登录或证明远端可连接。
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Executable
+12
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user