Compare commits

..
18 Commits
Author SHA1 Message Date
dijunkun 4e2e5b74dd [build] migrate CLI macOS runner and release 0.1.4 2026-10-31 05:34:29 +08:00
dijunkun 7bcda7a6fa [build] bump CLI version to 0.1.3 2026-10-31 05:27:31 +08:00
dijunkun baf48120bb [perf] optimize CLI ANSI output and refresh previews 2026-10-31 05:21:10 +08:00
dijunkun 1b716f3682 [docs] clarify platform and client badges in READMEs 2026-10-31 05:09:41 +08:00
dijunkun b361533d70 [docs] refresh CLI previews and clarify FPS capture 2026-10-31 04:58:15 +08:00
dijunkun af60fb2541 [docs] expand CLI guide with npm setup and previews 2026-10-31 04:49:13 +08:00
dijunkun 2279636829 [build] bump CLI version to 0.1.2 2026-10-31 01:16:36 +08:00
dijunkun 84270fe065 [fix] remove hardcoded MiniRTC revision check from CLI setup 2026-10-31 00:36:27 +08:00
dijunkun 915cfe3a04 [feat] add live FPS to CLI status bar 2026-10-31 00:36:27 +08:00
dijunkun d687c0e2ea [perf] adapt CLI preview rate and improve frame pacing 2026-10-31 00:36:27 +08:00
dijunkun 5f2caee0cb [build] update iOS project settings for Xcode 27.1 2026-10-31 00:36:27 +08:00
dijunkun e2cd4959c6 [perf] accelerate Kitty and ANSI rendering 2026-10-31 00:36:27 +08:00
dijunkun cd5e1c95d7 [fix] stop iOS file transfers after read or seek failures 2026-10-31 00:36:27 +08:00
dijunkun dde5b57165 [fix] update MiniRTC to prevent macOS connection hangs 2026-10-31 00:18:01 +08:00
dijunkun bcfc8947c9 [fix] resolve iOS deprecation and archive warnings 2026-10-30 21:56:07 +08:00
dijunkun 12ca90d827 [build] bump iOS version to 1.7.4 2026-10-30 21:43:45 +08:00
dijunkun 765e088f70 [build] configure iOS signing and Xcode Cloud dependencies 2026-10-30 21:37:06 +08:00
dijunkun 8f54be6c70 [ui] update iOS local network permission wording 2026-10-30 21:14:04 +08:00
39 changed files with 1562 additions and 293 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
fail-fast: false
matrix:
include:
- runner: macos-14
- runner: macos-15
target: darwin-arm64
arch: arm64
- runner: macos-15-intel
+68 -4
View File
@@ -8,7 +8,10 @@
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Clients](https://img.shields.io/badge/client-Desktop%20%7C%20Mobile%20%7C%20Web%20%7C%20CLI-2ea44f?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![npm](https://img.shields.io/npm/v/%40chunkdash%2Fcrossdesk-cli?style=flat)](https://www.npmjs.com/package/@chunkdash/crossdesk-cli)
[![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
@@ -51,7 +54,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
| 鸿蒙 PC(开发版) | **控制 / 被控**;API 26,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| 鸿蒙手机 / 平板(开发版) | **仅控制**;API 20+,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| Web | **仅控制**;支持 WebRTC 的浏览器;无需在控制端安装客户端 |
| 终端 CLI(开发版) | **仅控制**;构建目标为 macOS / Linux(x64、arm64)和 Windows(x64);从源码构建,平台验收范围见 [CLI 说明](apps/cli/README.md) |
| 终端 CLI(开发版) | **仅控制**;分发目标为 macOS / Linux(x64、arm64)和 Windows(x64);通过 [npm 安装](#cli-npm) 或从源码构建,平台验收范围见 [CLI 说明](apps/cli/README.md) |
系统要求按当前源码与构建配置整理,具体安装包以对应 Release 说明为准。各客户端的功能范围见 [终端 CLI](#cli) 与 [移动端与鸿蒙](#mobile)。
@@ -130,6 +133,38 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。
### 画面预览
| ANSI 字符画面 | Kitty 协议像素画面 |
| --- | --- |
| <img src="docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI 的 ANSI 四象限全景:顶部显示只读模式和连接状态,底部显示操作快捷键" /> | <img src="docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI 的 Kitty 协议全景:同一远程桌面以像素画面显示,保留状态栏和操作快捷键" /> |
| 用彩色字符显示桌面轮廓,适合普通终端;查看局部可按 `d` 放大。 | 直接显示像素画面,适合支持 Kitty 图形协议的终端,如 Ghostty。 |
两图采集于 **2026-10-31**,来自同一授权设备的实际只读连接,以 128 × 40 字符视口还原 CLI 的 ANSI / Kitty 协议输出。图中 FPS 是采集时的实际输出计数,静止画面下 ANSI 无变化可显示 `FPS 0`。图示为输出渲染,终端实窗效果受字体、窗口大小及协议支持影响;采集方法见 [图示来源](docs/images/README.md#cli-captures)。
<a id="cli-npm"></a>
### 通过 npm 安装并连接
准备 **Node.js 22 或以上**及 npm,从 [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli) 安装。npm 会按操作系统与 CPU 架构选择原生程序,无需安装 Xmake 或 C++ 编译工具链。
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
将 `123456789` 换成被控电脑的 9 位设备 ID,连接时按提示隐藏输入密码。默认只读、静音;`doctor` 仅检查本机环境,不发起连接。更新到最新版时,重新执行上面的 `npm install -g @chunkdash/crossdesk-cli@latest`。
无需全局安装时,也可使用 `npx`,首次运行会下载对应平台包:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
请安装统一入口 `@chunkdash/crossdesk-cli`,并保留 npm 的可选依赖,不要添加 `--omit=optional`;仅安装平台包不会注册 `crossdesk-cli` 命令。平台运行要求、旧包迁移及故障排查见 [npm 分发说明](apps/cli/docs/npm-distribution.md)。
### 从源码构建并连接
准备 Git、Xmake 3.1.1 或以上、C++17 编译工具链及对应平台依赖。以下以 macOS / Linux shell 为例;Windows 的 Git Bash / 开发者 PowerShell 步骤见 [CLI 构建说明](apps/cli/docs/terminal-client.md#构建与安装)。
@@ -146,7 +181,34 @@ xmake run crossdesk-cli 123456789
已有仓库时从仓库根目录执行依赖准备及后续命令。将 `123456789` 换成被控电脑的 ID,密码在连接时隐藏输入。`doctor` 只检查本机环境,不发起连接。仓库默认构建桌面端;以上配置只构建 CLI,需要同时构建桌面端时将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。产物位于 `build/<platform>/<arch>/release/`。
ANSI 全景受终端字符网格限制。阅读小字可添加 `--quality high --zoom native`,或进入画面后按 `d` 查看细节;`--renderer kitty` 需要终端支持对应协议。显示、鼠标报告、SSH 和终端兼容性见 [使用说明](apps/cli/docs/terminal-client.md)。
### 选择画面与连接参数
默认 `--renderer auto` 自动探测 Kitty 图形协议,未获支持时回退为 ANSI。macOS「终端」等普通终端可显式选择 `ansi`;Ghostty 等支持该协议的终端可选择 `kitty`。显式指定 `kitty` 但探测失败时会报错,此时改用 `ansi`。
以下示例使用 npm 全局安装后的命令;从源码构建时,可在仓库根目录将 `crossdesk-cli` 替换为 `xmake run crossdesk-cli`。
```sh
# 普通终端:以细节模式查看局部
crossdesk-cli 123456789 --renderer ansi --zoom native
# 支持 Kitty 图形协议的终端:显示像素画面
crossdesk-cli 123456789 --renderer kitty
# 降低远端画质与刷新请求
crossdesk-cli 123456789 --quality balanced --fps 8
```
| 参数 | 用途 |
| --- | --- |
| `--renderer auto\|ansi\|kitty` | 自动选择、字符画面或像素画面;默认 `auto` |
| `--zoom native` | 从原始细节模式开始;`--zoom 2` 表示相对全景放大 2 倍 |
| `--quality low\|balanced\|high\|ultra` | 请求最高 360p / 720p / 1080p / 2160p;默认 `high`,实际画质由被控端决定 |
| `--fps 8` | 固定本地输出目标和相应远端预览请求;允许 1–30,默认本地目标为 30,未指定时远端供帧会自适应 |
| `--display 1` | 选择第二个显示器,索引从 `0` 开始 |
| `--control` | 连接后直接启用键鼠控制;省略时默认只读 |
| `--server your-server.example --port 9099` | 使用自建信令服务,两端需接入同一服务 |
ANSI 全景受字符网格限制,提高画质不能让整屏小字变得清晰;可扩大终端、缩小字体,再按 `d` 查看局部,按 `f` 回到全景。顶部状态栏显示模式、连接进度、渲染器、缩放、实时输出 FPS 和网络信息,窄窗口会省略部分字段。通过 SSH 使用时分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。更多显示、鼠标报告和兼容性说明见 [终端使用指南](apps/cli/docs/terminal-client.md)。
### 控制、查看与退出
@@ -160,7 +222,9 @@ ANSI 全景受终端字符网格限制。阅读小字可添加 `--quality high -
| `d` / `f` | 在只读或本地命令模式中切换原始细节 / 完整桌面;只读时也可点击目标进入细节 |
| 方向键 / `i j k l` | 在只读或本地命令模式中平移;`i` 上、`j` 左、`k` 下、`l` 右 |
`Ctrl+]` 模式不会因为缩放、平移或按 `c` 自动结束。状态栏中的 `CONTROL PAUSED` / `READ ONLY` 表示返回后是否启用控制。控制模式中的普通 `c`、`q` 和 `Ctrl+C` 会发送到远端,需要先按 `Ctrl+]` 执行本地操作;再次按 `Ctrl+]` 也可返回,若返回控制模式则向远端发送一次该组合键。
**控制模式下退出:先按 `Ctrl+]`,再按 `q`。** 普通 `c`、`q` 和 `Ctrl+C` 在控制模式中会发送到远端。
`Ctrl+]` 模式不会因为缩放、平移或按 `c` 自动结束。状态栏中的 `CONTROL PAUSED` / `READ ONLY` 表示返回后是否启用控制;按 `Esc` / `Ctrl+C` 手动返回。再次按 `Ctrl+]` 也可返回,若返回控制模式则向远端发送一次该组合键。
### Pi 扩展与功能范围
@@ -173,7 +237,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 直连、音频播放、文件传输或剪贴板同步。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建配置与 npm 打包流程不代表全部平台已验收或包已发布。
CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty 实机显示及真实 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)
+68 -4
View File
@@ -8,7 +8,10 @@
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Clients](https://img.shields.io/badge/client-Desktop%20%7C%20Mobile%20%7C%20Web%20%7C%20CLI-2ea44f?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![npm](https://img.shields.io/npm/v/%40chunkdash%2Fcrossdesk-cli?style=flat)](https://www.npmjs.com/package/@chunkdash/crossdesk-cli)
[![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
@@ -51,7 +54,7 @@ Get a package matching your OS and CPU architecture from the [website](https://w
| HarmonyOS PC (development) | **Control + host**. API 26, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing |
| HarmonyOS phones / tablets (development) | **Control only**. API 20+, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing |
| Web | **Control only**. A WebRTC-capable browser; no client installation on the controlling device |
| Terminal CLI (development) | **Control only**. Build targets: macOS / Linux (x64, arm64) and Windows (x64). Build from source; see the [CLI guide](apps/cli/README.md) (Chinese) for platform validation status |
| Terminal CLI (development) | **Control only**. Distribution targets: macOS / Linux (x64, arm64) and Windows (x64). [Install from npm](#cli-npm) or build from source; see the [CLI guide](apps/cli/README.md) (Chinese) for platform validation status |
Requirements follow the current source and build configuration. Check the release notes for the downloaded package. Client capabilities are summarized under [Terminal CLI](#cli) and [Mobile and HarmonyOS](#mobile).
@@ -130,6 +133,38 @@ Remote sessions support monitor switching, video quality and frame-rate controls
`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.
### Display preview
| ANSI character rendering | Kitty pixel graphics |
| --- | --- |
| <img src="docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI ANSI quadrant overview, with read-only mode and connection status at the top and shortcuts at the bottom" /> | <img src="docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI Kitty protocol overview of the same remote desktop, rendered as pixels with the status bar and shortcuts" /> |
| Colored characters show the desktop in standard terminals; press `d` to inspect a smaller region. | Pixel graphics display the desktop in terminals supporting the Kitty graphics protocol, such as Ghostty. |
Both images were captured on **2026-10-31** from real read-only connections to the same authorized host. They render the CLI's ANSI / Kitty protocol output in a 128 × 40 character viewport. FPS counts actual output during capture; an unchanged ANSI view can show `FPS 0`. Actual terminal appearance depends on the font, window size, and protocol support. See [capture notes](docs/images/README.md#cli-captures) for the method.
<a id="cli-npm"></a>
### Install from npm and connect
Install **Node.js 22 or later** and npm, then get [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli). npm selects the native executable for your OS and CPU architecture; Xmake and a C++ toolchain are not required.
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
Replace `123456789` with the host's 9-digit device ID and enter its password at the hidden prompt. Connections start in read-only, silent mode; `doctor` checks the local environment without connecting. To update, run `npm install -g @chunkdash/crossdesk-cli@latest` again.
You can also use `npx` without a global installation. The first run downloads the matching platform package:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
Install the main `@chunkdash/crossdesk-cli` package with npm optional dependencies enabled; do not add `--omit=optional`. Installing a platform package alone does not register the `crossdesk-cli` command. See the [npm distribution guide](apps/cli/docs/npm-distribution.md) (Chinese) for platform requirements, migration from older packages, and troubleshooting.
### Build and connect
Install Git, Xmake 3.1.1 or later, a C++17 toolchain, and the platform dependencies. The following example uses a macOS / Linux shell; Windows Git Bash / Developer PowerShell steps are in the [CLI build guide](apps/cli/docs/terminal-client.md#构建与安装) (Chinese).
@@ -146,7 +181,34 @@ xmake run crossdesk-cli 123456789
For an existing checkout, run the dependency preparation and subsequent commands from the repository root. Replace `123456789` with the host's ID; the password prompt hides your input. `doctor` reports the local environment without connecting. The repository builds the desktop client by default; this configuration builds only the CLI. Set `CROSSDESK_BUILD_DESKTOP=y` to also build the desktop client. Binaries are placed in `build/<platform>/<arch>/release/`.
An ANSI overview is limited by the terminal's character grid. Add `--quality high --zoom native`, or press `d` in the viewer, to inspect small text. `--renderer kitty` requires a terminal supporting that protocol. See the [terminal guide](apps/cli/docs/terminal-client.md) (Chinese) for rendering, mouse reporting, SSH, and terminal compatibility.
### 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.
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.
```sh
# Standard terminal: start with a detail view
crossdesk-cli 123456789 --renderer ansi --zoom native
# Terminal supporting the Kitty graphics protocol: display pixel graphics
crossdesk-cli 123456789 --renderer kitty
# Request lower video quality and refresh rate
crossdesk-cli 123456789 --quality balanced --fps 8
```
| Option | Purpose |
| --- | --- |
| `--renderer auto\|ansi\|kitty` | 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 |
| `--display 1` | Select the second monitor; indexes start at `0` |
| `--control` | Enable keyboard/mouse control immediately after connecting; omitted by default for read-only viewing |
| `--server your-server.example --port 9099` | Use a self-hosted signaling service; both ends must use the same service |
ANSI overviews are limited by the character grid: higher video quality alone cannot make full-desktop text legible. Enlarge the terminal or reduce its font size, press `d` for detail, and `f` to return to the full desktop. The top bar shows input mode, connection progress, renderer, zoom, live output FPS, and network information; narrow windows omit some fields. Allocate a PTY over SSH (`ssh -t`), and use ANSI inside tmux / screen. See the [terminal guide](apps/cli/docs/terminal-client.md) (Chinese) for rendering, mouse reporting, and compatibility details.
### Control, navigation, and exit
@@ -160,7 +222,9 @@ An ANSI overview is limited by the terminal's character grid. Add `--quality hig
| `d` / `f` | Switch to native detail / full-desktop view in read-only or local command mode; clicking in read-only mode also opens detail at that point |
| Arrow keys / `i j k l` | Pan in read-only or local command mode: `i` up, `j` left, `k` down, `l` right |
Zooming, panning, or pressing `c` does not end `Ctrl+]` mode. The status bar shows `CONTROL PAUSED` / `READ ONLY` to indicate whether control will resume. In control mode, ordinary `c`, `q`, and `Ctrl+C` go to the host, so enter local command mode first for local actions. Pressing `Ctrl+]` again also leaves local mode and forwards that shortcut if control resumes.
**To quit while controlling the host, press `Ctrl+]`, then `q`.** Ordinary `c`, `q`, and `Ctrl+C` go to the host in control mode.
Zooming, panning, or pressing `c` does not end `Ctrl+]` mode. The status bar shows `CONTROL PAUSED` / `READ ONLY` to indicate whether control will resume; press `Esc` / `Ctrl+C` to return manually. Pressing `Ctrl+]` again also leaves local mode and forwards that shortcut if control resumes.
### Pi extension and feature scope
@@ -173,7 +237,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. 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 build configurations and npm packaging do not establish that every platform is validated or that packages have been published.
The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. 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.
[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)
+55 -3
View File
@@ -2,14 +2,38 @@
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty 图形后端,以及 Pi 自定义 TUI 扩展。
分发目标为 macOS / Linux(x64、arm64)和 Windows(x64)的原生终端客户端。npm 包发布后可统一使用:
## 画面预览
| ANSI 字符画面 | Kitty 协议像素画面 |
| --- | --- |
| <img src="../../docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI 的 ANSI 全景输出,包含只读状态栏与快捷键提示" /> | <img src="../../docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI 的 Kitty 像素全景输出,显示同一远程桌面" /> |
图示来自 **2026-10-31** 的实际只读连接,通过伪终端采集并渲染协议输出;来源与边界见 [图示说明](../../docs/images/README.md#cli-captures)。默认 `auto` 探测 Kitty 支持,失败时使用 ANSI;普通终端可指定 `--renderer ansi`,Ghostty 等支持 Kitty 图形协议的终端可指定 `--renderer kitty`。ANSI 全景适合查看桌面轮廓,阅读小字需进入细节模式。
## 安装与连接
### 通过 npm 安装
需要 **Node.js 22 或以上**和 npm。统一入口为 [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli),分发目标为 macOS / Linux(x64、arm64)和 Windows(x64)。安装时自动选择对应的原生程序,无需 Xmake 或 C++ 编译工具链:
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
将示例 ID 替换为被控电脑的 9 位 ID,按提示隐藏输入密码。`doctor` 仅检查本机环境,不发起连接。更新到最新版时重新执行上述安装命令。
无需全局安装时,使用 `npx`,首次运行会下载对应平台包:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
统一入口包为 `@chunkdash/crossdesk-cli`,安装后命令仍为 `crossdesk-cli`。五个平台依赖使用 `@chunkdash/crossdesk-cli-*`,由启动器自动选择。旧的无 scope 平台包不属于这套依赖;仅安装平台包不会注册命令。是否可安装以 npm 上完整版本的发布结果为准,三端实机验收尚未全部完成。运行要求、打包、本地安装与发布步骤见 [npm 分发](docs/npm-distribution.md)。
安装后命令为 `crossdesk-cli`。五个平台依赖使用 `@chunkdash/crossdesk-cli-*`,由启动器自动选择;请保留 npm 的可选依赖,不要使用 `--omit=optional`。旧的无 scope 平台包不属于这套依赖,仅安装平台包不会注册命令。三端实机验收尚未全部完成;运行要求、旧包迁移、打包与发布步骤见 [npm 分发](docs/npm-distribution.md)。
### 从源码构建
在 CrossDesk 仓库根目录执行以下命令构建 CLI:
@@ -21,7 +45,35 @@ xmake run crossdesk-cli doctor --json
xmake run crossdesk-cli 123456789 --quality high --zoom native
```
`crossdesk-cli <设备ID> [选项]` 为默认连接方式,`view <设备ID>` 保留为兼容别名。最后一个设备 ID 是格式示例,使用时替换为你有权访问的设备。密码通过隐藏提示输入。只读时 `c` 开启控制、`q` 或 `Ctrl+C` 退出;点击目标或按 `d` 进入原始细节模式,`+/-` 或滚轮缩放,方向键或 `i` 上、`j` 左、`k` 下、`l` 右平移,`f` 回到完整桌面。任何模式下先按 `Ctrl+]` 进入持续本地命令模式,再按 `c` 选择返回后的控制状态、`q` 退出或视图按键连续调整画面,`Esc` / `Ctrl+C` 手动返回。控制模式中的 `Ctrl+T`、`Ctrl+C` 和普通按键均传给远端。ANSI 全屏缩略图仍受字符网格限制,默认请求 high 画质,阅读小字建议使用细节模式;支持 Kitty 协议的终端可使用 `--renderer kitty` 获得像素画面。
构建后可直接运行 `build/<platform>/<arch>/release/crossdesk-cli`,也可将对应目录加入 PATH;Windows 的文件名为 `crossdesk-cli.exe`。
### 常用连接命令
以下示例适用于 npm 全局安装,或已将原生程序加入 PATH 的环境。`crossdesk-cli <设备ID> [选项]` 为默认连接方式,`view <设备ID>` 保留为兼容别名。设备 ID 是格式示例,使用时替换为你有权访问的设备,密码通过隐藏提示输入:
```sh
crossdesk-cli 123456789 --renderer kitty
crossdesk-cli 123456789 --renderer ansi --zoom native
crossdesk-cli 123456789 --quality balanced --fps 8
crossdesk-cli 123456789 --display 1
```
默认请求 `high` 画质(最高 1920 × 1080),`--display` 的索引从 `0` 开始。交互 CLI 默认本地输出目标为 30 FPS,远端供帧会随输出耗时自适应;显式 `--fps` 固定目标和相应预览请求,实际帧率仍取决于设备、终端与链路。更多参数见 [主 README](../../README.md#cli) 和 [终端使用指南](docs/terminal-client.md)。
## 查看、控制与退出
| 操作 | 按键 |
| --- | --- |
| 开启远端控制 | 只读模式下按 `c` |
| 放大局部 / 回到全景 | 只读或本地命令模式下按 `d` / `f`;只读时也可点击目标进入细节 |
| 缩放 / 平移 | 只读或本地命令模式下用 `+/-` 或滚轮缩放;方向键或 `i` 上、`j` 左、`k` 下、`l` 右平移 |
| 暂停远端输入并进入本地命令模式 | `Ctrl+]` |
| 选择返回后的控制状态 | 本地命令模式下按 `c`,再按 `Esc` / `Ctrl+C` 返回 |
| 退出连接 | 只读模式下按 `q` / `Ctrl+C`;控制模式下先按 `Ctrl+]`,再按 `q` |
本地命令模式在缩放、平移和按 `c` 后持续保持,直到手动返回。控制模式中的 `Ctrl+T`、`Ctrl+C` 和普通按键均传给远端。再次按 `Ctrl+]` 也可离开本地命令模式,若返回控制模式则同时向远端发送该组合键。
## 更多文档与功能范围
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
- [私有桥接协议](docs/bridge-protocol.md)
+2
View File
@@ -20,6 +20,8 @@
凭据不能放在 connect JSON 中。专用描述符可以由 Node `spawn` 的第四条 pipe 提供。成功读取后会话不会保留用户密码供上层访问;MiniRTC 内部为现有 Join/重连流程持有必要凭据直到销毁 Peer。下一次 connect 必须有可读的凭据来源;Pi 关闭视图后直接结束进程。
`viewport.fps` 是本地输出目标。对端支持预览订阅时,超过 15 的目标使用已有交互模式(协议 `fps=60`、编码 `frame_rate=30`),不再折算成 15 FPS;低目标仍按能力选择 1/8/15。bridge 始终输出字符行并要求宿主 ACK,不使用交互 Kitty 的共享内存传图。
帧事件:
```json
+12 -4
View File
@@ -2,16 +2,24 @@
## 使用方式
完整版本发布后的统一入口如下。构建产物或部分平台包上传成功,不代表入口包和全部平台依赖已经可用;发布者须完成本文的注册表检查。
从 [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli) 安装统一入口,npm 会选择对应系统和架构的原生平台包。需要 Node.js 22 或以上和 npm,无需本机编译工具链。
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
更新时重新执行同一条 `npm install -g @chunkdash/crossdesk-cli@latest`。将示例 ID 替换为被控电脑的 9 位 ID,密码在连接时隐藏输入;`doctor` 只检查本机环境,不建立远程连接。
无需全局安装时使用 `npx`,首次运行会下载对应平台包:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
npm install -g @chunkdash/crossdesk-cli
crossdesk-cli 123456789
```
需要 Node.js 22 或以上和 npm。启动器根据 Node 的 `process.platform` / `process.arch` 选择同版本的 `@chunkdash` 平台包,直接启动原生 C++ 程序,继承终端、工作目录、环境变量和命令参数。安装过程不下载外部可执行文件、不执行 postinstall、不在用户机器编译。不要使用 `--omit=optional`,否则对应平台包不会安装。
启动器根据 Node 的 `process.platform` / `process.arch` 选择同版本的 `@chunkdash` 平台包,直接启动原生 C++ 程序,继承终端、工作目录、环境变量和命令参数。原生可执行文件随 npm 平台包下载,安装过程不执行 postinstall,也不在本机编译。不要使用 `--omit=optional`,否则对应平台包不会安装。
入口包名为 `@chunkdash/crossdesk-cli`,`bin` 声明的终端命令仍为 `crossdesk-cli`。如已全局安装旧的无 scope 入口,先执行 `npm uninstall -g crossdesk-cli`,再安装 scoped 入口,避免两个包争用同一个命令。原来的 `crossdesk-cli@0.1.0` 是独立的旧包,改名不会更新它或自动迁移已有安装。
+2 -2
View File
@@ -3,12 +3,12 @@
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`,仅增加下述四处凭据日志脱敏修改。
- MiniRTC:`https://github.com/kunkundi/minirtc.git`,版本由主仓库记录的 `deps/submodules/minirtc` 子模块提交固定。整合时采用的 `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 或媒体协议。
`deps/patches/minirtc-credential-logs.patch` 是 CLI 所需的 MiniRTC 修补:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 按主仓库记录初始化子模块并幂等应用补丁,不另行校验提交哈希;更新 MiniRTC 时无需修改脚本中的版本。CLI 构建检查补丁已存在。共享子模块中的脱敏同样适用于桌面程序;补丁没有改变鉴权、TLS、TURN 或媒体协议。
npm 原生包携带 CLI、共享库、构建配置和已修补 MiniRTC 的源码快照,不复制桌面、移动端、测试或本机构建缓存。源码包保留仓库目录结构,在 `source/` 中配置 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 后构建。
+24 -6
View File
@@ -11,7 +11,7 @@ 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`。所有命令均从仓库根目录执行。
`prepare-deps.sh` 按主仓库记录的提交初始化 MiniRTC 子模块,并应用凭据日志脱敏补丁,可重复执行。脚本不另行校验提交哈希,升级 MiniRTC 时只需更新子模块提交;如果补丁与新版源码不兼容,准备步骤仍会失败,需要同步调整补丁。构建会检查补丁,缺失时失败。仓库默认开启桌面构建、关闭 CLI。上述参数只构建 CLI;需要同时构建桌面和 CLI 时,将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。恢复默认桌面配置使用 `xmake f -y --CROSSDESK_BUILD_DESKTOP=y --CROSSDESK_BUILD_CLI=n`。所有命令均从仓库根目录执行。
构建产物在 `build/<platform>/<arch>/<mode>/crossdesk-cli`。可把对应目录加入 PATH 或复制可执行文件到自己的 bin 目录。Pi 需要直接启动该可执行文件,不能使用会输出构建日志的 `xmake run` 作为桥接程序。
@@ -45,21 +45,31 @@ crossdesk-cli 123456789 --server your-server.example --port 9099
ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝交互连接,不输出全屏转义序列。
四象限模式将每个 RGB 通道变化均不超过 6/255 的低对比单元合并为纯背景色,减少平坦区域的字形接缝。其余单元在独立拟合后参考上下左右邻格的量化误差调整选色,保留源图的真实渐变,并在高对比边缘减弱约束;调整设有失真上限,超过低对比阈值且能精确表示的文字细线和双色边缘保持原样。这些处理自动应用于独立 CLI 和 Pi/bridge 的四象限画面,实际效果仍受终端字体和行距影响。
画面按字符单元比例等比缩放,留黑边及状态栏。交互连接通过终端字节流查询字符尺寸,支持通过 SSH 返回的回复:优先使用 `CSI 16 t` 的字符像素尺寸,否则组合 `CSI 14 t` 的完整文本区像素尺寸和 `CSI 18 t` 的完整行列数计算,兼容 GNOME/VTE 的文本区报告。计算后再扣除状态栏及画面行列上限,不把预留行当作图像高度。窗口改变时重新查询,每两秒刷新一次以覆盖字体/行距变化;回复到达后会重绘静止画面和更新鼠标映射。查询不阻塞连接;没有有效回复时使用系统提供的像素尺寸,再缺失时回退为宽高比 0.5。曾收到有效回复时保留最近有效尺寸,直到下一次有效回复。字符栅格的整数舍入仍可能产生小幅比例误差。格式依据 [XTerm 窗口尺寸报告](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 和 [GNOME VTE 实现](https://github.com/GNOME/vte/blob/master/src/vteseq.cc)。
Windows 的可见传统控制台优先读取当前字体尺寸;Windows Terminal/ConPTY 使用显示终端的尺寸回复,不采用隐藏控制台宿主的字体值。不同终端版本可能不支持查询,或返回虚拟图形尺寸而非实际字体尺寸,此时需要手动校准。
默认 `--cell-aspect auto` 自动确定比例。如果终端屏蔽查询或需要手动校准,可设置 `--cell-aspect 0.45`,数值表示单个字符的**宽/高**,允许 0.2~2;例如字符为 9×20 像素时使用 0.45。画面被压窄时调小此值,被拉宽时调大。此参数优先于自动检测,同时用于全景、细节和鼠标坐标映射。
每次只输出变化行,合并连续颜色设置;缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。
独立 CLI 每次只输出变化行中首尾变化单元之间的区间,光标或局部视频更新不再重写整行;一帧内复用颜色状态、按实际色板索引合并颜色设置,并省略纯背景空格的前景色指令。Pi/bridge 仍发送可独立绘制的完整字符行,同时使用上述颜色编码优化。四象限初始拟合使用整数子集求和计算同等精度的颜色和误差,边界连续性拟合复用加权样本,减少重复运算。
缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。
快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 `<c>`、`<+/->` 等标签标识。宽窗口中的分组分隔线按列对齐,窄窗口保留标题和完整按键标签,按可用宽度合并或减少分组。
顶部状态栏以整数显示实时帧率(例如 `FPS 30`),约每秒按最近统计区间内完整输出到终端的帧数计算;没有新画面输出时更新为 `FPS 0`。该数值是实际提交速率,不是 `--fps` 目标值或终端 GPU 呈现频率;窄窗口优先省略画面参数和网络信息,保留帧率。
ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;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 压缩无损,不代表上游视频编码无损。
本地 macOS/Linux 的 Kitty 模式会另用一个 1×1 像素的共享内存对象探测 `t=s`,最多等待 250ms;探测成功后,将未压缩 RGB 写入权限为 0600 的 POSIX 共享内存,仅通过终端字节流发送名字和显示命令。每次使用唯一名字,最多保留一个待确认的共享缓冲区(1920×1080×3 字节)。收到上传成功确认后才显示新图并释放缓冲区;上传报错、确认超过 500ms 或本地分配失败时,本次会话退回内联传输,继续保留旧画面。终端和 CLI 都会清理共享对象,退出时也会释放未确认的对象。SSH 中不尝试共享内存;Windows 暂用内联传输。
内联 Kitty 通过终端字节流发送 zlib 压缩的 RGB/Base64(每块至多 4096 字符),不使用远端临时图片文件。两条路径都交替使用图像 ID=31/32:以 `a=t` 上传到未显示的 ID,传完后以 `a=p` 创建 placement=1,再删除旧图。仅在整帧显示命令输出完成后交替 ID,上传和短写期间保留旧画面,退出时清理两个 ID。这样避免重传同一 ID 时终端先移除旧图造成的帧间黑屏。
依据终端报告的像素尺寸生成画面(未知时按 10×20 像素字符估算),上限 1920×1080。保留面积滤波、黑边、宽高比和裁剪坐标;原始细节模式按像素视口裁取源区域。共享内存路径使用可复用水平行计算的整数面积权重,避免逐像素重复浮点运算,也不再因 Base64 字节预算额外降采样。没有可靠终端像素尺寸时使用估算值,因此不承诺显示设备的物理像素一一对应。内联压缩结果超出单次输出预算时仍逐级降低尺寸;高熵视频的实际帧率可能低于目标。RGB 传输和压缩不丢失像素,不代表面积采样或上游视频编码无损。
```sh
ssh -t your-server
@@ -70,7 +80,13 @@ crossdesk-cli 123456789 --renderer ansi --fps 8
## 预览与背压
`--fps 1..30` 是本地输出目标。对端声明 PreviewSubscription 时,映射到合法 1/8/15 FPS(15 需要对应能力);普通编码设置仍使用合法 30 FPS。分辨率上限使用协商到的 v2,视频预算只在 bandwidth 能力存在时发送。`--quality low|balanced|high|ultra` 对应如下请求:
`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。
未显式指定 `--fps` 时,远端预览从 30 FPS 开始,根据完整一帧从转换开始到输出完成的耗时自动选择 8/15/30 FPS 请求;共享内存的上传确认等待也计入,等待源帧的空闲时间不计入。使用平滑估计、不同升降档阈值和至少 8 个新帧样本,持续变慢 2 秒才降档、持续恢复 4 秒才升档,保留供帧余量。15 FPS 仅在对端声明支持时使用;预览未获接受时不追加自动调节。视口变化、显示器切换和共享内存回退会重置耗时估计。显式 `--fps` 固定本地目标和对应的远端预览请求,关闭自动调节;例如 `--fps 30` 可用于与自动策略对比。
本地调度只在有新帧或需要重绘时消耗刷新时隙;短暂迟到保持原刷新节奏,长时间阻塞后跳过旧时隙,不连续补画过时帧。目标帧率不代表终端实际显示帧率。
对端声明 PreviewSubscription 时,预览请求不超过 15 映射到合法 1/8/15 FPS(15 需要对应能力);超过 15 则请求项目已有的交互模式:协议 `fps=60` 是模式标记,编码设置 `frame_rate=30` 才是帧率。分辨率上限使用协商到的 v2,视频预算只在 bandwidth 能力存在时发送。`--quality low|balanced|high|ultra` 对应如下请求:
| 档位 | 分辨率上限 | 质量枚举 | 视频预算 |
|---|---|---|---|
@@ -83,9 +99,11 @@ crossdesk-cli 123456789 --renderer ansi --fps 8
媒体回调仅同步复制借用的 NV12 数据,校验尺寸、容量、native 描述符与 stride;不保留借用指针。上限单帧 16,777,216 像素、每边 8192、偶数尺寸。最多一个待转换帧,替换前先完成校验与复制,无效帧不会挤掉已有画面;播放器另保留最近转换的一帧用于静止桌面的本地放大/重绘,并在接收新帧时回收上一帧缓冲区供后续复制使用。只丢弃解码后图像。显示器切换/关闭提升 generation,旧帧失效。
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),以 1MiB/s 令牌预算分块非阻塞写入;不能开始新画面时保留最新解码帧。ANSI 差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(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。
统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;交互连接结束日志另给平均提交 FPS。`client.log` 摘要的 `startup_ms` 记录从发起连接到连接成功(`connected`)、首个取出用于显示的视频帧入队(`first_frame`)、首帧完整写入终端(`first_output`)的毫秒数;未到达的阶段为 `null`。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。
测量像素渲染帧率时使用 release 构建;debug 的 `-O0` 面积采样开销明显更高。共享内存中的像素不经过终端字节预算,但仍受转换和终端消费速度限制;8MiB/s 是内联路径的输出上限,不是帧率保证。解码、像素转换、压缩、终端消费速度和帧大小都会影响结果。退出时,未完成画面最多继续写入 2s;取消图像传输及恢复屏幕的短写最多另重试 500ms。POSIX 立即恢复 termios 并清理输入,不等待输出排空;如果终端始终不消费输出,屏幕清理只能尽力发送。
统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;交互连接结束日志另给平均提交 FPS,以及实际 `renderer` 和 `output_bytes_per_second` 预算。`kitty_transport=shared_memory/inline/none` 标明生效路径,`target_fps` 是本地目标,`preview_fps` 是最近请求的远端预览目标(并非已生效或实测帧率),`adaptive_preview` 表示是否开启自动调节,`preview_adjustments` 记录切档次数;每次切档另记录 `preview_fps` 事件。`frame_service_ms` 是最近完整帧处理与输出耗时的平滑估计,`frame_processing_ms` 是平均本地布局/转换/传输准备耗时,`shared_ack_ms` 是平均上传命令写完到解析成功确认的耗时,`shared_fallbacks` 记录运行时回退次数。`client.log` 摘要的 `startup_ms` 记录从发起连接到连接成功(`connected`)、首个取出用于显示的视频帧入队(`first_frame`)、首帧显示命令完整写入终端(`first_output`)的毫秒数;未到达的阶段为 `null`。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。
## 输入与退出
+59
View File
@@ -73,6 +73,65 @@
- Pi 1.4.0 SDK 严格类型检查及模拟宿主运行通过:使用真实 SDK 分片组包,覆盖文本区成对回复、字符尺寸优先级、宿主缓存、字体/窗口变化、手动覆盖、旧比例帧丢弃及 ACK、控制键回归、启动失败/异常退出后的监听与定时器清理。
- 新增验证代码和产物全部位于工程外系统临时目录;尚未在真实 Windows、GNOME/SSH 或 Pi TUI 进行视觉验收。
## ANSI 四象限接缝优化(2026-10-30)
- macOS arm64 的 CLI debug 构建和 `doctor --json` 通过。工程外 ASan/UBSan 检查覆盖低对比合并、横纵细线/斜线/双色图案、等亮度彩色边缘、随机 NV12 输入的失真上限、局部更新范围和重复转换确定性。
- 合成低对比图案中,3,072 个非空白字形全部合并为纯背景。合成渐变纹理的加权接缝误差由约 207,373 降至 180,187(13.1%);仅统计两端均未合并为纯色的边界时,由约 198,007 降至 169,621。指标衡量相邻样本的重建色差相对源图色差的偏离,并降低真实高对比边缘的权重;不代表实机条纹减少的百分比。
- 8/24/256 色输出、half/ASCII 与原实现一致性、不同视口/比例/缩放的布局和鼠标映射、静止网格差分及 Kitty 输出检查通过。
- 本机 `-O2`、1920×1080 输入、240×100 字符视口、每项 12 次转换的合成基准:平滑画面约 7.14 → 6.03 ms/帧,随机亮度噪声约 5.44 → 7.73 ms/帧。仅衡量转换,不包含视频解码、ANSI 输出和终端绘制。
- 额外检查代码、基准和数据均位于工程外系统临时目录;未新增工程内测试,未连接真实远端或进行终端字形/行距的视觉验收。
## ANSI 转换与输出加速(2026-10-30)
- macOS arm64 CLI debug 构建和 `doctor --json` 通过。工程外 ASan/UBSan 与 `-O2` 检查均通过:加速后的转换与此前接缝优化版本逐格颜色/字形一致,覆盖随机 NV12、低对比及平滑纹理、half/quadrant/ASCII、缩放和细节模式;Kitty 数据保持一致。
- ANSI 转义序列模拟器验证最终屏幕一致,覆盖全部四象限字形、8/24/256 色、纯背景及色板复用、首尾单元、局部修改、视口/偏移变化和可独立绘制的 bridge 字符行。未向工程新增测试文件或用例。
- 本机 `-O2`、1920×1080 输入、240×100 字符视口、每项 24 次转换:平滑画面约 6.26 → 4.83 ms/帧,随机亮度噪声约 7.75 → 5.72 ms/帧。保留面积滤波、低对比合并及边界连续性约束。
- 240×100 真彩色合成网格、每项 100 次输出生成:整帧约 2.03 → 1.43 ms;120×50 单元的局部修改约 1.02 → 0.36 ms,输出 461,743 → 231,026 字节;2×3 单元的光标修改约 0.072 → 0.013 ms,输出 27,763 → 262 字节。纯背景色块整帧输出 875,352 → 449,826 字节。
- 这些结果衡量转换、编码及输出量,不包含实际终端绘制或网络传输。整帧高色彩变化图案仍需约 923 KB,仍受 1 MiB/s 输出预算限制;不能将转换耗时的倒数作为远程显示帧率。当时默认本地目标为 8 FPS,后续默认策略见“默认 30 FPS 与供帧自适应”。
- 额外检查代码、模拟器、基准和产物均位于工程外临时目录;未进行跨平台终端或真实远端帧率验收。
## Kitty 帧间黑屏与本地吞吐修复(2026-10-30)
- macOS arm64 debug、release 构建通过,release `doctor --json` 通过。release 使用工程外独立配置构建,保留工程原有 debug 配置;额外检查脚本、伪终端夹具和数据均位于系统临时目录。
- ASan/UBSan 检查及 Kitty 协议模拟验证:ID 31/32 交替上传,所有上传分块完成前旧图保持显示;先创建新图 placement,再释放旧图;最多两个完整图像驻留,切换后只保留一个。新旧输出的解压 RGB 和尺寸逐字节一致,覆盖尺寸、偏移和缩放变化;单帧小于 2MiB,全部分块边界的中断清理通过。
- ANSI 的 half/quadrant/ASCII 和 8/24/256 色共 12 种组合,转换单元及输出字节与此次修改前一致。
- 使用生产 `View`、`Output` 和 Kitty 编码代码、`-O2`、合成 NV12 持续回调的本地 PTY 对比:目标 8 FPS,单帧约 1.25 MB,稳定完整接收速率约 0.84 → 6.71 FPS,帧间隔中位数约 1,192 → 148 ms。只衡量完整输出接收,不包含真实网络、视频解码和 Ghostty GPU 显示,不能作为实机帧率承诺。
- 本地 Kitty 的日志预算为 8MiB/s;分别仅设置 `SSH_TTY`、`SSH_CONNECTION`、`SSH_CLIENT` 时均保持 1MiB/s,ANSI 也保持 1MiB/s。连续帧 ID 仅在完整写入后交替,退出清理两个 ID。
- 慢速 PTY 在第二帧上传中退出:首帧保留、第二帧未提交,约 2.06s 后退出;两个 ID 清理、备用屏幕/光标控制序列和原 termios 均恢复。测试发现原单次清理写入会在满缓冲区时丢失,现增加至多 500ms 的非阻塞短写重试。
- 模拟父 shell 保持会话、终端完全停止读取的 PTY:CLI 约 2.53s 后返回,termios 和非阻塞标志恢复。POSIX 恢复改为 `TCSANOW` 加输入清理,避免 `TCSAFLUSH` 等待输出排空。完全不读取时,屏幕清理字节不保证送达。
- 本机安装 Ghostty 1.4.0,但自动化工具拒绝访问其窗口,未完成 Ghostty 实窗视觉验收。npm 安装的 CLI 不会随本地构建自动更新,复测需要直接运行本次 release 产物。
## 本地 Kitty 共享内存与 30 FPS 目标(2026-10-30)
- 本地 POSIX Kitty 增加共享内存能力探测、唯一名称、0600 权限、上传确认和 500ms 超时回退。单个待确认共享缓冲区不超过 1920×1080×3 字节;确认前保留旧画面,退出释放未确认对象。Windows 暂保留内联路径,SSH 不发共享内存探测。
- 当时共享内存可用且未显式指定 `--fps` 时默认目标 30;自动回退内联恢复 8,显式帧率保持不变。后续默认策略见“默认 30 FPS 与供帧自适应”。目标大于 15 使用现有预览协议的交互模式 `fps=60, frame_rate=30`;没有新增协议取值或修改被控端。
- 工程外 ASan/UBSan 检查通过:110 组随机帧/视口/宽高比/缩放/细节模式,整数面积滤波相对原浮点参考的每通道最大误差为 1/255;内联 Kitty 和 ANSI 保持原输出。覆盖共享内存权限、读取内容、命名、容量限制、双方 unlink、分片确认及非法/溢出/重复 ID 回应。
- 使用实际 release CLI 对象代码、1920×1080 合成噪声 NV12 连续回调、1568×1088 报告视口的 PTY 测试:内联完整接收约 4.06 FPS,共享内存约 29.04 FPS;平均本地处理约 166.6 → 12.3 ms,终端字节流约 1,703,174 → 188 字节/帧。共享内存仍传输完整 RGB,并非总数据量只有 188 字节;数据复制、终端绘制和实际网络另有成本。
- 显式 8 FPS、探测不支持/静默、第三帧上传报错/丢失确认、80ms 延迟确认、SSH 禁用探测均通过。回退前不显示未确认帧,运行时回退仅触发一次,并正确恢复自动目标和协议请求;所有共享对象均清理。第二帧上传后立即退出时,首帧仍是已提交画面,未确认对象及两个图像 ID 均清理。
- 相同合成输入的 debug 构建约 5.91 FPS,本地处理约 163 ms,说明 `-O0` 仍明显影响像素转换;性能验收使用 release。上述精确数字是本地测试接收速率,不是 Ghostty GPU 显示帧率。
- 用户在本机 Ghostty 运行新版 release 后反馈“明显流畅了”。额外测试源码、数据、构建夹具和产物均位于系统临时目录,未在工程或子模块新增测试。
- 该次实机日志确认 `kitty_transport=shared_memory`、目标 30、预览请求已接受、运行时回退 0 次。会话约 13.61s 完整提交 273 帧,平均提交约 20.06 FPS(包含约 1.49s 首帧启动等待);平均本地处理约 10.87ms、共享内存确认约 0.42ms。该日志记录提交速率,不测量终端 GPU 呈现频率。
## 默认 30 FPS 与供帧自适应(2026-10-30)
- 交互 CLI 默认本地目标统一为 30 FPS,Kitty 共享内存回退不再改写该目标;远端请求独立在 8/15/30 FPS 档位间自适应,15 需要对端能力。显式 `--fps` 保留固定请求,bridge 默认仍为 8。
- macOS arm64 debug/release 构建、release `doctor --json` 和帮助输出检查通过。工程外 ASan/UBSan 验证了升降档阈值、迟滞、恢复、15 FPS 能力缺失、显式帧率、源帧稀疏、请求拒绝以及估计重置。
- 使用真实 release CLI 对象代码、支持预览确认和动态供帧的假传输、1920×1080 合成 NV12 与 1568×1088 像素视口进行 PTY 验证。固定 30 FPS 源的共享内存路径完整接收约 30 FPS,未触发自动降档;不再在没有新帧时提前消耗刷新时隙。
- 高熵内联路径实际约 4 FPS 时,自动请求由 30 降为 8,本地目标保持 30;显式 `--fps 30` 在同条件下仍约 4 FPS,远端请求不变。上传报错/超时和探测静默均保持本地 30 目标,随后按完整帧耗时调节供帧。
- 90ms 上传确认延迟使实际接收约 9 FPS,远端请求自动调整为 15;缺少 15 FPS 能力时不发送该档位。200ms 延迟后恢复正常确认,验证远端请求按 30 → 8 → 30 恢复。源仅提供 2 FPS、对端拒绝预览时均未误触发自动降档。
- SSH 环境保持原有 1MiB/s 字节预算且不探测共享内存;慢速输出降远端请求但保留本地目标。ANSI 默认 30/显式 15 和 bridge 默认 8/帧 ACK 通过。所有 Kitty 场景继续验证不显示未确认帧、最多两个终端图像、共享对象释放及退出后终端模式恢复。
- 以上数字是合成源和伪终端的完整输出接收率,不包含真实视频解码、网络或 Ghostty GPU 呈现。全部额外测试代码、数据、构建脚本和产物位于工程外系统临时目录;本次未新增工程内测试,尚未对新版进行 Ghostty 实窗验收。
## ANSI 多片段差分与本地吞吐(2026-10-31)
- 本地交互 ANSI 的持续输出预算由 1MiB/s 提高到 8MiB/s;SSH 与 bridge 仍为 1MiB/s。保留 2MiB 单消息、1MiB 突发、非阻塞短写、最新帧替换和完整输出后提交差分基准的约束。
- 同一行的分散修改按多个片段输出,短间隔按实际编码字节成本合并,比较包含下一单元的颜色设置。差分忽略色板量化后相同的颜色、空格前景色以及 ASCII 模式不显示的字形/背景字段;未修改采样、象限拟合及 Kitty 传输。
- macOS arm64 CLI debug/release 构建及两种产物的 `doctor --json` 通过,完成后工程配置恢复为桌面与 CLI 同时启用的 debug。工程外 ASan/UBSan 检查通过:全部四象限字形、8/24/256 色、ASCII、首尾单元、长短间隔、色板等价、多种尺寸及偏移变化、600 帧连续随机更新与整帧重绘的屏幕一致性;140 组转换逐格一致,bridge 行输出及 Kitty 数据保持一致。
- `-O2`、240×100 合成网格:10 行各两处分散修改由 90,058 降到 931 字节;隔列修改由 919,260 降到 562,601 字节;量化后颜色相同的整屏变化由 72,716 降到 0 字节。高色彩变化整帧仍为 922,929 字节,2×3 光标修改仍为 262 字节。多片段选择会增加部分交错修改场景的编码计算,以减少终端输出量。
- 使用生产 CLI/渲染器的 `-O3` 构建、1920×1080 连续变化合成 NV12、240×100 字符视口、固定目标 30 FPS 的本地 PTY 对比:跳过启动阶段后的完整帧接收速率约 1.64 → 6.42 FPS,约 3.9 倍;平均每帧约 640 KB。分别设置 `SSH_TTY`、`SSH_CONNECTION`、`SSH_CLIENT` 时预算均保持 1MiB/s;设置 `TMUX` 的本地 ANSI 路径使用 8MiB/s。
- 暂停 PTY 读取 1.3s,并在暂停期间发送退出命令,恢复读取后完整帧计数与提交计数一致,约 1.17s 内退出,备用屏幕、termios 和非阻塞标志恢复。没有连接真实远端,也未验证真实终端的 GPU 绘制、SSH 或 tmux 实际显示;上述帧率为合成源的完整输出接收率。
- 额外检查源码、脚本、数据和产物均位于工程外系统临时目录,未新增或修改工程内测试文件/用例。
## 实测边界
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。
+277 -24
View File
@@ -31,6 +31,11 @@ public:
explicit Output(int fd)
: fd_(fd), nonblocking_(fd, true), last_(Clock::now()) {}
bool Idle() const { return pending_.empty(); }
size_t ByteRate() const { return byte_rate_; }
void SetByteRate(size_t rate) {
byte_rate_ = rate;
last_ = Clock::now();
}
void Put(std::string text) {
if (!Idle() || text.size() > 2 * 1024 * 1024)
throw std::runtime_error("output budget exceeded");
@@ -41,7 +46,7 @@ public:
auto now = Clock::now();
credit_ = std::min(
1048576.0,
credit_ + std::chrono::duration<double>(now - last_).count() * 1048576);
credit_ + std::chrono::duration<double>(now - last_).count() * byte_rate_);
last_ = now;
if (Idle())
return false;
@@ -75,9 +80,67 @@ private:
NonBlocking nonblocking_;
std::string pending_;
size_t offset_ = 0;
size_t byte_rate_ = 1048576;
double credit_ = 1048576;
Clock::time_point last_;
};
// Measure occupied frame time, not the interval between incoming frames: a
// slow source or a quiet desktop must not trap the next preview at a low rate.
class AdaptivePreview {
public:
AdaptivePreview(int fps, bool enabled) : fps_(fps), enabled_(enabled) {}
void Reset() {
service_ms_ = 0;
samples_ = 0;
candidate_ = fps_;
candidate_since_.reset();
}
std::optional<int> Observe(double ms, Clock::time_point now, bool fps15,
bool accepted) {
service_ms_ = samples_++ ? service_ms_ + .2 * (ms - service_ms_) : ms;
if (!enabled_ || !accepted) {
candidate_since_.reset();
return {};
}
const double capacity = 1000 / std::max(.001, service_ms_);
int next = fps_;
// Request the next tier above sustainable output, leaving headroom for
// fresh frames. Separate up/down thresholds prevent boundary oscillation.
if (fps_ > 8 && capacity <= 6.4)
next = 8;
else if (fps_ == 30 && fps15 && capacity <= 12)
next = 15;
else if (fps_ < 30 && capacity >= 13.5)
next = 30;
else if (fps_ == 8 && capacity >= 7.2)
next = fps15 ? 15 : 30;
if (next == fps_) {
candidate_since_.reset();
return {};
}
if (next != candidate_ || !candidate_since_) {
candidate_ = next;
candidate_since_ = now;
}
const auto hold = std::chrono::seconds(next < fps_ ? 2 : 4);
if (samples_ < 8 || now - *candidate_since_ < hold)
return {};
fps_ = next;
++adjustments_;
candidate_since_.reset();
return fps_;
}
int Fps() const { return fps_; }
double ServiceMs() const { return service_ms_; }
uint64_t Adjustments() const { return adjustments_; }
private:
int fps_, candidate_ = 0;
bool enabled_;
double service_ms_ = 0;
uint64_t samples_ = 0, adjustments_ = 0;
std::optional<Clock::time_point> candidate_since_;
};
struct Tty {
int out;
RawTerminal terminal;
@@ -93,7 +156,24 @@ struct Tty {
"\033[0m\033[?1003l\033[?1002l\033[?1000l"
"\033[?1006l\033[?1004l\033[?2004l\033["
"?7h\033[?25h\033[?1049l";
Write(out, end.data(), end.size());
// A full PTY can reject even this small cleanup after an interrupted image.
// Retry short writes without indefinitely blocking exit on a stalled reader.
try {
NonBlocking nonblocking(out, true);
const auto until = Clock::now() + std::chrono::milliseconds(500);
size_t offset = 0;
while (offset < end.size() && Clock::now() < until) {
auto n = Write(out, end.data() + offset, end.size() - offset);
if (n > 0)
offset += n;
else if (n < 0 && errno != EAGAIN && errno != EINTR)
break;
else
WaitIo(-1, out, 10);
}
} catch (...) {
// A closed terminal must not prevent RawTerminal from restoring modes.
}
}
};
Viewport Size(int *status_rows = nullptr,
@@ -242,10 +322,11 @@ std::string WaitingScreen(const Viewport &v, const std::string &title,
}
int View(SessionOptions options, const std::string &device,
const std::string &renderer, bool control, int fd, AnsiDetail detail,
double zoom, bool native, double cell_aspect) {
double zoom, bool native, double cell_aspect, bool fps_explicit) {
if (!IsTty(0) || !IsTty(fd) || Env("TERM") == "dumb")
throw std::runtime_error("connecting needs an interactive ANSI terminal; use "
"bridge --stdio for a host");
throw std::runtime_error(
"connecting needs an interactive ANSI terminal; use "
"bridge --stdio for a host");
auto password = Password();
if (interrupted)
return 130;
@@ -269,6 +350,45 @@ int View(SessionOptions options, const std::string &device,
if (renderer == "kitty" && !kitty)
throw std::runtime_error(
"Kitty probe failed or multiplexer active; use --renderer ansi");
// 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.
const bool local_terminal = Env("SSH_CONNECTION").empty() &&
Env("SSH_CLIENT").empty() &&
Env("SSH_TTY").empty();
if (local_terminal)
output.SetByteRate(8 * 1024 * 1024);
SharedImage shared_image;
bool shared_enabled = false;
const uint8_t probe_pixel[] = {0, 0, 0};
if (kitty && local_terminal && output.Idle() &&
shared_image.Assign(probe_pixel, sizeof(probe_pixel))) {
output.Put(KittySharedUpload(shared_image.Name(), 1, 1, false, true));
std::string reply;
const auto until = Clock::now() + std::chrono::milliseconds(250);
while (Clock::now() < until && !interrupted) {
output.Pump();
WaitReadable(0, 10);
char bytes[1024];
const auto n = Read(0, bytes, sizeof(bytes));
if (n > 0 && reply.size() < 8192)
reply.append(bytes, n);
const auto pos = reply.find("\033_Gi=33;");
const auto end =
pos == std::string::npos ? pos : reply.find("\033\\", pos);
if (end != std::string::npos) {
const auto ack = ParseKittyAck(reply.substr(pos, end + 2 - pos));
shared_enabled = ack && ack->ok;
break;
}
}
shared_image.Reset();
}
if (!fps_explicit)
options.fps = 30;
AdaptivePreview preview(options.fps, !fps_explicit);
const auto frame_interval = std::chrono::duration_cast<Clock::duration>(
std::chrono::duration<double>(1.0 / options.fps));
ControllerSession s;
Input input;
input.control = control;
@@ -279,6 +399,12 @@ int View(SessionOptions options, const std::string &device,
s.SetControl(control);
std::optional<Grid> displayed, pending;
bool pending_frame = false;
std::optional<Clock::time_point> frame_started;
bool shared_upload = false;
std::optional<Clock::time_point> shared_deadline;
std::string pending_clear;
uint64_t shared_fallbacks = 0, rendered = 0, shared_acks = 0;
double render_ms = 0, shared_ack_ms = 0;
int last_status_rows = 3;
Viewport last_view =
Size(&last_status_rows, &input.terminal_metrics, cell_aspect);
@@ -286,18 +412,53 @@ int View(SessionOptions options, const std::string &device,
uint64_t view_revision = input.view_revision;
bool redraw = true;
uint64_t submitted = 0;
uint64_t fps_submitted = 0;
auto fps_sample_at = started;
long output_fps = 0;
std::optional<Clock::time_point> connected_at, first_frame_at,
first_output_at;
bool waiting_visible = false;
double queue_ms = 0;
auto next = Clock::now(), status_tick = next;
auto size_query_tick = next;
const auto disable_shared = [&] {
shared_enabled = shared_upload = false;
shared_deadline.reset();
shared_image.Reset();
++shared_fallbacks;
frame_started.reset();
preview.Reset();
};
const auto complete_sample = [&] {
if (!frame_started)
return;
const auto now = Clock::now();
const double ms =
std::chrono::duration<double, std::milli>(now - *frame_started).count();
frame_started.reset();
if (auto fps = preview.Observe(ms, now, s.SupportsPreviewFps15(),
s.Connected() && s.PreviewAccepted())) {
s.SetPreview(*fps, options.preview_width, options.preview_height,
options.quality, options.max_bitrate_bps);
std::cerr << json{{"event", "preview_fps"},
{"target_fps", options.fps},
{"requested_fps", *fps},
{"frame_service_ms", preview.ServiceMs()}}
.dump()
<< "\n";
}
};
const auto pump_output = [&] {
if (output.Pump() && pending_frame) {
if (!output.Pump())
return;
if (shared_upload) {
shared_deadline = Clock::now() + std::chrono::milliseconds(500);
} else if (pending_frame) {
displayed = std::move(pending);
pending.reset();
pending_frame = false;
++submitted;
complete_sample();
if (!first_output_at) {
first_output_at = Clock::now();
status_tick = *first_output_at;
@@ -317,6 +478,8 @@ int View(SessionOptions options, const std::string &device,
const auto previous_error = input.error;
input.Feed(n > 0 ? std::string(bytes, n) : "", s,
displayed ? &*displayed : nullptr);
if (input.exit || interrupted)
break;
if (previous_command != input.command_mode ||
previous_control != input.control || previous_error != input.error)
status_tick = Clock::now();
@@ -324,6 +487,35 @@ int View(SessionOptions options, const std::string &device,
if (s.Finished())
break;
auto now = Clock::now();
if (shared_upload && shared_deadline) {
const auto ack = input.kitty_ack;
const bool replied = ack && ack->image_id == (submitted % 2 ? 32u : 31u);
if (replied || now >= *shared_deadline) {
if (replied && ack->ok) {
shared_ack_ms +=
std::chrono::duration<double, std::milli>(
now - (*shared_deadline - std::chrono::milliseconds(500)))
.count();
++shared_acks;
shared_upload = false;
shared_deadline.reset();
shared_image.Reset();
// Display only an acknowledged upload. A missing/failed upload must
// leave the previous image and pointer mapping intact.
output.Put(pending_clear +
KittyPresent(pending->viewport, submitted % 2 != 0));
pending_frame = true;
waiting_visible = false;
} else {
disable_shared();
pending.reset();
redraw = true;
next = now;
}
pending_clear.clear();
}
}
input.kitty_ack.reset();
int status_rows;
auto v = Size(&status_rows, &input.terminal_metrics, cell_aspect);
v.detail = detail;
@@ -335,10 +527,11 @@ int View(SessionOptions options, const std::string &device,
if (input.view_revision != view_revision) {
view_revision = input.view_revision;
redraw = true;
preview.Reset();
}
const bool grid_resized =
v.columns != last_view.columns || v.rows != last_view.rows;
if (output.Idle() &&
if (output.Idle() && !shared_upload &&
(grid_resized || v.cell_aspect != last_view.cell_aspect ||
v.pixel_width != last_view.pixel_width ||
v.pixel_height != last_view.pixel_height ||
@@ -348,18 +541,27 @@ int View(SessionOptions options, const std::string &device,
last_view = v;
last_status_rows = status_rows;
redraw = true;
preview.Reset();
status_tick = now;
if (grid_resized)
size_query_tick = now;
}
// Query through the terminal byte stream so this also works over SSH.
// Never wait for a reply or interrupt a partially written video frame.
if (output.Idle() && now >= size_query_tick) {
if (output.Idle() && !shared_upload && now >= size_query_tick) {
input.terminal_metrics.BeginQuery();
output.Put(kTerminalSizeQuery);
size_query_tick = now + std::chrono::seconds(2);
}
if (output.Idle() && now >= status_tick) {
if (output.Idle() && !shared_upload && now >= status_tick) {
const double fps_seconds =
std::chrono::duration<double>(now - fps_sample_at).count();
// Mode changes can redraw the status early; keep a full sampling window.
if (fps_seconds >= 1) {
output_fps = std::lround((submitted - fps_submitted) / fps_seconds);
fps_submitted = submitted;
fps_sample_at = now;
}
auto st = s.Status();
const auto last_state = st["state"].get<std::string>();
std::string progress =
@@ -387,6 +589,7 @@ int View(SessionOptions options, const std::string &device,
bar.error = input.error;
bar.control = input.control;
bar.command_mode = input.command_mode;
bar.fps = "FPS " + std::to_string(output_fps);
bar.view = std::string(kitty ? "KITTY"
: detail == AnsiDetail::Quadrant ? "QUAD"
: "HALF") +
@@ -422,11 +625,12 @@ int View(SessionOptions options, const std::string &device,
output.Put(std::move(screen));
status_tick = now + std::chrono::seconds(1);
}
if (output.Idle() && now >= next) {
next = now + std::chrono::milliseconds(1000 / options.fps);
if (output.Idle() && !shared_upload && now >= next) {
bool fresh = false;
if (auto frame = s.TakeFrame(last_frame ? &*last_frame : nullptr);
frame && frame->generation == s.Generation()) {
if (!last_frame || last_frame->generation != frame->generation)
preview.Reset();
last_frame = std::move(frame);
fresh = true;
if (!first_frame_at)
@@ -435,10 +639,18 @@ int View(SessionOptions options, const std::string &device,
if (last_frame && last_frame->generation != s.Generation())
last_frame.reset();
if (last_frame && (fresh || redraw)) {
if (fresh)
const auto render_started = Clock::now();
// Keep cadence when only slightly late, skip missed slots after a
// stall, and never consume a slot while waiting for a source frame.
next = render_started > next + frame_interval
? render_started + frame_interval
: next + frame_interval;
if (fresh) {
frame_started = render_started;
queue_ms = std::chrono::duration<double, std::milli>(
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.
std::string clear;
@@ -446,19 +658,44 @@ int View(SessionOptions options, const std::string &device,
for (int row = 0; row < v.rows; ++row)
clear += "\033[" + std::to_string(v.row_offset + row + 1) +
";1H\033[0m\033[2K";
output.Put(clear + (kitty ? Kitty(*last_frame, v)
: Difference(*pending, displayed)));
waiting_visible = false;
if (shared_enabled) {
const auto pixels = KittyPixels(*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));
shared_upload = true;
pending_clear = std::move(clear);
} else {
disable_shared();
}
}
if (!shared_upload) {
if (fresh && !frame_started)
frame_started = Clock::now();
output.Put(clear + (kitty ? Kitty(*last_frame, v, submitted % 2 != 0)
: Difference(*pending, displayed)));
waiting_visible = false;
pending_frame = true;
}
render_ms += std::chrono::duration<double, std::milli>(Clock::now() -
render_started)
.count();
++rendered;
redraw = false;
pending_frame = true;
if (output.Idle()) {
if (output.Idle() && !shared_upload) {
displayed = std::move(pending);
pending.reset();
pending_frame = false;
complete_sample();
}
}
}
output.Wait(0);
const auto wait_ms = std::chrono::ceil<std::chrono::milliseconds>(
next - Clock::now())
.count();
output.Wait(0, output.Idle() && !shared_upload && wait_ms > 0
? int(std::min<int64_t>(5, wait_ms))
: 5);
}
auto final = s.Status();
s.Disconnect();
@@ -492,6 +729,19 @@ int View(SessionOptions options, const std::string &device,
{"reported", input.terminal_metrics.Known()},
{"manual_aspect", cell_aspect > 0}}},
{"submitted", submitted},
{"renderer", kitty ? "kitty" : "ansi"},
{"kitty_transport", !kitty ? "none"
: shared_enabled ? "shared_memory"
: "inline"},
{"target_fps", options.fps},
{"adaptive_preview", !fps_explicit},
{"preview_fps", preview.Fps()},
{"preview_adjustments", preview.Adjustments()},
{"frame_service_ms", preview.ServiceMs()},
{"frame_processing_ms", rendered ? render_ms / rendered : 0},
{"shared_ack_ms", shared_acks ? shared_ack_ms / shared_acks : 0},
{"shared_fallbacks", shared_fallbacks},
{"output_bytes_per_second", output.ByteRate()},
{"output_bytes", output.bytes},
{"last_queue_ms", queue_ms},
{"elapsed_seconds",
@@ -778,8 +1028,10 @@ 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.
--fps controls local output; negotiated previews use supported 1/8/15 FPS.
bridge: private NDJSON v1, credentials on a separate inherited descriptor.
--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.
bridge: private NDJSON v1, default 8 FPS, credentials on a separate inherited descriptor.
No IP direct connection, audio playback, clipboard or file transfer support.
)";
} // namespace
@@ -829,7 +1081,7 @@ int main(int argc, char **argv) {
std::string renderer = "auto", device;
AnsiDetail detail = AnsiDetail::Quadrant;
double zoom = 1, cell_aspect = 0;
bool control = false, stdio = false, native = false;
bool control = false, stdio = false, native = false, fps_explicit = false;
int secret_fd = 3;
if (command == "view") {
if (i >= argc)
@@ -860,9 +1112,10 @@ int main(int argc, char **argv) {
options.host = value;
else if (a == "--port")
options.port = number(value, 1, 65535);
else if (a == "--fps" && command == "view")
else if (a == "--fps" && command == "view") {
options.fps = number(value, 1, 30);
else if (a == "--display" && command == "view")
fps_explicit = true;
} else if (a == "--display" && command == "view")
options.display = number(value, 0, 63);
else if (a == "--renderer" && command == "view")
renderer = value;
@@ -916,7 +1169,7 @@ int main(int argc, char **argv) {
try {
return command == "view" ? View(options, device, renderer, control,
output.value, detail, zoom, native,
cell_aspect)
cell_aspect, fps_explicit)
: Bridge(output.value, options, secret_fd);
} catch (const std::exception &e) {
if (command == "view") {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chunkdash/crossdesk-cli",
"version": "0.1.1",
"version": "0.1.4",
"private": true,
"description": "CrossDesk remote desktop controller for the terminal",
"license": "GPL-3.0-only",
-2
View File
@@ -2,8 +2,6 @@
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
+16
View File
@@ -10,6 +10,7 @@
#include <atomic>
#include <csignal>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
@@ -34,6 +35,21 @@ struct TerminalSize {
};
TerminalSize GetTerminalSize(int fd);
// One private, uniquely named pixel buffer. Keep it alive until the terminal
// acknowledges reading it; Reset also unlinks objects the terminal left behind.
class SharedImage {
public:
SharedImage();
~SharedImage();
bool Assign(const uint8_t *data, size_t size);
void Reset();
const std::string &Name() const;
private:
struct State;
std::unique_ptr<State> state_;
};
class NonBlocking {
public:
explicit NonBlocking(int fd, bool output = false);
+67 -1
View File
@@ -1,10 +1,12 @@
#ifndef _WIN32
#include "terminal_io.h"
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <poll.h>
#include <stdexcept>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/select.h>
#include <termios.h>
#include <unistd.h>
@@ -78,6 +80,66 @@ TerminalSize GetTerminalSize(int fd) {
}
return size;
}
struct SharedImage::State {
std::string name;
void *data = MAP_FAILED;
size_t size = 0;
};
SharedImage::SharedImage() : state_(std::make_unique<State>()) {}
SharedImage::~SharedImage() { Reset(); }
const std::string &SharedImage::Name() const { return state_->name; }
void SharedImage::Reset() {
if (state_->data != MAP_FAILED)
munmap(state_->data, state_->size);
if (!state_->name.empty())
shm_unlink(state_->name.c_str());
state_->data = MAP_FAILED;
state_->size = 0;
state_->name.clear();
}
bool SharedImage::Assign(const uint8_t *data, size_t size) {
Reset();
if (!data || !size || size > 1920 * 1080 * 3)
return false;
static std::atomic<uint32_t> sequence{0};
int fd = -1;
for (int attempt = 0; attempt < 16; ++attempt) {
// Fits Darwin's 31-byte name limit. O_EXCL prevents opening another
// process's object, including a stale name from a recycled process ID.
auto name = "/cdx-" + std::to_string(getpid()) + "-" +
std::to_string(++sequence);
fd = shm_open(name.c_str(), O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd >= 0) {
state_->name = std::move(name);
break;
}
if (errno != EEXIST)
return false;
}
if (fd < 0)
return false;
fcntl(fd, F_SETFD, FD_CLOEXEC);
#ifdef __linux__
// Reserve tmpfs space before touching the mapping; a successful ftruncate
// alone can still SIGBUS later when /dev/shm is full.
if (posix_fallocate(fd, 0, size) != 0) {
close(fd);
Reset();
return false;
}
#endif
if (ftruncate(fd, size) == 0) {
state_->data = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
state_->size = size;
}
close(fd);
if (state_->data == MAP_FAILED) {
Reset();
return false;
}
std::memcpy(state_->data, data, size);
return true;
}
struct NonBlocking::State {
int fd, flags;
};
@@ -107,7 +169,11 @@ RawTerminal::RawTerminal(int input, int) : state_(std::make_unique<State>()) {
}
}
RawTerminal::~RawTerminal() {
tcsetattr(state_->fd, TCSAFLUSH, &state_->saved);
// TCSAFLUSH waits for queued output, which can hang exit when the terminal
// stops consuming a partially written image. Restore immediately and flush
// only pending input so protocol replies do not reach the next shell prompt.
tcsetattr(state_->fd, TCSANOW, &state_->saved);
tcflush(state_->fd, TCIFLUSH);
fcntl(state_->fd, F_SETFL, state_->flags);
}
std::string Password() {
+9
View File
@@ -330,6 +330,15 @@ TerminalSize GetTerminalSize(int) {
CloseHandle(handle);
return size;
}
struct SharedImage::State {
std::string name;
};
SharedImage::SharedImage() : state_(std::make_unique<State>()) {}
SharedImage::~SharedImage() = default;
bool SharedImage::Assign(const uint8_t *, size_t) { return false; }
void SharedImage::Reset() {}
const std::string &SharedImage::Name() const { return state_->name; }
struct NonBlocking::State {
HANDLE handle;
DWORD mode = 0;
+3 -1
View File
@@ -37,12 +37,14 @@ target_end()
target("crossdesk-cli")
set_kind("binary")
set_version("0.1.1")
set_version("0.1.4")
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", "user32")
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
elseif is_plat("linux") then
add_syslinks("rt")
end
target_end()
@@ -7,13 +7,6 @@
objects = {
/* Begin PBXBuildFile section */
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F20000000000000000001 /* RemotePrivacyScreen.swift */; };
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; };
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; };
100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; };
100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; };
@@ -23,30 +16,51 @@
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000A /* RemoteTouchInputView.swift */; };
100000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000D /* Assets.xcassets */; };
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000E /* ThirdPartyLicensesView.swift */; };
10000000000000000000000A /* ThirdPartyLicenses.json in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000F /* ThirdPartyLicenses.json */; };
10000000000000000000000A /* Resources/ThirdPartyLicenses.json in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000F /* Resources/ThirdPartyLicenses.json */; };
10000000000000000000000B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000010 /* PrivacyInfo.xcprivacy */; };
10000000000000000000000C /* OpenSSL.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 200000000000000000000011 /* OpenSSL.framework */; };
10000000000000000000000D /* OpenSSL.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 200000000000000000000011 /* OpenSSL.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
100000000000000000000012 /* PrivacyPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000012 /* PrivacyPreferences.swift */; };
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000013 /* ThumbnailFileStore.swift */; };
100000000000000000000014 /* PrivacyViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000014 /* PrivacyViews.swift */; };
100000000000000000000015 /* PRIVACY.md in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000015 /* PRIVACY.md */; };
100000000000000000000015 /* ../../../PRIVACY.md in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000015 /* ../../../PRIVACY.md */; };
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; };
100000000000000000000019 /* AnnouncementInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000019 /* AnnouncementInbox.swift */; };
10000000000000000000001A /* AnnouncementText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001A /* AnnouncementText.swift */; };
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001B /* AnnouncementInboxView.swift */; };
10000000000000000000001C /* AppUpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001C /* AppUpdateChecker.swift */; };
3C550ED141D4AEA30950BB7A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5BD6CC3A43B86A2E2267994F; };
4870B14C46F4C4B8F10FEF08 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A0A3C0DA09EE562AE21DF986; };
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
3C550ED141D4AEA30950BB7A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5BD6CC3A43B86A2E2267994F /* Localizable.strings */; };
4870B14C46F4C4B8F10FEF08 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A0A3C0DA09EE562AE21DF986 /* InfoPlist.strings */; };
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F20000000000000000001 /* RemotePrivacyScreen.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
300000000000000000000002 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
10000000000000000000000D /* OpenSSL.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
C10F20000000000000000001 /* RemotePrivacyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePrivacyScreen.swift; sourceTree = "<group>"; };
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = "<group>"; };
200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = "<group>"; };
200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = "<group>"; };
02A713C4B5A1623480A005D6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = Resources/ja.lproj/InfoPlist.strings; sourceTree = "<group>"; };
093D4362C938821DE02B9032 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = Resources/es.lproj/InfoPlist.strings; sourceTree = "<group>"; };
0BB7EC0F2A5A6A313F782DCA /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Resources/fr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
10083BB5398965A4432CEC1E /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Resources/ko.lproj/Localizable.strings; sourceTree = "<group>"; };
10718835138E0FDDFDE9B567 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Resources/ru.lproj/Localizable.strings; sourceTree = "<group>"; };
125E3BF17EC58E43B32F2AC3 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Resources/pt-BR.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
1C906F7AD7B701DB61B512D2 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Resources/pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = "<group>"; };
200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = "<group>"; };
200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -61,53 +75,41 @@
20000000000000000000000C /* build_minirtc_ios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_minirtc_ios.sh; sourceTree = "<group>"; };
20000000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
20000000000000000000000E /* ThirdPartyLicensesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyLicensesView.swift; sourceTree = "<group>"; };
20000000000000000000000F /* ThirdPartyLicenses.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Resources/ThirdPartyLicenses.json; sourceTree = "<group>"; };
20000000000000000000000F /* Resources/ThirdPartyLicenses.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Resources/ThirdPartyLicenses.json; sourceTree = "<group>"; };
200000000000000000000010 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
200000000000000000000011 /* OpenSSL.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OpenSSL.framework; sourceTree = BUILT_PRODUCTS_DIR; };
200000000000000000000012 /* PrivacyPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyPreferences.swift; sourceTree = "<group>"; };
200000000000000000000013 /* ThumbnailFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailFileStore.swift; sourceTree = "<group>"; };
200000000000000000000014 /* PrivacyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViews.swift; sourceTree = "<group>"; };
200000000000000000000015 /* PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = "<group>"; };
200000000000000000000015 /* ../../../PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = "<group>"; };
200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = "<group>"; };
200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = "<group>"; };
200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = "<group>"; };
200000000000000000000019 /* AnnouncementInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInbox.swift; sourceTree = "<group>"; };
20000000000000000000001A /* AnnouncementText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementText.swift; sourceTree = "<group>"; };
20000000000000000000001B /* AnnouncementInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInboxView.swift; sourceTree = "<group>"; };
20000000000000000000001C /* AppUpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateChecker.swift; sourceTree = "<group>"; };
51B43C654308785E6FC27C98 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en"; path = "Resources/en.lproj/Localizable.strings"; sourceTree = "<group>"; };
C04C96DC6D837D5C45257B37 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "Resources/zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
4E23FD4A4331F613A7BF3C81 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "Resources/zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
10718835138E0FDDFDE9B567 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ru"; path = "Resources/ru.lproj/Localizable.strings"; sourceTree = "<group>"; };
BA8BCB85D00055B796EBB9E1 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ja"; path = "Resources/ja.lproj/Localizable.strings"; sourceTree = "<group>"; };
10083BB5398965A4432CEC1E /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ko"; path = "Resources/ko.lproj/Localizable.strings"; sourceTree = "<group>"; };
8CEF64DB2A646A3C710CAAE7 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "de"; path = "Resources/de.lproj/Localizable.strings"; sourceTree = "<group>"; };
467DE174FF993E9771FAEE5D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr"; path = "Resources/fr.lproj/Localizable.strings"; sourceTree = "<group>"; };
2EA0435DFAEE685F04391B3B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es"; path = "Resources/es.lproj/Localizable.strings"; sourceTree = "<group>"; };
1C906F7AD7B701DB61B512D2 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Resources/pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
B6189259C1AE18847A742A13 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt"; path = "Resources/pt.lproj/Localizable.strings"; sourceTree = "<group>"; };
23D5AD87A9BF0CBF3A6AADDF /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en"; path = "Resources/en.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
23D5AD87A9BF0CBF3A6AADDF /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Resources/en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
2E83770B40B2D299796EB0CD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "Resources/zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
2EA0435DFAEE685F04391B3B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = Resources/es.lproj/Localizable.strings; sourceTree = "<group>"; };
36B135DFBFBD8F03E16FB09A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "Resources/zh-Hant.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
56DE65D659C5D65945CF834F /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ru"; path = "Resources/ru.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
02A713C4B5A1623480A005D6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ja"; path = "Resources/ja.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
EA650495031B45A6FE5892FD /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ko"; path = "Resources/ko.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
E653CAF5C4E87548BF6D8670 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "de"; path = "Resources/de.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
0BB7EC0F2A5A6A313F782DCA /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr"; path = "Resources/fr.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
093D4362C938821DE02B9032 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es"; path = "Resources/es.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
125E3BF17EC58E43B32F2AC3 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Resources/pt-BR.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
7DDF05F5D35AB063DB447879 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt"; path = "Resources/pt.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
467DE174FF993E9771FAEE5D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Resources/fr.lproj/Localizable.strings; sourceTree = "<group>"; };
4E23FD4A4331F613A7BF3C81 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "Resources/zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
51B43C654308785E6FC27C98 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Resources/en.lproj/Localizable.strings; sourceTree = "<group>"; };
56DE65D659C5D65945CF834F /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Resources/ru.lproj/InfoPlist.strings; sourceTree = "<group>"; };
7DDF05F5D35AB063DB447879 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = Resources/pt.lproj/InfoPlist.strings; sourceTree = "<group>"; };
8CEF64DB2A646A3C710CAAE7 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = Resources/de.lproj/Localizable.strings; sourceTree = "<group>"; };
B6189259C1AE18847A742A13 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = Resources/pt.lproj/Localizable.strings; sourceTree = "<group>"; };
BA8BCB85D00055B796EBB9E1 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = Resources/ja.lproj/Localizable.strings; sourceTree = "<group>"; };
C04C96DC6D837D5C45257B37 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "Resources/zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
C10F20000000000000000001 /* RemotePrivacyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePrivacyScreen.swift; sourceTree = "<group>"; };
E653CAF5C4E87548BF6D8670 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = Resources/de.lproj/InfoPlist.strings; sourceTree = "<group>"; };
EA650495031B45A6FE5892FD /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Resources/ko.lproj/InfoPlist.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXCopyFilesBuildPhase section */
300000000000000000000002 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (10000000000000000000000D /* OpenSSL.framework in Embed Frameworks */, );
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFrameworksBuildPhase section */
300000000000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -133,7 +135,7 @@
isa = PBXGroup;
children = (
200000000000000000000001 /* CrossDeskMobileApp.swift */,
200000000000000000000015 /* PRIVACY.md */,
200000000000000000000015 /* ../../../PRIVACY.md */,
200000000000000000000014 /* PrivacyViews.swift */,
200000000000000000000013 /* ThumbnailFileStore.swift */,
200000000000000000000012 /* PrivacyPreferences.swift */,
@@ -148,7 +150,7 @@
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
200000000000000000000003 /* ContentView.swift */,
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
20000000000000000000000F /* ThirdPartyLicenses.json */,
20000000000000000000000F /* Resources/ThirdPartyLicenses.json */,
A0A3C0DA09EE562AE21DF986 /* InfoPlist.strings */,
5BD6CC3A43B86A2E2267994F /* Localizable.strings */,
200000000000000000000010 /* PrivacyInfo.xcprivacy */,
@@ -232,18 +234,13 @@
};
/* End PBXNativeTarget section */
/* Begin PBXVariantGroup section */
5BD6CC3A43B86A2E2267994F /* Localizable.strings */ = {isa = PBXVariantGroup; children = (51B43C654308785E6FC27C98, C04C96DC6D837D5C45257B37, 4E23FD4A4331F613A7BF3C81, 10718835138E0FDDFDE9B567, BA8BCB85D00055B796EBB9E1, 10083BB5398965A4432CEC1E, 8CEF64DB2A646A3C710CAAE7, 467DE174FF993E9771FAEE5D, 2EA0435DFAEE685F04391B3B, 1C906F7AD7B701DB61B512D2, B6189259C1AE18847A742A13, ); name = Localizable.strings; sourceTree = "<group>"; };
A0A3C0DA09EE562AE21DF986 /* InfoPlist.strings */ = {isa = PBXVariantGroup; children = (23D5AD87A9BF0CBF3A6AADDF, 2E83770B40B2D299796EB0CD, 36B135DFBFBD8F03E16FB09A, 56DE65D659C5D65945CF834F, 02A713C4B5A1623480A005D6, EA650495031B45A6FE5892FD, E653CAF5C4E87548BF6D8670, 0BB7EC0F2A5A6A313F782DCA, 093D4362C938821DE02B9032, 125E3BF17EC58E43B32F2AC3, 7DDF05F5D35AB063DB447879, ); name = InfoPlist.strings; sourceTree = "<group>"; };
/* End PBXVariantGroup section */
/* Begin PBXProject section */
800000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
LastUpgradeCheck = 2710;
TargetAttributes = {
500000000000000000000001 = {
CreatedOnToolsVersion = 26.6;
@@ -254,7 +251,20 @@
compatibilityVersion = "Xcode 15.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (Base, "en", "zh-Hans", "zh-Hant", "ru", "ja", "ko", "de", "fr", "es", "pt-BR", "pt", );
knownRegions = (
Base,
en,
"zh-Hans",
"zh-Hant",
ru,
ja,
ko,
de,
fr,
es,
"pt-BR",
pt,
);
mainGroup = 400000000000000000000001;
productRefGroup = 400000000000000000000007 /* Products */;
projectDirPath = "";
@@ -271,11 +281,11 @@
buildActionMask = 2147483647;
files = (
100000000000000000000008 /* Assets.xcassets in Resources */,
10000000000000000000000A /* ThirdPartyLicenses.json in Resources */,
10000000000000000000000A /* Resources/ThirdPartyLicenses.json in Resources */,
4870B14C46F4C4B8F10FEF08 /* InfoPlist.strings in Resources */,
3C550ED141D4AEA30950BB7A /* Localizable.strings in Resources */,
10000000000000000000000B /* PrivacyInfo.xcprivacy in Resources */,
100000000000000000000015 /* PRIVACY.md in Resources */,
100000000000000000000015 /* ../../../PRIVACY.md in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -342,26 +352,94 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
5BD6CC3A43B86A2E2267994F /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
51B43C654308785E6FC27C98 /* en */,
C04C96DC6D837D5C45257B37 /* zh-Hans */,
4E23FD4A4331F613A7BF3C81 /* zh-Hant */,
10718835138E0FDDFDE9B567 /* ru */,
BA8BCB85D00055B796EBB9E1 /* ja */,
10083BB5398965A4432CEC1E /* ko */,
8CEF64DB2A646A3C710CAAE7 /* de */,
467DE174FF993E9771FAEE5D /* fr */,
2EA0435DFAEE685F04391B3B /* es */,
1C906F7AD7B701DB61B512D2 /* pt-BR */,
B6189259C1AE18847A742A13 /* pt */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
A0A3C0DA09EE562AE21DF986 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
23D5AD87A9BF0CBF3A6AADDF /* en */,
2E83770B40B2D299796EB0CD /* zh-Hans */,
36B135DFBFBD8F03E16FB09A /* zh-Hant */,
56DE65D659C5D65945CF834F /* ru */,
02A713C4B5A1623480A005D6 /* ja */,
EA650495031B45A6FE5892FD /* ko */,
E653CAF5C4E87548BF6D8670 /* de */,
0BB7EC0F2A5A6A313F782DCA /* fr */,
093D4362C938821DE02B9032 /* es */,
125E3BF17EC58E43B32F2AC3 /* pt-BR */,
7DDF05F5D35AB063DB447879 /* pt */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
A00000000000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = RJZC936RL7;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -370,15 +448,44 @@
A00000000000000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = RJZC936RL7;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
@@ -387,23 +494,25 @@
A00000000000000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(BUILT_PRODUCTS_DIR)",
);
GENERATE_INFOPLIST_FILE = NO;
FRAMEWORK_SEARCH_PATHS = ("$(inherited)", "$(BUILT_PRODUCTS_DIR)", );
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../../deps/submodules/minirtc/src/api",
"$(PROJECT_DIR)/../../libs/wire/include",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.6.1;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.7.4;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
@@ -424,7 +533,7 @@
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_BUNDLE_IDENTIFIER = com.chunkdash.crossdesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
@@ -437,23 +546,25 @@
A00000000000000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(BUILT_PRODUCTS_DIR)",
);
GENERATE_INFOPLIST_FILE = NO;
FRAMEWORK_SEARCH_PATHS = ("$(inherited)", "$(BUILT_PRODUCTS_DIR)", );
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../../deps/submodules/minirtc/src/api",
"$(PROJECT_DIR)/../../libs/wire/include",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.6.1;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.7.4;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
@@ -474,7 +585,7 @@
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_BUNDLE_IDENTIFIER = com.chunkdash.crossdesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
@@ -0,0 +1,9 @@
{
"id" : "528214fb-1170-4293-86d0-859db2b1908b",
"targets" : [
{
"id" : "34a4d067-7914-4e8a-b72a-fda820282321",
"name" : "CrossDeskMobile"
}
]
}
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
LastUpgradeVersion = "2710"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
@@ -41,7 +41,8 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
@@ -57,7 +58,8 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
@@ -67,7 +69,8 @@
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug">
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
@@ -986,9 +986,12 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
return;
}
const off_t end = ftello(input);
rewind(input);
if (end < 0 || fseeko(input, 0, SEEK_SET) != 0) {
finish(true);
return;
}
NSData *name_data = [fileName dataUsingEncoding:NSUTF8StringEncoding];
if (end < 0 || name_data.length == 0 ||
if (name_data.length == 0 ||
name_data.length > std::numeric_limits<uint16_t>::max()) {
finish(true);
return;
@@ -1014,7 +1017,9 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
const size_t bytes_read = to_read > 0
? std::fread(payload.data(), 1, to_read, input)
: 0;
if (to_read > 0 && bytes_read == 0) {
// A short read indicates EOF or an error. Stop before sending a partial
// chunk or reading again from a stream whose position may be invalid.
if (bytes_read != to_read) {
send_result = -1;
break;
}
+17 -5
View File
@@ -8,6 +8,18 @@ enum MobileLayout {
}
extension View {
// Keep change callbacks available on iOS 16 without using the deprecated
// overload on newer systems. Neither branch fires on initial appearance.
@ViewBuilder
func onValueChange<Value: Equatable>(of value: Value,
perform action: @escaping (Value) -> Void) -> some View {
if #available(iOS 17.0, *) {
onChange(of: value) { _, newValue in action(newValue) }
} else {
onChange(of: value, perform: action)
}
}
func readableContentWidth() -> some View {
frame(maxWidth: MobileLayout.readableMaximumWidth)
.frame(maxWidth: .infinity)
@@ -81,7 +93,7 @@ struct ContentView: View {
session.applicationDidBecomeActive()
}
}
.onChange(of: scenePhase) { phase in
.onValueChange(of: scenePhase) { phase in
if phase == .active {
session.applicationDidBecomeActive()
} else if phase == .background {
@@ -304,7 +316,7 @@ private struct ConnectionHomeView: View {
session.dismissDeviceOfflineAlert()
}
}
.onChange(of: session.remoteID) { value in
.onValueChange(of: session.remoteID) { value in
let formatted = String(value.filter { $0.isNumber }.prefix(9))
if formatted != value {
session.remoteID = formatted
@@ -916,16 +928,16 @@ private struct ServerSettingsView: View {
}
.allowsHitTesting(false)
}
.onChange(of: serverConfigurationExpanded) { expanded in
.onValueChange(of: serverConfigurationExpanded) { expanded in
if !expanded {
focusedServerField = nil
validateAndApplyServerConfiguration()
}
}
.onChange(of: usesCustomServer) { isCustom in
.onValueChange(of: usesCustomServer) { isCustom in
if !isCustom { useDefaultServer() }
}
.onChange(of: focusedServerField) { field in
.onValueChange(of: focusedServerField) { field in
if field == nil { validateAndApplyServerConfiguration() }
}
.onDisappear {
+46 -46
View File
@@ -2,56 +2,56 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>CrossDesk</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>CrossDesk</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIFileSharingEnabled</key>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>CrossDesk 需要访问本地网络,以连接同一局域网内的电脑,进行远程控制和文件传输。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -197,7 +197,7 @@ struct RemoteSessionView: View {
} message: {
Text(session.remoteUpdateMessage ?? "")
}
.onChange(of: session.privacyScreen.unlockNotice) { visible in
.onValueChange(of: session.privacyScreen.unlockNotice) { visible in
if visible {
mouseInput.releaseAll()
keyboardInputVisible = false
@@ -231,37 +231,37 @@ struct RemoteSessionView: View {
statusMenuVisible = false
statusOrbCenter = nil
}
.onChange(of: session.selectedDisplay) { _ in
.onValueChange(of: session.selectedDisplay) { _ in
systemKeyboardContext.remoteFocusWillChange()
mouseInput.releaseAll()
mouseInput.position(x: 0.5, y: 0.5)
cursorPosition = nil
viewport = RemoteViewportState()
}
.onChange(of: session.mouseControlMode) { _ in
.onValueChange(of: session.mouseControlMode) { _ in
mouseInput.releaseAll()
cursorPosition = session.remoteCursorPosition
if let point = cursorPosition {
mouseInput.position(x: Float(point.x), y: Float(point.y))
}
}
.onChange(of: scenePhase) { phase in
.onValueChange(of: scenePhase) { phase in
if phase != .active { mouseInput.releaseAll() }
if phase == .background { keyboardInputVisible = false }
}
.onChange(of: keyboardInputVisible) { visible in
.onValueChange(of: keyboardInputVisible) { visible in
if !visible {
animateSystemKeyboardPresentation = true
waitingForSystemKeyboard = false
}
}
.onChange(of: session.isConnected) { connected in
.onValueChange(of: session.isConnected) { connected in
if !connected {
mouseInput.releaseAll()
keyboardInputVisible = false
}
}
.onChange(of: session.remoteCursorPositionRevision) { _ in
.onValueChange(of: session.remoteCursorPositionRevision) { _ in
guard !mouseInput.hasVirtualButton, let position = session.remoteCursorPosition else { return }
mouseInput.position(x: Float(position.x), y: Float(position.y))
cursorPosition = position
@@ -481,7 +481,7 @@ struct RemoteSessionView: View {
)
}
.frame(width: containerSize.width, height: containerSize.height)
.onChange(of: containerSize) { _ in mouseInput.releaseAll() }
.onValueChange(of: containerSize) { _ in mouseInput.releaseAll() }
}
}
@@ -1361,7 +1361,7 @@ private struct ScrollingCodecName: View {
GeometryReader { text in
Color.clear
.onAppear { textWidth = text.size.width }
.onChange(of: text.size.width) { textWidth = $0; startedAt = Date() }
.onValueChange(of: text.size.width) { textWidth = $0; startedAt = Date() }
}
}
.offset(x: offset(in: bounds.size.width, at: context.date))
@@ -1369,8 +1369,8 @@ private struct ScrollingCodecName: View {
.clipped()
}
.onAppear { startedAt = Date() }
.onChange(of: value) { _ in startedAt = Date() }
.onChange(of: bounds.size.width) { _ in startedAt = Date() }
.onValueChange(of: value) { _ in startedAt = Date() }
.onValueChange(of: bounds.size.width) { _ in startedAt = Date() }
}
.frame(height: lineHeight)
.accessibilityElement(children: .ignore)
@@ -1 +1 @@
"NSLocalNetworkUsageDescription" = "CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。";
"NSLocalNetworkUsageDescription" = "CrossDesk 需要访问本地网络,以连接同一局域网内的电脑,进行远程控制和文件传输。";
@@ -142,7 +142,7 @@ struct ThirdPartyLicensesView: View {
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(NSLocalizedString("关于 CrossDesk", comment: ""))
.navigationBarTitleDisplayMode(.inline)
.onChange(of: session.hasNetworkConsent) { allowed in
.onValueChange(of: session.hasNetworkConsent) { allowed in
if allowed { appUpdates.checkNow() }
}
}
+22 -1
View File
@@ -88,7 +88,7 @@ with Xmake precompiled package downloads disabled.
target and scheme.
2. Under **Signing & Capabilities**, select your own development team and set
a bundle identifier available to that team. The checked-in signing team
and `cn.crossdesk.mobile` identifier belong to the project maintainer.
and `com.chunkdash.crossdesk` identifier belong to ChunkDash.
3. Connect and select a physical iPhone or iPad, configure device trust and
Developer Mode as prompted by Xcode, then build and run. See Apple's
[device setup guide](https://developer.apple.com/documentation/xcode/running-your-app-on-simulated-or-physical-devices).
@@ -141,6 +141,27 @@ for that server. Remote control sessions then log in as `C-<identity>` and use
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
`control_data`, `clipboard`, `file`, and `file_feedback` streams.
## Xcode Cloud
Select `apps/ios/CrossDeskMobile.xcodeproj`, the shared `CrossDeskMobile`
scheme, and an **Archive - iOS** action with **App Store Connect** distribution
preparation (called **TestFlight and App Store** in some Xcode versions).
Commit and push the intended signing settings and shared scheme to the branch
selected by the workflow before starting a build.
Xcode Cloud automatically runs `ci_scripts/ci_post_clone.sh` next to the Xcode
project. It installs Xmake, CMake, and Python 3.13 with Homebrew, prepares an
isolated host Python environment with pip/setuptools/wheel, verifies the minimum
Xmake version, and initializes the pinned MiniRTC submodules. The native build
phase selects these tools again and preserves Cloud's proxy settings for package
downloads. No manual workflow PATH setting or `sudo` command is required.
The first archive compiles native dependencies from source and may take longer
than later builds. After changing the setup script, start a build from the new
commit; rebuilding an older commit does not include the fix. App Store releases
still need the source-tag and bundle checks described in **App Store archive
and bundle validation** below.
## GitHub Actions
[`Build iOS`](../../.github/workflows/build-ios.yml) can run independently from
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
IOS_DIR="$(dirname "$SCRIPT_DIR")"
REPO_DIR="$(git -C "$IOS_DIR" rev-parse --show-toplevel)"
HOST_TOOLS_DIR="${IOS_DIR}/.xmake/xcode-cloud-host"
if ! command -v brew >/dev/null 2>&1; then
echo "Xcode Cloud requires Homebrew to install the iOS build tools." >&2
exit 69
fi
echo "Installing Xcode Cloud native build tools"
brew install xmake cmake python@3.13
BREW_PREFIX="$(brew --prefix)"
PYTHON_PREFIX="$(brew --prefix python@3.13)"
# Match GitHub Actions' host Python dependencies without modifying Homebrew's
# externally managed Python. The Xcode build phase selects this environment
# explicitly because exports here do not persist into later Cloud steps.
"${PYTHON_PREFIX}/bin/python3.13" -m venv "$HOST_TOOLS_DIR"
"${HOST_TOOLS_DIR}/bin/python3" -m pip install --upgrade pip setuptools wheel
for tool in xmake xrepo cmake ctest cpack; do
ln -sf "${BREW_PREFIX}/bin/${tool}" "${HOST_TOOLS_DIR}/bin/${tool}"
done
"${HOST_TOOLS_DIR}/bin/xmake" --version > "${HOST_TOOLS_DIR}/xmake-version.txt"
"${HOST_TOOLS_DIR}/bin/python3" -B "${IOS_DIR}/scripts/verify_native_build.py" \
--xmake-version "${HOST_TOOLS_DIR}/xmake-version.txt"
echo "Initializing MiniRTC submodules"
git -C "$REPO_DIR" submodule sync --recursive
git -C "$REPO_DIR" submodule update --init --recursive
echo "Xcode Cloud iOS build tools are ready"
+24 -1
View File
@@ -46,6 +46,17 @@ if [[ "${ARCH_NAME}" != "arm64" ]]; then
exit 64
fi
# Post-clone scripts run in a separate process, so select their installed host
# tools again here instead of relying on exported PATH changes surviving.
if [[ "${CI_XCODE_CLOUD:-}" == "TRUE" ]]; then
CLOUD_HOST_BIN="${IOS_DIR}/.xmake/xcode-cloud-host/bin"
if [[ ! -x "${CLOUD_HOST_BIN}/xmake" || ! -x "${CLOUD_HOST_BIN}/python3" ]]; then
print -u2 "Xcode Cloud build tools are missing. Run ci_scripts/ci_post_clone.sh before building."
exit 69
fi
export PATH="${CLOUD_HOST_BIN}:${PATH}"
fi
XMAKE_BIN="${XMAKE_BIN:-}"
if [[ -z "${XMAKE_BIN}" ]]; then
XMAKE_BIN="$(command -v xmake 2>/dev/null || true)"
@@ -89,6 +100,16 @@ run_xmake() {
if [[ -n "${XMAKE_MAIN_REPO:-}" ]]; then
xmake_env+=("XMAKE_MAIN_REPO=${XMAKE_MAIN_REPO}")
fi
# Cloud dependency downloads use Apple's HTTP proxy. Keep those settings
# while still stripping Xcode's iOS compiler and SDK environment variables.
if [[ "${CI_XCODE_CLOUD:-}" == "TRUE" ]]; then
local proxy_name
for proxy_name in HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy; do
if (( ${+parameters[$proxy_name]} )); then
xmake_env+=("${proxy_name}=${(P)proxy_name}")
fi
done
fi
/usr/bin/env -i \
HOME="${HOME}" \
PATH="${XMAKE_HOST_PATH}" \
@@ -252,7 +273,9 @@ if [[ -f "${OUTPUT_LIBRARY}" ]] && cmp -s "${TEMP_MANIFEST}" "${MERGE_MANIFEST}"
exit 0
fi
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
# Dependencies include empty objects for disabled or other-platform code.
# Suppress only that diagnostic; retain the members and other warnings.
/usr/bin/libtool -static -no_warning_for_no_symbols -o "${TEMP_LIBRARY}" \
"${MINIRTC_LIBRARY}" "${MERGE_ARCHIVES[@]}" \
"${WIRE_LIBRARY}"
verify_no_openh264 "${TEMP_LIBRARY}"
+3 -1
View File
@@ -40,7 +40,9 @@ def package(inputs, output, platform="iphoneos"):
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="openssl-framework-", dir=output.parent) as temp:
binary = Path(temp) / "OpenSSL"
subprocess.run(["/usr/bin/libtool", "-static", "-o", str(binary),
# OpenSSL includes empty objects for disabled or platform-specific code.
# Keep those members and all other libtool diagnostics intact.
subprocess.run(["/usr/bin/libtool", "-static", "-no_warning_for_no_symbols", "-o", str(binary),
*(str(archives[name][0]) for name in sorted(archives))], check=True)
subprocess.run(["xcrun", "lipo", str(binary), "-verify_arch", "arm64"], check=True)
symbols = subprocess.check_output(["xcrun", "nm", "-u", str(binary)], text=True)
+17
View File
@@ -35,9 +35,26 @@ On **2026-10-25**, 16 images were rendered from the actual Slint components at `
| `desktop-stream-video-settings-*.png` | 会话工具栏与画面设置 / Session video settings | 1920 × 1200 |
| `desktop-about-*.png` | 关于与检查更新 / About and update check | 1280 × 944 |
| `web-client.png`(中文) | Web 连接首页 / Web connection page | 1280 × 720 |
| `cli-ansi-overview.png` | CLI ANSI 四象限全景 / ANSI quadrant overview | 1280 × 800 |
| `cli-kitty-overview.png` | CLI Kitty 协议像素全景 / Kitty protocol pixel overview | 1280 × 800 |
桌面图片使用 2× 原生渲染缩放,捕获窗口内容区,不包含 macOS 原生标题栏。未放大旧 UI 截图,也未重绘控件;演示远端画面由实际组件按比例显示。Markdown 只设置展示宽度。常规设置可滚动,`desktop-settings` 展示首屏,`desktop-settings-network` 展示网络连接分组;Windows 专属采集方式与便携版服务入口不会出现在这些 macOS 图片中。
<a id="cli-captures"></a>
## CLI 图示 / CLI captures
- **2026-10-31 05:17–05:18(UTC+8)重新采集**,使用在 macOS arm64 上从 `1b716f36` 及当前工作区 ANSI 优化重新构建的 release CLI,连接同一用户授权的被控设备。两次连接均保持只读,展示远端播放动画时的不同画面。
- 沿用工程外的 POSIX 伪终端采集器,视口为 **128 列 × 40 行**,每格 **10 × 20 像素**,输出 PNG 为 **1280 × 800**。顶栏与底部快捷键直接来自 CLI 输出;图示不包含连接密码或真实设备 ID。
- ANSI 图由实际转义序列恢复字符网格及前景/背景色,四象限字符按对应色块渲染;采集器等待完整 ANSI 帧结束后保存,支持本轮新增的行内多片段差分。Kitty 图使用本地共享内存传输:采集器通过 `mmap` 复制完整 RGB 帧后返回确认,按协议 placement 的行列尺寸显示。采集时只读取状态栏所需的字符,保存图片时才做最终缩放和 PNG 渲染。
- 图中的 ANSI `FPS 26`、Kitty `FPS 24` 是对应统计区间的实际完整输出次数。会话日志确认本地输出预算均为 8MiB/s,Kitty 使用 `shared_memory`,未发生回退;两次连接正常退出。源分辨率与画面内容不同,不能用这两张图直接比较后端性能或量化本次优化收益。静止画面下 ANSI 差分为空时不产生新的输出计数,可显示 `FPS 0`。
- 这些图片是**实际 CLI 输出的渲染图**,本轮沿用 PTY 采集,没有取得 Ghostty 或 macOS「终端」的原生窗口截图。字体栅格、图像缩放和显示时序由采集器处理,不能用图中 FPS 推断终端的实际显示性能。
- 采集脚本、原始输出、构建日志、源码/二进制哈希和中间产物均存放在工程外临时目录;工程内只更新最终图示及来源说明。示例命令使用占位 ID,凭据通过隐藏提示输入;构建后已恢复原来的 debug 配置。
Refreshed at **05:17–05:18 (UTC+8) on 2026-10-31** with a freshly rebuilt macOS arm64 release CLI from `1b716f36` plus the working-tree ANSI optimizations, using real read-only connections to the same authorized host during animation playback. The external POSIX PTY collector retains 128 × 40 cells at 10 × 20 pixels and renders the actual CLI output to 1280 × 800 PNGs. The ANSI collector reconstructs character cells and colors, supports multiple changed spans per row, and waits for a complete frame before saving. The Kitty collector copies each complete shared-memory RGB frame through `mmap` before acknowledging it and applies placement dimensions when saving. The captured ANSI `FPS 26` and Kitty `FPS 24` are interval counts of complete outputs. Both sessions used an 8MiB/s local output budget and exited normally; Kitty used shared memory with no fallback. Source resolutions and scene content differ, so these images are not a direct backend comparison or an isolated optimization benchmark. An unchanged ANSI view can show `FPS 0`. These output renderings do not validate native Ghostty or macOS Terminal rendering or GPU performance. Passwords and real device IDs are omitted. Scripts, raw output, build logs, source/binary hashes, and intermediate files stay outside the repository; the original debug configuration was restored.
重新采集时,使用 release CLI,在支持相应协议的终端或 PTY 采集器中分别运行 `crossdesk-cli <设备ID> --renderer ansi --quality high` 和 `crossdesk-cli <设备ID> --renderer kitty --quality high`,通过隐藏提示输入密码,等待顶栏显示 `Displaying` 且统计稳定后捕获完整视口,再按 `q` 退出。若使用 PTY 采集器,须正确处理终端尺寸回复、ANSI 光标与颜色、Kitty 探测、分块上传、共享内存读取与确认,以及 placement 缩放,并在图注中明确采集方式。更新图片前检查敏感信息、画面比例和状态栏完整性,并同步更新本节的日期、版本与验证范围。
## 重新生成 / Regenerate
以下命令在已配置依赖的 macOS 工程根目录执行。生成程序只渲染演示状态;所有临时源码、可执行文件和 PPM 均存放在工程外,确认图片后才将 PNG 复制进 `docs/images`。`sips` 仅做无损格式转换,图片没有重绘或后期修改。
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

@@ -68,6 +68,8 @@ public:
std::optional<DecodedFrame> TakeFrame(DecodedFrame *recycle = nullptr);
nlohmann::json Status() const;
bool Connected() const { return state_ == "connected"; }
bool PreviewAccepted() const { return preview_accepted_; }
bool SupportsPreviewFps15() const { return fps15_; }
bool Finished() const { return state_ == "failed" || state_ == "closed"; }
uint64_t Generation() const { return generation_; }
@@ -335,9 +335,13 @@ void ControllerSession::Preview() {
s.request_id = ++preview_request_;
preview_accepted_ = false;
s.fps = options_.fps == 0 ? 0
: options_.fps > 15 ? 60
: options_.fps <= 1 ? 1
: options_.fps <= 8 || !fps15_ ? 8
: 15;
// 60 is the existing interactive-mode sentinel, not a 60 FPS request.
// The encoder cadence is independently specified by frame_rate.
s.frame_rate = 30;
s.audio = false;
s.quality = options_.quality;
if (resolution_) {
+21 -3
View File
@@ -63,13 +63,30 @@ std::optional<std::pair<float, float>> MapPointer(const Grid &, int column,
int row);
std::vector<std::string> Lines(const Grid &);
std::string Difference(const Grid &, const std::optional<Grid> &previous);
std::string Kitty(const DecodedFrame &, const Viewport &);
struct PixelFrame {
int width = 0, height = 0;
std::vector<uint8_t> rgb;
};
PixelFrame KittyPixels(const DecodedFrame &, const Viewport &);
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);
struct KittyAck {
uint32_t image_id = 0;
bool ok = false;
};
std::optional<KittyAck> ParseKittyAck(const std::string &);
// Toggle alternate_buffer only after the previous output has been written in
// full. Upload to the other image ID before replacing the visible placement.
std::string Kitty(const DecodedFrame &, const Viewport &,
bool alternate_buffer = false);
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\\";
inline constexpr char kKittyDelete[] =
"\033_Ga=d,d=I,i=31,q=2\033\\\033_Ga=d,d=I,i=32,q=2\033\\";
bool KittyReply(const std::string &response);
struct StatusBar {
std::string progress, compact_progress, error, view, network;
std::string progress, compact_progress, error, view, network, fps;
bool control = false, command_mode = false;
};
// Three padded ASCII rows. Shortcuts are compacted as whole groups to fit.
@@ -89,6 +106,7 @@ public:
uint64_t mouse_legacy_reports = 0, mouse_forwarded = 0;
uint64_t mouse_send_failures = 0;
TerminalMetrics terminal_metrics;
std::optional<KittyAck> kitty_ack;
private:
std::string pending_;
+2
View File
@@ -209,6 +209,8 @@ void Input::Feed(const std::string &bytes, ControllerSession &s,
auto end = pending_.find("\033\\", 2);
if (end == std::string::npos)
return;
if (auto ack = ParseKittyAck(pending_.substr(0, end + 2)))
kitty_ack = ack;
pending_.erase(0, end + 2);
continue;
}
+471 -84
View File
@@ -1,6 +1,7 @@
#include "terminal_renderer.h"
#include <algorithm>
#include <array>
#include <charconv>
#include <cmath>
#include <stdexcept>
#include <zlib.h>
@@ -88,52 +89,163 @@ static uint32_t Sample(const DecodedFrame &f, double x0, double y0, double x1,
};
return (round(0) << 16) | (round(1) << 8) | round(2);
}
static Cell Quadrant(const std::array<uint32_t, 4> &pixels) {
// Enumerate all seven two-colour partitions (complements are equivalent).
static constexpr uint32_t glyphs[16] = {
0x20, 0x2598, 0x259D, 0x2580, 0x2596, 0x258C, 0x259E, 0x259B,
0x2597, 0x259A, 0x2590, 0x259C, 0x2584, 0x2599, 0x259F, 0x2588};
Cell best{};
double best_error = 1e30;
for (unsigned mask = 1; mask < 8; ++mask) {
double sums[2][3] = {};
unsigned count[2] = {};
using QuadrantPixels = std::array<uint32_t, 4>;
struct QuadrantFit {
Cell cell;
QuadrantPixels pixels{};
double error = 0;
};
struct QuadrantEdge {
double weight = 0;
std::array<double, 3> error{};
};
using QuadrantEdges = std::array<QuadrantEdge, 4>;
static int Channel(uint32_t color, int channel) {
return (color >> (16 - 8 * channel)) & 255;
}
// Seven partitions suffice; their complements produce the same pixel colours.
static constexpr uint32_t kQuadrantGlyphs[] = {0x20, 0x2598, 0x259D, 0x2580,
0x2596, 0x258C, 0x259E, 0x259B};
static QuadrantFit Quadrant(const QuadrantPixels &pixels) {
int channels[4][3], total[3] = {}, squares = 0;
uint32_t mean = 0;
bool flat = true;
for (int c = 0; c < 3; ++c) {
int lo = 255, hi = 0;
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;
int value = channels[i][c] = Channel(pixels[i], c);
lo = std::min(lo, value);
hi = std::max(hi, value);
total[c] += value;
squares += value * value;
}
// Keep the per-channel threshold small to retain faint/coloured detail
// above this range, rather than judging flatness by average luminance.
flat = flat && hi - lo <= 6;
mean |= uint32_t((total[c] + 2) / 4) << (16 - 8 * c);
}
if (flat) {
QuadrantFit fit{
{mean, mean, 0x20}, {mean, mean, mean, mean}, double(squares)};
for (int c = 0; c < 3; ++c) {
int value = Channel(mean, c);
fit.error += 4 * value * value - 2 * value * total[c];
}
return fit;
}
// Reuse subset sums. Integer rounded means and sum-of-squares give exactly
// the original RGB squared error without rescanning four pixels per mask.
static constexpr int counts[] = {0, 1, 1, 2, 1, 2, 2, 3};
int sums[8][3] = {};
QuadrantFit best;
int best_error = INT32_MAX, best_mask = 0;
for (int mask = 1; mask < 8; ++mask) {
int bit = mask & 1 ? 0 : mask & 2 ? 1 : 2;
int rest = mask & (mask - 1), count = counts[mask];
uint32_t fg = 0, bg = 0;
int error = squares;
for (int c = 0; c < 3; ++c) {
int sum = sums[mask][c] = sums[rest][c] + channels[bit][c];
int front = (sum + count / 2) / count;
int back = (total[c] - sum + (4 - count) / 2) / (4 - count);
fg |= uint32_t(front) << (16 - 8 * c);
bg |= uint32_t(back) << (16 - 8 * c);
error += count * front * front - 2 * front * sum +
(4 - count) * back * back - 2 * back * (total[c] - sum);
}
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]};
best_mask = mask;
best.cell = {fg, bg, kQuadrantGlyphs[mask]};
}
}
if (best.top == best.bottom)
best.glyph = 0x20;
best.error = best_error;
for (int i = 0; i < 4; ++i)
best.pixels[i] = (best_mask >> i) & 1 ? best.cell.top : best.cell.bottom;
if (best.cell.top == best.cell.bottom)
best.cell.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))};
static QuadrantFit RefineQuadrant(const QuadrantPixels &pixels,
const QuadrantEdges &edges,
const QuadrantFit &initial) {
int channels[4][3];
double weighted[4][3];
for (int i = 0; i < 4; ++i)
for (int c = 0; c < 3; ++c) {
channels[i][c] = Channel(pixels[i], c);
weighted[i][c] =
(1 + edges[i].weight) * channels[i][c] + edges[i].error[c];
}
auto score = [&](const QuadrantFit &fit) {
double value = fit.error;
for (int i = 0; i < 4; ++i)
for (int c = 0; c < 3; ++c) {
double d = Channel(fit.pixels[i], c) - channels[i][c];
// Match neighbouring quantization errors, preserving the source
// gradient. The neighbour-only constant does not affect selection.
value += edges[i].weight * d * d - 2 * edges[i].error[c] * d;
}
return value;
};
QuadrantFit best = initial;
double best_score = score(best);
const double max_error = initial.error + std::min(48.0, initial.error * .05);
for (unsigned mask = 1; mask < 8; ++mask) {
double sums[2][3] = {};
double weights[2] = {};
for (int i = 0; i < 4; ++i) {
int g = (mask >> i) & 1;
double weight = 1 + edges[i].weight;
weights[g] += weight;
for (int c = 0; c < 3; ++c)
sums[g][c] += weighted[i][c];
}
uint32_t colors[2] = {};
for (int g = 0; g < 2; ++g)
for (int c = 0; c < 3; ++c) {
sums[g][c] =
std::clamp(std::round(sums[g][c] / weights[g]), 0.0, 255.0);
colors[g] |= uint32_t(sums[g][c]) << (16 - 8 * c);
}
QuadrantFit fit{{colors[1], colors[0], kQuadrantGlyphs[mask]}};
for (int i = 0; i < 4; ++i) {
fit.pixels[i] = colors[(mask >> i) & 1];
for (int c = 0; c < 3; ++c) {
double d = channels[i][c] - Channel(fit.pixels[i], c);
fit.error += d * d;
}
}
// Limit the cost of continuity to 5% of the independent fit's error,
// capped at 2 RGB levels RMS over the cell's twelve channel samples.
if (fit.error > max_error)
continue;
double value = score(fit);
if (value < best_score) {
best_score = value;
best = fit;
}
}
if (best.cell.top == best.cell.bottom)
best.cell.glyph = 0x20;
return best;
}
static void AppendUtf8(std::string &out, uint32_t c) {
if (c < 128) {
out += char(c);
} else if (c < 2048) {
out += char(0xc0 | (c >> 6));
out += char(0x80 | (c & 63));
} else {
out += char(0xe0 | (c >> 12));
out += char(0x80 | ((c >> 6) & 63));
out += char(0x80 | (c & 63));
}
}
static void AppendNumber(std::string &out, uint32_t value) {
char digits[10];
auto result = std::to_chars(digits, digits + sizeof(digits), value);
out.append(digits, result.ptr);
}
Grid Layout(const DecodedFrame &f, Viewport v) {
Validate(f, v);
@@ -156,6 +268,8 @@ Grid Convert(const DecodedFrame &f, Viewport 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);
std::vector<QuadrantPixels> samples(sx == 2 ? g.cells.size() : 0);
std::vector<QuadrantFit> fits(samples.size());
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)
@@ -167,15 +281,65 @@ Grid Convert(const DecodedFrame &f, Viewport v) {
};
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 {
const auto index = size_t(y) * v.columns + x;
auto &c = g.cells[index];
if (sx == 2) {
samples[index] = {pixel(x * 2, y * 2), pixel(x * 2 + 1, y * 2),
pixel(x * 2, y * 2 + 1), pixel(x * 2 + 1, y * 2 + 1)};
fits[index] = Quadrant(samples[index]);
c = fits[index].cell;
} else {
c.top = pixel(x, y * sy);
c.bottom = sy == 1 ? c.top : pixel(x, y * sy + sy - 1);
}
}
// Read only independent fits so refinement has no left-to-right/top-to-bottom
// feedback, and a changed cell cannot propagate through the entire frame.
if (sx == 2)
for (int y = 0; y < v.rows; ++y)
for (int x = 0; x < v.columns; ++x) {
const auto index = size_t(y) * v.columns + x;
if (fits[index].cell.glyph == 0x20 || fits[index].error == 0)
continue;
QuadrantEdges edges{};
auto edge = [&](int i, size_t neighbour, int j) {
int contrast = 0;
for (int c = 0; c < 3; ++c)
contrast =
std::max(contrast, std::abs(Channel(samples[index][i], c) -
Channel(samples[neighbour][j], c)));
// Fade out across real edges instead of smoothing text into its
// background. Equal source colours receive the strongest constraint.
double strength = std::max(0.0, 1 - contrast / 24.0);
double weight = .35 * strength * strength;
edges[i].weight += weight;
for (int c = 0; c < 3; ++c)
edges[i].error[c] +=
weight * (Channel(fits[neighbour].pixels[j], c) -
Channel(samples[neighbour][j], c));
};
if (x > 0) {
edge(0, index - 1, 1);
edge(2, index - 1, 3);
}
if (x + 1 < v.columns) {
edge(1, index + 1, 0);
edge(3, index + 1, 2);
}
if (y > 0) {
edge(0, index - v.columns, 2);
edge(1, index - v.columns, 3);
}
if (y + 1 < v.rows) {
edge(2, index + v.columns, 0);
edge(3, index + v.columns, 1);
}
if (edges[0].weight + edges[1].weight + edges[2].weight +
edges[3].weight >
0)
g.cells[index] =
RefineQuadrant(samples[index], edges, fits[index]).cell;
}
return g;
}
std::optional<std::pair<float, float>> MapPointer(const Grid &g, int x, int y) {
@@ -194,12 +358,10 @@ std::optional<std::pair<float, float>> MapPointer(const Grid &g, int x, int y) {
(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) {
static uint32_t TerminalColor(uint32_t c, 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";
return c;
if (depth == 256) {
constexpr int levels[] = {0, 95, 135, 175, 215, 255};
auto index = [&](int value) {
@@ -218,55 +380,149 @@ static std::string Color(uint32_t c, bool fg, int depth) {
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";
return gray_error < cube ? 232 + gray : 16 + 36 * ri + 6 * gi + bi;
}
int i = (r >= 128 ? 1 : 0) + (g >= 128 ? 2 : 0) + (b >= 128 ? 4 : 0);
return "\033[" + std::to_string((fg ? 30 : 40) + i) + "m";
return (r >= 128 ? 1 : 0) + (g >= 128 ? 2 : 0) + (b >= 128 ? 4 : 0);
}
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;
static void AppendColor(std::string &out, uint32_t c, bool fg, int depth) {
if (depth == 24 || depth == 256) {
out += fg ? "\033[38;" : "\033[48;";
if (depth == 24) {
out += "2;";
AppendNumber(out, c >> 16);
out += ';';
AppendNumber(out, (c >> 8) & 255);
out += ';';
AppendNumber(out, c & 255);
} else {
out += "5;";
AppendNumber(out, c);
}
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) : " ";
} else {
out += "\033[";
AppendNumber(out, (fg ? 30 : 40) + c);
}
return line + "\033[0m";
out += 'm';
}
struct AnsiColors {
uint32_t fg = UINT32_MAX, bg = UINT32_MAX;
};
static void AppendCells(std::string &out, const Grid &g, int y, int begin,
int end, AnsiColors &colors) {
for (int x = begin; x < end; ++x) {
const auto &c = g.cells[size_t(y) * g.viewport.columns + x];
// Spaces need no foreground SGR. Keep backgrounds for block glyphs too,
// since font rasterization can expose them at the cell's edges.
if (g.viewport.utf8 && c.glyph != 0x20) {
auto fg = TerminalColor(c.top, g.viewport.colors);
if (colors.fg != fg) {
AppendColor(out, fg, true, g.viewport.colors);
colors.fg = fg;
}
}
// Track the emitted palette index so quantized colours do not resend SGR.
auto bg =
TerminalColor(g.viewport.utf8 ? c.bottom : c.top, g.viewport.colors);
if (colors.bg != bg) {
AppendColor(out, bg, false, g.viewport.colors);
colors.bg = bg;
}
AppendUtf8(out, g.viewport.utf8 ? c.glyph : 0x20);
}
}
static bool SameTerminalCell(const Cell &a, const Cell &b, const Viewport &v) {
if (a == b)
return true;
if (!v.utf8)
return TerminalColor(a.top, v.colors) == TerminalColor(b.top, v.colors);
return a.glyph == b.glyph &&
TerminalColor(a.bottom, v.colors) == TerminalColor(b.bottom, v.colors) &&
(a.glyph == 0x20 ||
TerminalColor(a.top, v.colors) == TerminalColor(b.top, v.colors));
}
static void AppendCursor(std::string &out, int row, int column) {
out += "\033[";
AppendNumber(out, row + 1);
out += ';';
AppendNumber(out, column + 1);
out += 'H';
}
std::vector<std::string> Lines(const Grid &g) {
std::vector<std::string> lines;
lines.reserve(g.viewport.rows);
for (int y = 0; y < g.viewport.rows; ++y)
lines.push_back(Line(g, y));
for (int y = 0; y < g.viewport.rows; ++y) {
std::string line;
line.reserve(size_t(g.viewport.columns) * 40 + 4);
AnsiColors colors;
AppendCells(line, g, y, 0, g.viewport.columns, colors);
line += "\033[0m";
lines.push_back(std::move(line));
}
return lines;
}
std::string Difference(const Grid &g, const std::optional<Grid> &previous) {
std::string out;
std::string skipped, joined;
AnsiColors colors;
const bool same = previous &&
previous->viewport.columns == g.viewport.columns &&
previous->viewport.rows == g.viewport.rows &&
previous->viewport.row_offset == g.viewport.row_offset &&
previous->viewport.colors == g.viewport.colors &&
previous->viewport.utf8 == g.viewport.utf8;
previous->viewport.utf8 == g.viewport.utf8 &&
previous->cells.size() == g.cells.size();
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(g.viewport.row_offset + y + 1) +
";1H" + Line(g, y);
const auto offset = size_t(y) * g.viewport.columns;
const auto unchanged = [&](int x) {
return SameTerminalCell(g.cells[offset + x], previous->cells[offset + x],
g.viewport);
};
int begin = 0, end = g.viewport.columns;
if (same) {
while (begin < end && unchanged(begin))
++begin;
while (end > begin && unchanged(end - 1))
--end;
}
if (begin == end)
continue;
AppendCursor(out, g.viewport.row_offset + y, begin);
if (!same) {
AppendCells(out, g, y, begin, end, colors);
continue;
}
AppendCells(out, g, y, begin, begin + 1, colors);
for (int x = begin + 1; x < end;) {
const int start = x;
if (!unchanged(x)) {
while (++x < end && !unchanged(x)) {}
AppendCells(out, g, y, start, x, colors);
continue;
}
while (++x < end && unchanged(x)) {}
// end already excludes the unchanged suffix, so x is a changed cell.
// Compare actual bytes, including the next cell's SGR: crossing a short
// gap can be cheaper than moving the cursor and restoring its colours.
skipped.clear();
auto skip_colors = colors;
AppendCursor(skipped, g.viewport.row_offset + y, x);
AppendCells(skipped, g, y, x, x + 1, skip_colors);
joined.clear();
auto join_colors = colors;
for (int gap = start; gap <= x && joined.size() <= skipped.size(); ++gap)
AppendCells(joined, g, y, gap, gap + 1, join_colors);
if (joined.size() <= skipped.size()) {
out += joined;
colors = join_colors;
} else {
out += skipped;
colors = skip_colors;
}
++x;
}
}
if (!out.empty())
out += "\033[0m";
return out;
}
static std::string Base64(const std::vector<uint8_t> &data) {
@@ -287,7 +543,132 @@ static std::string Base64(const std::vector<uint8_t> &data) {
}
return out;
}
std::string Kitty(const DecodedFrame &f, const Viewport &requested) {
PixelFrame KittyPixels(const DecodedFrame &f, const Viewport &requested) {
auto v = requested;
v.pixel_mode = true;
Validate(f, v);
PixelFrame pixels;
pixels.width =
std::clamp(v.pixel_width > 0 ? v.pixel_width : v.columns * 10, 2, 1920);
pixels.height =
std::clamp(v.pixel_height > 0 ? v.pixel_height : v.rows * 20, 2, 1080);
pixels.rgb.resize(size_t(pixels.width) * pixels.height * 3, 0);
const auto crop = Crop(f, v);
const auto r = Fit(crop.width, crop.height, pixels.width, pixels.height,
double(v.columns) * v.cell_aspect * pixels.height /
(v.rows * pixels.width));
if (r.width == crop.width && r.height == crop.height) {
for (int y = 0; y < r.height; ++y)
for (int x = 0; x < r.width; ++x) {
const auto c = Pixel(f, crop.x + x, crop.y + y);
auto *dst = &pixels.rgb[(size_t(y + r.y) * pixels.width + x + r.x) * 3];
dst[0] = c >> 16;
dst[1] = c >> 8;
dst[2] = c;
}
return pixels;
}
// Separable area integration. Integer overlap weights avoid per-pixel
// floor/ceil and repeated NV12 conversion in overlapping vertical samples.
// Adjacent output rows can share only their boundary source row, so one
// cached horizontal row suffices, even when upscaling.
struct Span {
int first, last, begin, end;
};
std::vector<Span> spans;
for (int x = 0; x < r.width; ++x) {
const int begin = x * crop.width, end = begin + crop.width;
spans.push_back(
{begin / r.width, (end + r.width - 1) / r.width, begin, end});
}
std::vector<uint32_t> source(crop.width), horizontal(size_t(r.width) * 3);
std::vector<uint64_t> sums(size_t(r.width) * 3);
const uint64_t area = uint64_t(crop.width) * crop.height;
int cached_y = -1;
for (int y = 0; y < r.height; ++y) {
std::fill(sums.begin(), sums.end(), 0);
const int begin = y * crop.height, end = begin + crop.height;
for (int sy = begin / r.height; sy < (end + r.height - 1) / r.height;
++sy) {
if (cached_y != sy) {
for (int x = 0; x < crop.width; ++x)
source[x] = Pixel(f, crop.x + x, crop.y + sy);
std::fill(horizontal.begin(), horizontal.end(), 0);
for (int x = 0; x < r.width; ++x) {
const auto &span = spans[x];
for (int sx = span.first; sx < span.last; ++sx) {
const unsigned weight = std::min(span.end, (sx + 1) * r.width) -
std::max(span.begin, sx * r.width);
const auto c = source[sx];
horizontal[x * 3] += (c >> 16) * weight;
horizontal[x * 3 + 1] += ((c >> 8) & 255) * weight;
horizontal[x * 3 + 2] += (c & 255) * weight;
}
}
cached_y = sy;
}
const unsigned weight =
std::min(end, (sy + 1) * r.height) - std::max(begin, sy * r.height);
for (size_t i = 0; i < sums.size(); ++i)
sums[i] += uint64_t(horizontal[i]) * weight;
}
auto *dst = &pixels.rgb[(size_t(y + r.y) * pixels.width + r.x) * 3];
for (size_t i = 0; i < sums.size(); ++i)
dst[i] = (sums[i] + area / 2) / area;
}
return pixels;
}
std::string KittySharedUpload(const std::string &name, int width, int height,
bool alternate_buffer, bool query) {
if (name.empty() || name.size() > 128 || width < 1 || width > 1920 ||
height < 1 || height > 1080)
throw std::invalid_argument("invalid shared image");
return "\033_Ga=" + std::string(query ? "q" : "t") + ",t=s,f=24,q=0,i=" +
std::to_string(query ? 33
: alternate_buffer ? 32
: 31) +
",s=" + std::to_string(width) + ",v=" + std::to_string(height) +
",S=" + std::to_string(size_t(width) * height * 3) + ";" +
Base64(std::vector<uint8_t>(name.begin(), name.end())) + "\033\\";
}
std::string KittyPresent(const Viewport &v, bool alternate_buffer) {
return "\033[" + std::to_string(v.row_offset + 1) +
";1H\033_Ga=p,i=" + std::to_string(alternate_buffer ? 32 : 31) +
",p=1,C=1,q=2,c=" + std::to_string(v.columns) +
",r=" + std::to_string(v.rows) +
"\033\\\033_Ga=d,d=I,i=" + std::to_string(alternate_buffer ? 31 : 32) +
",q=2\033\\";
}
std::optional<KittyAck> ParseKittyAck(const std::string &s) {
if (s.size() > 1024 || s.size() < 10 || s.compare(0, 3, "\033_G") != 0 ||
s.compare(s.size() - 2, 2, "\033\\") != 0)
return {};
const auto separator = s.find(';', 3);
if (separator == std::string::npos)
return {};
uint32_t image_id = 0;
for (size_t pos = 3; pos < separator;) {
const auto end = std::min(s.find(',', pos), separator);
if (s.compare(pos, 2, "i=") == 0) {
if (image_id)
return {};
const auto parsed =
std::from_chars(s.data() + pos + 2, s.data() + end, image_id);
if (parsed.ec != std::errc{} || parsed.ptr != s.data() + end || !image_id)
return {};
} else if (s.compare(pos, 2, "p=") == 0 &&
s.substr(pos, end - pos) != "p=0") {
return {}; // Only upload/query acknowledgements are relevant here.
}
pos = end + 1;
}
if (!image_id)
return {};
return KittyAck{image_id,
s.substr(separator + 1, s.size() - separator - 3) == "OK"};
}
std::string Kitty(const DecodedFrame &f, const Viewport &requested,
bool alternate_buffer) {
auto v = requested;
v.pixel_mode = true;
Validate(f, v);
@@ -327,16 +708,22 @@ std::string Kitty(const DecodedFrame &f, const Viewport &requested) {
w = std::max(2, w * 3 / 4);
h = std::max(2, h * 3 / 4);
}
std::string out = "\033[" + std::to_string(v.row_offset + 1) + ";1H";
const auto image_id = std::to_string(alternate_buffer ? 32 : 31);
std::string out;
out.reserve(b64.size() + (b64.size() / 4096 + 1) * 12 + 256);
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\\";
out += "a=t,t=d,f=24,o=z,q=2,i=" + image_id + ",s=" +
std::to_string(w) + ",v=" + std::to_string(h) + ",";
out += i + 4096 < b64.size() ? "m=1;" : "m=0;";
out.append(b64, i, 4096);
out += "\033\\";
}
// Re-transmitting a visible image ID can remove its placement as soon as
// upload starts. Keep the old image throughout chunking/short writes, then
// place the fully uploaded image before freeing the previous one.
out += KittyPresent(v, alternate_buffer);
return out;
}
bool KittyReply(const std::string &s) {
+7 -2
View File
@@ -59,11 +59,16 @@ std::vector<std::string> StatusLines(const StatusBar &bar, int columns) {
const auto progress =
bar.error.empty() ? bar.progress : "Error: " + bar.error;
auto left = mode + " | " + progress;
auto right = bar.view + (bar.network.empty() ? "" : " | " + bar.network);
auto metrics = bar.fps;
if (!bar.network.empty())
metrics += (metrics.empty() ? "" : " | ") + bar.network;
auto right = bar.view + (metrics.empty() ? "" : " | " + metrics);
if (left.size() + right.size() + 2 > inner)
right = bar.network;
right = metrics;
if (left.size() + right.size() + 2 > inner && bar.error.empty())
left = mode + " | " + bar.compact_progress;
if (left.size() + right.size() + 2 > inner)
right = bar.fps;
if (left.size() + right.size() + 2 > inner)
right.clear();
std::string status = Clip(left, inner);