diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml new file mode 100644 index 00000000..f91bee9d --- /dev/null +++ b/.github/workflows/build-cli.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2bdc7969..3772bdbf 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ xcuserdata/ # VSCode cache .vscode + +# CLI npm dependencies +node_modules/ diff --git a/README.md b/README.md index be30f547..bdc11257 100644 --- a/README.md +++ b/README.md @@ -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 直连的寻址、认证、协商、数据路径及生命周期。 diff --git a/README_EN.md b/README_EN.md index 95e51c10..327cf3f9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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. diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 00000000..6ac1b10e --- /dev/null +++ b/apps/cli/README.md @@ -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 服务。 diff --git a/apps/cli/docs/bridge-protocol.md b/apps/cli/docs/bridge-protocol.md new file mode 100644 index 00000000..ff2dc36f --- /dev/null +++ b/apps/cli/docs/bridge-protocol.md @@ -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 命令方法;未知方法报错。模型接入者必须另行取得截图/自动操作授权,不能把持续帧事件附加到聊天记录。 diff --git a/apps/cli/docs/npm-distribution.md b/apps/cli/docs/npm-distribution.md new file mode 100644 index 00000000..7e9a672e --- /dev/null +++ b/apps/cli/docs/npm-distribution.md @@ -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///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)。 diff --git a/apps/cli/docs/source-provenance.md b/apps/cli/docs/source-provenance.md new file mode 100644 index 00000000..e0f74270 --- /dev/null +++ b/apps/cli/docs/source-provenance.md @@ -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 依赖的许可证、对应源码及重链接材料仍需随正式版本提供。 diff --git a/apps/cli/docs/terminal-client.md b/apps/cli/docs/terminal-client.md new file mode 100644 index 00000000..9c24161a --- /dev/null +++ b/apps/cli/docs/terminal-client.md @@ -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////crossdesk-cli`。可把对应目录加入 PATH 或复制可执行文件到自己的 bin 目录。Pi 需要直接启动该可执行文件,不能使用会输出构建日志的 `xmake run` 作为桥接程序。 + +macOS 的 MiniRTC 依赖仍链接 VideoToolbox/CoreMedia 和 GLib 声明的 AppKit 等系统框架;CLI 不创建 GUI、采集器或音频设备。硬件解码参数显式关闭。Linux 无需 DISPLAY/WAYLAND_DISPLAY;本轮尚未在 Linux 主机实际构建验证。 + +Windows 产物为 `build/windows//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 时打开独立终端或给出播放器命令。 + +每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。 diff --git a/apps/cli/docs/validation.md b/apps/cli/docs/validation.md new file mode 100644 index 00000000..bbddf956 --- /dev/null +++ b/apps/cli/docs/validation.md @@ -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` 仅报告本机环境,不会联网登录或证明远端可连接。 diff --git a/apps/cli/main.cpp b/apps/cli/main.cpp new file mode 100644 index 00000000..b86c1524 --- /dev/null +++ b/apps/cli/main.cpp @@ -0,0 +1,730 @@ +#include "terminal_io.h" +#include +#include +#include +#include +#include +#include +#include + +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(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(); + 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 displayed, pending; + bool pending_frame = false; + Viewport last_view = Size(); + std::optional 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(); + 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( + 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(Clock::now() - started).count()}, + {"submitted_fps", + submitted / std::max(.001, std::chrono::duration( + Clock::now() - started) + .count())}} + .dump() + << "\n"; + if (final["state"] == "failed") + throw std::runtime_error(final["error"].get()); + 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 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 current, pending_grid; + std::optional 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(); + 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()); + next_options.fps = Integer(j, "fps", 1, 30, 8); + if (j.contains("quality")) + Quality(next_options, j.at("quality").get()); + 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::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(); + 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(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( + 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; + } +} diff --git a/apps/cli/npm/cli.cjs b/apps/cli/npm/cli.cjs new file mode 100644 index 00000000..8429f58f --- /dev/null +++ b/apps/cli/npm/cli.cjs @@ -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; +} diff --git a/apps/cli/npm/platforms.json b/apps/cli/npm/platforms.json new file mode 100644 index 00000000..f3379461 --- /dev/null +++ b/apps/cli/npm/platforms.json @@ -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" } +} diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 00000000..2cbe637a --- /dev/null +++ b/apps/cli/package.json @@ -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" + } +} diff --git a/apps/cli/plugins/pi/index.ts b/apps/cli/plugins/pi/index.ts new file mode 100644 index 00000000..819651c3 --- /dev/null +++ b/apps/cli/plugins/pi/index.ts @@ -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 = { 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 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((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 | 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((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 = {}) => { + 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; } + }, + }); +} diff --git a/apps/cli/plugins/pi/package.json b/apps/cli/plugins/pi/package.json new file mode 100644 index 00000000..6365c0a1 --- /dev/null +++ b/apps/cli/plugins/pi/package.json @@ -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" + } +} diff --git a/apps/cli/scripts/pack-npm.cjs b/apps/cli/scripts/pack-npm.cjs new file mode 100644 index 00000000..8766ddac --- /dev/null +++ b/apps/cli/scripts/pack-npm.cjs @@ -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 }); +} diff --git a/apps/cli/scripts/prepare-deps.sh b/apps/cli/scripts/prepare-deps.sh new file mode 100755 index 00000000..636fd492 --- /dev/null +++ b/apps/cli/scripts/prepare-deps.sh @@ -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" diff --git a/apps/cli/terminal_io.h b/apps/cli/terminal_io.h new file mode 100644 index 00000000..60a89da7 --- /dev/null +++ b/apps/cli/terminal_io.h @@ -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 +#include +#include +#include +#include + +namespace crossdesk::cli { +static_assert(std::atomic::is_always_lock_free); +extern std::atomic 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_; +}; + +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_; +}; +} // namespace crossdesk::cli + +#endif diff --git a/apps/cli/terminal_io_posix.cpp b/apps/cli/terminal_io_posix.cpp new file mode 100644 index 00000000..cdeb1ab1 --- /dev/null +++ b/apps/cli/terminal_io_posix.cpp @@ -0,0 +1,162 @@ +#ifndef _WIN32 +#include "terminal_io.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk::cli { +std::atomic 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_->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_->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 diff --git a/apps/cli/terminal_io_windows.cpp b/apps/cli/terminal_io_windows.cpp new file mode 100644 index 00000000..b5f00d93 --- /dev/null +++ b/apps/cli/terminal_io_windows.cpp @@ -0,0 +1,477 @@ +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include "terminal_io.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk::cli { +std::atomic 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(value); +#else + return reinterpret_cast(_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(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(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(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(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, available); + } + DWORD count = 0; + if (!ReadFile(handle, buffer, DWORD(std::min(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, 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_->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(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 diff --git a/apps/cli/xmake.lua b/apps/cli/xmake.lua new file mode 100644 index 00000000..dc106f10 --- /dev/null +++ b/apps/cli/xmake.lua @@ -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() diff --git a/apps/desktop/xmake/options.lua b/apps/desktop/xmake/options.lua index 338b18f1..28dc05ec 100644 --- a/apps/desktop/xmake/options.lua +++ b/apps/desktop/xmake/options.lua @@ -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") diff --git a/deps/patches/minirtc-credential-logs.patch b/deps/patches/minirtc-credential-logs.patch new file mode 100644 index 00000000..2eb86b55 --- /dev/null +++ b/deps/patches/minirtc-credential-logs.patch @@ -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('@'))); + } diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index cdcd0d8f..b9ee1fa7 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit cdcd0d8fb0e9b688f3bf6416fe52b634cf20d9ca +Subproject commit b9ee1fa7fe038fdf771f5dec1b1d561d78273f15 diff --git a/docs/BUILD.md b/docs/BUILD.md index b42b9ac2..d7f23c03 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -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)。 diff --git a/docs/BUILD_EN.md b/docs/BUILD_EN.md index 643de524..b10fe669 100644 --- a/docs/BUILD_EN.md +++ b/docs/BUILD_EN.md @@ -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. diff --git a/libs/controller/include/controller_session.h b/libs/controller/include/controller_session.h new file mode 100644 index 00000000..8f7fb57d --- /dev/null +++ b/libs/controller/include/controller_session.h @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 Take(DecodedFrame *recycle = nullptr); + void Reset(uint64_t generation); + nlohmann::json Stats() const; + +private: + mutable std::mutex mutex_; + std::optional pending_; + std::vector 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 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 displays_; + std::set keys_, buttons_; + float pointer_x_ = .5f, pointer_y_ = .5f; + std::chrono::steady_clock::time_point deadline_; + mutable std::mutex mutex_; + std::deque 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 diff --git a/libs/controller/src/controller_session.cpp b/libs/controller/src/controller_session.cpp new file mode 100644 index 00000000..ee9bfe2e --- /dev/null +++ b/libs/controller/src/controller_session.cpp @@ -0,0 +1,436 @@ +#include "controller_session.h" +#include +#include +#include +#include +#include +#include +#include +#include + +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(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(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(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(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(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 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(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 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 diff --git a/libs/controller/src/latest_frame.cpp b/libs/controller/src/latest_frame.cpp new file mode 100644 index 00000000..aa067098 --- /dev/null +++ b/libs/controller/src/latest_frame.cpp @@ -0,0 +1,118 @@ +#include "controller_session.h" +#include +#include + +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 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 diff --git a/libs/terminal/include/terminal_renderer.h b/libs/terminal/include/terminal_renderer.h new file mode 100644 index 00000000..b51eff5a --- /dev/null +++ b/libs/terminal/include/terminal_renderer.h @@ -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 +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 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> MapPointer(const Grid &, int column, + int row); +std::vector Lines(const Grid &); +std::string Difference(const Grid &, const std::optional &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 diff --git a/libs/terminal/src/input.cpp b/libs/terminal/src/input.cpp new file mode 100644 index 00000000..45bdd4be --- /dev/null +++ b/libs/terminal/src/input.cpp @@ -0,0 +1,255 @@ +#include "terminal_renderer.h" +#include +#include +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 diff --git a/libs/terminal/src/terminal_renderer.cpp b/libs/terminal/src/terminal_renderer.cpp new file mode 100644 index 00000000..51c17107 --- /dev/null +++ b/libs/terminal/src/terminal_renderer.cpp @@ -0,0 +1,341 @@ +#include "terminal_renderer.h" +#include +#include +#include +#include +#include +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 &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> 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{ + (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 Lines(const Grid &g) { + std::vector 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 &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 &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 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 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 diff --git a/xmake.lua b/xmake.lua index cd7c1fce..f5595986 100644 --- a/xmake.lua +++ b/xmake.lua @@ -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