diff --git a/README.md b/README.md index d5bcb283..a5e87638 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也 - **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。 - **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。 -- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。 +- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty / Sixel 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。 - **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。 - **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。 - **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。 @@ -150,7 +150,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也 ## 终端客户端 CLI -`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。 +`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 或 Sixel 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。 ### 画面预览 @@ -202,7 +202,7 @@ xmake run crossdesk-cli 123456789 ### 选择画面与连接参数 -默认 `--renderer auto` 自动探测 Kitty 图形协议,未获支持时回退为 ANSI。macOS「终端」等普通终端可显式选择 `ansi`;Ghostty 等支持该协议的终端可选择 `kitty`。显式指定 `kitty` 但探测失败时会报错,此时改用 `ansi`。 +默认 `--renderer auto` 按 Kitty → Sixel → ANSI 的顺序选择。macOS「终端」等普通终端可显式选择 `ansi`;支持对应图形协议的终端可选择 `kitty` 或 `sixel`。显式像素模式探测失败时会报错,此时改用 `ansi`。Xshell 是否能显示像素图片取决于终端自身的协议支持,CLI 无法单方面补齐。 以下示例使用 npm 全局安装后的命令;从源码构建时,可在仓库根目录将 `crossdesk-cli` 替换为 `xmake run crossdesk-cli`。 @@ -219,7 +219,7 @@ crossdesk-cli 123456789 --quality balanced --fps 8 | 参数 | 用途 | | --- | --- | -| `--renderer auto\|ansi\|kitty` | 自动选择、字符画面或像素画面;默认 `auto` | +| `--renderer auto\|ansi\|kitty\|sixel` | 自动选择、字符画面或像素画面;默认 `auto` | | `--zoom native` | 从原始细节模式开始;`--zoom 2` 表示相对全景放大 2 倍 | | `--quality low\|balanced\|high\|ultra` | 请求最高 360p / 720p / 1080p / 2160p;默认 `high`,实际画质由被控端决定 | | `--fps 8` | 固定本地输出目标和相应远端预览请求;允许 1–30,默认本地目标为 30,未指定时远端供帧会自适应 | @@ -256,7 +256,7 @@ pi -e ./apps/cli/plugins/pi/index.ts 其他平台将 `CROSSDESK_CLI` 改为原生可执行文件的绝对路径。在 Pi 中输入 `/crossdesk` 或 `/crossdesk 123456789`,按提示输入密码。安装要求与验证范围见 [Pi 扩展说明](apps/cli/docs/terminal-client.md#pi-扩展)。 -CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步,也暂不支持连接开启[可信设备限制](#trusted-devices)的被控端。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建与 npm 发布不代表全部平台已完成实机验收。 +CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步,也暂不支持连接开启[可信设备限制](#trusted-devices)的被控端。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty / Sixel 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建与 npm 发布不代表全部平台已完成实机验收。 [CLI 文档入口](apps/cli/README.md) · [终端使用与故障排查](apps/cli/docs/terminal-client.md) · [桥接协议](apps/cli/docs/bridge-protocol.md) · [npm 打包与分发](apps/cli/docs/npm-distribution.md) diff --git a/README_EN.md b/README_EN.md index 51715412..1875f118 100644 --- a/README_EN.md +++ b/README_EN.md @@ -32,7 +32,7 @@ CrossDesk connects your computers from desktop, browser, mobile, and terminal cl - **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development. - **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode. -- **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI. +- **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty / Sixel rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI. - **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution. - **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device. - **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer. @@ -150,7 +150,7 @@ Remote sessions support monitor switching, video quality and frame-rate controls ## Terminal client CLI -`crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities. +`crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty or Sixel graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities. ### Display preview @@ -202,7 +202,7 @@ For an existing checkout, run the dependency preparation and subsequent commands ### Rendering and connection options -The default `--renderer auto` probes for Kitty graphics support and falls back to ANSI. Use `ansi` explicitly in standard terminals such as macOS Terminal, or `kitty` in compatible terminals such as Ghostty. Explicit `kitty` mode reports an error if the probe fails; use `ansi` in that case. +The default `--renderer auto` selects Kitty, then Sixel, then ANSI according to terminal capability replies. Use `ansi` in standard terminals such as macOS Terminal, or `kitty` / `sixel` in terminals supporting the corresponding protocol. Explicit pixel modes report an error if the probe fails; use `ansi` in that case. Sixel output alone does not guarantee Xshell compatibility: the terminal itself must support the protocol. These examples use the command installed globally by npm. For a source build, replace `crossdesk-cli` with `xmake run crossdesk-cli` and run it from the repository root. @@ -219,7 +219,7 @@ crossdesk-cli 123456789 --quality balanced --fps 8 | Option | Purpose | | --- | --- | -| `--renderer auto\|ansi\|kitty` | Automatic selection, character rendering, or pixel graphics; default: `auto` | +| `--renderer auto\|ansi\|kitty\|sixel` | Automatic selection, character rendering, or pixel graphics; default: `auto` | | `--zoom native` | Start in native detail mode; `--zoom 2` zooms to twice the full-desktop scale | | `--quality low\|balanced\|high\|ultra` | Request up to 360p / 720p / 1080p / 2160p; default: `high`. The host determines actual quality | | `--fps 8` | Fix the local output target and corresponding remote preview request; accepts 1–30. The default local target is 30, with adaptive remote requests when omitted | @@ -256,7 +256,7 @@ pi -e ./apps/cli/plugins/pi/index.ts On other platforms, set `CROSSDESK_CLI` to the absolute path of the native executable. Enter `/crossdesk` or `/crossdesk 123456789` in Pi and follow the password prompt. See the [Pi extension guide](apps/cli/docs/terminal-client.md#pi-扩展) (Chinese) for requirements and validation scope. -The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. It also cannot yet connect to hosts with [trusted-device restrictions](#trusted-devices) enabled. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty terminal, and actual Pi TUI checks. Platform builds and npm publication do not establish that every platform has been validated on real devices. +The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. It also cannot yet connect to hosts with [trusted-device restrictions](#trusted-devices) enabled. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty / Sixel terminal, and actual Pi TUI checks. Platform builds and npm publication do not establish that every platform has been validated on real devices. [CLI documentation](apps/cli/README.md) · [Terminal usage and troubleshooting](apps/cli/docs/terminal-client.md) · [Bridge protocol](apps/cli/docs/bridge-protocol.md) · [npm packaging and distribution](apps/cli/docs/npm-distribution.md) (detailed guides in Chinese) diff --git a/apps/cli/README.md b/apps/cli/README.md index ee9bdcd9..5a931271 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,6 +1,6 @@ # CrossDesk CLI -CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty 图形后端,以及 Pi 自定义 TUI 扩展。 +CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty / Sixel 图形后端,以及 Pi 自定义 TUI 扩展。 ## 画面预览 @@ -8,7 +8,7 @@ CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通 | --- | --- | | CrossDesk CLI 的 ANSI 全景输出,包含只读状态栏与快捷键提示 | CrossDesk CLI 的 Kitty 像素全景输出,显示同一远程桌面 | -图示来自 **2026-10-31** 的实际只读连接,通过伪终端采集并渲染协议输出;来源与边界见 [图示说明](../../docs/images/README.md#cli-captures)。默认 `auto` 探测 Kitty 支持,失败时使用 ANSI;普通终端可指定 `--renderer ansi`,Ghostty 等支持 Kitty 图形协议的终端可指定 `--renderer kitty`。ANSI 全景适合查看桌面轮廓,阅读小字需进入细节模式。 +图示来自 **2026-10-31** 的实际只读连接,通过伪终端采集并渲染协议输出;来源与边界见 [图示说明](../../docs/images/README.md#cli-captures)。默认 `auto` 依次选择 Kitty、Sixel、ANSI;普通终端可指定 `--renderer ansi`,支持对应协议的终端可指定 `--renderer kitty` 或 `--renderer sixel`。显式像素模式需要通过能力探测;增加 Sixel 输出不代表所有 Xshell 版本均可显示图片,终端本身也必须支持该协议。ANSI 全景适合查看桌面轮廓,阅读小字需进入细节模式。 ## 安装与连接 @@ -53,6 +53,7 @@ xmake run crossdesk-cli 123456789 --quality high --zoom native ```sh crossdesk-cli 123456789 --renderer kitty +crossdesk-cli 123456789 --renderer sixel --fps 8 crossdesk-cli 123456789 --renderer ansi --zoom native crossdesk-cli 123456789 --quality balanced --fps 8 crossdesk-cli 123456789 --display 1 @@ -82,4 +83,4 @@ crossdesk-cli 123456789 --display 1 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 服务。 +终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty/Sixel 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。 diff --git a/apps/cli/docs/npm-distribution.md b/apps/cli/docs/npm-distribution.md index 94a962c7..89c79cca 100644 --- a/apps/cli/docs/npm-distribution.md +++ b/apps/cli/docs/npm-distribution.md @@ -39,7 +39,7 @@ npx @chunkdash/crossdesk-cli@latest 123456789 这些是构建与分发目标,不代表均已实机通过。当前完整构建与 npm 运行验证只覆盖 macOS arm64;CLI 主程序与平台 I/O 已通过 Windows x64 与 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。 +普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty / Sixel 图形协议是可选增强,需终端自身支持并通过能力探测。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 通用版。 diff --git a/apps/cli/docs/terminal-client.md b/apps/cli/docs/terminal-client.md index e4e25268..4c3af6b8 100644 --- a/apps/cli/docs/terminal-client.md +++ b/apps/cli/docs/terminal-client.md @@ -41,7 +41,7 @@ crossdesk-cli 123456789 --server your-server.example --port 9099 保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。 -## ANSI、Kitty 与 SSH +## ANSI、Kitty、Sixel 与 SSH ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝交互连接,不输出全屏转义序列。 @@ -63,7 +63,7 @@ Windows 的可见传统控制台优先读取当前字体尺寸;Windows Termina ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。 -auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没有有效 Kitty 回应就用 ANSI。显式 kitty 模式探测失败时报错。tmux/screen 中 auto 使用 ANSI,显式 kitty 报不支持;不修改用户终端配置。 +auto 模式发送 Kitty 查询与主设备属性查询(DA1),等待最多 250ms;优先选择 Kitty,否则在 DA1 的能力参数中找到 `4` 时选择 Sixel,再回退为 ANSI。显式 `kitty` 或 `sixel` 模式探测失败时报错,不强制向未知终端输出图像。tmux/screen 中 auto 使用 ANSI,显式像素模式报不支持;不修改用户终端配置。 本地 macOS/Linux 的 Kitty 模式会另用一个 1×1 像素的共享内存对象探测 `t=s`,最多等待 250ms;探测成功后,将未压缩 RGB 写入权限为 0600 的 POSIX 共享内存,仅通过终端字节流发送名字和显示命令。每次使用唯一名字,最多保留一个待确认的共享缓冲区(1920×1080×3 字节)。收到上传成功确认后才显示新图并释放缓冲区;上传报错、确认超过 500ms 或本地分配失败时,本次会话退回内联传输,继续保留旧画面。终端和 CLI 都会清理共享对象,退出时也会释放未确认的对象。SSH 中不尝试共享内存;Windows 暂用内联传输。 @@ -71,16 +71,24 @@ auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没 依据终端报告的像素尺寸生成画面(未知时按 10×20 像素字符估算),上限 1920×1080。保留面积滤波、黑边、宽高比和裁剪坐标;原始细节模式按像素视口裁取源区域。共享内存路径使用可复用水平行计算的整数面积权重,避免逐像素重复浮点运算,也不再因 Base64 字节预算额外降采样。没有可靠终端像素尺寸时使用估算值,因此不承诺显示设备的物理像素一一对应。内联压缩结果超出单次输出预算时仍逐级降低尺寸;高熵视频的实际帧率可能低于目标。RGB 传输和压缩不丢失像素,不代表面积采样或上游视频编码无损。 +Sixel 使用终端字节流传图,无需额外图像库或临时图片文件:每六行像素按颜色平面编码,使用重复压缩。每帧最多 248 色(RGB 色立方体加灰阶),复杂画面超过约 1.9MB 时逐级降低到 43 色、16 色,必要时降到 4 级灰阶,以保留原有 2MiB 单消息上限。Sixel 有调色板量化损失;它没有 Kitty 的图像缩放 placement,因此依据字符像素尺寸限制画面行列,使图像保持在 1920×1080 内,缩放、细节模式和鼠标映射使用相同视口。每帧覆盖整个图像区域(包括黑边),最后不足六行的部分不会绘制到状态栏;通过保存/恢复 DECSDM 模式使图像从视频区光标位置开始,退出时恢复。未收到像素尺寸时按 10×20 字符估算,实际对齐仍取决于终端尺寸报告。高熵画面或慢速 SSH 建议先用 `--fps 8`。 + +```sh +crossdesk-cli 123456789 --renderer sixel --fps 8 +``` + +CLI 支持 Sixel 不等于 Xshell 必然支持。查阅的 [Xshell 官方完整功能列表](https://www.netsarang.com/en/xshell-all-features/) 与 [更新记录](https://www.netsarang.com/en/xshell-update-history/) 尚未提供 Sixel 支持说明;以终端实际能力回应及显示测试为准,不能仅修改 `TERM` 就获得像素图像支持。无能力回应时可继续使用 `--renderer ansi --zoom native`。Sixel 协议与 DA1 能力位依据 [XTerm 控制序列文档](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Sixel-Graphics)。 + ```sh ssh -t your-server crossdesk-cli 123456789 --renderer ansi --fps 8 ``` -用户本地终端负责显示 SSH 字节流。ANSI、Kitty over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。 +用户本地终端负责显示 SSH 字节流。ANSI、Kitty / Sixel over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。 ## 预览与背压 -`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。 +`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、Sixel、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。 未显式指定 `--fps` 时,远端预览从 30 FPS 开始,根据完整一帧从转换开始到输出完成的耗时自动选择 8/15/30 FPS 请求;共享内存的上传确认等待也计入,等待源帧的空闲时间不计入。使用平滑估计、不同升降档阈值和至少 8 个新帧样本,持续变慢 2 秒才降档、持续恢复 4 秒才升档,保留供帧余量。15 FPS 仅在对端声明支持时使用;预览未获接受时不追加自动调节。视口变化、显示器切换和共享内存回退会重置耗时估计。显式 `--fps` 固定本地目标和对应的远端预览请求,关闭自动调节;例如 `--fps 30` 可用于与自动策略对比。 @@ -99,7 +107,7 @@ crossdesk-cli 123456789 --renderer ansi --fps 8 媒体回调仅同步复制借用的 NV12 数据,校验尺寸、容量、native 描述符与 stride;不保留借用指针。上限单帧 16,777,216 像素、每边 8192、偶数尺寸。最多一个待转换帧,替换前先完成校验与复制,无效帧不会挤掉已有画面;播放器另保留最近转换的一帧用于静止桌面的本地放大/重绘,并在接收新帧时回收上一帧缓冲区供后续复制使用。只丢弃解码后图像。显示器切换/关闭提升 generation,旧帧失效。 -转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),按令牌预算分块非阻塞写入,突发额度上限仍为 1MiB。本地交互 ANSI 和 Kitty 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,两者保持 1MiB/s,Pi 桥接始终保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分按终端实际使用的颜色与字形比较,忽略量化后相同的颜色和空格不可见的前景色;同一行可输出多个变化片段,并按光标定位、颜色切换和字符的实际字节成本合并短间隔。差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。 +转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),按令牌预算分块非阻塞写入,突发额度上限仍为 1MiB。本地交互 ANSI、Kitty 和 Sixel 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,三者保持 1MiB/s,Pi 桥接始终保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分按终端实际使用的颜色与字形比较,忽略量化后相同的颜色和空格不可见的前景色;同一行可输出多个变化片段,并按光标定位、颜色切换和字符的实际字节成本合并短间隔。差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。 测量像素渲染帧率时使用 release 构建;debug 的 `-O0` 面积采样开销明显更高。共享内存中的像素不经过终端字节预算,但仍受转换和终端消费速度限制;8MiB/s 是内联路径的输出上限,不是帧率保证。解码、像素转换、压缩、终端消费速度和帧大小都会影响结果。退出时,未完成画面最多继续写入 2s;取消图像传输及恢复屏幕的短写最多另重试 500ms。POSIX 立即恢复 termios 并清理输入,不等待输出排空;如果终端始终不消费输出,屏幕清理只能尽力发送。 @@ -169,7 +177,7 @@ Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监 - 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。交互连接的摘要在 client.log,包含收到的终端鼠标报告数 `mouse_reports`、其中的传统报告数 `mouse_legacy_reports`、成功交给传输层的鼠标事件数 `mouse_forwarded`、发送失败数 `mouse_send_failures`。一次点击报告可能生成定位和按键两条事件,所以发送数可能大于报告数;发送成功不表示被控端已执行。transport.log 只记录白名单中的 ICE/DTLS 状态及本地控制开关,不记录密码、SDP 或原始信令。使用完可删除。错误退出信息在恢复终端后显示,并给出本次日志目录。 - `connection_failed` 表示连接建立失败;`connection_lost` 表示连接成功后传输失效。xmake 随后的 `execv ... failed(1)` 表示程序以错误码 1 退出,本身不代表进程崩溃。遇到控制失效时,对照 transport.log 中的控制开关和连接状态;`mouse_reports` 为 0 表示终端没有向 CLI 提供可识别的鼠标事件。报告数增长但 `mouse_forwarded` 为 0 时,检查是否仍为只读/本地命令模式、是否已有画面,以及点击是否落在视频区域;`mouse_send_failures` 增长时检查连接状态。 - 如果整个 macOS Terminal 窗口退出且 CLI 记录 `output closed`,还应检查系统 DiagnosticReports 中的 Terminal 报告。已观察到 Terminal 2.15 在创建标签页并调整窗口尺寸时发生 `TTLogicalScreen expandUnicharOffsetCacheToLine:` 行缓存越界;这与 CLI 的 `connection_failed` 不同。临时可在独立窗口运行,避开该标签页操作,不能据此认为 Terminal 缺陷已修复。 -- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。 +- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty / Sixel 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。 - 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。 第一轮无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker、IP 直连、MCP 或自动截图。其他宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端或给出播放器命令。 diff --git a/apps/cli/docs/validation.md b/apps/cli/docs/validation.md index 64745e6a..0253cd6b 100644 --- a/apps/cli/docs/validation.md +++ b/apps/cli/docs/validation.md @@ -132,8 +132,16 @@ - 暂停 PTY 读取 1.3s,并在暂停期间发送退出命令,恢复读取后完整帧计数与提交计数一致,约 1.17s 内退出,备用屏幕、termios 和非阻塞标志恢复。没有连接真实远端,也未验证真实终端的 GPU 绘制、SSH 或 tmux 实际显示;上述帧率为合成源的完整输出接收率。 - 额外检查源码、脚本、数据和产物均位于工程外系统临时目录,未新增或修改工程内测试文件/用例。 +## Sixel 图形输出(2026-11-02) + +- 新增 `--renderer sixel`;auto 按 Kitty → Sixel → ANSI 选择,Sixel 通过 DA1 能力参数 `4` 探测。显式像素模式没有有效回应或处于 tmux/screen 时拒绝启动;`doctor --json` 增加 `sixel=probe_on_view`,不主动探测。 +- macOS arm64 CLI debug 构建、帮助信息和 `doctor --json` 通过。工程外 ASan/UBSan 检查覆盖能力回复解析、20,000 组随机尺寸的视口限制及重复计算一致性、7 组合成 NV12 图像(纯黑、纯白、渐变、彩色、部分六行、裁剪缩放和大视口)。 +- 工程外独立 Sixel 解码器验证完整像素覆盖、颜色量化误差、图像边界和光标保存/恢复;1920×1080 高熵样本触发 4 级灰阶回退,输出约 1.30MB,保持原尺寸且低于单消息上限。 +- 生产 CLI/终端 I/O 配合工程外假会话的 11 组 POSIX PTY 检查通过:Kitty 优先、Sixel 自动及显式选择、分片 DA1 回应、错误/无回应回退、显式模式失败、tmux/screen 限制、窗口变化和细节缩放;SSH 字节预算保持 1MiB/s,退出后 termios 与备用屏幕恢复,Sixel 路径发送 DECSDM 恢复命令。 +- 未连接真实远端,未进行 Xshell、其他 Sixel 终端或真实 SSH 显示验收,不保证任何 Xshell 版本支持该协议。额外测试源码、脚本、数据及产物均保存在工程外系统临时目录,未新增或修改工程内测试文件/用例。 + ## 实测边界 -本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。 +本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/Sixel/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。 CLI 需要支持 `cli` 临时注册的服务端。`doctor --json` 仅报告本机环境,不会联网登录或证明远端可连接。 diff --git a/apps/cli/main.cpp b/apps/cli/main.cpp index b50bcebf..2b2cf01d 100644 --- a/apps/cli/main.cpp +++ b/apps/cli/main.cpp @@ -143,6 +143,7 @@ private: }; struct Tty { int out; + bool sixel = false; RawTerminal terminal; Tty(int input, int output) : out(output), terminal(input, output) { // Fall back to button/drag reporting if all-motion tracking is unsupported. @@ -153,6 +154,7 @@ struct Tty { ~Tty() { // Cancel any incomplete string/control sequence before restoration. std::string end = "\030\033\\" + std::string(kKittyDelete) + + (sixel ? "\033[?80r" : "") + "\033[0m\033[?1003l\033[?1002l\033[?1000l" "\033[?1006l\033[?1004l\033[?2004l\033[" "?7h\033[?25h\033[?1049l"; @@ -332,9 +334,9 @@ int View(SessionOptions options, const std::string &device, return 130; Tty tty(0, fd); Output output(fd); - bool kitty = false; + bool kitty = false, sixel = false; if (renderer != "ansi" && Env("TMUX").empty() && Env("STY").empty()) { - output.Put(std::string(kKittyQuery) + "\033[c"); + output.Put((renderer == "sixel" ? std::string{} : kKittyQuery) + "\033[c"); std::string reply; auto until = Clock::now() + std::chrono::milliseconds(250); while (Clock::now() < until && !interrupted) { @@ -345,11 +347,21 @@ int View(SessionOptions options, const std::string &device, if (n > 0 && reply.size() < 8192) reply.append(b, n); } - kitty = KittyReply(reply); + kitty = renderer != "sixel" && KittyReply(reply); + sixel = renderer != "kitty" && !kitty && SixelReply(reply); } if (renderer == "kitty" && !kitty) throw std::runtime_error( "Kitty probe failed or multiplexer active; use --renderer ansi"); + if (renderer == "sixel" && !sixel) + throw std::runtime_error( + "Sixel probe failed or multiplexer active; use --renderer ansi"); + if (sixel) { + // DECSDM reset places graphics at the cursor. Save the user's setting + // and restore it on exit, including interrupted image writes. + output.Put("\033[?80s\033[?80l"); + tty.sixel = true; + } // Both pixel frames and large true-colour character grids need local // throughput. Keep SSH conservative and retain the one-message/burst limits // and nonblocking writes so slow terminals still apply backpressure. @@ -520,10 +532,12 @@ int View(SessionOptions options, const std::string &device, auto v = Size(&status_rows, &input.terminal_metrics, cell_aspect); v.detail = detail; v.native = input.native; - v.pixel_mode = kitty; + v.pixel_mode = kitty || sixel; v.zoom = input.zoom; v.center_x = input.center_x; v.center_y = input.center_y; + if (sixel) + v = SixelViewport(v); if (input.view_revision != view_revision) { view_revision = input.view_revision; redraw = true; @@ -591,6 +605,7 @@ int View(SessionOptions options, const std::string &device, bar.command_mode = input.command_mode; bar.fps = "FPS " + std::to_string(output_fps); bar.view = std::string(kitty ? "KITTY" + : sixel ? "SIXEL" : detail == AnsiDetail::Quadrant ? "QUAD" : "HALF") + (input.native ? " | Detail x" : " | Fit x") + @@ -651,15 +666,15 @@ int View(SessionOptions options, const std::string &device, now - last_frame->queued) .count(); } - pending = kitty ? Layout(*last_frame, v) : Convert(*last_frame, v); - // Clear waiting text and Kitty margins without erasing the status bars. + pending = v.pixel_mode ? Layout(*last_frame, v) : Convert(*last_frame, v); + // Clear waiting text and image margins without erasing the status bars. std::string clear; if (waiting_visible) for (int row = 0; row < v.rows; ++row) clear += "\033[" + std::to_string(v.row_offset + row + 1) + ";1H\033[0m\033[2K"; if (shared_enabled) { - const auto pixels = KittyPixels(*last_frame, v); + const auto pixels = ImagePixels(*last_frame, v); if (shared_image.Assign(pixels.rgb.data(), pixels.rgb.size())) { output.Put(KittySharedUpload(shared_image.Name(), pixels.width, pixels.height, submitted % 2 != 0)); @@ -673,7 +688,8 @@ int View(SessionOptions options, const std::string &device, if (fresh && !frame_started) frame_started = Clock::now(); output.Put(clear + (kitty ? Kitty(*last_frame, v, submitted % 2 != 0) - : Difference(*pending, displayed))); + : sixel ? Sixel(*last_frame, v) + : Difference(*pending, displayed))); waiting_visible = false; pending_frame = true; } @@ -729,7 +745,7 @@ int View(SessionOptions options, const std::string &device, {"reported", input.terminal_metrics.Known()}, {"manual_aspect", cell_aspect > 0}}}, {"submitted", submitted}, - {"renderer", kitty ? "kitty" : "ansi"}, + {"renderer", kitty ? "kitty" : sixel ? "sixel" : "ansi"}, {"kitty_transport", !kitty ? "none" : shared_enabled ? "shared_memory" : "inline"}, @@ -1011,7 +1027,7 @@ int Bridge(int fd, SessionOptions options, int password_fd) { s.Disconnect(); return interrupted ? 130 : 0; } -const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty] [--fps 1..30] +const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty|sixel] [--fps 1..30] [--display 0..63] [--control] [--server host] [--port 9099] [--quality low|balanced|high|ultra] [--ansi-detail quadrant|half] [--zoom 1..32|native] [--cell-aspect auto|0.2..2] @@ -1028,6 +1044,7 @@ Ctrl+T and Ctrl+C pass through in control mode; another Ctrl+] leaves local mode Default ANSI detail: quadrant. Default quality: high (1920x1080); ultra requests up to 3840x2160. Cell aspect is character width/height: auto detects terminal geometry; use --cell-aspect to calibrate. Display index is zero-based. ANSI cannot reproduce full desktop text at fit scale. +Auto rendering prefers Kitty, then Sixel, then ANSI. Explicit pixel renderers require a successful probe. --fps controls local output (default 30). Without it, remote preview adapts to measured output speed. Explicit --fps keeps a fixed preview request. Kitty transport fallback keeps the local target unchanged. Preview requests above 15 use the negotiated interactive 30 FPS stream; lower requests use 1/8/15 FPS. @@ -1064,6 +1081,7 @@ int main(int argc, char **argv) { {"multiplexer", !Env("TMUX").empty() || !Env("STY").empty()}, {"software_decode", true}, {"kitty", "probe_on_view"}, + {"sixel", "probe_on_view"}, {"audio_playback", false}, {"bridge_version", 1}}; std::cout << d.dump(argc == 3 ? -1 : 2) << "\n"; @@ -1142,7 +1160,8 @@ int main(int argc, char **argv) { throw std::invalid_argument("unsupported option"); } } - if (renderer != "auto" && renderer != "ansi" && renderer != "kitty") + if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" && + renderer != "sixel") throw std::invalid_argument("unknown renderer"); if (command == "bridge" && !stdio) throw std::invalid_argument("bridge requires --stdio"); diff --git a/libs/terminal/include/terminal_renderer.h b/libs/terminal/include/terminal_renderer.h index 65917a84..0e9b8437 100644 --- a/libs/terminal/include/terminal_renderer.h +++ b/libs/terminal/include/terminal_renderer.h @@ -67,7 +67,12 @@ struct PixelFrame { int width = 0, height = 0; std::vector rgb; }; -PixelFrame KittyPixels(const DecodedFrame &, const Viewport &); +PixelFrame ImagePixels(const DecodedFrame &, const Viewport &); +// Sixel has no placement scaling. Limit the cell viewport, not the bitmap, +// so the displayed pixels and pointer mapping keep the same geometry. +Viewport SixelViewport(Viewport); +std::string Sixel(const DecodedFrame &, const Viewport &); +bool SixelReply(const std::string &response); std::string KittySharedUpload(const std::string &name, int width, int height, bool alternate_buffer, bool query = false); std::string KittyPresent(const Viewport &, bool alternate_buffer); diff --git a/libs/terminal/src/sixel_renderer.cpp b/libs/terminal/src/sixel_renderer.cpp new file mode 100644 index 00000000..0303c75c --- /dev/null +++ b/libs/terminal/src/sixel_renderer.cpp @@ -0,0 +1,169 @@ +#include "terminal_renderer.h" +#include +#include +#include +#include + +namespace crossdesk::terminal { +Viewport SixelViewport(Viewport v) { + if (v.columns < 1 || v.columns > 240 || v.rows < 1 || v.rows > 100) + throw std::invalid_argument("invalid Sixel viewport"); + const auto limit = [](int &cells, int &pixels, int fallback, int maximum) { + const int original_cells = cells; + if (pixels <= 0) + pixels = cells * fallback; + cells = + std::max(1, std::min(cells, int(int64_t(maximum) * cells / pixels))); + pixels = std::clamp( + int((int64_t(pixels) * cells + original_cells / 2) / original_cells), 2, + maximum); + }; + limit(v.columns, v.pixel_width, 10, 1920); + limit(v.rows, v.pixel_height, 20, 1080); + v.pixel_mode = true; + return v; +} + +bool SixelReply(const std::string &response) { + for (size_t pos = 0; + (pos = response.find("\033[?", pos)) != std::string::npos; ++pos) { + const auto end = response.find('c', pos + 3); + if (end == std::string::npos || end - pos > 1024) + continue; + const char *cur = response.data() + pos + 3; + const char *last = response.data() + end; + bool first = true, supported = false; + while (cur < last) { + unsigned value = 0; + const auto parsed = std::from_chars(cur, last, value); + if (parsed.ec != std::errc{}) + break; + // The first parameter identifies the terminal, not a capability. + supported = supported || (!first && value == 4); + first = false; + cur = parsed.ptr; + if (cur == last) { + if (supported) + return true; + break; + } + if (*cur++ != ';') + break; + } + } + return false; +} + +namespace { +struct Palette { + int levels, grays; + std::vector> colors; + Palette(int cube_levels, int gray_levels) + : levels(cube_levels), grays(gray_levels) { + for (int r = 0; r < levels; ++r) + for (int g = 0; g < levels; ++g) + for (int b = 0; b < levels; ++b) + colors.push_back({Level(r), Level(g), Level(b)}); + for (int i = 0; i < grays; ++i) { + const int value = Gray(i); + colors.push_back({value, value, value}); + } + } + int Level(int i) const { return (i * 255 + (levels - 1) / 2) / (levels - 1); } + int Gray(int i) const { return (i * 255 + (grays - 1) / 2) / (grays - 1); } + uint8_t Index(const uint8_t *rgb) const { + const int gray = + ((int(rgb[0]) + rgb[1] + rgb[2]) * (grays - 1) + 382) / 765; + const int gray_index = levels * levels * levels + gray; + if (!levels) + return uint8_t(gray_index); + int index = 0, cube_error = 0, gray_error = 0; + for (int c = 0; c < 3; ++c) { + const int step = (rgb[c] * (levels - 1) + 127) / 255; + index = index * levels + step; + const int dc = rgb[c] - Level(step), dg = rgb[c] - Gray(gray); + cube_error += dc * dc; + gray_error += dg * dg; + } + return uint8_t(gray_error < cube_error ? gray_index : index); + } +}; + +void Run(std::string &out, uint8_t bits, int count) { + if (count > 3) { + out += '!'; + out += std::to_string(count); + out += char('?' + bits); + } else { + out.append(size_t(count), char('?' + bits)); + } +} + +std::string Encode(const PixelFrame &pixels, const Viewport &v, + const Palette &palette) { + constexpr size_t budget = 1900000; // Leave room for terminal/UI commands. + const int width = pixels.width, height = pixels.height; + std::string out = "\0337\033[" + std::to_string(v.row_offset + 1) + + ";1H\033P0;1;0q\"1;1;" + std::to_string(width) + ";" + + std::to_string(height); + // Define a complete palette for every frame, including black margins. + // Pb=1 preserves pixels outside the image in its final partial six-row band. + for (size_t i = 0; i < palette.colors.size(); ++i) { + out += '#' + std::to_string(i) + ";2"; + for (int c : palette.colors[i]) + out += ';' + std::to_string((c * 100 + 127) / 255); + } + std::vector planes(palette.colors.size() * width); + std::vector ends(palette.colors.size()); + for (int y = 0; y < height; y += 6) { + std::fill(planes.begin(), planes.end(), 0); + std::fill(ends.begin(), ends.end(), 0); + for (int dy = 0; dy < std::min(6, height - y); ++dy) + for (int x = 0; x < width; ++x) { + const auto color = + palette.Index(&pixels.rgb[(size_t(y + dy) * width + x) * 3]); + planes[size_t(color) * width + x] |= uint8_t(1 << dy); + ends[color] = std::max(ends[color], x + 1); + } + bool first = true; + for (size_t color = 0; color < ends.size(); ++color) { + if (!ends[color]) + continue; + if (!first) + out += '$'; // Return to the left edge without advancing the band. + first = false; + out += '#' + std::to_string(color); + const auto *plane = &planes[color * width]; + for (int x = 0; x < ends[color];) { + int end = x + 1; + while (end < ends[color] && plane[end] == plane[x]) + ++end; + Run(out, plane[x], end - x); + x = end; + } + if (out.size() > budget) + return {}; + } + if (y + 6 < height) + out += '-'; + } + out += "\033\\\0338"; + return out; +} +} // namespace + +std::string Sixel(const DecodedFrame &frame, const Viewport &viewport) { + const auto v = SixelViewport(viewport); + const auto pixels = ImagePixels(frame, v); + // Sixel cannot scale a smaller bitmap to a cell placement. Reduce color + // detail on high-entropy frames instead of shrinking or misplacing them. + for (const auto &sizes : + {std::pair{6, 32}, std::pair{3, 16}, std::pair{2, 8}, std::pair{0, 4}}) { + auto out = Encode(pixels, v, Palette(sizes.first, sizes.second)); + if (!out.empty()) + return out; + } + // Four planes at 1920 x 1080 always fit the budget above. + throw std::runtime_error("Sixel output budget exceeded"); +} +} // namespace crossdesk::terminal diff --git a/libs/terminal/src/terminal_renderer.cpp b/libs/terminal/src/terminal_renderer.cpp index 2fc6286f..e7e2e4c7 100644 --- a/libs/terminal/src/terminal_renderer.cpp +++ b/libs/terminal/src/terminal_renderer.cpp @@ -543,7 +543,7 @@ static std::string Base64(const std::vector &data) { } return out; } -PixelFrame KittyPixels(const DecodedFrame &f, const Viewport &requested) { +PixelFrame ImagePixels(const DecodedFrame &f, const Viewport &requested) { auto v = requested; v.pixel_mode = true; Validate(f, v);