mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-03 12:32:53 +08:00
[feat] integrate CrossDesk CLI with terminal rendering and npm packaging
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
name: CLI native builds and npm packages
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
target: darwin-arm64
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
target: darwin-x64
|
||||
arch: x86_64
|
||||
- runner: ubuntu-22.04
|
||||
target: linux-x64
|
||||
arch: x86_64
|
||||
- runner: ubuntu-22.04-arm
|
||||
target: linux-arm64
|
||||
arch: arm64
|
||||
- runner: windows-2022
|
||||
target: win32-x64
|
||||
arch: x64
|
||||
- runner: windows-11-arm
|
||||
target: win32-arm64
|
||||
arch: arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: xmake-io/github-action-setup-xmake@v1
|
||||
with:
|
||||
xmake-version: 3.1.1
|
||||
- uses: ilammy/msvc-dev-cmd@v1
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
- name: Linux build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential pkg-config autoconf automake libtool nasm yasm zlib1g-dev python3-pip
|
||||
- name: Prepare pinned dependency
|
||||
run: sh apps/cli/scripts/prepare-deps.sh
|
||||
- name: Configure macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: xmake f -y -a ${{ matrix.arch }} -m release --target_minver=13.0 --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
|
||||
- name: Configure Linux or Windows
|
||||
if: runner.os != 'macOS'
|
||||
run: xmake f -y -a ${{ matrix.arch }} -m release --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
|
||||
- name: Build
|
||||
run: xmake build crossdesk-cli
|
||||
- name: Report native environment
|
||||
run: xmake run crossdesk-cli doctor --json
|
||||
- name: Package native program and npm entry
|
||||
run: npm --prefix apps/cli run pack:npm
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.target }}
|
||||
path: build/npm/*.tgz
|
||||
if-no-files-found: error
|
||||
@@ -13,3 +13,6 @@ xcuserdata/
|
||||
|
||||
# VSCode cache
|
||||
.vscode
|
||||
|
||||
# CLI npm dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -169,6 +169,8 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
|
||||
|
||||
## 构建与反馈
|
||||
|
||||
- [终端客户端 CLI](apps/cli/README.md):ANSI / Kitty 远程桌面观看、可选键鼠控制和 Pi 扩展;支持独立于桌面 GUI 的构建,npm 分发尚未发布。
|
||||
|
||||
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
|
||||
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
|
||||
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
|
||||
|
||||
@@ -169,6 +169,8 @@ Host your own signaling and TURN services, then enable them under **Settings →
|
||||
|
||||
## Build and feedback
|
||||
|
||||
- [Terminal client CLI](apps/cli/README.md) (Chinese): ANSI / Kitty remote viewing, optional keyboard and mouse control, and a Pi extension. The CLI can be built without the desktop GUI; npm packages are not published yet.
|
||||
|
||||
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
|
||||
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
|
||||
- [Connection architecture](docs/connection-architecture.md) (Chinese): discovery, authentication, negotiation, data paths, and lifecycle for ID and direct IP modes.
|
||||
|
||||
@@ -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()
|
||||
@@ -1,4 +1,4 @@
|
||||
function setup_options_and_dependencies()
|
||||
function setup_options_and_dependencies(enable_dependencies)
|
||||
option("CROSSDESK_UPDATE_PUBLIC_KEY")
|
||||
set_showmenu(true)
|
||||
set_description("Ed25519 public key (64 lowercase hex characters); empty disables automatic installation")
|
||||
@@ -34,6 +34,11 @@ function setup_options_and_dependencies()
|
||||
set_description("Build CrossDesk as a portable package that stores data beside the executable")
|
||||
option_end()
|
||||
|
||||
-- Keep options discoverable when switching back from a CLI-only build.
|
||||
if enable_dependencies == false then
|
||||
return
|
||||
end
|
||||
|
||||
-- set_policy("build.warning", true)
|
||||
-- set_warnings("all", "extra")
|
||||
-- add_cxxflags("/W4", "/WX")
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
diff --git a/src/pc/peer_connection.cpp b/src/pc/peer_connection.cpp
|
||||
index bb9eb49..eda133f 100644
|
||||
--- a/src/pc/peer_connection.cpp
|
||||
+++ b/src/pc/peer_connection.cpp
|
||||
@@ -459,3 +459,3 @@ int PeerConnection::SendJoinRequestLocked(
|
||||
LOG_INFO("[{}] sends join transmission [{}] request", user_id_,
|
||||
- transmission_id);
|
||||
+ transmission_id.substr(0, transmission_id.find('@')));
|
||||
}
|
||||
@@ -481,3 +481,3 @@ int PeerConnection::Join(const std::string& transmission_id) {
|
||||
LOG_INFO("Queue join transmission [{}] until signal login completes",
|
||||
- transmission_id);
|
||||
+ transmission_id.substr(0, transmission_id.find('@')));
|
||||
return 0;
|
||||
@@ -498,3 +498,3 @@ int PeerConnection::Leave(const std::string& transmission_id) {
|
||||
LOG_INFO("Cancel queued join transmission [{}]",
|
||||
- *pending_join_transmission_id_);
|
||||
+ pending_join_transmission_id_->substr(0, pending_join_transmission_id_->find('@')));
|
||||
pending_join_transmission_id_.reset();
|
||||
@@ -508,3 +508,3 @@ int PeerConnection::Leave(const std::string& transmission_id) {
|
||||
LOG_INFO("[{}] leave transmission [{}] notification queued",
|
||||
- user_id_, transmission_id);
|
||||
+ user_id_, transmission_id.substr(0, transmission_id.find('@')));
|
||||
}
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: cdcd0d8fb0...b9ee1fa7fe
@@ -9,6 +9,8 @@
|
||||
|
||||
## 构建依赖
|
||||
|
||||
默认只构建桌面客户端。终端主控可通过 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 单独构建;同时开启两个选项可构建两个客户端。CLI 的依赖准备、运行和 npm 打包见 [终端客户端](../apps/cli/README.md)。
|
||||
|
||||
- Git、[Xmake](https://xmake.io/guide/quick-start.html)(桌面端和移动端均要求 3.1.1 或更新版本)及下文对应平台的编译工具链。
|
||||
- 构建脚本通过 Xmake 管理 **CMake 3.x(≥ 3.21、< 4.0)**、**Rust 1.92.0** 等依赖;首次构建需要下载工具与源码。具体约束见 [Slint 包配置](../deps/thirdparty/slint/xmake.lua)。
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ After cloning, run build and packaging commands from the repository root. The [X
|
||||
|
||||
## Build requirements
|
||||
|
||||
The default configuration builds the desktop client. Use `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` for the terminal controller alone, or enable both options to build both clients. See the [CLI guide](../apps/cli/README.md) (Chinese) for dependency preparation, usage and npm packaging.
|
||||
|
||||
- Git, [Xmake](https://xmake.io/guide/quick-start.html) (3.1.1 or newer for both desktop and mobile), and the platform toolchain listed below.
|
||||
- The build scripts use Xmake to manage **CMake 3.x (≥ 3.21, < 4.0)**, **Rust 1.92.0**, and other dependencies. The first build downloads tools and source archives. See the [Slint package configuration](../deps/thirdparty/slint/xmake.lua) for the constraints.
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-30
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CONTROLLER_SESSION_H_
|
||||
#define _CONTROLLER_SESSION_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <minirtc.h>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <optional>
|
||||
#include <remote_action.h>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
struct DecodedFrame {
|
||||
unsigned width = 0, height = 0;
|
||||
uint64_t generation = 0, frame_id = 0;
|
||||
std::chrono::steady_clock::time_point queued;
|
||||
std::vector<uint8_t> nv12;
|
||||
};
|
||||
// One replaceable pending picture; Take transfers ownership to the consumer.
|
||||
// Callback memory is copied synchronously. Native descriptors never escape.
|
||||
class LatestFrame {
|
||||
public:
|
||||
bool Submit(const MiniRtcVideoFrame &, uint64_t generation);
|
||||
// Recycle the caller's old buffer only when a new frame is available.
|
||||
std::optional<DecodedFrame> Take(DecodedFrame *recycle = nullptr);
|
||||
void Reset(uint64_t generation);
|
||||
nlohmann::json Stats() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
std::optional<DecodedFrame> pending_;
|
||||
std::vector<uint8_t> spare_;
|
||||
uint64_t generation_ = 0, received_ = 0, dropped_ = 0, invalid_ = 0;
|
||||
};
|
||||
struct SessionOptions {
|
||||
std::string host = "api.crossdesk.cn", log_directory;
|
||||
int port = 9099, display = 0, fps = 8;
|
||||
int preview_width = 1920, preview_height = 1080;
|
||||
int quality = 2, max_bitrate_bps = 6000000;
|
||||
std::chrono::seconds timeout{30};
|
||||
};
|
||||
// All public methods except the destructor run on one owner thread. Call Tick
|
||||
// regularly. Library callbacks only enqueue bounded events or copy one frame.
|
||||
class ControllerSession {
|
||||
public:
|
||||
ControllerSession() = default;
|
||||
~ControllerSession();
|
||||
void Connect(SessionOptions options, std::string device,
|
||||
std::string password);
|
||||
void Tick();
|
||||
void Disconnect();
|
||||
bool SelectDisplay(int index);
|
||||
void SetPreview(int fps, int width, int height, int quality = -1,
|
||||
int max_bitrate_bps = -1);
|
||||
void SetControl(bool enabled);
|
||||
bool Text(const std::string &utf8);
|
||||
bool Key(int key, bool down);
|
||||
bool Pointer(float x, float y, int flag, int wheel = 0);
|
||||
std::optional<DecodedFrame> TakeFrame(DecodedFrame *recycle = nullptr);
|
||||
nlohmann::json Status() const;
|
||||
bool Connected() const { return state_ == "connected"; }
|
||||
bool Finished() const { return state_ == "failed" || state_ == "closed"; }
|
||||
uint64_t Generation() const { return generation_; }
|
||||
|
||||
private:
|
||||
struct Event {
|
||||
int kind, code;
|
||||
std::string data;
|
||||
};
|
||||
void Enqueue(Event event) noexcept;
|
||||
void Fail(const char *reason);
|
||||
void ClosePeers();
|
||||
Params Parameters();
|
||||
bool Send(const char *stream, const std::string &payload);
|
||||
void Preview();
|
||||
void ReleaseInput();
|
||||
static void Signal(SignalStatus, const char *, size_t, void *);
|
||||
static void Connection(ConnectionStatus, const char *, size_t, void *);
|
||||
static void Data(const char *, size_t, const char *, size_t, const char *,
|
||||
size_t, void *);
|
||||
static void Video(const MiniRtcVideoFrame *, const char *, size_t,
|
||||
const char *, size_t, void *);
|
||||
static void Network(const char *, size_t, TraversalMode,
|
||||
const MiniRtcNetTrafficStats *, const char *, size_t,
|
||||
void *);
|
||||
SessionOptions options_;
|
||||
PeerPtr *peer_ = nullptr;
|
||||
std::string state_ = "idle", error_, device_, password_, client_id_;
|
||||
uint64_t generation_ = 0;
|
||||
uint32_t preview_request_ = 0, preview_ack_ = 0;
|
||||
bool control_ = false, preview_supported_ = false, resolution_ = false;
|
||||
bool fps15_ = false, bandwidth_ = false, text_supported_ = false;
|
||||
bool preview_accepted_ = false;
|
||||
std::vector<HostDisplay> displays_;
|
||||
std::set<int> keys_, buttons_;
|
||||
float pointer_x_ = .5f, pointer_y_ = .5f;
|
||||
std::chrono::steady_clock::time_point deadline_;
|
||||
mutable std::mutex mutex_;
|
||||
std::deque<Event> events_;
|
||||
bool stopping_ = true, overflow_ = false;
|
||||
uint64_t callback_generation_ = 0;
|
||||
int callback_display_ = 0;
|
||||
MiniRtcNetTrafficStats network_{};
|
||||
TraversalMode traversal_ = UnknownMode;
|
||||
LatestFrame frames_;
|
||||
};
|
||||
nlohmann::json ParseBoundedJson(const std::string &text);
|
||||
std::string SafeText(const std::string &text, size_t limit = 160);
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,436 @@
|
||||
#include "controller_session.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <display_stream_id.h>
|
||||
#include <log.h>
|
||||
#include <preview_subscription.h>
|
||||
#include <stdexcept>
|
||||
#include <stream_names.h>
|
||||
|
||||
namespace crossdesk {
|
||||
using nlohmann::json;
|
||||
ControllerSession::~ControllerSession() { Disconnect(); }
|
||||
void ControllerSession::Enqueue(Event e) noexcept {
|
||||
try {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (stopping_)
|
||||
return;
|
||||
if (events_.size() == 64) {
|
||||
overflow_ = true;
|
||||
return;
|
||||
}
|
||||
events_.push_back(std::move(e));
|
||||
} catch (...) { /* Allocation failure cannot escape a C callback. */
|
||||
}
|
||||
}
|
||||
void ControllerSession::Signal(SignalStatus s, const char *id, size_t n,
|
||||
void *p) {
|
||||
try {
|
||||
static_cast<ControllerSession *>(p)->Enqueue(
|
||||
{0, s, id && n < 256 ? std::string(id, n) : ""});
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void ControllerSession::Connection(ConnectionStatus s, const char *, size_t,
|
||||
void *p) {
|
||||
auto &session = *static_cast<ControllerSession *>(p);
|
||||
session.Enqueue({1, s, {}});
|
||||
}
|
||||
void ControllerSession::Data(const char *data, size_t n, const char *, size_t,
|
||||
const char *stream, size_t sn, void *p) {
|
||||
auto &session = *static_cast<ControllerSession *>(p);
|
||||
try {
|
||||
if (data && n && n <= 65536 && stream && sn < 64 &&
|
||||
(std::string(stream, sn) == kControlStream ||
|
||||
std::string(stream, sn) == kWebControlStream))
|
||||
session.Enqueue({2, 0, std::string(data, n)});
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void ControllerSession::Video(const MiniRtcVideoFrame *f, const char *, size_t,
|
||||
const char *stream, size_t n, void *p) {
|
||||
auto &s = *static_cast<ControllerSession *>(p);
|
||||
try {
|
||||
std::lock_guard lock(s.mutex_);
|
||||
if (!s.stopping_ && f && stream && n < 64 &&
|
||||
std::string(stream, n) == MakeDisplayStreamId(s.callback_display_))
|
||||
s.frames_.Submit(*f, s.callback_generation_);
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void ControllerSession::Network(const char *, size_t, TraversalMode mode,
|
||||
const MiniRtcNetTrafficStats *stats,
|
||||
const char *, size_t, void *p) {
|
||||
auto &session = *static_cast<ControllerSession *>(p);
|
||||
if (stats) {
|
||||
std::lock_guard lock(session.mutex_);
|
||||
session.network_ = *stats;
|
||||
session.traversal_ = mode;
|
||||
}
|
||||
}
|
||||
Params ControllerSession::Parameters() {
|
||||
Params p{};
|
||||
std::snprintf(p.signal_server_ip, sizeof(p.signal_server_ip), "%s",
|
||||
options_.host.c_str());
|
||||
std::snprintf(p.log_path, sizeof(p.log_path), "%s",
|
||||
options_.log_directory.c_str());
|
||||
p.signal_server_port = options_.port;
|
||||
p.hardware_acceleration = false;
|
||||
p.native_video_output = true;
|
||||
p.av1_encoding = false;
|
||||
p.turn_mode = TurnAutoUdpTcp;
|
||||
p.enable_srtp = true;
|
||||
p.video_content_type = VideoContentType::ScreenContent;
|
||||
p.video_quality = QualityLow;
|
||||
p.video_frame_rate = 30;
|
||||
p.video_degradation_preference =
|
||||
VideoDegradationPreference::MaintainResolution;
|
||||
p.on_signal_status = Signal;
|
||||
p.on_connection_status = Connection;
|
||||
p.on_net_status_report = Network;
|
||||
p.on_receive_data_buffer = Data;
|
||||
p.on_receive_video_frame = Video;
|
||||
// No audio callback, clipboard receiver, file writer or incoming input
|
||||
// handler.
|
||||
p.user_id = "cli";
|
||||
p.user_data = this;
|
||||
return p;
|
||||
}
|
||||
void ControllerSession::Connect(SessionOptions options, std::string device,
|
||||
std::string password) {
|
||||
Disconnect();
|
||||
if (device.size() != 9 ||
|
||||
device.find_first_not_of("0123456789") != std::string::npos)
|
||||
throw std::invalid_argument("device ID must contain 9 digits");
|
||||
if (password.size() > 256 || password.find('\0') != std::string::npos)
|
||||
throw std::invalid_argument("invalid password length or encoding");
|
||||
if (options.host.empty() || options.host.size() >= 256 || options.port < 1 ||
|
||||
options.port > 65535 || options.log_directory.empty() ||
|
||||
options.log_directory.size() >= 256 || options.display < 0 ||
|
||||
options.display > 63)
|
||||
throw std::invalid_argument(
|
||||
"invalid server, log directory or display index");
|
||||
options_ = std::move(options);
|
||||
device_ = std::move(device);
|
||||
password_ = std::move(password);
|
||||
state_ = "registering";
|
||||
error_.clear();
|
||||
displays_.clear();
|
||||
preview_supported_ = resolution_ = fps15_ = bandwidth_ = text_supported_ =
|
||||
false;
|
||||
preview_request_ = preview_ack_ = 0;
|
||||
preview_accepted_ = false;
|
||||
deadline_ = std::chrono::steady_clock::now() + options_.timeout;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
stopping_ = false;
|
||||
overflow_ = false;
|
||||
events_.clear();
|
||||
network_ = {};
|
||||
traversal_ = UnknownMode;
|
||||
callback_generation_ = ++generation_;
|
||||
callback_display_ = options_.display;
|
||||
frames_.Reset(generation_);
|
||||
}
|
||||
// The server assigns a temporary controller identity on this connection.
|
||||
// Never register a desktop identity or persist local credentials.
|
||||
client_id_.clear();
|
||||
minirtc::InitLogger(options_.log_directory);
|
||||
minirtc::get_logger()->set_level(spdlog::level::off);
|
||||
auto p = Parameters();
|
||||
peer_ = CreatePeer(&p);
|
||||
if (!peer_) {
|
||||
Fail("media_initialization_failed");
|
||||
return;
|
||||
}
|
||||
AddAudioStream(peer_, kAudioStream);
|
||||
AddDataStream(peer_, kDataStream, false);
|
||||
for (auto stream : {kMouseStream, kKeyboardStream, kControlStream})
|
||||
AddDataStream(peer_, stream, true);
|
||||
if (Init(peer_) != 0)
|
||||
Fail("media_initialization_failed");
|
||||
}
|
||||
void ControllerSession::Tick() {
|
||||
std::deque<Event> pending;
|
||||
bool overflow;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
pending.swap(events_);
|
||||
overflow = overflow_;
|
||||
}
|
||||
if (Finished() || state_ == "idle")
|
||||
return;
|
||||
if (overflow) {
|
||||
Fail("control_event_overflow");
|
||||
return;
|
||||
}
|
||||
for (auto &e : pending) {
|
||||
if (Finished())
|
||||
break;
|
||||
if (e.kind == 0) {
|
||||
if (e.code == SignalTlsCertError) {
|
||||
Fail("tls_certificate_error");
|
||||
break;
|
||||
}
|
||||
if (e.code == SignalFailed) {
|
||||
Fail("signaling_login_failed");
|
||||
break;
|
||||
}
|
||||
if (e.code == SignalClosed || e.code == SignalServerClosed ||
|
||||
e.code == SignalReconnecting) {
|
||||
Fail("signaling_disconnected");
|
||||
break;
|
||||
}
|
||||
if (e.code != SignalConnected)
|
||||
continue;
|
||||
if (state_ == "registering") {
|
||||
if (e.data.size() != 13 || e.data.rfind("cli-", 0) != 0 ||
|
||||
e.data.find_first_not_of("0123456789", 4) != std::string::npos) {
|
||||
Fail("cli_identity_not_supported");
|
||||
break;
|
||||
}
|
||||
client_id_ = e.data;
|
||||
state_ = "connecting";
|
||||
const auto join = device_ + "@" + password_;
|
||||
if (JoinConnection(peer_, join.c_str()) != 0)
|
||||
Fail("join_request_failed");
|
||||
std::fill(password_.begin(), password_.end(), '\0');
|
||||
password_.clear();
|
||||
}
|
||||
} else if (e.kind == 1) {
|
||||
if (e.code == ::Connected && !Connected()) {
|
||||
state_ = "connected";
|
||||
auto hello = MakeHostInformation("CrossDesk CLI", {}, false);
|
||||
Send(kControlStream, hello.to_json());
|
||||
FreeRemoteAction(hello);
|
||||
RemoteAction mute{};
|
||||
mute.type = audio_capture;
|
||||
mute.a = false;
|
||||
Send(kControlStream, mute.to_json());
|
||||
} else if (e.code == IncorrectPassword)
|
||||
Fail("incorrect_password");
|
||||
else if (e.code == NoSuchTransmissionId)
|
||||
Fail("device_not_found");
|
||||
else if (e.code == RemoteUnavailable)
|
||||
Fail("device_offline_or_unavailable");
|
||||
else if (e.code == Failed)
|
||||
Fail("connection_failed");
|
||||
else if (e.code == Closed || e.code == Disconnected)
|
||||
Fail("remote_disconnected");
|
||||
} else if (e.kind == 2 && Connected()) {
|
||||
auto j = ParseBoundedJson(e.data);
|
||||
if (!j.is_object())
|
||||
continue;
|
||||
if (j.value("type", json{}) == "deskhub_preview_status") {
|
||||
if (j.value("request_id", json{}) == preview_request_ &&
|
||||
j.contains("accepted") && j["accepted"].is_boolean()) {
|
||||
preview_ack_ = preview_request_;
|
||||
preview_accepted_ = j["accepted"];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
RemoteAction a{};
|
||||
if (!a.from_json(e.data))
|
||||
continue;
|
||||
if (a.type == host_infomation) {
|
||||
displays_.clear();
|
||||
for (size_t i = 0; i < std::min(a.i.display_num, size_t(64)); ++i)
|
||||
displays_.push_back({SafeText(a.i.display_list[i]), a.i.left[i],
|
||||
a.i.top[i], a.i.right[i], a.i.bottom[i]});
|
||||
preview_supported_ = a.i.supports_deskhub_preview;
|
||||
resolution_ = a.i.supports_deskhub_resolution;
|
||||
fps15_ = a.i.supports_deskhub_fps15;
|
||||
bandwidth_ = a.i.supports_deskhub_bandwidth;
|
||||
text_supported_ = a.i.supports_text_input;
|
||||
if (!SelectDisplay(options_.display))
|
||||
Fail("display_not_available");
|
||||
else
|
||||
Preview();
|
||||
}
|
||||
FreeRemoteAction(a);
|
||||
}
|
||||
}
|
||||
if (!Connected() && !Finished() &&
|
||||
std::chrono::steady_clock::now() > deadline_)
|
||||
Fail("connection_timeout");
|
||||
}
|
||||
bool ControllerSession::Send(const char *stream, const std::string &payload) {
|
||||
return peer_ && Connected() && !payload.empty() &&
|
||||
SendReliableDataFrame(peer_, payload.data(), payload.size(), stream) ==
|
||||
0;
|
||||
}
|
||||
void ControllerSession::ClosePeers() {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
stopping_ = true;
|
||||
callback_generation_ = ++generation_;
|
||||
frames_.Reset(generation_);
|
||||
}
|
||||
if (peer_) {
|
||||
LeaveConnection(peer_, device_.c_str());
|
||||
DestroyPeer(&peer_);
|
||||
}
|
||||
std::fill(password_.begin(), password_.end(), '\0');
|
||||
password_.clear();
|
||||
}
|
||||
void ControllerSession::Fail(const char *reason) {
|
||||
ReleaseInput();
|
||||
error_ = reason;
|
||||
state_ = "failed";
|
||||
control_ = false;
|
||||
ClosePeers();
|
||||
}
|
||||
void ControllerSession::Disconnect() {
|
||||
if (state_ == "closed" && !control_)
|
||||
return;
|
||||
ReleaseInput();
|
||||
control_ = false;
|
||||
ClosePeers();
|
||||
state_ = "closed";
|
||||
}
|
||||
bool ControllerSession::SelectDisplay(int index) {
|
||||
if (!Connected() || index < 0 || size_t(index) >= displays_.size())
|
||||
return false;
|
||||
ReleaseInput();
|
||||
options_.display = index;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
callback_display_ = index;
|
||||
callback_generation_ = ++generation_;
|
||||
frames_.Reset(generation_);
|
||||
}
|
||||
RemoteAction a{};
|
||||
a.type = display_id;
|
||||
a.d = index;
|
||||
return Send(kControlStream, a.to_json());
|
||||
}
|
||||
void ControllerSession::SetPreview(int fps, int w, int h, int quality,
|
||||
int bitrate) {
|
||||
if (fps < 0 || fps > 30 || w < 2 || h < 2 || w > 3840 || h > 2160 ||
|
||||
quality < -1 || quality > 2 || bitrate < -1 || bitrate > 100000000)
|
||||
throw std::invalid_argument("preview out of range");
|
||||
quality = quality < 0 ? options_.quality : quality;
|
||||
bitrate = bitrate < 0 ? options_.max_bitrate_bps : bitrate;
|
||||
if (options_.fps == fps && options_.preview_width == w &&
|
||||
options_.preview_height == h && options_.quality == quality &&
|
||||
options_.max_bitrate_bps == bitrate)
|
||||
return;
|
||||
options_.fps = fps;
|
||||
options_.preview_width = w;
|
||||
options_.preview_height = h;
|
||||
options_.quality = quality;
|
||||
options_.max_bitrate_bps = bitrate;
|
||||
Preview();
|
||||
}
|
||||
void ControllerSession::Preview() {
|
||||
if (!Connected() || !preview_supported_)
|
||||
return;
|
||||
PreviewSubscription s;
|
||||
s.request_id = ++preview_request_;
|
||||
preview_accepted_ = false;
|
||||
s.fps = options_.fps == 0 ? 0
|
||||
: options_.fps <= 1 ? 1
|
||||
: options_.fps <= 8 || !fps15_ ? 8
|
||||
: 15;
|
||||
s.audio = false;
|
||||
s.quality = options_.quality;
|
||||
if (resolution_) {
|
||||
s.version = 2;
|
||||
s.width = options_.preview_width;
|
||||
s.height = options_.preview_height;
|
||||
}
|
||||
if (bandwidth_)
|
||||
s.max_bitrate_bps = options_.max_bitrate_bps;
|
||||
Send(kControlStream, s.ToJson().dump());
|
||||
}
|
||||
void ControllerSession::SetControl(bool enabled) {
|
||||
if (!enabled)
|
||||
ReleaseInput();
|
||||
control_ = enabled;
|
||||
}
|
||||
void ControllerSession::ReleaseInput() {
|
||||
auto keys = keys_;
|
||||
for (int k : keys)
|
||||
Key(k, false);
|
||||
auto buttons = buttons_;
|
||||
for (int b : buttons)
|
||||
Pointer(pointer_x_, pointer_y_, b + 1);
|
||||
keys_.clear();
|
||||
buttons_.clear();
|
||||
}
|
||||
bool ControllerSession::Key(int key, bool down) {
|
||||
if (!control_ || !Connected() || key < 0 || key > 255)
|
||||
return false;
|
||||
RemoteAction a{};
|
||||
a.type = keyboard;
|
||||
a.k = {size_t(key), 0, false, down ? key_down : key_up};
|
||||
if (down)
|
||||
keys_.insert(key);
|
||||
else
|
||||
keys_.erase(key);
|
||||
return Send(kKeyboardStream, a.to_json());
|
||||
}
|
||||
bool ControllerSession::Pointer(float x, float y, int flag, int wheel) {
|
||||
if (!control_ || !Connected() || !std::isfinite(x) || !std::isfinite(y) ||
|
||||
x < 0 || y < 0 || x > 1 || y > 1 || flag < 0 || flag > 8 ||
|
||||
std::abs(int64_t(wheel)) > 1200)
|
||||
return false;
|
||||
RemoteAction a{};
|
||||
a.type = mouse;
|
||||
a.m = {x, y, wheel, static_cast<MouseFlag>(flag)};
|
||||
pointer_x_ = x;
|
||||
pointer_y_ = y;
|
||||
if (flag >= 1 && flag <= 6) {
|
||||
if (flag & 1)
|
||||
buttons_.insert(flag);
|
||||
else
|
||||
buttons_.erase(flag - 1);
|
||||
}
|
||||
return Send(kMouseStream, a.to_json());
|
||||
}
|
||||
bool ControllerSession::Text(const std::string &text) {
|
||||
if (!control_ || !Connected() || !text_supported_ || text.empty() ||
|
||||
text.size() > kMaxTextInputBytes)
|
||||
return false;
|
||||
RemoteAction a{};
|
||||
a.type = text_input;
|
||||
a.text = text;
|
||||
try {
|
||||
return Send(kKeyboardStream, a.to_json());
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::optional<DecodedFrame> ControllerSession::TakeFrame(DecodedFrame *recycle) {
|
||||
return frames_.Take(recycle);
|
||||
}
|
||||
json ControllerSession::Status() const {
|
||||
json ds = json::array();
|
||||
for (size_t i = 0; i < displays_.size(); ++i)
|
||||
ds.push_back({{"index", i}, {"name", displays_[i].name}});
|
||||
std::lock_guard lock(mutex_);
|
||||
return {{"state", state_},
|
||||
{"client_id", client_id_},
|
||||
{"error", error_},
|
||||
{"generation", generation_},
|
||||
{"control", control_},
|
||||
{"displays", ds},
|
||||
{"display", options_.display},
|
||||
{"text_input", text_supported_},
|
||||
{"preview_supported", preview_supported_},
|
||||
{"preview_request", preview_request_},
|
||||
{"preview_ack", preview_ack_},
|
||||
{"preview_accepted", preview_accepted_},
|
||||
{"preview_width", options_.preview_width},
|
||||
{"preview_height", options_.preview_height},
|
||||
{"preview_quality", options_.quality},
|
||||
{"preview_bitrate_bps", options_.max_bitrate_bps},
|
||||
{"frames", frames_.Stats()},
|
||||
{"network",
|
||||
{{"video_bps", network_.video_inbound_stats.bitrate},
|
||||
{"rtt_ms", network_.rtt_ms},
|
||||
{"srtp", network_.srtp_active},
|
||||
{"traversal", int(traversal_)}}}};
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "controller_session.h"
|
||||
#include <climits>
|
||||
#include <cstring>
|
||||
|
||||
namespace crossdesk {
|
||||
bool LatestFrame::Submit(const MiniRtcVideoFrame &f, uint64_t generation) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (generation != generation_)
|
||||
return false;
|
||||
const size_t w = f.width, h = f.height;
|
||||
if (!w || !h || (w & 1) || (h & 1) || w > 8192 || h > 8192 ||
|
||||
w * h > 16777216) {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
const size_t size = w * h * 3 / 2;
|
||||
const auto *n = f.native_frame;
|
||||
const bool native = n && n->struct_size >= sizeof(*n) && n->width == w &&
|
||||
n->height == h && n->owner;
|
||||
if (native && n->type == MiniRtcNativeVideoFrameCpuNv12) {
|
||||
const auto &p = n->payload.cpu_nv12;
|
||||
if (!p.y_plane || !p.uv_plane || p.y_stride < w || p.uv_stride < w ||
|
||||
p.y_stride > INT_MAX || p.uv_stride > INT_MAX) {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
spare_.resize(size);
|
||||
for (size_t y = 0; y < h; ++y)
|
||||
std::memcpy(spare_.data() + y * w, p.y_plane + y * p.y_stride, w);
|
||||
for (size_t y = 0; y < h / 2; ++y)
|
||||
std::memcpy(spare_.data() + w * h + y * w, p.uv_plane + y * p.uv_stride,
|
||||
w);
|
||||
} else if (f.data && f.size >= size) {
|
||||
spare_.resize(size);
|
||||
std::memcpy(spare_.data(), f.data, size);
|
||||
} else if (native && n->copy_to_nv12) {
|
||||
spare_.resize(size);
|
||||
if (n->copy_to_nv12(n->owner, spare_.data(), size) != 0) {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
DecodedFrame frame{f.width,
|
||||
f.height,
|
||||
generation,
|
||||
++received_,
|
||||
std::chrono::steady_clock::now(),
|
||||
std::move(spare_)};
|
||||
// Keep a valid pending frame until the replacement has been copied.
|
||||
if (pending_) {
|
||||
spare_.swap(pending_->nv12);
|
||||
++dropped_;
|
||||
}
|
||||
pending_ = std::move(frame);
|
||||
return true;
|
||||
}
|
||||
std::optional<DecodedFrame> LatestFrame::Take(DecodedFrame *recycle) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!pending_)
|
||||
return {};
|
||||
auto result = std::move(pending_);
|
||||
pending_.reset();
|
||||
if (recycle && recycle->nv12.capacity() > spare_.capacity())
|
||||
spare_.swap(recycle->nv12);
|
||||
return result;
|
||||
}
|
||||
void LatestFrame::Reset(uint64_t generation) {
|
||||
std::lock_guard lock(mutex_);
|
||||
generation_ = generation;
|
||||
pending_.reset();
|
||||
spare_.clear();
|
||||
}
|
||||
nlohmann::json LatestFrame::Stats() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return {{"received", received_},
|
||||
{"replaced", dropped_},
|
||||
{"invalid", invalid_},
|
||||
{"pending", pending_ ? 1 : 0}};
|
||||
}
|
||||
nlohmann::json ParseBoundedJson(const std::string &text) {
|
||||
if (text.size() > 65536)
|
||||
return {};
|
||||
int depth = 0;
|
||||
bool quoted = false, escaped = false;
|
||||
for (char c : text) {
|
||||
if (quoted) {
|
||||
if (escaped)
|
||||
escaped = false;
|
||||
else if (c == '\\')
|
||||
escaped = true;
|
||||
else if (c == '"')
|
||||
quoted = false;
|
||||
} else if (c == '"')
|
||||
quoted = true;
|
||||
else if (c == '{' || c == '[') {
|
||||
if (++depth > 32)
|
||||
return {};
|
||||
} else if (c == '}' || c == ']') {
|
||||
if (--depth < 0)
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return nlohmann::json::parse(text, nullptr, false);
|
||||
}
|
||||
std::string SafeText(const std::string &text, size_t limit) {
|
||||
// ASCII metadata avoids escape injection, C1 controls and bidi spoofing.
|
||||
std::string out;
|
||||
for (unsigned char c : text) {
|
||||
if (out.size() == limit)
|
||||
break;
|
||||
out += c >= 32 && c < 127 ? char(c) : '?';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-30
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _TERMINAL_RENDERER_H_
|
||||
#define _TERMINAL_RENDERER_H_
|
||||
|
||||
#include <controller_session.h>
|
||||
namespace crossdesk::terminal {
|
||||
enum class AnsiDetail { Half, Quadrant };
|
||||
struct Viewport {
|
||||
int columns = 80, rows = 23;
|
||||
double cell_aspect = .5;
|
||||
bool utf8 = true;
|
||||
int colors = 24;
|
||||
AnsiDetail detail = AnsiDetail::Half;
|
||||
double zoom = 1, center_x = .5, center_y = .5;
|
||||
int pixel_width = 0, pixel_height = 0;
|
||||
bool native = false, pixel_mode = false;
|
||||
};
|
||||
struct Rect {
|
||||
int x, y, width, height;
|
||||
};
|
||||
struct Cell {
|
||||
uint32_t top = 0, bottom = 0;
|
||||
uint32_t glyph = 0x2580;
|
||||
bool operator==(const Cell &other) const {
|
||||
return top == other.top && bottom == other.bottom && glyph == other.glyph;
|
||||
}
|
||||
};
|
||||
struct Grid {
|
||||
Viewport viewport;
|
||||
Rect content{};
|
||||
std::vector<Cell> cells;
|
||||
Rect source{};
|
||||
unsigned source_width = 0, source_height = 0;
|
||||
int samples_x = 1, samples_y = 2;
|
||||
};
|
||||
Rect Fit(int width, int height, int columns, int pixel_rows,
|
||||
double pixel_aspect = 1);
|
||||
Grid Layout(const DecodedFrame &, Viewport);
|
||||
Grid Convert(const DecodedFrame &, Viewport);
|
||||
std::optional<std::pair<float, float>> MapPointer(const Grid &, int column,
|
||||
int row);
|
||||
std::vector<std::string> Lines(const Grid &);
|
||||
std::string Difference(const Grid &, const std::optional<Grid> &previous);
|
||||
std::string Kitty(const DecodedFrame &, const Viewport &);
|
||||
inline constexpr char kKittyQuery[] =
|
||||
"\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\";
|
||||
inline constexpr char kKittyDelete[] = "\033_Ga=d,d=I,i=31,q=2\033\\";
|
||||
bool KittyReply(const std::string &response);
|
||||
// Incremental parser: protocol replies and unknown escapes are consumed
|
||||
// locally. Key taps always release immediately; paste uses the UTF-8 text
|
||||
// protocol.
|
||||
class Input {
|
||||
public:
|
||||
void Feed(const std::string &bytes, ControllerSession &, const Grid *grid);
|
||||
bool exit = false, control = false, native = false;
|
||||
std::string error;
|
||||
double zoom = 1, center_x = .5, center_y = .5;
|
||||
uint64_t view_revision = 0;
|
||||
|
||||
private:
|
||||
std::string pending_;
|
||||
std::chrono::steady_clock::time_point last_input_{};
|
||||
bool pasting_ = false;
|
||||
float pointer_x_ = .5f, pointer_y_ = .5f;
|
||||
void Tap(ControllerSession &, int key, int modifier = 0);
|
||||
void Navigate(char key, const Grid *);
|
||||
};
|
||||
} // namespace crossdesk::terminal
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,255 @@
|
||||
#include "terminal_renderer.h"
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
namespace crossdesk::terminal {
|
||||
void Input::Tap(ControllerSession &s, int key, int modifier) {
|
||||
if (modifier)
|
||||
s.Key(modifier, true);
|
||||
s.Key(key, true);
|
||||
s.Key(key, false);
|
||||
if (modifier)
|
||||
s.Key(modifier, false);
|
||||
}
|
||||
void Input::Navigate(char key, const Grid *grid) {
|
||||
if (key == '+' || key == '=')
|
||||
zoom = std::min(32.0, zoom * 2);
|
||||
if (key == '-')
|
||||
zoom = std::max(1.0, zoom / 2);
|
||||
if (key == '0') {
|
||||
native = false;
|
||||
zoom = 1;
|
||||
center_x = center_y = .5;
|
||||
}
|
||||
if (key == '1') {
|
||||
native = true;
|
||||
zoom = 1;
|
||||
}
|
||||
// Use the displayed crop as the pan step, including native pixel mode.
|
||||
const double dx = grid && grid->source_width
|
||||
? double(grid->source.width) / grid->source_width
|
||||
: 1 / zoom;
|
||||
const double dy = grid && grid->source_height
|
||||
? double(grid->source.height) / grid->source_height
|
||||
: 1 / zoom;
|
||||
if (key == 'h' || key == 'j' || key == 'k' || key == 'l') {
|
||||
center_x = std::clamp(center_x, dx / 2, 1 - dx / 2);
|
||||
center_y = std::clamp(center_y, dy / 2, 1 - dy / 2);
|
||||
}
|
||||
if (key == 'h')
|
||||
center_x -= .2 * dx;
|
||||
if (key == 'l')
|
||||
center_x += .2 * dx;
|
||||
if (key == 'k')
|
||||
center_y -= .2 * dy;
|
||||
if (key == 'j')
|
||||
center_y += .2 * dy;
|
||||
center_x = std::clamp(center_x, 0.0, 1.0);
|
||||
center_y = std::clamp(center_y, 0.0, 1.0);
|
||||
++view_revision;
|
||||
}
|
||||
void Input::Feed(const std::string &bytes, ControllerSession &s,
|
||||
const Grid *grid) {
|
||||
if (pending_.size() + bytes.size() > 32768) {
|
||||
pending_.clear();
|
||||
pasting_ = false;
|
||||
s.SetControl(false);
|
||||
control = false;
|
||||
return;
|
||||
}
|
||||
if (!bytes.empty()) {
|
||||
last_input_ = std::chrono::steady_clock::now();
|
||||
error.clear();
|
||||
}
|
||||
if (bytes.empty() && pending_ == "\033" &&
|
||||
std::chrono::steady_clock::now() - last_input_ >
|
||||
std::chrono::milliseconds(80)) {
|
||||
Tap(s, 27);
|
||||
pending_.clear();
|
||||
return;
|
||||
}
|
||||
pending_ += bytes;
|
||||
while (!pending_.empty()) {
|
||||
if (pasting_) {
|
||||
auto end = pending_.find("\033[201~");
|
||||
if (end == std::string::npos)
|
||||
return; // bounded by 32 KiB; one UTF-8 transaction
|
||||
if (control && !s.Text(pending_.substr(0, end)))
|
||||
error = "Remote UTF-8 text input unavailable";
|
||||
pending_.erase(0, end + 6);
|
||||
pasting_ = false;
|
||||
continue;
|
||||
}
|
||||
unsigned char c = pending_[0];
|
||||
if (c == 29) {
|
||||
exit = true;
|
||||
pending_.erase(0, 1);
|
||||
s.SetControl(false);
|
||||
return;
|
||||
}
|
||||
if (c == 20) {
|
||||
control = !control;
|
||||
s.SetControl(control);
|
||||
pending_.erase(0, 1);
|
||||
continue;
|
||||
}
|
||||
if (!control && (c == '+' || c == '=' || c == '-' || c == '0' || c == '1' ||
|
||||
c == 'h' || c == 'j' || c == 'k' || c == 'l')) {
|
||||
Navigate(c, grid);
|
||||
pending_.erase(0, 1);
|
||||
continue;
|
||||
}
|
||||
if (c == 27) {
|
||||
if (pending_.size() < 2)
|
||||
return;
|
||||
if (pending_.compare(0, 6, "\033[200~") == 0) {
|
||||
pasting_ = true;
|
||||
pending_.erase(0, 6);
|
||||
continue;
|
||||
}
|
||||
if (pending_[1] == '_') {
|
||||
auto end = pending_.find("\033\\", 2);
|
||||
if (end == std::string::npos)
|
||||
return;
|
||||
pending_.erase(0, end + 2);
|
||||
continue;
|
||||
}
|
||||
if (pending_[1] == '[' || pending_[1] == 'O') {
|
||||
size_t end = 2;
|
||||
while (end < pending_.size() &&
|
||||
!(pending_[end] >= '@' && pending_[end] <= '~'))
|
||||
++end;
|
||||
if (end == pending_.size())
|
||||
return;
|
||||
std::string seq = pending_.substr(0, end + 1);
|
||||
pending_.erase(0, end + 1);
|
||||
if (seq == "\033[O") {
|
||||
control = false;
|
||||
s.SetControl(false);
|
||||
continue;
|
||||
}
|
||||
int key = 0;
|
||||
if (seq == "\033[A")
|
||||
key = 38;
|
||||
else if (seq == "\033[B")
|
||||
key = 40;
|
||||
else if (seq == "\033[C")
|
||||
key = 39;
|
||||
else if (seq == "\033[D")
|
||||
key = 37;
|
||||
else if (seq == "\033[H")
|
||||
key = 36;
|
||||
else if (seq == "\033[F")
|
||||
key = 35;
|
||||
else if (seq == "\033[3~")
|
||||
key = 46;
|
||||
else if (seq == "\033[5~")
|
||||
key = 33;
|
||||
else if (seq == "\033[6~")
|
||||
key = 34;
|
||||
else if (seq == "\033OP")
|
||||
key = 112;
|
||||
else if (seq == "\033OQ")
|
||||
key = 113;
|
||||
else if (seq == "\033OR")
|
||||
key = 114;
|
||||
else if (seq == "\033OS")
|
||||
key = 115;
|
||||
if (key) {
|
||||
if (!control && key >= 37 && key <= 40) {
|
||||
Navigate(key == 37 ? 'h'
|
||||
: key == 38 ? 'k'
|
||||
: key == 39 ? 'l'
|
||||
: 'j',
|
||||
grid);
|
||||
continue;
|
||||
}
|
||||
Tap(s, key);
|
||||
continue;
|
||||
}
|
||||
int b = 0, x = 0, y = 0;
|
||||
char action = seq.back();
|
||||
bool mouse =
|
||||
seq.rfind("\033[<", 0) == 0 && (action == 'M' || action == 'm');
|
||||
const char *cur = seq.data() + std::min(size_t(3), seq.size());
|
||||
const char *limit = seq.data() + seq.size() - 1;
|
||||
for (int *value : {&b, &x, &y}) {
|
||||
if (!mouse)
|
||||
break;
|
||||
auto result = std::from_chars(cur, limit, *value);
|
||||
mouse = result.ec == std::errc{} && *value >= 0 && *value <= 10000;
|
||||
cur = result.ptr;
|
||||
if (value != &y) {
|
||||
mouse = mouse && cur < limit && *cur == ';';
|
||||
if (mouse)
|
||||
++cur;
|
||||
} else
|
||||
mouse = mouse && cur == limit;
|
||||
}
|
||||
if (grid && mouse) {
|
||||
auto pos = MapPointer(*grid, x - 1, y - 1);
|
||||
if (!control) {
|
||||
if (pos && action == 'M' && ((b & 64) || b == 0)) {
|
||||
center_x = pos->first;
|
||||
center_y = pos->second;
|
||||
if (b & 64)
|
||||
Navigate((b & 1) ? '-' : '+', grid);
|
||||
else
|
||||
Navigate('1', grid);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!pos && action != 'm')
|
||||
continue;
|
||||
if (pos) {
|
||||
pointer_x_ = pos->first;
|
||||
pointer_y_ = pos->second;
|
||||
}
|
||||
int flag = 0, wheel = 0;
|
||||
if (b & 64) {
|
||||
flag = (b & 2) ? 8 : 7;
|
||||
wheel = (b & 1) ? -120 : 120;
|
||||
} else if (!(b & 32)) {
|
||||
int base = b & 3;
|
||||
if (base == 3)
|
||||
continue;
|
||||
flag = (base == 0 ? 1
|
||||
: base == 1 ? 5
|
||||
: 3) +
|
||||
(action == 'm' ? 1 : 0);
|
||||
}
|
||||
s.Pointer(pointer_x_, pointer_y_, flag, wheel);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Alt+ASCII letter: a tap with explicit modifier release.
|
||||
if (pending_[1] >= 'a' && pending_[1] <= 'z')
|
||||
Tap(s, pending_[1] - 'a' + 'A', 18);
|
||||
pending_.erase(0, 2);
|
||||
continue;
|
||||
}
|
||||
if (c < 32 || c == 127) {
|
||||
pending_.erase(0, 1);
|
||||
if (c == 13 || c == 10)
|
||||
Tap(s, 13);
|
||||
else if (c == 9)
|
||||
Tap(s, 9);
|
||||
else if (c == 127 || c == 8)
|
||||
Tap(s, 8);
|
||||
else if (c >= 1 && c <= 26)
|
||||
Tap(s, 'A' + c - 1, 17);
|
||||
continue;
|
||||
}
|
||||
size_t n = c < 128 ? 1
|
||||
: (c & 0xe0) == 0xc0 ? 2
|
||||
: (c & 0xf0) == 0xe0 ? 3
|
||||
: (c & 0xf8) == 0xf0 ? 4
|
||||
: 1;
|
||||
if (pending_.size() < n)
|
||||
return;
|
||||
auto text = pending_.substr(0, n);
|
||||
pending_.erase(0, n);
|
||||
if (control && !s.Text(text))
|
||||
error = "Remote UTF-8 text input unavailable";
|
||||
}
|
||||
}
|
||||
} // namespace crossdesk::terminal
|
||||
@@ -0,0 +1,341 @@
|
||||
#include "terminal_renderer.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <zlib.h>
|
||||
namespace crossdesk::terminal {
|
||||
Rect Fit(int w, int h, int cols, int rows, double aspect) {
|
||||
if (w <= 0 || h <= 0 || cols <= 0 || rows <= 0 || !std::isfinite(aspect) ||
|
||||
aspect <= 0)
|
||||
return {};
|
||||
const double scale = std::min(cols * aspect / w, double(rows) / h);
|
||||
const int cw = std::max(1, std::min(cols, int(w * scale / aspect)));
|
||||
const int ch = std::max(1, std::min(rows, int(h * scale)));
|
||||
return {(cols - cw) / 2, (rows - ch) / 2, cw, ch};
|
||||
}
|
||||
static uint32_t Pixel(const DecodedFrame &f, int x, int y) {
|
||||
const size_t offset =
|
||||
size_t(f.width) * f.height + (y / 2) * f.width + (x & ~1);
|
||||
int yy = std::max(0, int(f.nv12[size_t(y) * f.width + x]) - 16);
|
||||
int u = int(f.nv12[offset]) - 128, v = int(f.nv12[offset + 1]) - 128;
|
||||
auto clamp = [](int n) { return uint32_t(std::clamp(n, 0, 255)); };
|
||||
return (clamp((298 * yy + 409 * v + 128) >> 8) << 16) |
|
||||
(clamp((298 * yy - 100 * u - 208 * v + 128) >> 8) << 8) |
|
||||
clamp((298 * yy + 516 * u + 128) >> 8);
|
||||
}
|
||||
static void Validate(const DecodedFrame &f, const Viewport &v) {
|
||||
if (v.columns < 1 || v.rows < 1 || v.columns > 240 || v.rows > 100 ||
|
||||
!std::isfinite(v.cell_aspect) || v.cell_aspect < .2 ||
|
||||
v.cell_aspect > 2 || !std::isfinite(v.zoom) || v.zoom < 1 ||
|
||||
v.zoom > 32 || !std::isfinite(v.center_x) || !std::isfinite(v.center_y) ||
|
||||
v.center_x < 0 || v.center_x > 1 || v.center_y < 0 || v.center_y > 1 ||
|
||||
!f.width || !f.height || f.width > 8192 || f.height > 8192 ||
|
||||
(f.width & 1) || (f.height & 1) ||
|
||||
size_t(f.width) * f.height > 16777216 ||
|
||||
f.nv12.size() < size_t(f.width) * f.height * 3 / 2)
|
||||
throw std::invalid_argument("invalid frame or viewport");
|
||||
}
|
||||
static Rect Crop(const DecodedFrame &f, const Viewport &v) {
|
||||
double width = f.width, height = f.height;
|
||||
if (v.native) {
|
||||
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1;
|
||||
const int sy = v.utf8 ? 2 : 1;
|
||||
const int ow = v.pixel_mode ? std::clamp(v.pixel_width > 0 ? v.pixel_width
|
||||
: v.columns * 10,
|
||||
2, 1920)
|
||||
: v.columns * sx;
|
||||
const int oh = v.pixel_mode ? std::clamp(v.pixel_height > 0 ? v.pixel_height
|
||||
: v.rows * 20,
|
||||
2, 1080)
|
||||
: v.rows * sy;
|
||||
const double aspect =
|
||||
double(v.columns) * v.cell_aspect * oh / (v.rows * ow);
|
||||
// Fit square source pixels into non-square character samples without
|
||||
// downsampling either dimension. Native mode may show a small crop.
|
||||
width = std::min(width, std::floor(ow * std::min(1.0, aspect)));
|
||||
height = std::min(height, std::floor(oh * std::min(1.0, 1 / aspect)));
|
||||
}
|
||||
int w = std::max(1, int(width / v.zoom)),
|
||||
h = std::max(1, int(height / v.zoom));
|
||||
int x = std::clamp(int(v.center_x * f.width - w * .5), 0, int(f.width) - w);
|
||||
int y = std::clamp(int(v.center_y * f.height - h * .5), 0, int(f.height) - h);
|
||||
return {x, y, w, h};
|
||||
}
|
||||
// Area resampling integrates the entire source footprint. Thin lines cannot
|
||||
// disappear merely because a nearest-neighbour sample landed beside them.
|
||||
static uint32_t Sample(const DecodedFrame &f, double x0, double y0, double x1,
|
||||
double y1) {
|
||||
double sums[3] = {}, weight = 0;
|
||||
for (int y = int(std::floor(y0));
|
||||
y < std::min(int(f.height), int(std::ceil(y1))); ++y) {
|
||||
double wy = std::min(y1, double(y + 1)) - std::max(y0, double(y));
|
||||
for (int x = int(std::floor(x0));
|
||||
x < std::min(int(f.width), int(std::ceil(x1))); ++x) {
|
||||
double w = wy * (std::min(x1, double(x + 1)) - std::max(x0, double(x)));
|
||||
auto c = Pixel(f, std::max(0, x), std::max(0, y));
|
||||
sums[0] += (c >> 16) * w;
|
||||
sums[1] += ((c >> 8) & 255) * w;
|
||||
sums[2] += (c & 255) * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
if (weight <= 0)
|
||||
return 0;
|
||||
auto round = [&](int i) {
|
||||
return uint32_t(std::clamp(int(std::lround(sums[i] / weight)), 0, 255));
|
||||
};
|
||||
return (round(0) << 16) | (round(1) << 8) | round(2);
|
||||
}
|
||||
static Cell Quadrant(const std::array<uint32_t, 4> &pixels) {
|
||||
// Enumerate all seven two-colour partitions (complements are equivalent).
|
||||
static constexpr uint32_t glyphs[16] = {
|
||||
0x20, 0x2598, 0x259D, 0x2580, 0x2596, 0x258C, 0x259E, 0x259B,
|
||||
0x2597, 0x259A, 0x2590, 0x259C, 0x2584, 0x2599, 0x259F, 0x2588};
|
||||
Cell best{};
|
||||
double best_error = 1e30;
|
||||
for (unsigned mask = 1; mask < 8; ++mask) {
|
||||
double sums[2][3] = {};
|
||||
unsigned count[2] = {};
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
int g = (mask >> i) & 1;
|
||||
++count[g];
|
||||
sums[g][0] += pixels[i] >> 16;
|
||||
sums[g][1] += (pixels[i] >> 8) & 255;
|
||||
sums[g][2] += pixels[i] & 255;
|
||||
}
|
||||
uint32_t colors[2] = {};
|
||||
double error = 0;
|
||||
for (int g = 0; g < 2; ++g)
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sums[g][c] = std::round(sums[g][c] / count[g]);
|
||||
colors[g] |= uint32_t(sums[g][c]) << (16 - 8 * c);
|
||||
}
|
||||
for (int i = 0; i < 4; ++i)
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
double d = double((pixels[i] >> (16 - 8 * c)) & 255) -
|
||||
sums[(mask >> i) & 1][c];
|
||||
error += d * d;
|
||||
}
|
||||
if (error < best_error) {
|
||||
best_error = error;
|
||||
best = {colors[1], colors[0], glyphs[mask]};
|
||||
}
|
||||
}
|
||||
if (best.top == best.bottom)
|
||||
best.glyph = 0x20;
|
||||
return best;
|
||||
}
|
||||
static std::string EncodeUtf8(uint32_t c) {
|
||||
if (c < 128)
|
||||
return std::string(1, char(c));
|
||||
if (c < 2048)
|
||||
return {char(0xc0 | (c >> 6)), char(0x80 | (c & 63))};
|
||||
return {char(0xe0 | (c >> 12)), char(0x80 | ((c >> 6) & 63)),
|
||||
char(0x80 | (c & 63))};
|
||||
}
|
||||
Grid Layout(const DecodedFrame &f, Viewport v) {
|
||||
Validate(f, v);
|
||||
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1,
|
||||
sy = v.utf8 ? 2 : 1;
|
||||
const int width = v.columns * sx, height = v.rows * sy;
|
||||
Rect crop = Crop(f, v);
|
||||
Grid g{v,
|
||||
Fit(crop.width, crop.height, width, height, v.cell_aspect * sy / sx),
|
||||
{}};
|
||||
g.source = crop;
|
||||
g.source_width = f.width;
|
||||
g.source_height = f.height;
|
||||
g.samples_x = sx;
|
||||
g.samples_y = sy;
|
||||
return g;
|
||||
}
|
||||
Grid Convert(const DecodedFrame &f, Viewport v) {
|
||||
auto g = Layout(f, v);
|
||||
const auto crop = g.source;
|
||||
const int sx = g.samples_x, sy = g.samples_y;
|
||||
g.cells.resize(size_t(v.columns) * v.rows);
|
||||
auto pixel = [&](int x, int y) {
|
||||
const auto &r = g.content;
|
||||
if (x < r.x || y < r.y || x >= r.x + r.width || y >= r.y + r.height)
|
||||
return uint32_t(0);
|
||||
double x0 = crop.x + double(x - r.x) * crop.width / r.width;
|
||||
double y0 = crop.y + double(y - r.y) * crop.height / r.height;
|
||||
return Sample(f, x0, y0, x0 + double(crop.width) / r.width,
|
||||
y0 + double(crop.height) / r.height);
|
||||
};
|
||||
for (int y = 0; y < v.rows; ++y)
|
||||
for (int x = 0; x < v.columns; ++x) {
|
||||
auto &c = g.cells[size_t(y) * v.columns + x];
|
||||
if (sx == 2)
|
||||
c = Quadrant({pixel(x * 2, y * 2), pixel(x * 2 + 1, y * 2),
|
||||
pixel(x * 2, y * 2 + 1), pixel(x * 2 + 1, y * 2 + 1)});
|
||||
else {
|
||||
c.top = pixel(x, y * sy);
|
||||
c.bottom = sy == 1 ? c.top : pixel(x, y * sy + sy - 1);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
std::optional<std::pair<float, float>> MapPointer(const Grid &g, int x, int y) {
|
||||
x *= g.samples_x;
|
||||
y *= g.samples_y;
|
||||
const auto &r = g.content;
|
||||
if (!r.width || !r.height || !g.source_width || !g.source_height || x < r.x ||
|
||||
y < r.y || x >= r.x + r.width || y >= r.y + r.height)
|
||||
return {};
|
||||
float fx = float(x - r.x) / std::max(1, r.width - 1),
|
||||
fy = float(y - r.y) / std::max(1, r.height - 1);
|
||||
return std::pair<float, float>{
|
||||
(g.source.x + fx * std::max(0, g.source.width - 1)) /
|
||||
std::max(1u, g.source_width - 1),
|
||||
(g.source.y + fy * std::max(0, g.source.height - 1)) /
|
||||
std::max(1u, g.source_height - 1)};
|
||||
}
|
||||
static std::string Color(uint32_t c, bool fg, int depth) {
|
||||
const int r = c >> 16, g = (c >> 8) & 255, b = c & 255;
|
||||
const std::string prefix = fg ? "\033[38;" : "\033[48;";
|
||||
if (depth == 24)
|
||||
return prefix + "2;" + std::to_string(r) + ";" + std::to_string(g) + ";" +
|
||||
std::to_string(b) + "m";
|
||||
if (depth == 256) {
|
||||
constexpr int levels[] = {0, 95, 135, 175, 215, 255};
|
||||
auto index = [&](int value) {
|
||||
int best = 0;
|
||||
for (int i = 1; i < 6; ++i)
|
||||
if (std::abs(levels[i] - value) < std::abs(levels[best] - value))
|
||||
best = i;
|
||||
return best;
|
||||
};
|
||||
int ri = index(r), gi = index(g), bi = index(b);
|
||||
int cube = (r - levels[ri]) * (r - levels[ri]) +
|
||||
(g - levels[gi]) * (g - levels[gi]) +
|
||||
(b - levels[bi]) * (b - levels[bi]);
|
||||
int gray =
|
||||
std::clamp(int(std::lround(((r + g + b) / 3.0 - 8) / 10)), 0, 23),
|
||||
gv = 8 + 10 * gray;
|
||||
int gray_error =
|
||||
(r - gv) * (r - gv) + (g - gv) * (g - gv) + (b - gv) * (b - gv);
|
||||
return prefix + "5;" +
|
||||
std::to_string(gray_error < cube ? 232 + gray
|
||||
: 16 + 36 * ri + 6 * gi + bi) +
|
||||
"m";
|
||||
}
|
||||
int i = (r >= 128 ? 1 : 0) + (g >= 128 ? 2 : 0) + (b >= 128 ? 4 : 0);
|
||||
return "\033[" + std::to_string((fg ? 30 : 40) + i) + "m";
|
||||
}
|
||||
static std::string Line(const Grid &g, int y) {
|
||||
std::string line;
|
||||
uint32_t fg = UINT32_MAX, bg = UINT32_MAX;
|
||||
for (int x = 0; x < g.viewport.columns; ++x) {
|
||||
const auto &c = g.cells[size_t(y) * g.viewport.columns + x];
|
||||
if (g.viewport.utf8 && fg != c.top) {
|
||||
line += Color(c.top, true, g.viewport.colors);
|
||||
fg = c.top;
|
||||
}
|
||||
const auto bottom = g.viewport.utf8 ? c.bottom : c.top;
|
||||
if (bg != bottom) {
|
||||
line += Color(bottom, false, g.viewport.colors);
|
||||
bg = bottom;
|
||||
}
|
||||
line += g.viewport.utf8 ? EncodeUtf8(c.glyph) : " ";
|
||||
}
|
||||
return line + "\033[0m";
|
||||
}
|
||||
std::vector<std::string> Lines(const Grid &g) {
|
||||
std::vector<std::string> lines;
|
||||
lines.reserve(g.viewport.rows);
|
||||
for (int y = 0; y < g.viewport.rows; ++y)
|
||||
lines.push_back(Line(g, y));
|
||||
return lines;
|
||||
}
|
||||
std::string Difference(const Grid &g, const std::optional<Grid> &previous) {
|
||||
std::string out;
|
||||
const bool same = previous &&
|
||||
previous->viewport.columns == g.viewport.columns &&
|
||||
previous->viewport.rows == g.viewport.rows &&
|
||||
previous->viewport.colors == g.viewport.colors &&
|
||||
previous->viewport.utf8 == g.viewport.utf8;
|
||||
for (int y = 0; y < g.viewport.rows; ++y) {
|
||||
auto start = g.cells.begin() + size_t(y) * g.viewport.columns;
|
||||
if (!same ||
|
||||
!std::equal(start, start + g.viewport.columns,
|
||||
previous->cells.begin() + size_t(y) * g.viewport.columns))
|
||||
out += "\033[" + std::to_string(y + 1) + ";1H" + Line(g, y);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
static std::string Base64(const std::vector<uint8_t> &data) {
|
||||
constexpr char table[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve((data.size() + 2) / 3 * 4);
|
||||
for (size_t i = 0; i < data.size(); i += 3) {
|
||||
unsigned n = unsigned(data[i]) << 16;
|
||||
if (i + 1 < data.size())
|
||||
n |= unsigned(data[i + 1]) << 8;
|
||||
if (i + 2 < data.size())
|
||||
n |= data[i + 2];
|
||||
out += table[n >> 18];
|
||||
out += table[(n >> 12) & 63];
|
||||
out += i + 1 < data.size() ? table[(n >> 6) & 63] : '=';
|
||||
out += i + 2 < data.size() ? table[n & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::string Kitty(const DecodedFrame &f, const Viewport &requested) {
|
||||
auto v = requested;
|
||||
v.pixel_mode = true;
|
||||
Validate(f, v);
|
||||
// Use reported terminal pixel dimensions; otherwise a conservative 10x20
|
||||
// cell.
|
||||
int w =
|
||||
std::clamp(v.pixel_width > 0 ? v.pixel_width : v.columns * 10, 2, 1920);
|
||||
int h =
|
||||
std::clamp(v.pixel_height > 0 ? v.pixel_height : v.rows * 20, 2, 1080);
|
||||
const auto crop = Crop(f, v);
|
||||
std::string b64;
|
||||
for (;;) {
|
||||
auto r = Fit(crop.width, crop.height, w, h,
|
||||
double(v.columns) * v.cell_aspect * h / (v.rows * w));
|
||||
std::vector<uint8_t> rgb(size_t(w) * h * 3, 0);
|
||||
for (int y = 0; y < r.height; ++y)
|
||||
for (int x = 0; x < r.width; ++x) {
|
||||
double x0 = crop.x + double(x) * crop.width / r.width,
|
||||
y0 = crop.y + double(y) * crop.height / r.height;
|
||||
auto c = Sample(f, x0, y0, x0 + double(crop.width) / r.width,
|
||||
y0 + double(crop.height) / r.height);
|
||||
auto i = (size_t(y + r.y) * w + x + r.x) * 3;
|
||||
rgb[i] = c >> 16;
|
||||
rgb[i + 1] = c >> 8;
|
||||
rgb[i + 2] = c;
|
||||
}
|
||||
uLongf length = compressBound(rgb.size());
|
||||
std::vector<uint8_t> compressed(length);
|
||||
if (compress2(compressed.data(), &length, rgb.data(), rgb.size(),
|
||||
Z_BEST_SPEED) != Z_OK)
|
||||
throw std::runtime_error("pixel compression failed");
|
||||
compressed.resize(length);
|
||||
b64 = Base64(compressed);
|
||||
// Reserve space for protocol chunk headers; keep the existing 2 MiB cap.
|
||||
if (b64.size() < 1900000)
|
||||
break;
|
||||
w = std::max(2, w * 3 / 4);
|
||||
h = std::max(2, h * 3 / 4);
|
||||
}
|
||||
std::string out = "\033[H";
|
||||
for (size_t i = 0; i < b64.size(); i += 4096) {
|
||||
out += "\033_G";
|
||||
if (i == 0)
|
||||
out += "a=T,t=d,f=24,o=z,q=2,i=31,p=1,C=1,s=" + std::to_string(w) +
|
||||
",v=" + std::to_string(h) + ",c=" + std::to_string(v.columns) +
|
||||
",r=" + std::to_string(v.rows) + ",";
|
||||
out += "m=" + std::to_string(i + 4096 < b64.size() ? 1 : 0) + ";" +
|
||||
b64.substr(i, 4096) + "\033\\";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
bool KittyReply(const std::string &s) {
|
||||
return s.find("\033_Gi=31;OK\033\\") != std::string::npos;
|
||||
}
|
||||
} // namespace crossdesk::terminal
|
||||
@@ -11,14 +11,35 @@ set_languages("c++17")
|
||||
set_encodings("utf-8")
|
||||
add_requires("nlohmann_json 3.11.3")
|
||||
|
||||
option("CROSSDESK_BUILD_DESKTOP")
|
||||
set_default(desktop_platform)
|
||||
set_showmenu(true)
|
||||
set_description("Build the desktop client")
|
||||
option_end()
|
||||
|
||||
option("CROSSDESK_BUILD_CLI")
|
||||
set_default(false)
|
||||
set_showmenu(true)
|
||||
set_description("Build the terminal controller")
|
||||
option_end()
|
||||
|
||||
local build_desktop = desktop_platform and not is_config("CROSSDESK_BUILD_DESKTOP", false)
|
||||
local build_cli = has_config("CROSSDESK_BUILD_CLI")
|
||||
if build_cli and not desktop_platform then
|
||||
raise("CrossDesk CLI supports Windows, macOS and Linux")
|
||||
end
|
||||
|
||||
if desktop_platform then
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/options.lua"))
|
||||
setup_options_and_dependencies(build_desktop)
|
||||
end
|
||||
|
||||
if build_desktop then
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/platform.lua"))
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/rules/slint.lua"))
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/rules/about_resources.lua"))
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/targets.lua"))
|
||||
|
||||
setup_options_and_dependencies()
|
||||
setup_platform_settings()
|
||||
end
|
||||
|
||||
@@ -26,28 +47,42 @@ local wire_dir = path.join(source_dir, "libs/wire")
|
||||
|
||||
target("crossdesk_wire")
|
||||
set_kind("static")
|
||||
add_packages("nlohmann_json")
|
||||
add_packages("nlohmann_json", {public = true})
|
||||
add_files(path.join(wire_dir, "src/*.cpp"))
|
||||
add_headerfiles(path.join(wire_dir,
|
||||
"include/(*.h)"))
|
||||
add_includedirs(path.join(wire_dir, "include"), {public = true})
|
||||
target_end()
|
||||
|
||||
target("crossdesk_wire_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("crossdesk_wire")
|
||||
add_files(path.join(wire_dir, "tests/wire_test.cpp"))
|
||||
target_end()
|
||||
-- CLI source packages omit tests; full checkouts retain these existing targets.
|
||||
if os.isdir(path.join(wire_dir, "tests")) then
|
||||
target("crossdesk_wire_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("crossdesk_wire")
|
||||
add_files(path.join(wire_dir, "tests/wire_test.cpp"))
|
||||
target_end()
|
||||
|
||||
target("keyboard_state_wire_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("crossdesk_wire")
|
||||
add_files(path.join(wire_dir,
|
||||
"tests/keyboard_state_wire_test.cpp"))
|
||||
target_end()
|
||||
target("keyboard_state_wire_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("crossdesk_wire")
|
||||
add_files(path.join(wire_dir,
|
||||
"tests/keyboard_state_wire_test.cpp"))
|
||||
target_end()
|
||||
end
|
||||
|
||||
if desktop_platform then
|
||||
if build_desktop then
|
||||
setup_targets()
|
||||
end
|
||||
|
||||
if build_cli then
|
||||
-- Desktop setup already includes MiniRTC and the shared OpenSSL recipe.
|
||||
if not build_desktop then
|
||||
add_requireconfs("**.openssl", "**.openssl3",
|
||||
{version = "3.3.2", system = false, override = true})
|
||||
includes(path.join(source_dir, "deps/thirdparty/openssl"))
|
||||
includes(path.join(source_dir, "deps/submodules/minirtc"))
|
||||
end
|
||||
includes(path.join(source_dir, "apps/cli/xmake.lua"))
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user