mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-31 19:02:37 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ff87d8195 | ||
|
|
7ce974f3d4 | ||
|
|
cf039b8f91 | ||
|
|
15092b16d6 | ||
|
|
de1a663ab4 | ||
|
|
7b18937df0 | ||
|
|
c5ee7d0691 | ||
|
|
d984aa55de | ||
|
|
e6e7e4c0f0 | ||
|
|
5d0a8f857f | ||
|
|
699d867956 |
@@ -0,0 +1,131 @@
|
||||
name: CLI native builds and npm packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags-ignore: ["cli-v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
target: darwin-arm64
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
target: darwin-x64
|
||||
arch: x86_64
|
||||
- runner: ubuntu-22.04
|
||||
target: linux-x64
|
||||
arch: x86_64
|
||||
- runner: ubuntu-22.04-arm
|
||||
target: linux-arm64
|
||||
arch: arm64
|
||||
- runner: windows-2022
|
||||
target: win32-x64
|
||||
arch: x64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
XMAKE_VERSION: "3.1.1"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
package-manager-cache: false
|
||||
# A runner label can receive compiler/SDK updates without changing its name.
|
||||
- name: Set cache environment
|
||||
id: runner-image
|
||||
shell: bash
|
||||
run: |
|
||||
printf 'version=%s\n' "${ImageVersion:?Missing GitHub runner image version}" >> "$GITHUB_OUTPUT"
|
||||
# Keep installed packages separate from the project's build/config state.
|
||||
printf 'XMAKE_PKG_INSTALLDIR=%s/crossdesk-cli-packages\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
- uses: xmake-io/github-action-setup-xmake@v1
|
||||
with:
|
||||
xmake-version: ${{ env.XMAKE_VERSION }}
|
||||
actions-cache-folder: ${{ runner.temp }}/crossdesk-cli-xmake-toolchain
|
||||
actions-cache-key: cli-${{ matrix.runner }}-${{ matrix.target }}-image${{ steps.runner-image.outputs.version }}-xmake${{ env.XMAKE_VERSION }}
|
||||
- name: Configure MSVC (x64)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
||||
$vsPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
if ($LASTEXITCODE -ne 0 -or -not $vsPath) {
|
||||
throw 'Visual Studio C++ build tools were not found'
|
||||
}
|
||||
$previousEnv = @{}
|
||||
Get-ChildItem Env: | ForEach-Object { $previousEnv[$_.Name] = $_.Value }
|
||||
& (Join-Path $vsPath 'Common7\Tools\Launch-VsDevShell.ps1') -Arch amd64 -HostArch amd64 -SkipAutomaticLocation
|
||||
Get-Command cl.exe -ErrorAction Stop | Out-Null
|
||||
# Persist the compiler, SDK and PATH settings for subsequent steps.
|
||||
Get-ChildItem Env: | Where-Object { $previousEnv[$_.Name] -cne $_.Value } | ForEach-Object {
|
||||
"$($_.Name)=$($_.Value)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
}
|
||||
- name: Linux build tools
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
missing=()
|
||||
for package in build-essential pkg-config autoconf automake libtool nasm yasm zlib1g-dev python3-pip; do
|
||||
if [[ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" != "install ok installed" ]]; then
|
||||
missing+=("$package")
|
||||
fi
|
||||
done
|
||||
if (( ${#missing[@]} )); then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends "${missing[@]}"
|
||||
fi
|
||||
- name: Prepare pinned dependency
|
||||
run: sh apps/cli/scripts/prepare-deps.sh
|
||||
# Exact matches only: changed local recipes/patches must not reuse old libraries.
|
||||
# Source, README and npm metadata edits do not change this key.
|
||||
- name: Restore native dependencies
|
||||
id: native-cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ env.XMAKE_PKG_INSTALLDIR }}
|
||||
key: cli-deps-v1-${{ matrix.runner }}-${{ matrix.target }}-image${{ steps.runner-image.outputs.version }}-xmake${{ env.XMAKE_VERSION }}-${{ hashFiles('xmake.lua', 'apps/cli/xmake.lua', 'apps/desktop/xmake/options.lua', 'deps/thirdparty/openssl/**', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**', 'deps/patches/**', '.github/workflows/build-cli.yml') }}
|
||||
- name: Configure macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: xmake f -y -p macosx -a ${{ matrix.arch }} -m release --target_minver=13.0 --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
|
||||
- name: Configure Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xmake f -y -p linux -a ${{ matrix.arch }} -m release --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
|
||||
- name: Configure Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: xmake f -y -p windows -a ${{ matrix.arch }} -m release --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
|
||||
# xmake f installs required packages. Save before compiling or packaging the CLI,
|
||||
# so failures in those later steps do not discard successfully installed dependencies.
|
||||
- name: Save native dependencies
|
||||
if: steps.native-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ env.XMAKE_PKG_INSTALLDIR }}
|
||||
key: ${{ steps.native-cache.outputs.cache-primary-key }}
|
||||
- name: Build
|
||||
run: xmake build crossdesk-cli
|
||||
- name: Sign macOS CLI
|
||||
if: runner.os == 'macOS'
|
||||
run: codesign --force --sign - build/macosx/${{ matrix.arch }}/release/crossdesk-cli
|
||||
- name: Report native environment
|
||||
run: xmake run crossdesk-cli doctor --json
|
||||
- name: Package native program and npm entry
|
||||
run: npm --prefix apps/cli run pack:npm
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: npm-${{ matrix.target }}
|
||||
path: build/npm/*.tgz
|
||||
if-no-files-found: error
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- "*"
|
||||
- "!ios-v*"
|
||||
- "!android-v*"
|
||||
- "!cli-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
desktop_version:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Publish CLI to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["cli-v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Build and validate all packages without publishing (disable only on a cli-v* tag)"
|
||||
type: boolean
|
||||
default: true
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
dry_run: ${{ steps.release.outputs.dry_run }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Validate release version and Git tag
|
||||
id: release
|
||||
env:
|
||||
RELEASE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
run: node apps/cli/scripts/release-npm.cjs plan
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
uses: ./.github/workflows/build-cli.yml
|
||||
|
||||
publish:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
environment: npm
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
# Serialize registry writes across versions; never cancel an in-flight publish.
|
||||
concurrency:
|
||||
group: npm-cli-publish
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
RELEASE_DRY_RUN: ${{ needs.prepare.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Install npm with Trusted Publishing support
|
||||
run: npm install --global npm@12.2.1 --ignore-scripts --no-audit --no-fund
|
||||
# Keep each platform in its own directory: all five artifacts contain an
|
||||
# entry tarball, and Windows checkouts may have different line endings.
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: npm-*
|
||||
path: ${{ runner.temp }}/cli-release
|
||||
merge-multiple: false
|
||||
- name: Validate and publish six packages
|
||||
env:
|
||||
RELEASE_ARTIFACTS: ${{ runner.temp }}/cli-release
|
||||
run: node apps/cli/scripts/release-npm.cjs publish "$RELEASE_ARTIFACTS"
|
||||
@@ -13,3 +13,6 @@ xcuserdata/
|
||||
|
||||
# VSCode cache
|
||||
.vscode
|
||||
|
||||
# CLI npm dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk 标志:图标在左,双色文字在右" />
|
||||
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
|
||||
<p>轻量、跨平台的远程桌面,支持电脑、浏览器、移动端和终端连接,用桌面墙集中查看与操控多台设备。</p>
|
||||
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
|
||||
</div>
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
|
||||
CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
|
||||
|
||||
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [IP 直连](#lan-direct) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
|
||||
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [终端 CLI](#cli) · [IP 直连](#lan-direct) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
@@ -29,6 +29,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
|
||||
|
||||
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
|
||||
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
|
||||
- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。
|
||||
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
|
||||
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
|
||||
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
|
||||
@@ -50,8 +51,9 @@ 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) |
|
||||
|
||||
系统要求按当前源码与构建配置整理,具体安装包以对应 Release 说明为准。各客户端的功能范围见 [移动端与鸿蒙](#mobile)。
|
||||
系统要求按当前源码与构建配置整理,具体安装包以对应 Release 说明为准。各客户端的功能范围见 [终端 CLI](#cli) 与 [移动端与鸿蒙](#mobile)。
|
||||
|
||||
- **Linux:** 已有桌面选普通版 `crossdesk-linux-…deb`;需要独立 Xfce 桌面的服务器选 `crossdesk-virtual-desktop-xfce-…deb`。两者选择其一,安装与启动方法见 [使用指南](docs/USER_GUIDE.md#installation) 和 [无头运行](docs/HEADLESS.md)。
|
||||
- **macOS:** 首次运行时按提示授予屏幕录制及辅助功能权限;前者用于桌面采集,后者用于远程键鼠输入。[权限说明](docs/USER_GUIDE.md#installation)
|
||||
@@ -122,6 +124,59 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
|
||||
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
|
||||
- [桌面墙技术说明](docs/deskhub.md):预览订阅、带宽分配、协议与兼容性。
|
||||
|
||||
<a id="cli"></a>
|
||||
|
||||
## 终端客户端 CLI
|
||||
|
||||
`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。
|
||||
|
||||
### 从源码构建并连接
|
||||
|
||||
准备 Git、Xmake 3.1.1 或以上、C++17 编译工具链及对应平台依赖。以下以 macOS / Linux shell 为例;Windows 的 Git Bash / 开发者 PowerShell 步骤见 [CLI 构建说明](apps/cli/docs/terminal-client.md#构建与安装)。
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
|
||||
cd crossdesk
|
||||
./apps/cli/scripts/prepare-deps.sh
|
||||
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
|
||||
xmake build crossdesk-cli
|
||||
xmake run crossdesk-cli doctor --json
|
||||
xmake run crossdesk-cli 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)。
|
||||
|
||||
### 控制、查看与退出
|
||||
|
||||
| 模式 / 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| 只读模式 | `c` 开启远端控制;`q` 或 `Ctrl+C` 退出 CLI |
|
||||
| 控制模式 | 键鼠输入传给远端;`Ctrl+]` 进入本地命令模式并暂停远端输入 |
|
||||
| 本地命令模式 | 执行命令后持续保持;`c` 选择手动返回后是否启用控制,`q` 退出 CLI |
|
||||
| `Esc` / `Ctrl+C` | 在本地命令模式中手动返回所选的控制或只读模式 |
|
||||
| `+` / `=` / `-` | 在只读或本地命令模式中放大 / 缩小 |
|
||||
| `d` / `f` | 在只读或本地命令模式中切换原始细节 / 完整桌面;只读时也可点击目标进入细节 |
|
||||
| 方向键 / `i j k l` | 在只读或本地命令模式中平移;`i` 上、`j` 左、`k` 下、`l` 右 |
|
||||
|
||||
`Ctrl+]` 模式不会因为缩放、平移或按 `c` 自动结束。状态栏中的 `CONTROL PAUSED` / `READ ONLY` 表示返回后是否启用控制。控制模式中的普通 `c`、`q` 和 `Ctrl+C` 会发送到远端,需要先按 `Ctrl+]` 执行本地操作;再次按 `Ctrl+]` 也可返回,若返回控制模式则向远端发送一次该组合键。
|
||||
|
||||
### Pi 扩展与功能范围
|
||||
|
||||
CLI 附带 Pi 自定义 TUI 扩展,使用同样的输入模式。在仓库根目录启动,例如 macOS Apple Silicon 的 release 构建:
|
||||
|
||||
```sh
|
||||
export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli"
|
||||
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 文档入口](apps/cli/README.md) · [终端使用与故障排查](apps/cli/docs/terminal-client.md) · [桥接协议](apps/cli/docs/bridge-protocol.md) · [npm 打包与分发](apps/cli/docs/npm-distribution.md)
|
||||
|
||||
<a id="mobile"></a>
|
||||
|
||||
## 移动端与鸿蒙
|
||||
@@ -169,6 +224,7 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
|
||||
|
||||
## 构建与反馈
|
||||
|
||||
- [终端客户端 CLI](apps/cli/README.md):独立构建、终端使用、Pi 扩展、桥接协议和 npm 打包。
|
||||
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
|
||||
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
|
||||
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
|
||||
|
||||
+60
-4
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk logo with the icon on the left and a two-tone wordmark on the right" />
|
||||
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
|
||||
<p>A lightweight, cross-platform remote desktop for computers, browsers, mobile devices, and terminals, with DeskHub for viewing and controlling multiple desktops.</p>
|
||||
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
|
||||
</div>
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
CrossDesk connects your computers from desktop, browser, and mobile clients, with **DeskHub** for viewing and controlling multiple devices together. It is built on [MiniRTC](https://github.com/kunkundi/minirtc) and supports self-hosting.
|
||||
CrossDesk connects your computers from desktop, browser, mobile, and terminal clients, with **DeskHub** for viewing and controlling multiple devices together. It is built on [MiniRTC](https://github.com/kunkundi/minirtc) and supports self-hosting.
|
||||
|
||||
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Direct IP](#lan-direct) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
|
||||
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Terminal CLI](#cli) · [Direct IP](#lan-direct) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
@@ -29,6 +29,7 @@ CrossDesk connects your computers from desktop, browser, and mobile clients, wit
|
||||
|
||||
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
|
||||
- **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode.
|
||||
- **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI.
|
||||
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution.
|
||||
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
|
||||
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
|
||||
@@ -50,8 +51,9 @@ 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 |
|
||||
|
||||
Requirements follow the current source and build configuration. Check the release notes for the downloaded package. Client capabilities are summarized under [Mobile and HarmonyOS](#mobile).
|
||||
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).
|
||||
|
||||
- **Linux:** use `crossdesk-linux-…deb` for an existing desktop, or `crossdesk-virtual-desktop-xfce-…deb` for a server needing a separate Xfce desktop. Choose one. See [installation details](docs/USER_GUIDE_EN.md#installation) and [headless operation](docs/HEADLESS_EN.md).
|
||||
- **macOS:** grant Screen Recording and Accessibility when prompted on first launch. These enable desktop capture and remote keyboard/mouse input. See [permissions](docs/USER_GUIDE_EN.md#installation).
|
||||
@@ -122,6 +124,59 @@ Remote sessions support monitor switching, video quality and frame-rate controls
|
||||
- [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
|
||||
- [DeskHub technical guide](docs/deskhub.md) (Chinese): preview subscriptions, bandwidth allocation, protocol, and compatibility.
|
||||
|
||||
<a id="cli"></a>
|
||||
|
||||
## Terminal client CLI
|
||||
|
||||
`crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities.
|
||||
|
||||
### 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).
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
|
||||
cd crossdesk
|
||||
./apps/cli/scripts/prepare-deps.sh
|
||||
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
|
||||
xmake build crossdesk-cli
|
||||
xmake run crossdesk-cli doctor --json
|
||||
xmake run crossdesk-cli 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.
|
||||
|
||||
### Control, navigation, and exit
|
||||
|
||||
| Mode / key | Behavior |
|
||||
| --- | --- |
|
||||
| Read-only mode | `c` enables remote control; `q` or `Ctrl+C` exits the CLI |
|
||||
| Control mode | Keyboard/mouse input goes to the host; `Ctrl+]` enters local command mode and pauses remote input |
|
||||
| Local command mode | Stays active after commands; `c` selects whether control resumes when you leave; `q` exits the CLI |
|
||||
| `Esc` / `Ctrl+C` | Manually leave local command mode and return to the selected control or read-only mode |
|
||||
| `+` / `=` / `-` | Zoom in / out in read-only or local command mode |
|
||||
| `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.
|
||||
|
||||
### Pi extension and feature scope
|
||||
|
||||
The CLI includes a Pi custom TUI extension with the same input modes. Launch it from the repository root; for example, with a macOS Apple Silicon release build:
|
||||
|
||||
```sh
|
||||
export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli"
|
||||
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.
|
||||
|
||||
[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)
|
||||
|
||||
<a id="mobile"></a>
|
||||
|
||||
## Mobile and HarmonyOS
|
||||
@@ -169,6 +224,7 @@ Host your own signaling and TURN services, then enable them under **Settings →
|
||||
|
||||
## Build and feedback
|
||||
|
||||
- [Terminal client CLI](apps/cli/README.md) (Chinese): independent builds, terminal usage, Pi extension, bridge protocol, and npm packaging.
|
||||
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
|
||||
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
|
||||
- [Connection architecture](docs/connection-architecture.md) (Chinese): discovery, authentication, negotiation, data paths, and lifecycle for ID and direct IP modes.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# CrossDesk CLI
|
||||
|
||||
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty 图形后端,以及 Pi 自定义 TUI 扩展。
|
||||
|
||||
分发目标为 macOS / Linux(x64、arm64)和 Windows(x64)的原生终端客户端。npm 包发布后可统一使用:
|
||||
|
||||
```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:
|
||||
|
||||
```sh
|
||||
./apps/cli/scripts/prepare-deps.sh
|
||||
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
|
||||
xmake build crossdesk-cli
|
||||
xmake run crossdesk-cli doctor --json
|
||||
xmake run crossdesk-cli 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` 获得像素画面。
|
||||
|
||||
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
|
||||
- [私有桥接协议](docs/bridge-protocol.md)
|
||||
- [实际验证记录](docs/validation.md)
|
||||
- [源码来源和依赖修补](docs/source-provenance.md)
|
||||
|
||||
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
|
||||
|
||||
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。
|
||||
@@ -0,0 +1,39 @@
|
||||
# Bridge v1(CrossDesk 私有协议,不是 MCP)
|
||||
|
||||
启动:`crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099] [--quality low|balanced|high|ultra]`。
|
||||
通过参数数组启动,不经 shell。stdin/stdout 为 UTF-8 NDJSON,每行一个完整 JSON。stdout 使用保留的描述符,底层 stdout 重定向到 stderr。宿主应消费/隔离 stderr,不能展示为视频行或模型消息。bridge 不要求 TTY。
|
||||
|
||||
每条请求含整数 `id` 和字符串 `method`。响应含同一 `id`、布尔 `ok`,成功时还含 `version:1`、完整 `status`;失败含固定 `error`(无效请求、版本或操作不可用等),不回显原输入/凭据。无有效 id 的错误为 null。事件没有 id。先 hello,再发送其他操作。
|
||||
|
||||
| method | 字段 | 行为 |
|
||||
|---|---|---|
|
||||
| hello | version:1 | 协议版本握手 |
|
||||
| viewport | columns:1..240, rows:1..100, colors:8/24/256, utf8:boolean, fps:1..30;可选 cell_aspect:0.2..2, detail:quadrant/half, quality:low/balanced/high/ultra, zoom:1..32, native:boolean | 设置画面行数(不含状态栏)、字符宽高比、本地目标与预览档位;默认 cell_aspect=0.5、quadrant/high/1、native=false,保留未显式指定的 cell_aspect/detail/quality/zoom/native;不探测宿主 TTY |
|
||||
| connect | device:9位数字字符串, display:0..63(默认0) | 从专用 password fd 读取至换行/EOF(最多256字节、等待5秒),开始异步注册/连接;ok不是远端已连接 |
|
||||
| status | 无 | 返回当前状态 |
|
||||
| control | enabled:boolean | 显式启停控制;关闭时释放输入 |
|
||||
| input | data:string,最多16KiB | 终端原始按键/UTF-8/bracketed paste/SGR鼠标;只读时 c 开启控制、q/Ctrl+C 退出,d/鼠标点击进入细节,+/-/滚轮缩放,方向键或 i 上、j 左、k 下、l 右平移,f 全景;Ctrl+] 打开本地命令,随后 c 选择返回后的控制状态、q 退出或视图键连续调整画面,Esc/Ctrl+C 手动返回;控制模式中 Ctrl+T/Ctrl+C 传给远端,本地模式中再按 Ctrl+] 手动返回,并在返回控制模式时传递远端 Ctrl+] |
|
||||
| display | index:0..63 | 选择已广告的零起始显示器,提升 generation |
|
||||
| ack | frame_id、generation:非负整数 | 确认宿主消费帧;匹配当前等待帧才开放下一帧 |
|
||||
| disconnect | 无 | 释放输入、结束当前媒体会话 |
|
||||
| exit | 无 | 断开并结束进程 |
|
||||
|
||||
凭据不能放在 connect JSON 中。专用描述符可以由 Node `spawn` 的第四条 pipe 提供。成功读取后会话不会保留用户密码供上层访问;MiniRTC 内部为现有 Join/重连流程持有必要凭据直到销毁 Peer。下一次 connect 必须有可读的凭据来源;Pi 关闭视图后直接结束进程。
|
||||
|
||||
帧事件:
|
||||
|
||||
```json
|
||||
{"event":"frame","frame_id":42,"decoded_frame_id":38,"generation":4,"source_width":1280,"source_height":720,"zoom":2,"native":false,"source_rect":[320,180,640,360],"detail":"quadrant","columns":80,"rows":23,"cell_aspect":0.45,"lines":["..."],"queue_ms":3.2}
|
||||
```
|
||||
|
||||
实际 lines 数为 rows,仅包含本地生成的 SGR 颜色序列、空格和 U+2580~U+259F 块字符,不含远端名称或任意终端指令。`frame_id` 是进程递增的输出序号,静止画面重绘仍产生新序号;`decoded_frame_id` 标识源解码帧,同源重绘可以相同。`source_width/height` 为实际解码尺寸,source_rect 为实际源裁剪矩形 [x,y,width,height]。native=true 表示原始细节模式,zoom 为当前模式下的倍率;generation 在关闭、重新连接和显示器切换时变化。viewport 变化可能与已写出的旧尺寸帧交错,宿主应丢弃不匹配行列数/cell_aspect 或旧 generation 的帧并释放对应 ACK。宿主返回的 ACK 表示消费/布局,不表示屏幕物理显示完成。
|
||||
|
||||
`cell_aspect` 为单个字符的宽/高,必须是 0.2~2 范围内的有限 JSON 数值;例如 9×20 像素字符应传 0.45。宿主负责测量或提供手动设置,bridge 根据该比例计算图像布局与鼠标映射,响应及状态事件中的 `status.cell_aspect` 返回当前值。该字段为 v1 的可选扩展;省略时保留上次值,首次默认 0.5。旧版 bridge 的帧可能不含此字段。修改比例也会触发静止画面重绘,新的鼠标映射仍在帧 ACK 后生效。
|
||||
|
||||
状态事件每秒最多一次:`{"event":"state","status":{...},"output_bytes":123,"submitted_frames":3,"elapsed_seconds":1.5}`。status 含 registering/connecting/connected/failed/closed/idle、client_id(当前或刚结束会话的 `cli-xxxxxxxxx`,注册前为空)、error、control、generation、显示器、文本/预览能力、预览确认状态、请求的 preview_width/height/quality/bitrate_bps、帧计数与网络统计。接收/提交 FPS 由相应计数的增量除以时间增量计算。
|
||||
|
||||
状态和成功响应中的 `status.command_mode` 表示处于持续本地命令模式,此时 `control=false`,远端输入暂停;`status.resume_control` 表示手动返回后是否启用控制。执行命令、未知按键或粘贴不会退出此模式,失焦或输入溢出只会将返回模式设为只读。`Esc` / `Ctrl+C` 或再次 `Ctrl+]` 手动返回;`status.exit_requested` 表示用户按键请求退出,宿主可据此关闭组件。宿主应从 `input` 响应立即更新命令提示,不必等待下一次状态事件;不要自行截获 `Ctrl+]` 退出。显式 `control` 请求只更新所选控制状态,在本地模式中仍暂停远端输入。新建连接或断开会重置本地模式。具体快捷键见 [终端客户端](terminal-client.md)。
|
||||
|
||||
边界:输入每条及当前解析缓冲上限64KiB、嵌套32层;响应队列64条;输出一条在途消息上限2MiB;1MiB/s;最多一条待 ACK 帧;一个最新待渲染 NV12 加一个最近画面的重绘缓存。协议错误明确响应;超限缓冲后结束。EOF 清理会话,完整的已收请求按序处理,不完整末尾报错;最多两秒排空输出后退出。宿主应持续消费响应,不能无限发送而不读取。失去连接后的释放只能尽力发送。
|
||||
|
||||
没有 `screenshot`、`audio`、文件、剪贴板、任意远端动作或 shell 命令方法;未知方法报错。模型接入者必须另行取得截图/自动操作授权,不能把持续帧事件附加到聊天记录。
|
||||
@@ -0,0 +1,198 @@
|
||||
# npm 分发与平台兼容性
|
||||
|
||||
## 使用方式
|
||||
|
||||
完整版本发布后的统一入口如下。构建产物或部分平台包上传成功,不代表入口包和全部平台依赖已经可用;发布者须完成本文的注册表检查。
|
||||
|
||||
```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`,否则对应平台包不会安装。
|
||||
|
||||
入口包名为 `@chunkdash/crossdesk-cli`,`bin` 声明的终端命令仍为 `crossdesk-cli`。如已全局安装旧的无 scope 入口,先执行 `npm uninstall -g crossdesk-cli`,再安装 scoped 入口,避免两个包争用同一个命令。原来的 `crossdesk-cli@0.1.0` 是独立的旧包,改名不会更新它或自动迁移已有安装。
|
||||
|
||||
平台包仅提供原生二进制,不声明 `bin` 命令。`npm install @chunkdash/crossdesk-cli-linux-x64` 不会让 shell 出现 `crossdesk-cli`;普通用户应安装上面的通用入口。入口包的本地安装将命令放在 `node_modules/.bin`,全局安装才会放入 npm 的全局命令目录。
|
||||
|
||||
## 平台与终端
|
||||
|
||||
| 平台 | 原生包 | 终端适配与运行边界 |
|
||||
| --- | --- | --- |
|
||||
| macOS Apple Silicon | `@chunkdash/crossdesk-cli-darwin-arm64` | POSIX TTY;具体最低系统版本写入包内 `build-info.json` |
|
||||
| macOS Intel | `@chunkdash/crossdesk-cli-darwin-x64` | POSIX TTY;最低系统版本同上 |
|
||||
| Linux x64 | `@chunkdash/crossdesk-cli-linux-x64` | glibc、libstdc++、zlib;所需 GLIBC / GLIBCXX 版本写入 `build-info.json` |
|
||||
| Linux arm64 | `@chunkdash/crossdesk-cli-linux-arm64` | 同上 |
|
||||
| Windows x64 | `@chunkdash/crossdesk-cli-windows-x64` | Windows 10 或以上,原生 Console / ConPTY 与 VT 输出 |
|
||||
|
||||
`apps/cli/npm/platforms.json` 的 `packageName` 是打包脚本、入口包依赖和启动器共同使用的名称来源。Windows 的内部目标键仍为 `win32-x64`,包内 `os` 仍为 `win32`;只将 npm 包名写为 `windows-x64`,不能把 Node 平台标识也改成 `windows`。Scope 用于明确组织归属,不保证免于 npm 的名称或反垃圾审核。
|
||||
|
||||
这些是构建与分发目标,不代表均已实机通过。当前完整构建与 npm 运行验证只覆盖 macOS arm64;CLI 主程序与平台 I/O 已通过 Windows x64 与 Linux x64、arm64 的交叉编译检查。其他平台的完整 MiniRTC 链接、实际视频连接、键鼠、终端恢复仍需对应机器验收。
|
||||
|
||||
普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty 图形协议是可选增强。macOS Terminal、iTerm2、Linux 常用终端、Windows Terminal 和 VS Code 集成终端属于目标场景。SSH 应分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。不支持 `TERM=dumb` 或把交互观看输出重定向到文件;非交互宿主使用 `bridge --stdio`。Alpine / musl 当前没有预编译包。Windows 独立 mintty 窗口可能不提供 Console / ConPTY,应使用 Windows Terminal 承载 Git Bash。
|
||||
|
||||
CI 的 macOS 构建配置指定 `--target_minver=13.0`,但最终必须检查实际产物及依赖。当前开发机的本地二进制最低要求为 **macOS 27.0**,不应将这个本地产物宣称为旧 macOS 通用版。
|
||||
|
||||
## 生成包
|
||||
|
||||
先按 [构建说明](terminal-client.md) 构建 release。macOS 打包前需要签名,CI 使用 ad-hoc 签名;本地可执行以下命令(Intel 将 `arm64` 换成 `x86_64`):
|
||||
|
||||
```sh
|
||||
codesign --force --sign - build/macosx/arm64/release/crossdesk-cli
|
||||
```
|
||||
|
||||
然后生成 npm 包:
|
||||
|
||||
```sh
|
||||
npm --prefix apps/cli run pack:npm
|
||||
```
|
||||
|
||||
脚本读取 `apps/cli/package.json` 与 `apps/cli/xmake.lua` 的版本,检查二者一致;确认源码中的 MiniRTC 日志脱敏补丁已应用,再检查当前平台二进制格式、CPU、动态链接依赖,并运行离线 `doctor --json`。macOS 只接受系统动态库,Linux 只接受列明的基础系统运行库,Windows 拒绝未随产品处理的 DLL 和动态 MSVC 运行库。Windows 在含 `dumpbin.exe` 的 Visual Studio 开发者环境中运行打包。脚本预先检查两个输出文件是否存在,并在临时目录中完成两个包后再写入输出目录;写入失败时清理本次已写入的包,已有同名产物不会被覆盖。
|
||||
|
||||
默认从 `build/<platform>/<arch>/release/` 读取原生程序,将两个 `.tgz` 写入 `build/npm/`。可指定路径:
|
||||
|
||||
```sh
|
||||
npm --prefix apps/cli run pack:npm -- --binary /absolute/path/crossdesk-cli --out-dir /absolute/path/output
|
||||
```
|
||||
|
||||
每台构建机生成当前平台的原生包与相同版本的通用启动包。以 macOS arm64、版本 0.1.0 为例,文件名为 `chunkdash-crossdesk-cli-darwin-arm64-0.1.0.tgz` 和 `chunkdash-crossdesk-cli-0.1.0.tgz`;npm 将 `@scope/name` 的 tarball 文件名写作 `scope-name-version.tgz`。五个 CI artifact 各含一份入口包,最终需要五个平台包和一份入口包,共六个 tarball。
|
||||
|
||||
`apps/cli/npm/platforms.json` 集中定义五个平台;脚本校验平台包名合法且不重复,启动包通过精确版本的 `optionalDependencies` 让 npm 仅安装匹配系统和 CPU 的原生包。生成的六个包都包含作者、源码仓库、主页、问题反馈地址,以及面向 npm 官方注册表的公开发布配置。原生包包含二进制、运行要求、SHA-256,以及当前控制端和已应用修补的 MiniRTC 源码快照。外部 Xmake 依赖的许可证、对应源码和重链接材料需要随正式版本一并提供,参见 [源码来源](source-provenance.md)。源码快照已含 MiniRTC,解包后无需运行 `prepare-deps.sh`;在 `source/` 目录按上述构建说明显式关闭桌面、开启 CLI,再构建 `crossdesk-cli`。源码包仅包含 CLI 及其依赖,不包含桌面或移动应用。
|
||||
|
||||
`apps/cli/package.json` 的 `private: true` 表示源码目录不是发布产物。只发布打包脚本生成的 tarball;生成的包不带 private 标记。
|
||||
|
||||
## 本地安装验证
|
||||
|
||||
以 macOS arm64 为例,用生成包的绝对路径,在工程外的临时目录执行:
|
||||
|
||||
```sh
|
||||
npx --yes --offline \
|
||||
--package /absolute/path/chunkdash-crossdesk-cli-darwin-arm64-0.1.0.tgz \
|
||||
--package /absolute/path/chunkdash-crossdesk-cli-0.1.0.tgz \
|
||||
-- crossdesk-cli doctor --json
|
||||
```
|
||||
|
||||
也可先 `npm install --ignore-scripts` 安装这两个本地 tarball,再使用安装目录中的 `crossdesk-cli`。验证交互观看需要真实 TTY 与授权的被控端,doctor 不会建立远程连接。
|
||||
|
||||
Pi 等宿主应直接启动原生程序以可靠控制进程和专用密码描述符。全局安装后,用 `crossdesk-cli --print-binary-path` 获取路径,再将该路径设为 `CROSSDESK_CLI`;不要把整条 npx 命令放进此变量。
|
||||
|
||||
## CI 与发布
|
||||
|
||||
`.github/workflows/build-cli.yml` 配置五个平台的原生 release 构建、doctor 和打包,并上传 tarball。普通 push、PR 和手动构建只生成 artifact;`.github/workflows/release-cli.yml` 复用同一构建流程,再汇总并发布六个 npm 包。`cli-v*` 标签只进入 CLI 发布工作流,不重复触发独立 CLI 构建或桌面发布。更新 MiniRTC 引用时,必须先将对应提交推送到子模块远端,再推送 CrossDesk,否则全新检出和 CI 会在拉取子模块时失败。
|
||||
|
||||
### GitHub Actions 自动发布
|
||||
|
||||
| 操作 | 结果 |
|
||||
| --- | --- |
|
||||
| 推送普通提交 / 提交 PR | 构建五个平台、上传 artifact,不发布 npm |
|
||||
| Actions → Publish CLI to npm → Run workflow,保持 `dry_run` 勾选 | 完整构建并执行六个包的发布预演,不写入 npm |
|
||||
| 推送 `cli-v0.1.1` | 校验源码版本为 `0.1.1`,构建并发布到 `latest` |
|
||||
| 推送 `cli-v0.2.0-rc.1` | 校验源码版本为 `0.2.0-rc.1`,构建并发布到 `next` |
|
||||
| 在对应 `cli-v*` 标签上手动运行并取消 `dry_run` | 执行真实发布;分支上不允许关闭预演 |
|
||||
|
||||
准备下一版时同步修改 `apps/cli/package.json` 的 `version` 与 `apps/cli/xmake.lua` 的 `set_version`,提交并推送代码后创建同版本标签。例如源码已更新为 `0.1.1` 时:
|
||||
|
||||
```sh
|
||||
git tag cli-v0.1.1
|
||||
git push origin cli-v0.1.1
|
||||
```
|
||||
|
||||
已经上传过 `0.1.0` 的包不能被新构建覆盖;下一次原生程序发布应使用新版本。手动预演使用 `npm pack --dry-run` 加上内容校验,不调用 `npm publish` 或交换 OIDC 凭据;可对分支运行,也允许检查已发布的版本,但不验证 npm 发布权限。标签运行始终校验版本一致性。GitHub 的手动运行入口需要该工作流先进入默认分支。
|
||||
|
||||
发布 job 只下载当前运行的五个 `npm-*` artifact,并保留独立目录。`release-npm.cjs` 校验包名、版本、平台、入口依赖、原生程序 SHA-256 和 tarball SHA-512;五份入口包的文件内容必须一致(允许 Windows CRLF 差异),实际采用 Linux x64 构建生成的入口,保留 Unix 启动权限。真实上传前先检查所有包的注册表状态,同版本内容不同或目标 dist-tag 已指向更新版本就停止。
|
||||
|
||||
上传顺序为五个平台包 → 等待其精确版本、完整性及 dist-tag 可查询 → 入口包 → 最终确认。npm 返回异步处理时,每 15 秒查询一次,每批最多等待 15 分钟;不会因查询不到版本就重复上传。真实发布带 provenance,使用 OIDC 短期凭据,只有发布 job 获得 `id-token: write`,整个流程不需要 `NPM_TOKEN`。发布 job 串行执行,不中断正在上传的版本。
|
||||
|
||||
如果发布 job 失败,优先使用 GitHub 的 **Re-run failed jobs**,复用本次已成功构建的 artifact。已存在且完整性、目标 dist-tag 一致的包会跳过;同版本不同内容、仍为 `0.0.0-stage` 的占位包、403 或网络错误都会停止并给出原因。等待 npm 处理完成或修复权限后再重跑,不要直接重跑全部构建;重新编译可能产生不同字节。如果已有版本的 dist-tag 与本次目标不一致,需要先人工检查并调整标签。本工作流不自动修改已有版本的 dist-tag,也不提供 `next` 到 `latest` 的推广操作。
|
||||
|
||||
### npm 账号一次性绑定
|
||||
|
||||
在下面六个包各自的 npm **Settings → Trusted Publisher** 中选择 **GitHub Actions**,不是在 npm 账号菜单中授权 GitHub:
|
||||
|
||||
- `@chunkdash/crossdesk-cli`
|
||||
- `@chunkdash/crossdesk-cli-darwin-arm64`
|
||||
- `@chunkdash/crossdesk-cli-darwin-x64`
|
||||
- `@chunkdash/crossdesk-cli-linux-arm64`
|
||||
- `@chunkdash/crossdesk-cli-linux-x64`
|
||||
- `@chunkdash/crossdesk-cli-windows-x64`
|
||||
|
||||
六个包使用同一组配置:
|
||||
|
||||
| npm 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Organization or user | `kunkundi`(GitHub 仓库所有者,不是 npm scope) |
|
||||
| Repository | `crossdesk` |
|
||||
| Workflow filename | `release-cli.yml`(只填写文件名) |
|
||||
| Environment name | `npm` |
|
||||
| Allowed actions | 勾选 **Allow npm publish**;本流程不需要 **Allow npm dist-tag** |
|
||||
|
||||
工作流使用 GitHub-hosted runner、Node.js 24 和 npm 12.2.1。GitHub 仓库的 `npm` environment 名称必须与 npm 配置完全一致,可在仓库 Settings → Environments 中限定只有 `cli-v*` 标签能部署;如设置此限制,分支预演也会被该环境规则拦截,应选择对应标签预演。可按团队需要设置 required reviewers;这是 GitHub 的发布审批,与 npm 2FA 不同。
|
||||
|
||||
新建 Trusted Publisher 后应在 **2 天内完成首次真实发布**,否则配置过期,需要删除后重建;预演不算成功发布。因此建议先推送工作流并完成预演,再创建 npm 信任配置,随后发布一个新版本。设置时 npm 可能要求当前账号完成浏览器 / 2FA 认证,不能由 GitHub Secrets 替代。若 scoped 入口包还没创建,需要先按下文从本地发布一次,再配置它的 Trusted Publisher。该绑定只解决身份认证,不豁免 npm 的包名与反垃圾审核。
|
||||
|
||||
### 构建缓存
|
||||
|
||||
CLI 构建分别缓存 Xmake 工具和已安装的原生依赖。原生依赖使用 `XMAKE_PKG_INSTALLDIR` 指定的 runner 临时目录,不缓存工程的 `.xmake` 配置或 `build` 产物。缓存 key 包含 runner 类型、目标架构、镜像版本、Xmake 版本以及构建配置、依赖配方和补丁的内容;不包含运行编号,也不因 CLI C++ 源码、README 或 npm 元数据修改而失效。镜像更新可能包含编译器/SDK 更新,因此会重新生成缓存;依赖配置变化时只接受新的完整 key,不回退使用旧配方的库。
|
||||
|
||||
`Restore native dependencies` 会显示缓存是否命中。各平台的 `xmake f` 成功安装依赖后立即执行 `Save native dependencies`,后续 CLI 编译、签名或打包失败也能在下一次复用;依赖安装失败时不会保存不完整缓存。Linux 仅对镜像中缺失的系统工具执行 APT 更新和安装。首次运行、缓存被清理、镜像或依赖配置变化仍需下载/编译依赖;缓存命中不代表跳过 CLI 自身构建和打包。
|
||||
|
||||
### 本地首次发布与故障恢复
|
||||
|
||||
首次使用 `@chunkdash` 前,确认当前 npm 账号属于该组织且有目标包的发布权限。本地发布需要已启用的 2FA 或符合 npm 要求的发布凭据;组织 owner 身份不代替发布认证。
|
||||
|
||||
```sh
|
||||
npm whoami --registry=https://registry.npmjs.org/
|
||||
npm org ls chunkdash --json --registry=https://registry.npmjs.org/
|
||||
```
|
||||
|
||||
先重新构建五个平台的包;旧的 `crossdesk-cli-<平台>` tarball 不会自动变成 scoped 包,仅重命名文件也不会改变包内名称。已经发布的旧平台包保持原样,新入口只依赖 `@chunkdash` 包。每次版本发布同步更新 `apps/cli/package.json` 和 `apps/cli/xmake.lua`;若某个目标包的版本已经发布,不能用新的内容覆盖该版本。
|
||||
|
||||
如果五个 `@chunkdash/crossdesk-cli-*` 平台包的 `0.1.0` 已经发布,此次仅将入口从 `crossdesk-cli` 迁移到 `@chunkdash/crossdesk-cli`,可以沿用新入口的 `0.1.0` 并依赖原来的五个平台版本。确认注册表中各平台版本的完整性后,只发布新的 `chunkdash-crossdesk-cli-0.1.0.tgz`;不要重发已有平台版本。后续原生程序更新仍需整套提升版本并重新构建。
|
||||
|
||||
下载并解出同一提交、同一版本的六个 tarball,在其目录内执行发布。以下以 0.1.0 为例,先检查打包内容,再逐个发布五个平台包到 `next`:
|
||||
|
||||
```sh
|
||||
(
|
||||
set -e
|
||||
for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 windows-x64; do
|
||||
npm publish "./chunkdash-crossdesk-cli-${target}-0.1.0.tgz" \
|
||||
--dry-run --ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
|
||||
done
|
||||
npm publish ./chunkdash-crossdesk-cli-0.1.0.tgz \
|
||||
--dry-run --ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
|
||||
)
|
||||
```
|
||||
|
||||
`--dry-run` 只做本地发布预演,不能证明包名会被注册表接受。检查通过后发布平台包:
|
||||
|
||||
```sh
|
||||
(
|
||||
set -e
|
||||
for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 windows-x64; do
|
||||
npm publish "./chunkdash-crossdesk-cli-${target}-0.1.0.tgz" \
|
||||
--ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
|
||||
done
|
||||
)
|
||||
```
|
||||
|
||||
若 npm 返回正在处理(HTTP 202),等待对应的确切版本可查询,不要立即重复上传。遇到 `403 Package name triggered spam detection` 时停止,保留已成功的包,向 [npm 支持](https://www.npmjs.com/support)提供包名、仓库地址、版本及错误日志申请复核;不要自动重试整轮发布。恢复时先查询各包的确切版本,核对注册表的 `dist.integrity` 与 `npm pack <本地.tgz> --dry-run --json --ignore-scripts` 的 `integrity`,只补发未成功的包;同版本内容不同则停止并提升整套版本。
|
||||
|
||||
全部原生包可查询、完整性一致后,才发布通用入口:
|
||||
|
||||
```sh
|
||||
(
|
||||
set -e
|
||||
for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 windows-x64; do
|
||||
npm view "@chunkdash/crossdesk-cli-${target}@0.1.0" version dist.integrity \
|
||||
--registry=https://registry.npmjs.org/
|
||||
done
|
||||
npm publish ./chunkdash-crossdesk-cli-0.1.0.tgz \
|
||||
--ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
|
||||
)
|
||||
```
|
||||
|
||||
再确认 `@chunkdash/crossdesk-cli@0.1.0` 的依赖和完整性,并在五个平台用 `npx @chunkdash/crossdesk-cli@0.1.0 doctor --json` 做安装验证;交互连接仍需实机验收。验收后按需将六个包的 `latest` 指向该版本。不要只凭 `--tag next` 假定首次发布的包不会出现 `latest`,应查询实际 `dist-tags`。缺失平台包时启动器明确报错,不下载别的平台程序或回退为源码编译。
|
||||
|
||||
依据:[npm bin / optionalDependencies / os / cpu](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/)、[npm scopes](https://docs.npmjs.com/about-scopes/)、[npm publish](https://docs.npmjs.com/cli/v11/commands/npm-publish/)、[Trusted Publishing](https://docs.npmjs.com/trusted-publishers/)、[npx](https://docs.npmjs.com/cli/v11/commands/npx/)、[GitHub runner 平台](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)、[Windows Console 模式](https://learn.microsoft.com/en-us/windows/console/getconsolemode)。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 源码来源
|
||||
|
||||
CLI 从独立开发目录整合到 CrossDesk,入口、打包脚本、npm 启动器和 Pi 扩展位于 `apps/cli`,会话和终端渲染实现分别位于 `libs/controller`、`libs/terminal`。
|
||||
|
||||
- CrossDesk 仓库:`https://github.com/kunkundi/crossdesk.git`。独立 CLI 的协议代码原取自 `becd1c0437f6c4fad757052c70db1eba71cf312f`;整合时目标仓库基于 `2f57d0ab`,两边 wire 源文件一致,直接复用现有 `libs/wire`。
|
||||
- MiniRTC:`https://github.com/kunkundi/minirtc.git`,固定提交 `b9ee1fa7fe038fdf771f5dec1b1d561d78273f15`。该提交基于整合前的 `cdcd0d8fb0e9b688f3bf6416fe52b634cf20d9ca`,仅增加下述四处凭据日志脱敏修改。
|
||||
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
|
||||
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
|
||||
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
|
||||
|
||||
`deps/patches/minirtc-credential-logs.patch` 是 CLI 所需的 MiniRTC 修补:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 验证固定版本并幂等应用补丁,CLI 构建检查补丁已存在。共享子模块中的脱敏同样适用于桌面程序;补丁没有改变鉴权、TLS、TURN 或媒体协议。
|
||||
|
||||
npm 原生包携带 CLI、共享库、构建配置和已修补 MiniRTC 的源码快照,不复制桌面、移动端、测试或本机构建缓存。源码包保留仓库目录结构,在 `source/` 中配置 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 后构建。
|
||||
|
||||
LICENSE 沿用 CrossDesk GPL-3.0;MiniRTC 子模块保留其 LGPL-3.0 和第三方声明。外部 Xmake 依赖的许可证、对应源码及重链接材料仍需随正式版本提供。
|
||||
@@ -0,0 +1,159 @@
|
||||
# 终端客户端
|
||||
|
||||
## 构建与安装
|
||||
|
||||
要求 Git、Xmake >= 3.1.1、C++17 编译器、系统 zlib 头文件/链接库,以及 MiniRTC 包构建使用的系统工具。macOS SDK 已提供 zlib;Linux 需相应开发包(如 zlib1g-dev);Windows 使用 Visual Studio C++ 构建工具,zlib 由 Xmake 获取并静态链接,运行库使用 MT。依赖需要可访问 Xmake 包仓库。Linux 使用 GCC/Clang;macOS 使用 Xcode 命令行工具。无需安装 Slint、SDL、X11、PulseAudio 或桌面采集 SDK。
|
||||
|
||||
```sh
|
||||
./apps/cli/scripts/prepare-deps.sh
|
||||
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
|
||||
xmake build crossdesk-cli
|
||||
xmake run crossdesk-cli doctor --json
|
||||
```
|
||||
|
||||
`prepare-deps.sh` 固定 MiniRTC commit 并应用凭据日志脱敏补丁,可重复执行。构建会检查补丁,缺失时失败。不要直接升级子模块。仓库默认开启桌面构建、关闭 CLI。上述参数只构建 CLI;需要同时构建桌面和 CLI 时,将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。恢复默认桌面配置使用 `xmake f -y --CROSSDESK_BUILD_DESKTOP=y --CROSSDESK_BUILD_CLI=n`。所有命令均从仓库根目录执行。
|
||||
|
||||
构建产物在 `build/<platform>/<arch>/<mode>/crossdesk-cli`。可把对应目录加入 PATH 或复制可执行文件到自己的 bin 目录。Pi 需要直接启动该可执行文件,不能使用会输出构建日志的 `xmake run` 作为桥接程序。
|
||||
|
||||
macOS 的 MiniRTC 依赖仍链接 VideoToolbox/CoreMedia 和 GLib 声明的 AppKit 等系统框架;CLI 不创建 GUI、采集器或音频设备。硬件解码参数显式关闭。Linux 无需 DISPLAY/WAYLAND_DISPLAY;本轮尚未在 Linux 主机实际构建验证。
|
||||
|
||||
Windows 仅支持 x64,产物为 `build/windows/x64/release/crossdesk-cli.exe`;先在 Git Bash 中执行 `sh apps/cli/scripts/prepare-deps.sh`,然后在开发者 PowerShell 中执行上述 Xmake 命令,配置时额外指定 `-p windows -a x64`。在 Git Bash 中构建时同样必须指定 `-p windows`,避免被识别为 MSYS 平台。Windows 使用 Console / ConPTY 的原生键盘、鼠标事件和 VT 输出,保存并恢复控制台模式与代码页;无需 WSL。Windows Terminal、现代 PowerShell / CMD 和 VS Code 的 ConPTY 终端属于目标运行环境,仍需实机验收。Git Bash 的独立 mintty 窗口没有原生控制台时,应从 Windows Terminal 的 Git Bash 配置启动。
|
||||
|
||||
预编译 npm 包的用户无需构建工具,见 [npm 分发](npm-distribution.md)。
|
||||
|
||||
## 连接与身份
|
||||
|
||||
```sh
|
||||
crossdesk-cli 123456789
|
||||
crossdesk-cli 123456789 --renderer ansi --fps 8 --quality high --zoom native
|
||||
crossdesk-cli 123456789 --renderer kitty --quality ultra --zoom native --display 0
|
||||
crossdesk-cli 123456789 --control
|
||||
crossdesk-cli 123456789 --server your-server.example --port 9099
|
||||
```
|
||||
|
||||
直接将设备 ID 作为第一个参数即可连接;`crossdesk-cli view <设备ID> [选项]` 保留为兼容别名,支持相同选项。设备 ID 必须是 9 位数字,保留前导零,以上数字仅示例。默认信令地址沿用当前 CrossDesk:`api.crossdesk.cn:9099`。密码从本地终端隐藏输入(POSIX `/dev/tty`、Windows `CONIN$` / `CONOUT$`),不提供密码命令行参数。CLI 不读取、不修改桌面配置。每次连接直接用 `cli` 请求服务端分配临时 `cli-xxxxxxxxx` ID(后缀为 9 位数字),注册、Join 和媒体使用同一个 Peer。CLI 仅作为控制端,不申请普通数字身份或 `C-` 身份,也不保存本机密码或重连凭据。每次启动或重新连接都会申请新的 ID;断开后服务端按 Web 临时身份的规则清理注册记录。
|
||||
|
||||
需先部署支持 `cli` 注册的新版 crossdesk-server;旧服务端会登录失败,本客户端不会回退为普通设备身份。桥接 `status.client_id` 返回服务端分配的 ID。
|
||||
|
||||
流程为创建主控 Peer、注册流 → 以 `cli` 注册 → SignalConnected 返回 `cli-xxxxxxxxx` → Join → Connected → 现有 HostInformation 握手 → 显示器选择、能力协商 → 视频。Init 返回 0 不是远端连接成功。连接总超时默认 30 秒;信令断开后结束本次会话,用户重新连接,不自动循环重试密码。
|
||||
|
||||
交互界面区分 `Connecting to server`(连接信令)、`Connecting to device`(连接设备)、`Connected, waiting for first frame`(已连接,等待首帧)和 `Displaying`(正在显示)。首帧前显示居中的等待提示及秒数;连接阶段从发起连接计时,首帧等待阶段从连接成功重新计时。首帧完整写入终端后才切换到 `Displaying` 并清除等待提示。已连接但暂时没有画面不会因此判为连接失败,也不代表中继未启用。状态栏右侧为接收视频码率,收到网络统计后显示 `P2P` 或 `RELAY`,码率按 bps、kbps、Mbps 显示;初始码率为零或很低时仍继续等待视频。
|
||||
|
||||
保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。
|
||||
|
||||
## ANSI、Kitty 与 SSH
|
||||
|
||||
ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝交互连接,不输出全屏转义序列。
|
||||
|
||||
画面按字符单元比例等比缩放,留黑边及状态栏。交互连接通过终端字节流查询字符尺寸,支持通过 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 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。
|
||||
|
||||
快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 `<c>`、`<+/->` 等标签标识。宽窗口中的分组分隔线按列对齐,窄窗口保留标题和完整按键标签,按可用宽度合并或减少分组。
|
||||
|
||||
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 压缩无损,不代表上游视频编码无损。
|
||||
|
||||
```sh
|
||||
ssh -t your-server
|
||||
crossdesk-cli 123456789 --renderer ansi --fps 8
|
||||
```
|
||||
|
||||
用户本地终端负责显示 SSH 字节流。ANSI、Kitty over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。
|
||||
|
||||
## 预览与背压
|
||||
|
||||
`--fps 1..30` 是本地输出目标。对端声明 PreviewSubscription 时,映射到合法 1/8/15 FPS(15 需要对应能力);普通编码设置仍使用合法 30 FPS。分辨率上限使用协商到的 v2,视频预算只在 bandwidth 能力存在时发送。`--quality low|balanced|high|ultra` 对应如下请求:
|
||||
|
||||
| 档位 | 分辨率上限 | 质量枚举 | 视频预算 |
|
||||
|---|---|---|---|
|
||||
| low | 640×360 | 0 | 1Mbps |
|
||||
| balanced | 1280×720 | 1 | 2.5Mbps |
|
||||
| high(默认) | 1920×1080 | 2 | 6Mbps |
|
||||
| ultra | 3840×2160 | 2 | 12Mbps |
|
||||
|
||||
这些是请求上限,实际视频尺寸与码率由被控端决定,ultra 不会把低分辨率源变成真实 4K。低带宽链路可显式选择 balanced/low。窗口缩小不会再把远端预览降到字符网格尺寸;选定清晰度用于后续局部放大。旧端继续基础观看并本地限帧缩放,不发送未声明的扩展。预览 request_id/ack/accepted 可从桥接状态检查;未确认或被拒绝不冒充成功,也不阻止基础视频。
|
||||
|
||||
媒体回调仅同步复制借用的 NV12 数据,校验尺寸、容量、native 描述符与 stride;不保留借用指针。上限单帧 16,777,216 像素、每边 8192、偶数尺寸。最多一个待转换帧,替换前先完成校验与复制,无效帧不会挤掉已有画面;播放器另保留最近转换的一帧用于静止桌面的本地放大/重绘,并在接收新帧时回收上一帧缓冲区供后续复制使用。只丢弃解码后图像。显示器切换/关闭提升 generation,旧帧失效。
|
||||
|
||||
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),以 1MiB/s 令牌预算分块非阻塞写入;不能开始新画面时保留最新解码帧。ANSI 差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
|
||||
|
||||
统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;交互连接结束日志另给平均提交 FPS。`client.log` 摘要的 `startup_ms` 记录从发起连接到连接成功(`connected`)、首个取出用于显示的视频帧入队(`first_frame`)、首帧完整写入终端(`first_output`)的毫秒数;未到达的阶段为 `null`。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。
|
||||
|
||||
## 输入与退出
|
||||
|
||||
默认只读、静音。`--control` 或只读时按 `c` 明确启用输入。`Ctrl+]` 进入持续的本地命令模式:先按下并松开,再按下表中的命令键,可以连续执行多个命令。状态栏即时显示当前模式与可用按键。
|
||||
|
||||
macOS 自带 Terminal 还需要在当前窗口勾选“显示 → 允许鼠标报告”(View → Allow Mouse Reporting)。CLI 会启用鼠标报告,但无法覆盖 Terminal 关闭此选项的设置;未勾选时,点击、拖动和滚轮事件不会传给 CLI。参见 [Apple 官方说明](https://support.apple.com/zh-cn/guide/terminal/trmlc69728a5/mac)。
|
||||
|
||||
| 操作 | 只读模式 | 控制模式 |
|
||||
|---|---|---|
|
||||
| 开启/关闭远端控制 | `c` | `Ctrl+]`,再按 `c` |
|
||||
| 退出 CLI | `q` 或 `Ctrl+C` | `Ctrl+]`,再按 `q` |
|
||||
| 进入本地命令模式 | `?` 或 `Ctrl+]` | `Ctrl+]` |
|
||||
| 放大/缩小 | `+` / `=` / `-` | `Ctrl+]`,再按对应键 |
|
||||
| 原始细节/完整画面 | `d`(detail)/ `f`(fit),相邻字母键 | `Ctrl+]`,再按对应键 |
|
||||
| 平移 | 方向键或 `i` 上、`j` 左、`k` 下、`l` 右 | `Ctrl+]`,再按对应键 |
|
||||
|
||||
进入本地命令模式时暂停远端输入,并释放已记录按键/鼠标。执行缩放、平移、`c` 或未知命令后均保持此模式;`c` 切换手动返回后是否启用远端控制,状态栏显示 `CONTROL PAUSED` 或 `READ ONLY`。`Esc` 或 `Ctrl+C` 手动返回所选模式;`q` 退出 CLI。再次按 `Ctrl+]` 也会返回,并在所选模式为控制时向远端发送一次 `Ctrl+]`。命令模式中的鼠标事件被忽略,bracketed paste 内容被丢弃,均不会退出模式或触发快捷键。窗口失焦或输入缓冲区溢出时继续保持本地命令模式,但将返回模式设为只读。控制模式下普通 `c/q/?`、`Ctrl+T`、`Ctrl+C` 和单独 `Esc` 均传给远端。目前不支持重绑定。
|
||||
|
||||
只读时,`d` 或在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍),滚轮以鼠标指向为中心调整倍率。方向键或 `i/j/k/l` 分别向上/左/下/右平移,`f` 恢复完整画面。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;进入本地命令模式后可连续调整视图,直至手动返回。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。
|
||||
|
||||
支持 SGR 和传统 `CSI M` 鼠标报告,包括移动、左右中键、拖动和滚轮;完整缓冲跨读取分片的传统报告,坐标字节不会作为按键执行。启动时依次启用普通、拖动和全移动报告,兼容只支持部分跟踪模式的终端;退出时关闭这些模式。传统报告的坐标上限为 223,较大窗口应使用支持 SGR 的终端。协议格式依据 [XTerm 鼠标协议](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking)。
|
||||
|
||||
点击和滚轮先发送对应位置的移动事件,不依赖终端提前上报移动;黑边不发送新点击,黑边处松开仍释放旧按钮。传统报告无法区分松开的按钮,因此释放本客户端记录的全部已按下鼠标按钮。输入坐标映射到所选显示器,`--display` 是 HostInformation 列表的零起始索引,视频流为 Display1、Display2……
|
||||
|
||||
控制模式显示画面后,状态栏的 `Mouse RX`(窄窗口为 `M:`)显示累计收到的有效鼠标报告数,即使画面中看不清远端指针也能检查输入。若点击或移动时计数一直为 0,检查本地终端的鼠标报告设置及 SSH 是否分配 PTY(`ssh -t`);若计数增长但远端仍无响应,退出后查看以下日志统计。窗口失焦会退回只读,重新聚焦后需再按 `c` 开启控制。
|
||||
|
||||
支持 UTF-8 文本与 bracketed paste(远端须声明 text_input),Enter、Tab、Backspace、Esc、方向、Home/End、Delete、PageUp/Down、F1–F4、Ctrl/Alt+英文字母。不支持的远端文本能力会提示,不把粘贴执行为本地 shell 命令。普通终端采用立即按下/释放的 tap 策略;不承诺持续按键、所有组合键、IME 或完整键盘布局。Pi 使用宿主 SDK 解码增强按键后转为这组基础输入。
|
||||
|
||||
失焦报告、关闭控制、切换显示器、退出和断开都尝试释放已记录输入;网络已中断时不能保证远端收到释放。正常退出在普通执行路径恢复终端模式、鼠标报告、光标、自动换行和备用屏幕。POSIX 同样处理 SIGINT/SIGTERM/SIGHUP;Windows 处理控制台 Ctrl+C / Ctrl+Break,恢复控制台模式与代码页。中止时最多等待两秒排空当前画面,随后发送取消/终止转义序列并恢复;输出连接彻底损坏或 SIGKILL 时无法保证恢复,可在 shell 执行 `reset`。
|
||||
|
||||
只读是本客户端行为限制,不是服务端强制的独立只读授权。没有文件/剪贴板/远端命令接收处理器。
|
||||
|
||||
## Pi 扩展
|
||||
|
||||
依据已下载的 `@earendil-works/pi-coding-agent` 与 `pi-tui` **1.4.0** SDK 实现和类型检查。旧的 `@mariozechner` 包未验证,缺少 ctx.mode 的宿主会降级。
|
||||
|
||||
```sh
|
||||
export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli"
|
||||
pi -e ./apps/cli/plugins/pi/index.ts
|
||||
```
|
||||
|
||||
在 Pi 输入 `/crossdesk` 或 `/crossdesk 123456789`。先输入 ID,再在自定义遮蔽组件输入密码。扩展使用独立 fd 3 写密码,随后关闭;不写配置、聊天、工具结果或模型上下文。查看组件与独立 CLI 使用相同的 `Ctrl+]` 命令前缀和上述模式按键,由 bridge 统一解析。插件默认请求 high 预览并显示 ANSI 象限字符行,支持同样的只读细节/放大/平移键,颜色深度依据终端环境回退,由 Pi 自身布局和刷新;不直接占用 TTY,不发送 Kitty 图形或自行打开鼠标报告。
|
||||
|
||||
Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监听接口查询字符尺寸或完整文本区尺寸,每两秒及窗口变化时刷新;尺寸回复不会传给远端。得到的宽高比通过 bridge 的 `viewport.cell_aspect` 传入,比例变化时重绘静止画面,并丢弃仍在途的旧比例帧。自动检测不可用或不准确时,可在启动 Pi 前设置环境变量 `CROSSDESK_CELL_ASPECT=0.45`(宽/高,允许 0.2~2;`auto` 恢复自动检测)。视图关闭或 bridge 停止时移除查询定时器和输入监听。
|
||||
|
||||
需要全局发现时,把整个 `apps/cli/plugins/pi` 目录复制到 `~/.pi/agent/extensions/crossdesk/` 后重启 Pi 或执行 `/reload`。卸载时先关闭视图,删除该目录并 `/reload`;使用 `-e` 时从启动参数移除即可。无需修改包中的密码或设备配置。默认连接官方信令;自建信令当前通过独立 CLI 连接命令使用,插件尚无服务器设置界面。
|
||||
|
||||
仅在 `ctx.mode === "tui"`、hasUI 且进程双向 TTY 时打开组件。RPC/print/json 返回独立播放器提示,不声称内嵌视频。UI 关闭/会话退出先关闭 bridge stdin,超时 SIGTERM,再超时 SIGKILL。bridge 尚未就绪或已经停止时,组件可直接用 `q`、`Esc` 或 `Ctrl+C` 关闭。SDK 类型和模拟 UI 生命周期已验证,真实 Pi TUI 视觉刷新、增强按键与鼠标传递未实机验收。真实设备的 bridge 与独立 CLI 连接验证见 [验证记录](validation.md)。
|
||||
|
||||
官方依据:[扩展文档](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)、[TUI 文档](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/tui.md)、[Kitty 图形协议](https://sw.kovidgoyal.net/kitty/graphics-protocol/)。
|
||||
|
||||
## 故障排查与边界
|
||||
|
||||
- `incorrect_password`:重新启动并输入正确密码,不自动重复尝试。
|
||||
- `device_not_found` / `device_offline_or_unavailable`:核对 ID、服务端和被控端在线状态。
|
||||
- `signaling_login_failed` / `signaling_disconnected`:检查服务器、网络、代理、防火墙;确认服务端已支持 `cli` 注册。
|
||||
- `cli_identity_not_supported`:服务端未返回有效的 `cli-` 加 9 位数字的身份,需升级或检查服务端。
|
||||
- `tls_certificate_error`:检查证书链、主机名、机器时钟和系统 CA,不关闭验证。
|
||||
- `connection_timeout` / `connection_failed`:检查 NAT/TURN 与被控端。TURN 凭据只取服务端下发值。
|
||||
- `display_not_available`:换成远端声明的索引;单会话一次只显示一个屏幕。
|
||||
- `media_initialization_failed`:检查编译依赖和本地媒体运行环境。
|
||||
- 只读下按键不会发送到远端;远端未声明文本协议时文本输入不可用。
|
||||
- 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。交互连接的摘要在 client.log,包含收到的终端鼠标报告数 `mouse_reports`、其中的传统报告数 `mouse_legacy_reports`、成功交给传输层的鼠标事件数 `mouse_forwarded`、发送失败数 `mouse_send_failures`。一次点击报告可能生成定位和按键两条事件,所以发送数可能大于报告数;发送成功不表示被控端已执行。transport.log 只记录白名单中的 ICE/DTLS 状态及本地控制开关,不记录密码、SDP 或原始信令。使用完可删除。错误退出信息在恢复终端后显示,并给出本次日志目录。
|
||||
- `connection_failed` 表示连接建立失败;`connection_lost` 表示连接成功后传输失效。xmake 随后的 `execv ... failed(1)` 表示程序以错误码 1 退出,本身不代表进程崩溃。遇到控制失效时,对照 transport.log 中的控制开关和连接状态;`mouse_reports` 为 0 表示终端没有向 CLI 提供可识别的鼠标事件。报告数增长但 `mouse_forwarded` 为 0 时,检查是否仍为只读/本地命令模式、是否已有画面,以及点击是否落在视频区域;`mouse_send_failures` 增长时检查连接状态。
|
||||
- 如果整个 macOS Terminal 窗口退出且 CLI 记录 `output closed`,还应检查系统 DiagnosticReports 中的 Terminal 报告。已观察到 Terminal 2.15 在创建标签页并调整窗口尺寸时发生 `TTLogicalScreen expandUnicharOffsetCacheToLine:` 行缓存越界;这与 CLI 的 `connection_failed` 不同。临时可在独立窗口运行,避开该标签页操作,不能据此认为 Terminal 缺陷已修复。
|
||||
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
|
||||
- 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。
|
||||
|
||||
第一轮无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker、IP 直连、MCP 或自动截图。其他宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端或给出播放器命令。
|
||||
|
||||
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
|
||||
@@ -0,0 +1,80 @@
|
||||
# CLI 验证记录
|
||||
|
||||
## 本地仓库整合(2026-10-30)
|
||||
|
||||
环境为 macOS arm64、Xcode SDK 27.0、Xmake 3.1.1、C++17,关闭 CUDA。CLI 源码整合到 CrossDesk 的 `apps/cli`,共享现有 wire 与 MiniRTC;未新增或修改工程内测试。
|
||||
|
||||
- CLI 单独配置和 release 构建通过,目标为 `crossdesk-cli`;`doctor --json` 返回有效 JSON,动态链接依赖仅为系统库及框架,无 Slint / SDL。
|
||||
- 同时启用桌面与 CLI 时,debug 配置下 `crossdesk`、`crossdesk-cli` 均编译通过。
|
||||
- 原有仓库结构、wire 和键盘状态测试通过,未修改测试源码。
|
||||
- 工程外桥接检查通过:JSON 协议、版本校验、分片、EOF、消息上限、慢消费者、本地信令失败、凭据描述符和非 TTY 拒绝。
|
||||
- 工程外 POSIX PTY 检查通过:隐藏密码、无 DISPLAY/WAYLAND_DISPLAY、原始模式恢复、Kitty 探测超时和 tmux 下明确拒绝 Kitty。
|
||||
- macOS arm64 原生 npm 包和通用启动包生成成功;离线安装、启动器 `doctor` 和原生程序路径解析通过。该本地产物最低要求为 macOS 27.0。
|
||||
- 从原生包解出源码后,在没有 Git 元数据、没有桌面应用源码的目录中重新配置、构建 CLI 并运行 `doctor` 成功。
|
||||
- 验证后恢复整合前的桌面 debug 构建配置,CLI 默认关闭;新命令行二进制保留在 `build/macosx/arm64/release/crossdesk-cli`。
|
||||
|
||||
## 优化回归(2026-10-30)
|
||||
|
||||
- Release 构建、实际二进制 bridge/PTY 回归通过。ASan/UBSan 检查覆盖原有会话和渲染行为,以及无效帧保留、NV12 缓冲复用、预览参数去重、ACK 状态、重复断开和重连网络统计复位。
|
||||
- 生产 bridge 配合工程外假传输验证静止画面重绘、细节模式、质量切换、窗口变化、无效配置原子性和帧 ACK 背压,通过。
|
||||
- ANSI 渲染前后对比覆盖 8/24/256 色、UTF-8/ASCII、half/quadrant、全景/细节模式,输出校验值一致。在本机 240×100 网格连续执行 200 次差分生成,静止网格约 447.4 ms → 3.6 ms,单行变化约 422.8 ms → 9.5 ms。该结果只衡量差分文本生成,不包含视频解码、图像缩放和终端显示时间。
|
||||
- npm 真实打包、离线安装及源码包重建通过;工程外故障注入确认已有产物保留、第二个包生成失败时无半套输出、缺失脱敏补丁时拒绝打包。
|
||||
|
||||
新增回归代码、基准、假传输和验证产物均位于工程外临时目录,未向工程或子模块添加测试。此次完整构建与运行验证覆盖 macOS arm64,未重新进行 Linux/Windows 实机验证。
|
||||
|
||||
## 控制按键调整(2026-10-30)
|
||||
|
||||
- 当前桌面与 CLI 同时启用的 debug 配置下,`xmake build crossdesk-cli`、`xmake run crossdesk-cli doctor --json` 和命令帮助检查通过。
|
||||
- 工程外 ASan/UBSan 检查覆盖本地命令前缀、只读快捷键、远端 Ctrl+T/Ctrl+C/Ctrl+]、鼠标释放、取消命令、粘贴隔离、UTF-8/CSI 分片、失焦与退出;原有会话/渲染检查通过。
|
||||
- 平移字母键采用 `i` 上、`j` 左、`k` 下、`l` 右;工程外 ASan/UBSan 检查确认四个方向与普通/应用模式方向键一致,控制模式中普通字母仍传给远端。
|
||||
- 细节/全景采用相邻的 `d`/`f`;工程外 ASan/UBSan 检查确认细节保留中心、全景复位缩放与中心、前缀命令和鼠标点击行为正确,控制模式中普通字母仍传给远端。
|
||||
- 实际二进制 bridge 验证命令状态、错误恢复和退出响应;生产 view 配合假传输的 PTY 检查验证快捷键、模式提示、隐藏密码和终端恢复。实际二进制的 bridge 与 PTY 回归同样通过。
|
||||
- Pi 1.4.0 SDK 严格类型检查和模拟组件运行通过,覆盖控制按键转发、命令提示、取消、缩放、退出与 bridge 启动失败后的关闭操作。
|
||||
|
||||
此次没有连接真实远端设备,也没有进行 Linux/Windows 或真实 Pi TUI 实机验证。新增测试代码与产物均保存在工程外系统临时目录。
|
||||
|
||||
## 直接 ID 入口与 npm 源码包(2026-10-30)
|
||||
|
||||
- 直接输入 9 位设备 ID 和 `view` 兼容别名均通过参数检查,保留前导零,支持相同的连接选项;无效 ID 在密码提示前拒绝。macOS arm64 终端中的密码提示与 Ctrl+C 取消通过。
|
||||
- 本地 npm 包离线安装、启动器帮助和 `doctor --json` 通过;源码包包含完整的 `libs/controller/include`,补齐诊断头文件后,从安装包内源码独立配置、release 构建及 `doctor` 通过。
|
||||
- 原有仓库结构、wire 和键盘状态测试通过;未新增或修改工程内测试,额外验证和产物均位于工程外临时目录。
|
||||
- Build and Release #899 和 CLI native builds and npm packages #1 的首次运行均因 MiniRTC 提交 `b9ee1fa7` 尚未推送而在检出阶段失败。将该提交推送到 MiniRTC 远端后,全新递归浅克隆通过。
|
||||
|
||||
## 独立开发阶段的验证范围
|
||||
|
||||
以下为迁入前的验证记录摘要,不表示本次重新连接了远程设备:
|
||||
|
||||
- macOS arm64 的 debug/release 构建、CLI bridge 的 NDJSON、POSIX PTY 隐藏密码与终端恢复均已验证。
|
||||
- 工程外的 ASan/UBSan 检查覆盖会话状态、只读限制、输入释放、帧内存所有权、渲染、缩放和平移、输出背压和帧 ACK。
|
||||
- Pi 1.4.0 SDK 类型检查与模拟组件生命周期已验证;真实 Pi TUI 视觉效果未验收。
|
||||
- 早期身份协议版本曾连接真实设备进行只读 ANSI 观看和 Kitty 协议数据验证;Kitty 数据解压检查不等于 Kitty 应用或 SSH 实机显示验收。
|
||||
- 改为 `cli-` 临时身份后,正式 CLI 与本地 WSS 服务端验证了临时注册、Join、身份不可复用及退出清理;线上服务尚未在该轮部署,早期画面验证不能代替新身份协议的线上联调。
|
||||
- npm 的 macOS arm64 本地生成及安装已验证;Linux / Windows 当时仅完成 CLI 主程序和平台 I/O 的交叉编译检查,没有完整 MiniRTC 链接和实机连接结果。
|
||||
|
||||
## 终端鼠标兼容修复(2026-10-30)
|
||||
|
||||
- 工程外假传输复现:旧解析器把传统 `CSI M` 报告的三个负载字节作为键盘文本发送,`mouse_reports` 仍为 0;SGR 点击只发送按下/松开,没有提前定位。
|
||||
- 修复后的 ASan/UBSan 检查通过,覆盖 SGR/传统报告的全部二段分片与逐字节输入、左右中键、拖动、滚轮、点击前定位、高位坐标字节、黑边松开、只读/本地命令模式、失焦释放及发送失败。既有持续本地命令模式回归同样通过。
|
||||
- macOS arm64 的 CLI debug 构建和 `doctor --json` 通过。生产 CLI 配合工程外假传输的 POSIX PTY 检查在 120/50 列窗口通过,验证逐字节鼠标输入、`Mouse RX`/`M:` 状态栏计数、日志统计、鼠标报告模式启停和原始终端模式恢复。
|
||||
- 所有额外测试代码、脚本和产物均位于工程外临时目录。上述结果未覆盖真实 SSH 链路、GNOME Terminal 或 Linux 被控端实机,不能据此确认用户现场的问题已消失。
|
||||
|
||||
## 字符宽高比自动检测(2026-10-30)
|
||||
|
||||
- macOS arm64 CLI debug 构建通过。工程外 ASan/UBSan 检查覆盖字符/文本区尺寸报告、回复顺序与优先级、跨查询部分回复隔离、逐字节输入、本地命令/粘贴隔离、非法尺寸,以及 ANSI half/quadrant/ASCII 的画面比例与鼠标映射。
|
||||
- 以 9×20 像素字符、120×40 字符视口和 1920×1080 源图模拟非 0.5 字符比例,旧默认值产生约 8%~9.3% 的宽高比误差;应用实际字符尺寸后为 1.25%,剩余为该网格的整数采样舍入。
|
||||
- 生产 CLI 配合工程外假传输的 POSIX PTY 检查通过,覆盖 GNOME 风格的文本区像素/行列回复、直接字符尺寸、字体变化、窗口缩放、无回复/部分回复/非法回复、TTY 回退、手动覆盖、auto 和 Kitty。字体变化时没有新视频帧也能重绘,退出后终端模式恢复。
|
||||
- 新增测试及产物均在工程外临时目录;尚未在真实 Linux GNOME Terminal 或 SSH 链路进行视觉验收。
|
||||
|
||||
## Windows 与 Pi/bridge 字符比例补全(2026-10-30)
|
||||
|
||||
- macOS arm64 CLI debug 构建通过;Windows x64 的主程序、平台 I/O、输入解析和尺寸解析通过交叉编译,平台 I/O 独立链接通过(含 user32/advapi32),不代表 Windows MiniRTC 完整构建或实机运行。
|
||||
- 工程外 Win32 API 模拟检查直接使用生产尺寸读取函数,覆盖可见控制台字体尺寸、隐藏 ConPTY 回退、失败/非法字体尺寸与句柄释放,ASan/UBSan 通过。既有尺寸解析 ASan/UBSan 和 POSIX PTY 回归再次通过。
|
||||
- 生产 bridge 配合工程外假传输验证可选比例字段保留、有限数值及范围检查、无效请求原子性、实际布局比例、静止画面重绘与帧比例元数据。
|
||||
- Pi 1.4.0 SDK 严格类型检查及模拟宿主运行通过:使用真实 SDK 分片组包,覆盖文本区成对回复、字符尺寸优先级、宿主缓存、字体/窗口变化、手动覆盖、旧比例帧丢弃及 ACK、控制键回归、启动失败/异常退出后的监听与定时器清理。
|
||||
- 新增验证代码和产物全部位于工程外系统临时目录;尚未在真实 Windows、GNOME/SSH 或 Pi TUI 进行视觉验收。
|
||||
|
||||
## 实测边界
|
||||
|
||||
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。
|
||||
|
||||
CLI 需要支持 `cli` 临时注册的服务端。`doctor --json` 仅报告本机环境,不会联网登录或证明远端可连接。
|
||||
@@ -0,0 +1,935 @@
|
||||
#include "terminal_io.h"
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <clocale>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <terminal_renderer.h>
|
||||
|
||||
using namespace crossdesk;
|
||||
using namespace crossdesk::terminal;
|
||||
using namespace crossdesk::cli;
|
||||
using nlohmann::json;
|
||||
using Clock = std::chrono::steady_clock;
|
||||
namespace {
|
||||
std::string Env(const char *name) {
|
||||
const auto *p = std::getenv(name);
|
||||
return p ? p : "";
|
||||
}
|
||||
struct Descriptor {
|
||||
int value;
|
||||
explicit Descriptor(int v) : value(v) {}
|
||||
~Descriptor() {
|
||||
if (value >= 0)
|
||||
Close(value);
|
||||
}
|
||||
};
|
||||
class Output {
|
||||
public:
|
||||
explicit Output(int fd)
|
||||
: fd_(fd), nonblocking_(fd, true), last_(Clock::now()) {}
|
||||
bool Idle() const { return pending_.empty(); }
|
||||
void Put(std::string text) {
|
||||
if (!Idle() || text.size() > 2 * 1024 * 1024)
|
||||
throw std::runtime_error("output budget exceeded");
|
||||
pending_ = std::move(text);
|
||||
offset_ = 0;
|
||||
}
|
||||
bool Pump() {
|
||||
auto now = Clock::now();
|
||||
credit_ = std::min(
|
||||
1048576.0,
|
||||
credit_ + std::chrono::duration<double>(now - last_).count() * 1048576);
|
||||
last_ = now;
|
||||
if (Idle())
|
||||
return false;
|
||||
auto size =
|
||||
std::min({pending_.size() - offset_, size_t(16384), size_t(credit_)});
|
||||
if (!size)
|
||||
return false;
|
||||
auto n = Write(fd_, pending_.data() + offset_, size);
|
||||
if (n < 0 && errno != EAGAIN && errno != EINTR)
|
||||
throw std::runtime_error("output closed");
|
||||
if (n > 0) {
|
||||
offset_ += n;
|
||||
credit_ -= n;
|
||||
bytes += n;
|
||||
}
|
||||
if (offset_ == pending_.size()) {
|
||||
pending_.clear();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void Wait(int input_fd, int timeout_ms = 5) const {
|
||||
// Wake for writable output instead of adding 5ms after each short PTY
|
||||
// write. When rate-limited, wait for input/time to replenish the budget.
|
||||
WaitIo(input_fd, !Idle() && credit_ >= 1 ? fd_ : -1, timeout_ms);
|
||||
}
|
||||
uint64_t bytes = 0;
|
||||
|
||||
private:
|
||||
int fd_;
|
||||
NonBlocking nonblocking_;
|
||||
std::string pending_;
|
||||
size_t offset_ = 0;
|
||||
double credit_ = 1048576;
|
||||
Clock::time_point last_;
|
||||
};
|
||||
struct Tty {
|
||||
int out;
|
||||
RawTerminal terminal;
|
||||
Tty(int input, int output) : out(output), terminal(input, output) {
|
||||
// Fall back to button/drag reporting if all-motion tracking is unsupported.
|
||||
const char start[] = "\033[?1049h\033[?25l\033[?7l\033[?1000h\033[?1002h"
|
||||
"\033[?1003h\033[?1006h\033[?1004h\033[?2004h\033[2J";
|
||||
Write(out, start, sizeof(start) - 1);
|
||||
}
|
||||
~Tty() {
|
||||
// Cancel any incomplete string/control sequence before restoration.
|
||||
std::string end = "\030\033\\" + std::string(kKittyDelete) +
|
||||
"\033[0m\033[?1003l\033[?1002l\033[?1000l"
|
||||
"\033[?1006l\033[?1004l\033[?2004l\033["
|
||||
"?7h\033[?25h\033[?1049l";
|
||||
Write(out, end.data(), end.size());
|
||||
}
|
||||
};
|
||||
Viewport Size(int *status_rows = nullptr,
|
||||
const TerminalMetrics *metrics = nullptr, double cell_aspect = 0) {
|
||||
auto ws = GetTerminalSize(0);
|
||||
const int reserved_rows = std::clamp(int(ws.rows) - 1, 0, 3);
|
||||
if (status_rows)
|
||||
*status_rows = reserved_rows;
|
||||
Viewport v;
|
||||
v.columns = std::clamp(int(ws.columns) - 1, 1, 240);
|
||||
v.rows = std::clamp(int(ws.rows) - reserved_rows, 1, 100);
|
||||
v.row_offset = reserved_rows > 0 ? 1 : 0;
|
||||
if (ws.columns && ws.rows) {
|
||||
v.pixel_width = int(ws.pixel_width) * v.columns / ws.columns;
|
||||
v.pixel_height = int(ws.pixel_height) * v.rows / ws.rows;
|
||||
}
|
||||
if (ws.pixel_width && ws.pixel_height && ws.columns && ws.rows)
|
||||
v.cell_aspect = std::clamp(double(ws.pixel_width) * ws.rows /
|
||||
(double(ws.pixel_height) * ws.columns),
|
||||
.2, 2.0);
|
||||
// A visible Windows console's font metrics are authoritative. ConPTY has
|
||||
// no local font geometry, so use the display terminal's replies there.
|
||||
#ifdef _WIN32
|
||||
if (metrics && (!ws.pixel_width || !ws.pixel_height))
|
||||
#else
|
||||
if (metrics)
|
||||
#endif
|
||||
metrics->Apply(v);
|
||||
if (cell_aspect > 0)
|
||||
v.cell_aspect = cell_aspect;
|
||||
auto locale = Env("LC_ALL");
|
||||
if (locale.empty())
|
||||
locale = Env("LC_CTYPE");
|
||||
if (locale.empty())
|
||||
locale = Env("LANG");
|
||||
v.utf8 = locale.find("UTF-8") != std::string::npos ||
|
||||
locale.find("utf8") != std::string::npos ||
|
||||
locale.find("utf-8") != std::string::npos;
|
||||
auto color = Env("COLORTERM");
|
||||
v.colors = color == "truecolor" || color == "24bit" ? 24
|
||||
: Env("TERM").find("256color") != std::string::npos ? 256
|
||||
: 8;
|
||||
#ifdef _WIN32
|
||||
v.utf8 = true;
|
||||
v.colors = 24;
|
||||
#endif
|
||||
return v;
|
||||
}
|
||||
std::string SecretFd(int fd) {
|
||||
if (fd < 3 || fd > 1024)
|
||||
throw std::runtime_error("password_fd must be a dedicated descriptor >= 3");
|
||||
std::string secret;
|
||||
auto deadline = Clock::now() + std::chrono::seconds(5);
|
||||
while (Clock::now() < deadline && !interrupted) {
|
||||
if (!WaitReadable(fd, 20))
|
||||
continue;
|
||||
char c;
|
||||
auto n = Read(fd, &c, 1);
|
||||
if (n == 0)
|
||||
return secret;
|
||||
if (n < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
throw std::runtime_error("credential read failed");
|
||||
}
|
||||
if (c == '\n')
|
||||
return secret;
|
||||
if (c == '\0' || secret.size() >= 256)
|
||||
throw std::runtime_error("invalid credentials");
|
||||
secret += c;
|
||||
}
|
||||
throw std::runtime_error("credential channel timeout");
|
||||
}
|
||||
int Integer(const json &j, const char *key, int lo, int hi, int fallback) {
|
||||
if (!j.contains(key))
|
||||
return fallback;
|
||||
if (!j[key].is_number_integer())
|
||||
throw std::invalid_argument(std::string(key) + " must be an integer");
|
||||
auto n = j[key].get<int64_t>();
|
||||
if (n < lo || n > hi)
|
||||
throw std::invalid_argument(std::string(key) + " out of range");
|
||||
return int(n);
|
||||
}
|
||||
bool Boolean(const json &j, const char *key, bool fallback) {
|
||||
if (!j.contains(key))
|
||||
return fallback;
|
||||
if (!j[key].is_boolean())
|
||||
throw std::invalid_argument(std::string(key) + " must be boolean");
|
||||
return j[key];
|
||||
}
|
||||
double Real(const json &j, const char *key, double lo, double hi,
|
||||
double fallback) {
|
||||
if (!j.contains(key))
|
||||
return fallback;
|
||||
if (!j[key].is_number())
|
||||
throw std::invalid_argument(std::string(key) + " must be a number");
|
||||
const double value = j[key].get<double>();
|
||||
if (!std::isfinite(value) || value < lo || value > hi)
|
||||
throw std::invalid_argument(std::string(key) + " out of range");
|
||||
return value;
|
||||
}
|
||||
void Quality(SessionOptions &options, const std::string &name) {
|
||||
if (name == "low") {
|
||||
options.quality = 0;
|
||||
options.preview_width = 640;
|
||||
options.preview_height = 360;
|
||||
options.max_bitrate_bps = 1000000;
|
||||
} else if (name == "balanced") {
|
||||
options.quality = 1;
|
||||
options.preview_width = 1280;
|
||||
options.preview_height = 720;
|
||||
options.max_bitrate_bps = 2500000;
|
||||
} else if (name == "high") {
|
||||
options.quality = 2;
|
||||
options.preview_width = 1920;
|
||||
options.preview_height = 1080;
|
||||
options.max_bitrate_bps = 6000000;
|
||||
} else if (name == "ultra") {
|
||||
options.quality = 2;
|
||||
options.preview_width = 3840;
|
||||
options.preview_height = 2160;
|
||||
options.max_bitrate_bps = 12000000;
|
||||
} else
|
||||
throw std::invalid_argument("quality must be low, balanced, high or ultra");
|
||||
}
|
||||
AnsiDetail Detail(const std::string &name) {
|
||||
if (name == "half")
|
||||
return AnsiDetail::Half;
|
||||
if (name == "quadrant")
|
||||
return AnsiDetail::Quadrant;
|
||||
throw std::invalid_argument("ansi-detail must be half or quadrant");
|
||||
}
|
||||
std::string WaitingScreen(const Viewport &v, const std::string &title,
|
||||
const std::string &message) {
|
||||
const std::string lines[] = {title, message, "Ctrl+] then q to quit"};
|
||||
const int count = std::min(v.rows, 3);
|
||||
const int top = v.row_offset + (v.rows - count) / 2 + 1;
|
||||
std::string out;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const auto text = lines[i].substr(0, v.columns);
|
||||
const auto column = (v.columns - text.size()) / 2 + 1;
|
||||
out += "\033[" + std::to_string(top + i) + ";1H\033[0m\033[K\033[" +
|
||||
std::to_string(column) + "G" + text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
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) {
|
||||
if (!IsTty(0) || !IsTty(fd) || Env("TERM") == "dumb")
|
||||
throw std::runtime_error("connecting needs an interactive ANSI terminal; use "
|
||||
"bridge --stdio for a host");
|
||||
auto password = Password();
|
||||
if (interrupted)
|
||||
return 130;
|
||||
Tty tty(0, fd);
|
||||
Output output(fd);
|
||||
bool kitty = false;
|
||||
if (renderer != "ansi" && Env("TMUX").empty() && Env("STY").empty()) {
|
||||
output.Put(std::string(kKittyQuery) + "\033[c");
|
||||
std::string reply;
|
||||
auto until = Clock::now() + std::chrono::milliseconds(250);
|
||||
while (Clock::now() < until && !interrupted) {
|
||||
output.Pump();
|
||||
WaitReadable(0, 10);
|
||||
char b[1024];
|
||||
auto n = Read(0, b, sizeof(b));
|
||||
if (n > 0 && reply.size() < 8192)
|
||||
reply.append(b, n);
|
||||
}
|
||||
kitty = KittyReply(reply);
|
||||
}
|
||||
if (renderer == "kitty" && !kitty)
|
||||
throw std::runtime_error(
|
||||
"Kitty probe failed or multiplexer active; use --renderer ansi");
|
||||
ControllerSession s;
|
||||
Input input;
|
||||
input.control = control;
|
||||
input.zoom = zoom;
|
||||
input.native = native;
|
||||
const auto started = Clock::now();
|
||||
s.Connect(options, device, std::move(password));
|
||||
s.SetControl(control);
|
||||
std::optional<Grid> displayed, pending;
|
||||
bool pending_frame = false;
|
||||
int last_status_rows = 3;
|
||||
Viewport last_view =
|
||||
Size(&last_status_rows, &input.terminal_metrics, cell_aspect);
|
||||
std::optional<DecodedFrame> last_frame;
|
||||
uint64_t view_revision = input.view_revision;
|
||||
bool redraw = true;
|
||||
uint64_t submitted = 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 pump_output = [&] {
|
||||
if (output.Pump() && pending_frame) {
|
||||
displayed = std::move(pending);
|
||||
pending.reset();
|
||||
pending_frame = false;
|
||||
++submitted;
|
||||
if (!first_output_at) {
|
||||
first_output_at = Clock::now();
|
||||
status_tick = *first_output_at;
|
||||
}
|
||||
}
|
||||
};
|
||||
while (!interrupted && !input.exit) {
|
||||
s.Tick();
|
||||
if (s.Connected() && !connected_at) {
|
||||
connected_at = Clock::now();
|
||||
status_tick = *connected_at;
|
||||
}
|
||||
char bytes[8192];
|
||||
auto n = Read(0, bytes, sizeof(bytes));
|
||||
const bool previous_command = input.command_mode;
|
||||
const bool previous_control = input.control;
|
||||
const auto previous_error = input.error;
|
||||
input.Feed(n > 0 ? std::string(bytes, n) : "", s,
|
||||
displayed ? &*displayed : nullptr);
|
||||
if (previous_command != input.command_mode ||
|
||||
previous_control != input.control || previous_error != input.error)
|
||||
status_tick = Clock::now();
|
||||
pump_output();
|
||||
if (s.Finished())
|
||||
break;
|
||||
auto now = Clock::now();
|
||||
int status_rows;
|
||||
auto v = Size(&status_rows, &input.terminal_metrics, cell_aspect);
|
||||
v.detail = detail;
|
||||
v.native = input.native;
|
||||
v.pixel_mode = kitty;
|
||||
v.zoom = input.zoom;
|
||||
v.center_x = input.center_x;
|
||||
v.center_y = input.center_y;
|
||||
if (input.view_revision != view_revision) {
|
||||
view_revision = input.view_revision;
|
||||
redraw = true;
|
||||
}
|
||||
const bool grid_resized =
|
||||
v.columns != last_view.columns || v.rows != last_view.rows;
|
||||
if (output.Idle() &&
|
||||
(grid_resized || v.cell_aspect != last_view.cell_aspect ||
|
||||
v.pixel_width != last_view.pixel_width ||
|
||||
v.pixel_height != last_view.pixel_height ||
|
||||
status_rows != last_status_rows)) {
|
||||
output.Put("\033[2J");
|
||||
displayed.reset();
|
||||
last_view = v;
|
||||
last_status_rows = status_rows;
|
||||
redraw = true;
|
||||
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) {
|
||||
input.terminal_metrics.BeginQuery();
|
||||
output.Put(kTerminalSizeQuery);
|
||||
size_query_tick = now + std::chrono::seconds(2);
|
||||
}
|
||||
if (output.Idle() && now >= status_tick) {
|
||||
auto st = s.Status();
|
||||
const auto last_state = st["state"].get<std::string>();
|
||||
std::string progress =
|
||||
first_output_at ? "Displaying"
|
||||
: s.Connected() ? "Connected, waiting for first frame"
|
||||
: last_state == "registering" ? "Connecting to server"
|
||||
: "Connecting to device";
|
||||
const auto elapsed =
|
||||
std::to_string(std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - connected_at.value_or(started))
|
||||
.count()) +
|
||||
"s";
|
||||
if (!first_output_at)
|
||||
progress += " (" + elapsed + ")";
|
||||
StatusBar bar;
|
||||
bar.progress = progress;
|
||||
bar.compact_progress = first_output_at ? "LIVE"
|
||||
: s.Connected() ? "WAIT " + elapsed
|
||||
: "CONNECT " + elapsed;
|
||||
if (first_output_at && input.control && !input.command_mode) {
|
||||
const auto reports = std::to_string(input.mouse_reports);
|
||||
bar.progress += " | Mouse RX " + reports;
|
||||
bar.compact_progress += " M:" + reports;
|
||||
}
|
||||
bar.error = input.error;
|
||||
bar.control = input.control;
|
||||
bar.command_mode = input.command_mode;
|
||||
bar.view = std::string(kitty ? "KITTY"
|
||||
: detail == AnsiDetail::Quadrant ? "QUAD"
|
||||
: "HALF") +
|
||||
(input.native ? " | Detail x" : " | Fit x") +
|
||||
std::to_string(int(input.zoom)) +
|
||||
(last_frame ? " | " + std::to_string(last_frame->width) + "x" +
|
||||
std::to_string(last_frame->height)
|
||||
: "");
|
||||
const auto bps = st["network"]["video_bps"].get<uint64_t>();
|
||||
const auto traversal = st["network"]["traversal"].get<int>();
|
||||
bar.network =
|
||||
std::string(traversal == P2P ? "P2P "
|
||||
: traversal == Relay ? "RELAY "
|
||||
: "Video ") +
|
||||
(bps < 1000 ? std::to_string(bps) + " bps"
|
||||
: bps < 1000000 ? std::to_string(bps / 1000) + " kbps"
|
||||
: std::to_string(bps / 1000000) + "." +
|
||||
std::to_string(bps / 100000 % 10) + " Mbps");
|
||||
std::string screen;
|
||||
if (!last_frame && !first_output_at) {
|
||||
screen = WaitingScreen(
|
||||
v, progress,
|
||||
s.Connected() ? "The connection is established; waiting for video."
|
||||
: "Establishing a secure connection...");
|
||||
waiting_visible = true;
|
||||
}
|
||||
const auto lines = StatusLines(bar, v.columns);
|
||||
for (int row = 0; row < status_rows; ++row)
|
||||
screen += "\033[" +
|
||||
std::to_string(row == 0 ? 1 : v.row_offset + v.rows + row) +
|
||||
";1H\033[0m\033[K" + (row == 0 ? "\033[7m" : "") +
|
||||
lines[row] + "\033[0m";
|
||||
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);
|
||||
bool fresh = false;
|
||||
if (auto frame = s.TakeFrame(last_frame ? &*last_frame : nullptr);
|
||||
frame && frame->generation == s.Generation()) {
|
||||
last_frame = std::move(frame);
|
||||
fresh = true;
|
||||
if (!first_frame_at)
|
||||
first_frame_at = last_frame->queued;
|
||||
}
|
||||
if (last_frame && last_frame->generation != s.Generation())
|
||||
last_frame.reset();
|
||||
if (last_frame && (fresh || redraw)) {
|
||||
if (fresh)
|
||||
queue_ms = std::chrono::duration<double, std::milli>(
|
||||
now - last_frame->queued)
|
||||
.count();
|
||||
pending = kitty ? Layout(*last_frame, v) : Convert(*last_frame, v);
|
||||
// Clear waiting text and Kitty margins without erasing the status bars.
|
||||
std::string clear;
|
||||
if (waiting_visible)
|
||||
for (int row = 0; row < v.rows; ++row)
|
||||
clear += "\033[" + std::to_string(v.row_offset + row + 1) +
|
||||
";1H\033[0m\033[2K";
|
||||
output.Put(clear + (kitty ? Kitty(*last_frame, v)
|
||||
: Difference(*pending, displayed)));
|
||||
waiting_visible = false;
|
||||
redraw = false;
|
||||
pending_frame = true;
|
||||
if (output.Idle()) {
|
||||
displayed = std::move(pending);
|
||||
pending.reset();
|
||||
pending_frame = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
output.Wait(0);
|
||||
}
|
||||
auto final = s.Status();
|
||||
s.Disconnect();
|
||||
auto until = Clock::now() + std::chrono::seconds(2);
|
||||
while (!output.Idle() && Clock::now() < until) {
|
||||
pump_output();
|
||||
output.Wait(-1);
|
||||
}
|
||||
// These diagnostics go to the private file, never through the active screen.
|
||||
const auto elapsed_ms =
|
||||
[&](const std::optional<Clock::time_point> &time) -> json {
|
||||
return time
|
||||
? json(std::chrono::duration<double, std::milli>(*time - started)
|
||||
.count())
|
||||
: json(nullptr);
|
||||
};
|
||||
std::cerr
|
||||
<< json{{"session", final},
|
||||
{"startup_ms",
|
||||
{{"connected", elapsed_ms(connected_at)},
|
||||
{"first_frame", elapsed_ms(first_frame_at)},
|
||||
{"first_output", elapsed_ms(first_output_at)}}},
|
||||
{"mouse_reports", input.mouse_reports},
|
||||
{"mouse_legacy_reports", input.mouse_legacy_reports},
|
||||
{"mouse_forwarded", input.mouse_forwarded},
|
||||
{"mouse_send_failures", input.mouse_send_failures},
|
||||
{"terminal_geometry",
|
||||
{{"cell_aspect", last_view.cell_aspect},
|
||||
{"pixel_width", last_view.pixel_width},
|
||||
{"pixel_height", last_view.pixel_height},
|
||||
{"reported", input.terminal_metrics.Known()},
|
||||
{"manual_aspect", cell_aspect > 0}}},
|
||||
{"submitted", submitted},
|
||||
{"output_bytes", output.bytes},
|
||||
{"last_queue_ms", queue_ms},
|
||||
{"elapsed_seconds",
|
||||
std::chrono::duration<double>(Clock::now() - started).count()},
|
||||
{"submitted_fps",
|
||||
submitted / std::max(.001, std::chrono::duration<double>(
|
||||
Clock::now() - started)
|
||||
.count())}}
|
||||
.dump()
|
||||
<< "\n";
|
||||
if (final["state"] == "failed")
|
||||
throw std::runtime_error(final["error"].get<std::string>());
|
||||
return interrupted ? 130 : 0;
|
||||
}
|
||||
int Bridge(int fd, SessionOptions options, int password_fd) {
|
||||
Output output(fd);
|
||||
ControllerSession s;
|
||||
Input input;
|
||||
Viewport viewport;
|
||||
viewport.detail = AnsiDetail::Quadrant;
|
||||
auto status = [&] {
|
||||
auto value = s.Status();
|
||||
value["command_mode"] = input.command_mode;
|
||||
value["resume_control"] = input.control;
|
||||
value["exit_requested"] = input.exit;
|
||||
value["cell_aspect"] = viewport.cell_aspect;
|
||||
return value;
|
||||
};
|
||||
NonBlocking input_mode(0);
|
||||
std::deque<json> replies;
|
||||
std::string buffer;
|
||||
bool hello = false, quit = false, eof = false, writing_frame = false;
|
||||
uint64_t submitted_frames = 0;
|
||||
const auto started = Clock::now();
|
||||
uint64_t awaiting = 0;
|
||||
std::optional<Grid> current, pending_grid;
|
||||
std::optional<DecodedFrame> last_frame;
|
||||
uint64_t frame_sequence = 0, view_revision = 0;
|
||||
bool redraw = true;
|
||||
auto next = Clock::now(), status_tick = next;
|
||||
auto respond = [&](json j) {
|
||||
if (replies.size() >= 64)
|
||||
throw std::runtime_error("bridge response queue exceeded");
|
||||
replies.push_back(std::move(j));
|
||||
};
|
||||
auto quit_deadline = Clock::time_point::max();
|
||||
while (!interrupted) {
|
||||
s.Tick();
|
||||
input.Feed("", s, current ? &*current : nullptr);
|
||||
if (output.Pump() && writing_frame) {
|
||||
++submitted_frames;
|
||||
writing_frame = false;
|
||||
}
|
||||
if (!quit) {
|
||||
char bytes[8192];
|
||||
auto n = Read(0, bytes, sizeof(bytes));
|
||||
if (n > 0) {
|
||||
buffer.append(bytes, n);
|
||||
if (buffer.size() > 65536) {
|
||||
respond(
|
||||
{{"id", nullptr}, {"ok", false}, {"error", "message_too_large"}});
|
||||
quit = true;
|
||||
}
|
||||
} else if (n == 0) {
|
||||
eof = true;
|
||||
} else if (errno != EAGAIN && errno != EINTR) {
|
||||
quit = true;
|
||||
}
|
||||
for (int count = 0; !quit && count < 16; ++count) {
|
||||
auto end = buffer.find('\n');
|
||||
if (end == std::string::npos)
|
||||
break;
|
||||
auto raw = buffer.substr(0, end);
|
||||
buffer.erase(0, end + 1);
|
||||
auto j = ParseBoundedJson(raw);
|
||||
json id = nullptr;
|
||||
try {
|
||||
if (!j.is_object())
|
||||
throw std::invalid_argument("invalid_json");
|
||||
if (!j.contains("id") || !j["id"].is_number_integer())
|
||||
throw std::invalid_argument("integer_request_id_required");
|
||||
id = j["id"];
|
||||
std::string method = j.at("method").get<std::string>();
|
||||
if (method == "hello") {
|
||||
if (Integer(j, "version", 1, 1, 0) != 1)
|
||||
throw std::invalid_argument("unsupported_version");
|
||||
hello = true;
|
||||
} else if (!hello)
|
||||
throw std::invalid_argument("hello_required");
|
||||
else if (method == "viewport") {
|
||||
auto next_view = viewport;
|
||||
auto next_options = options;
|
||||
next_view.columns = Integer(j, "columns", 1, 240, 80);
|
||||
next_view.rows = Integer(j, "rows", 1, 100, 23);
|
||||
next_view.colors = Integer(j, "colors", 8, 256, 24);
|
||||
if (next_view.colors != 8 && next_view.colors != 24 &&
|
||||
next_view.colors != 256)
|
||||
throw std::invalid_argument("unsupported_colors");
|
||||
next_view.utf8 = Boolean(j, "utf8", true);
|
||||
next_view.cell_aspect =
|
||||
Real(j, "cell_aspect", .2, 2, viewport.cell_aspect);
|
||||
if (j.contains("detail"))
|
||||
next_view.detail = Detail(j.at("detail").get<std::string>());
|
||||
next_options.fps = Integer(j, "fps", 1, 30, 8);
|
||||
if (j.contains("quality"))
|
||||
Quality(next_options, j.at("quality").get<std::string>());
|
||||
int next_zoom = Integer(j, "zoom", 1, 32, int(input.zoom));
|
||||
bool next_native = Boolean(j, "native", input.native);
|
||||
s.SetPreview(next_options.fps, next_options.preview_width,
|
||||
next_options.preview_height, next_options.quality,
|
||||
next_options.max_bitrate_bps);
|
||||
viewport = next_view;
|
||||
options = next_options;
|
||||
input.zoom = next_zoom;
|
||||
input.native = next_native;
|
||||
redraw = true;
|
||||
current.reset();
|
||||
awaiting = 0;
|
||||
} else if (method == "connect") {
|
||||
if (s.Connected() ||
|
||||
(!s.Finished() && s.Status()["state"] != "idle"))
|
||||
throw std::invalid_argument("session_active");
|
||||
options.display = Integer(j, "display", 0, 63, 0);
|
||||
auto password = SecretFd(password_fd);
|
||||
s.Connect(options, j.at("device").get<std::string>(),
|
||||
std::move(password));
|
||||
input.control = false;
|
||||
input.command_mode = false;
|
||||
input.center_x = input.center_y = .5;
|
||||
last_frame.reset();
|
||||
redraw = true;
|
||||
current.reset();
|
||||
awaiting = 0;
|
||||
} else if (method == "control") {
|
||||
input.control = Boolean(j, "enabled", false);
|
||||
s.SetControl(input.control && !input.command_mode);
|
||||
} else if (method == "input") {
|
||||
auto text = j.at("data").get<std::string>();
|
||||
if (text.size() > 16384)
|
||||
throw std::invalid_argument("input_too_large");
|
||||
input.Feed(text, s, current ? &*current : nullptr);
|
||||
if (input.exit)
|
||||
quit = true;
|
||||
if (!input.error.empty())
|
||||
throw std::invalid_argument(input.error);
|
||||
} else if (method == "ack") {
|
||||
if (!j.at("frame_id").is_number_unsigned() ||
|
||||
!j.at("generation").is_number_unsigned())
|
||||
throw std::invalid_argument("invalid_frame_ack");
|
||||
if (j["generation"] == s.Generation() &&
|
||||
j["frame_id"] == awaiting) {
|
||||
current = std::move(pending_grid);
|
||||
pending_grid.reset();
|
||||
awaiting = 0;
|
||||
}
|
||||
} else if (method == "display") {
|
||||
if (!s.SelectDisplay(Integer(j, "index", 0, 63, 0)))
|
||||
throw std::invalid_argument("display_unavailable");
|
||||
current.reset();
|
||||
awaiting = 0;
|
||||
} else if (method == "disconnect") {
|
||||
s.Disconnect();
|
||||
input.control = false;
|
||||
input.command_mode = false;
|
||||
last_frame.reset();
|
||||
current.reset();
|
||||
awaiting = 0;
|
||||
} else if (method == "exit") {
|
||||
quit = true;
|
||||
} else if (method != "status")
|
||||
throw std::invalid_argument("unsupported_method");
|
||||
respond(
|
||||
{{"id", id}, {"ok", true}, {"version", 1}, {"status", status()}});
|
||||
} catch (const std::exception &) {
|
||||
respond({{"id", id},
|
||||
{"ok", false},
|
||||
{"error", "invalid_request_or_unavailable_operation"},
|
||||
{"status", status()}});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (eof && buffer.find('\n') == std::string::npos) {
|
||||
if (!buffer.empty()) {
|
||||
respond(
|
||||
{{"id", nullptr}, {"ok", false}, {"error", "incomplete_message"}});
|
||||
buffer.clear();
|
||||
}
|
||||
quit = true;
|
||||
}
|
||||
auto now = Clock::now();
|
||||
if (quit && quit_deadline == Clock::time_point::max()) {
|
||||
s.Disconnect();
|
||||
quit_deadline = now + std::chrono::seconds(2);
|
||||
}
|
||||
if (output.Idle()) {
|
||||
if (!replies.empty()) {
|
||||
output.Put(replies.front().dump() + "\n");
|
||||
replies.pop_front();
|
||||
} else if (quit)
|
||||
break;
|
||||
else if (hello && now >= status_tick) {
|
||||
output.Put(json{{"event", "state"},
|
||||
{"status", status()},
|
||||
{"output_bytes", output.bytes},
|
||||
{"submitted_frames", submitted_frames},
|
||||
{"elapsed_seconds",
|
||||
std::chrono::duration<double>(now - started).count()}}
|
||||
.dump() +
|
||||
"\n");
|
||||
status_tick = now + std::chrono::seconds(1);
|
||||
} else if (hello && !awaiting && now >= next) {
|
||||
next = now + std::chrono::milliseconds(1000 / options.fps);
|
||||
bool fresh = false;
|
||||
if (auto f = s.TakeFrame(last_frame ? &*last_frame : nullptr);
|
||||
f && f->generation == s.Generation()) {
|
||||
last_frame = std::move(f);
|
||||
fresh = true;
|
||||
}
|
||||
if (last_frame && last_frame->generation != s.Generation())
|
||||
last_frame.reset();
|
||||
if (input.view_revision != view_revision) {
|
||||
view_revision = input.view_revision;
|
||||
redraw = true;
|
||||
}
|
||||
if (last_frame && (fresh || redraw)) {
|
||||
viewport.zoom = input.zoom;
|
||||
viewport.native = input.native;
|
||||
viewport.center_x = input.center_x;
|
||||
viewport.center_y = input.center_y;
|
||||
pending_grid = Convert(*last_frame, viewport);
|
||||
redraw = false;
|
||||
writing_frame = true;
|
||||
awaiting = ++frame_sequence;
|
||||
output.Put(
|
||||
json{{"event", "frame"},
|
||||
{"frame_id", awaiting},
|
||||
{"decoded_frame_id", last_frame->frame_id},
|
||||
{"generation", last_frame->generation},
|
||||
{"columns", viewport.columns},
|
||||
{"rows", viewport.rows},
|
||||
{"cell_aspect", viewport.cell_aspect},
|
||||
{"source_width", last_frame->width},
|
||||
{"source_height", last_frame->height},
|
||||
{"zoom", viewport.zoom},
|
||||
{"native", viewport.native},
|
||||
{"source_rect",
|
||||
{pending_grid->source.x, pending_grid->source.y,
|
||||
pending_grid->source.width, pending_grid->source.height}},
|
||||
{"detail", viewport.detail == AnsiDetail::Quadrant
|
||||
? "quadrant"
|
||||
: "half"},
|
||||
{"lines", Lines(*pending_grid)},
|
||||
{"queue_ms", fresh
|
||||
? std::chrono::duration<double, std::milli>(
|
||||
now - last_frame->queued)
|
||||
.count()
|
||||
: 0}}
|
||||
.dump() +
|
||||
"\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (now >= quit_deadline)
|
||||
break;
|
||||
output.Wait(eof ? -1 : 0);
|
||||
}
|
||||
s.Disconnect();
|
||||
return interrupted ? 130 : 0;
|
||||
}
|
||||
const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty] [--fps 1..30]
|
||||
[--display 0..63] [--control] [--server host] [--port 9099]
|
||||
[--quality low|balanced|high|ultra] [--ansi-detail quadrant|half] [--zoom 1..32|native]
|
||||
[--cell-aspect auto|0.2..2]
|
||||
crossdesk-cli view <9-digit-device-id> [options] (compatibility alias)
|
||||
crossdesk-cli doctor [--json]
|
||||
crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099]
|
||||
|
||||
Connect by device ID: read-only and silent by default; hidden password prompt on the local terminal.
|
||||
Read-only: c enables control; q or Ctrl+C quits; ? shows local commands.
|
||||
Ctrl+] enters persistent local mode: c selects control on return; q quits; Esc/Ctrl+C returns.
|
||||
View keys: click/d native detail, wheel/+/- zoom, arrows/ijkl pan (i up, j left, k down, l right), f fit.
|
||||
Local mode stays active after commands, paste or focus loss; remote input remains paused.
|
||||
Ctrl+T and Ctrl+C pass through in control mode; another Ctrl+] leaves local mode and forwards Ctrl+] if control is enabled.
|
||||
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.
|
||||
No IP direct connection, audio playback, clipboard or file transfer support.
|
||||
)";
|
||||
} // namespace
|
||||
int main(int argc, char **argv) {
|
||||
try {
|
||||
std::setlocale(LC_ALL, "");
|
||||
InitializeIo();
|
||||
if (argc < 2 || std::string(argv[1]) == "--help") {
|
||||
std::cout << help;
|
||||
return 0;
|
||||
}
|
||||
std::string command = argv[1];
|
||||
if (command == "doctor") {
|
||||
if (argc > 3 || (argc == 3 && std::string(argv[2]) != "--json"))
|
||||
throw std::invalid_argument("doctor accepts only --json");
|
||||
auto v = Size();
|
||||
json d = {{"version", 1},
|
||||
{"platform", Platform()},
|
||||
{"stdin_tty", IsTty(0)},
|
||||
{"stdout_tty", IsTty(1)},
|
||||
{"term", SafeText(Env("TERM"))},
|
||||
{"terminal_program", SafeText(Env("TERM_PROGRAM"))},
|
||||
{"input_shortcuts", "local_commands_v2"},
|
||||
{"transport_diagnostics", true},
|
||||
{"utf8", v.utf8},
|
||||
{"colors", v.colors},
|
||||
{"cell_aspect", v.cell_aspect},
|
||||
{"pixel_width", v.pixel_width},
|
||||
{"pixel_height", v.pixel_height},
|
||||
{"multiplexer", !Env("TMUX").empty() || !Env("STY").empty()},
|
||||
{"software_decode", true},
|
||||
{"kitty", "probe_on_view"},
|
||||
{"audio_playback", false},
|
||||
{"bridge_version", 1}};
|
||||
std::cout << d.dump(argc == 3 ? -1 : 2) << "\n";
|
||||
return 0;
|
||||
}
|
||||
int i = 2;
|
||||
if (command != "view" && command != "bridge") {
|
||||
if (command.find_first_not_of("0123456789") != std::string::npos)
|
||||
throw std::invalid_argument(
|
||||
"expected a 9-digit device ID or a command; use --help");
|
||||
command = "view";
|
||||
i = 1;
|
||||
}
|
||||
SessionOptions options;
|
||||
std::string renderer = "auto", device;
|
||||
AnsiDetail detail = AnsiDetail::Quadrant;
|
||||
double zoom = 1, cell_aspect = 0;
|
||||
bool control = false, stdio = false, native = false;
|
||||
int secret_fd = 3;
|
||||
if (command == "view") {
|
||||
if (i >= argc)
|
||||
throw std::invalid_argument("device ID required");
|
||||
device = argv[i++];
|
||||
if (device.size() != 9 ||
|
||||
device.find_first_not_of("0123456789") != std::string::npos)
|
||||
throw std::invalid_argument("device ID must contain 9 digits");
|
||||
}
|
||||
auto number = [](const std::string &s, int lo, int hi) {
|
||||
size_t used = 0;
|
||||
int n = std::stoi(s, &used);
|
||||
if (used != s.size() || n < lo || n > hi)
|
||||
throw std::invalid_argument("numeric option out of range");
|
||||
return n;
|
||||
};
|
||||
for (; i < argc; ++i) {
|
||||
std::string a = argv[i];
|
||||
if (a == "--control" && command == "view")
|
||||
control = true;
|
||||
else if (a == "--stdio" && command == "bridge")
|
||||
stdio = true;
|
||||
else {
|
||||
if (i + 1 >= argc)
|
||||
throw std::invalid_argument("option value missing");
|
||||
std::string value = argv[++i];
|
||||
if (a == "--server")
|
||||
options.host = value;
|
||||
else if (a == "--port")
|
||||
options.port = number(value, 1, 65535);
|
||||
else if (a == "--fps" && command == "view")
|
||||
options.fps = number(value, 1, 30);
|
||||
else if (a == "--display" && command == "view")
|
||||
options.display = number(value, 0, 63);
|
||||
else if (a == "--renderer" && command == "view")
|
||||
renderer = value;
|
||||
else if (a == "--quality")
|
||||
Quality(options, value);
|
||||
else if (a == "--ansi-detail" && command == "view")
|
||||
detail = Detail(value);
|
||||
else if (a == "--cell-aspect" && command == "view") {
|
||||
if (value == "auto") {
|
||||
cell_aspect = 0;
|
||||
} else {
|
||||
size_t used = 0;
|
||||
cell_aspect = std::stod(value, &used);
|
||||
if (used != value.size() || !std::isfinite(cell_aspect) ||
|
||||
cell_aspect < .2 || cell_aspect > 2)
|
||||
throw std::invalid_argument("cell aspect must be auto or 0.2..2");
|
||||
}
|
||||
} else if (a == "--zoom" && command == "view") {
|
||||
native = value == "native";
|
||||
zoom = native ? 1 : number(value, 1, 32);
|
||||
} else if (a == "--password-fd" && command == "bridge")
|
||||
secret_fd = number(value, 3, 1024);
|
||||
else
|
||||
throw std::invalid_argument("unsupported option");
|
||||
}
|
||||
}
|
||||
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty")
|
||||
throw std::invalid_argument("unknown renderer");
|
||||
if (command == "bridge" && !stdio)
|
||||
throw std::invalid_argument("bridge requires --stdio");
|
||||
Descriptor output(Duplicate(1));
|
||||
if (output.value < 0)
|
||||
throw std::runtime_error("cannot preserve output channel");
|
||||
// All library stdout writes go to diagnostics. Only Output uses the saved
|
||||
// fd.
|
||||
options.log_directory = Logs();
|
||||
if (command == "view") {
|
||||
Descriptor logs(OpenLog(options.log_directory + "/client.log"));
|
||||
if (logs.value < 0 || Redirect(logs.value, 2) < 0)
|
||||
throw std::runtime_error("cannot open diagnostic log");
|
||||
}
|
||||
if (Redirect(2, 1) < 0)
|
||||
throw std::runtime_error("cannot isolate protocol output");
|
||||
std::cerr << json{{"event", "startup"},
|
||||
{"executable", SafeText(argv[0], 4096)},
|
||||
{"terminal_program", SafeText(Env("TERM_PROGRAM"))},
|
||||
{"input_shortcuts", "local_commands_v2"},
|
||||
{"log_directory", options.log_directory}}
|
||||
.dump()
|
||||
<< "\n";
|
||||
try {
|
||||
return command == "view" ? View(options, device, renderer, control,
|
||||
output.value, detail, zoom, native,
|
||||
cell_aspect)
|
||||
: Bridge(output.value, options, secret_fd);
|
||||
} catch (const std::exception &e) {
|
||||
if (command == "view") {
|
||||
std::string msg = "crossdesk-cli: " + SafeText(e.what()) + "\r\n";
|
||||
if (std::string(e.what()) == "connection_lost")
|
||||
msg += "The remote connection was lost after connecting.\r\n";
|
||||
msg += "Diagnostics: " + SafeText(options.log_directory) + "\r\n";
|
||||
Write(output.value, msg.data(), msg.size());
|
||||
}
|
||||
throw;
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
std::cerr << "crossdesk-cli: " << SafeText(e.what()) << "\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const platforms = require('./platforms.json');
|
||||
const { name: launcherPackage, version } = require('../package.json');
|
||||
|
||||
function main() {
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
const platform = platforms[target];
|
||||
if (!platform) {
|
||||
throw new Error(`No crossdesk-cli build for ${target}. Supported targets: ${Object.keys(platforms).join(', ')}.`);
|
||||
}
|
||||
if (process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime) {
|
||||
throw new Error('This release requires glibc Linux. Alpine/musl needs a separate native build.');
|
||||
}
|
||||
const packageName = platform.packageName;
|
||||
let manifestPath;
|
||||
try {
|
||||
manifestPath = require.resolve(`${packageName}/package.json`);
|
||||
} catch {
|
||||
throw new Error(`Missing ${packageName}@${version}. Install with optional dependencies enabled; this platform's package must be published before ${launcherPackage}.`);
|
||||
}
|
||||
if (require(manifestPath).version !== version) {
|
||||
throw new Error(`Expected ${packageName}@${version}; reinstall matching package versions.`);
|
||||
}
|
||||
const binary = path.join(path.dirname(manifestPath), 'bin', process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli');
|
||||
fs.accessSync(binary, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
||||
const args = process.argv.slice(2);
|
||||
// Hosts such as Pi should spawn the native path directly, retaining descriptor
|
||||
// inheritance and control of the process lifetime without an npm intermediary.
|
||||
if (args.length === 1 && args[0] === '--print-binary-path') {
|
||||
process.stdout.write(`${binary}\n`);
|
||||
return;
|
||||
}
|
||||
const stdio = ['inherit', 'inherit', 'inherit'];
|
||||
if (args[0] === 'bridge') {
|
||||
const index = args.lastIndexOf('--password-fd');
|
||||
const fd = index === -1 ? 3 : Number(args[index + 1]);
|
||||
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (stat.isFIFO() || stat.isSocket() || stat.isFile()) {
|
||||
while (stdio.length <= fd) stdio.push('ignore');
|
||||
stdio[fd] = fd;
|
||||
}
|
||||
} catch { /* The native bridge reports a missing credential descriptor. */ }
|
||||
}
|
||||
}
|
||||
const child = spawn(binary, args, { stdio, shell: false });
|
||||
const handlers = new Map();
|
||||
if (process.platform !== 'win32') {
|
||||
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||||
const handler = () => { if (child.exitCode === null) child.kill(signal); };
|
||||
handlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
} else {
|
||||
// Console Ctrl+C is delivered to both processes. Do not terminate the native
|
||||
// child before it has restored console modes.
|
||||
const handler = () => {};
|
||||
handlers.set('SIGINT', handler);
|
||||
process.on('SIGINT', handler);
|
||||
}
|
||||
const cleanup = () => {
|
||||
for (const [signal, handler] of handlers) process.removeListener(signal, handler);
|
||||
};
|
||||
child.once('error', (error) => {
|
||||
cleanup();
|
||||
process.stderr.write(`crossdesk-cli: cannot start native program (${error.code || 'unknown error'}).\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
cleanup();
|
||||
if (signal && process.platform !== 'win32') process.kill(process.pid, signal);
|
||||
else process.exitCode = code ?? 1;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`crossdesk-cli: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"darwin-arm64": { "packageName": "@chunkdash/crossdesk-cli-darwin-arm64", "os": "darwin", "cpu": "arm64", "xmakePlatform": "macosx", "xmakeArch": "arm64" },
|
||||
"darwin-x64": { "packageName": "@chunkdash/crossdesk-cli-darwin-x64", "os": "darwin", "cpu": "x64", "xmakePlatform": "macosx", "xmakeArch": "x86_64" },
|
||||
"linux-arm64": { "packageName": "@chunkdash/crossdesk-cli-linux-arm64", "os": "linux", "cpu": "arm64", "libc": "glibc", "xmakePlatform": "linux", "xmakeArch": "arm64" },
|
||||
"linux-x64": { "packageName": "@chunkdash/crossdesk-cli-linux-x64", "os": "linux", "cpu": "x64", "libc": "glibc", "xmakePlatform": "linux", "xmakeArch": "x86_64" },
|
||||
"win32-x64": { "packageName": "@chunkdash/crossdesk-cli-windows-x64", "os": "win32", "cpu": "x64", "xmakePlatform": "windows", "xmakeArch": "x64" }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@chunkdash/crossdesk-cli",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"description": "CrossDesk remote desktop controller for the terminal",
|
||||
"license": "GPL-3.0-only",
|
||||
"author": "DI JUNKUN",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/kunkundi/crossdesk.git",
|
||||
"directory": "apps/cli"
|
||||
},
|
||||
"homepage": "https://github.com/kunkundi/crossdesk#readme",
|
||||
"bugs": { "url": "https://github.com/kunkundi/crossdesk/issues" },
|
||||
"engines": { "node": ">=22" },
|
||||
"scripts": {
|
||||
"pack:npm": "node scripts/pack-npm.cjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { truncateToWidth, parseKey, decodeKittyPrintable, isKeyRelease, getCellDimensions } from "@earendil-works/pi-tui";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { Writable } from "node:stream";
|
||||
|
||||
const clean = (s: unknown) => String(s ?? "").replace(/[^\x20-\x7e]/g, "?").slice(0, 180);
|
||||
const validAspect = (n: number) => Number.isFinite(n) && n >= 0.2 && n <= 2;
|
||||
function aspectOf(width: number, height: number): number | undefined {
|
||||
const ratio = width / height;
|
||||
return width >= 1 && width <= 1024 && height >= 1 && height <= 1024 && validAspect(ratio) ? ratio : undefined;
|
||||
}
|
||||
|
||||
// Normalize enhanced key reports with the host's actual keyboard decoder.
|
||||
function legacyInput(data: string): string {
|
||||
if (isKeyRelease(data)) return "";
|
||||
const key = parseKey(data);
|
||||
if (key === "ctrl+]") return "\x1d";
|
||||
if (key?.match(/^ctrl\+[a-z]$/)) return String.fromCharCode(key.charCodeAt(5) - 96);
|
||||
if (key?.match(/^alt\+[a-z]$/)) return "\x1b" + key[4];
|
||||
const keys: Record<string, string> = { enter: "\r", escape: "\x1b", backspace: "\x7f", tab: "\t",
|
||||
up: "\x1b[A", down: "\x1b[B", right: "\x1b[C", left: "\x1b[D", home: "\x1b[H", end: "\x1b[F",
|
||||
delete: "\x1b[3~", pageUp: "\x1b[5~", pageDown: "\x1b[6~" };
|
||||
return (key && keys[key]) || decodeKittyPrintable(data) || data;
|
||||
}
|
||||
|
||||
export default function crossdesk(pi: ExtensionAPI) {
|
||||
let stop: (() => void) | undefined;
|
||||
pi.on("session_shutdown", async () => stop?.());
|
||||
pi.registerCommand("crossdesk", {
|
||||
description: "Open a human-only remote desktop view (Ctrl+] for commands)",
|
||||
handler: async (args, ctx) => {
|
||||
if (ctx.mode !== "tui" || !ctx.hasUI || !process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
ctx.ui.notify("CrossDesk embedded view requires Pi interactive TUI. Run crossdesk-cli <device-id> in a terminal.", "warning");
|
||||
return;
|
||||
}
|
||||
if (stop) { ctx.ui.notify("A CrossDesk view is already open", "warning"); return; }
|
||||
const configuredAspect = process.env.CROSSDESK_CELL_ASPECT?.trim();
|
||||
const manualAspect = !configuredAspect || configuredAspect === "auto" ? undefined : Number(configuredAspect);
|
||||
if (manualAspect !== undefined && !validAspect(manualAspect)) {
|
||||
ctx.ui.notify("CROSSDESK_CELL_ASPECT must be auto or a width/height ratio from 0.2 to 2", "warning");
|
||||
return;
|
||||
}
|
||||
const device = (args.trim() || await ctx.ui.input("CrossDesk device ID", "9 digits"))?.trim();
|
||||
if (!device) return;
|
||||
if (!/^\d{9}$/.test(device)) { ctx.ui.notify("Expected a 9-digit device ID", "error"); return; }
|
||||
// A custom masked prompt keeps credentials out of the editor, history,
|
||||
// extension messages, model tools and package configuration.
|
||||
let password = await ctx.ui.custom<string | undefined>((tui, _theme, _keys, done) => {
|
||||
let secret = "";
|
||||
return {
|
||||
render: (width: number) => [truncateToWidth("Remote password (Enter connects, Esc cancels): " + "*".repeat([...secret].length), width)],
|
||||
invalidate() {},
|
||||
handleInput(data: string) {
|
||||
data = legacyInput(data);
|
||||
if (!data) return;
|
||||
if (data === "\x1b" || data === "\x1d") { secret = ""; done(undefined); }
|
||||
else if (data === "\r" || data === "\n") { const value = secret; secret = ""; done(value); }
|
||||
else if (data === "\x7f" || data === "\b") secret = [...secret].slice(0, -1).join("");
|
||||
else {
|
||||
const text = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
|
||||
if (!/[\x00-\x1f\x7f-\x9f]/.test(text) && Buffer.byteLength(secret + text) <= 256) secret += text;
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
if (password === undefined) return;
|
||||
let child: ChildProcess | undefined;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopGeometry: (() => void) | undefined;
|
||||
const cleanup = () => {
|
||||
stopGeometry?.(); stopGeometry = undefined;
|
||||
if (!child) return;
|
||||
const owned = child; child = undefined;
|
||||
owned.stdin?.end(); // EOF asks the bridge to disconnect and release input.
|
||||
killTimer = setTimeout(() => {
|
||||
owned.kill("SIGTERM");
|
||||
const force = setTimeout(() => owned.kill("SIGKILL"), 2000); force.unref();
|
||||
owned.once("exit", () => clearTimeout(force));
|
||||
}, 2000);
|
||||
killTimer.unref();
|
||||
owned.once("exit", () => { if (killTimer) clearTimeout(killTimer); });
|
||||
};
|
||||
try {
|
||||
await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
|
||||
let closed = false, status = "starting", error = "", lines: string[] = [], buffer = "";
|
||||
let columns = 0, rows = 0, request = 0, ready = false, generation = 0;
|
||||
let frame: { frame_id: number; generation: number } | undefined;
|
||||
let helloId = 0, zoom = 1, native = false;
|
||||
let control = false, commandMode = false, bridgeStopped = false;
|
||||
const hostCell = getCellDimensions();
|
||||
let cellAspect = manualAspect ?? aspectOf(hostCell.widthPx, hostCell.heightPx) ?? 0.5;
|
||||
const colorHint = process.env.COLORTERM || "";
|
||||
const colors = /^(truecolor|24bit)$/.test(colorHint) ? 24 : process.env.TERM?.includes("256color") ? 256 : 8;
|
||||
const finish = () => { if (closed) return; closed = true; cleanup(); stop = undefined; lines = []; done(); };
|
||||
stop = finish;
|
||||
const fail = (reason: string) => { bridgeStopped = true; error = clean(reason); cleanup(); tui.requestRender(); };
|
||||
child = spawn(process.env.CROSSDESK_CLI || "crossdesk-cli", ["bridge", "--stdio", "--password-fd", "3"], {
|
||||
shell: false, stdio: ["pipe", "pipe", "pipe", "pipe"],
|
||||
});
|
||||
const processChild = child;
|
||||
const send = (method: string, fields: Record<string, unknown> = {}) => {
|
||||
if (closed || !child?.stdin || child.stdin.destroyed) return 0;
|
||||
if (child.stdin.writableLength > 65536) { fail("Bridge is not consuming requests"); return 0; }
|
||||
const id = ++request;
|
||||
child.stdin.write(JSON.stringify({ id, method, ...fields }) + "\n"); return id;
|
||||
};
|
||||
const sendViewport = () => send("viewport", {
|
||||
columns: columns || 80, rows: rows || 20, cell_aspect: cellAspect,
|
||||
colors, utf8: true, fps: 8, detail: "quadrant", quality: "high",
|
||||
});
|
||||
child.stdin?.on("error", () => fail("Bridge input closed"));
|
||||
const secrets = child.stdio[3] as Writable;
|
||||
secrets.on("error", () => fail("Credential channel closed"));
|
||||
secrets.end(password + "\n"); password = undefined;
|
||||
child.stderr?.resume(); // Never insert native diagnostics in the TUI or chat.
|
||||
child.on("error", () => fail("Cannot start crossdesk-cli; check CROSSDESK_CLI or PATH"));
|
||||
child.on("exit", (code) => {
|
||||
if (child === processChild) child = undefined;
|
||||
stopGeometry?.(); stopGeometry = undefined;
|
||||
if (!closed) { bridgeStopped = true; error ||= `Bridge exited (${code ?? "signal"})`; tui.requestRender(); }
|
||||
});
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (data: string) => {
|
||||
if (closed) return;
|
||||
buffer += data;
|
||||
if (buffer.length > 2 * 1024 * 1024) { fail("Bridge message exceeds 2 MiB"); return; }
|
||||
for (;;) {
|
||||
const end = buffer.indexOf("\n"); if (end < 0) break;
|
||||
const raw = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
||||
try {
|
||||
const message = JSON.parse(raw);
|
||||
if (message.id === helloId && message.ok && message.version === 1 && !ready) {
|
||||
ready = true;
|
||||
sendViewport();
|
||||
send("connect", { device });
|
||||
}
|
||||
if (message.ok === false) error = clean(message.error);
|
||||
if (message.status) {
|
||||
if (message.status.exit_requested) { clearTimeout(watchdog); finish(); return; }
|
||||
if (Number.isSafeInteger(message.status.generation)) generation = message.status.generation;
|
||||
control = message.status.control === true;
|
||||
commandMode = message.status.command_mode === true;
|
||||
status = clean(message.status.state) + (commandMode
|
||||
? " LOCAL COMMAND / " + (message.status.resume_control === true ? "CONTROL PAUSED" : "READ ONLY")
|
||||
: control ? " CONTROL" : " READ ONLY");
|
||||
if (message.status.error) error = clean(message.status.error);
|
||||
if (message.status.state !== "connected") lines = [];
|
||||
}
|
||||
if (message.event === "frame") {
|
||||
if (!Number.isSafeInteger(message.frame_id) || !Number.isSafeInteger(message.generation) ||
|
||||
!Array.isArray(message.lines) || message.lines.length > 100)
|
||||
throw new Error("Invalid frame");
|
||||
if (message.columns !== columns || message.rows !== rows || message.generation < generation ||
|
||||
(message.cell_aspect !== undefined && message.cell_aspect !== cellAspect)) {
|
||||
send("ack", {frame_id: message.frame_id, generation: message.generation});
|
||||
continue; // A frame already in flight may precede the resize response.
|
||||
}
|
||||
for (const line of message.lines) {
|
||||
// Only our SGR colors, spaces and block glyphs may reach Pi.
|
||||
if (typeof line !== "string" || line.length > 20000 || /[^ \u2580-\u259f]/u.test(line.replace(/\x1b\[[0-9;]*m/g, "")))
|
||||
throw new Error("Invalid terminal line");
|
||||
}
|
||||
if (Number.isFinite(message.zoom) && message.zoom >= 1 && message.zoom <= 32) zoom = message.zoom;
|
||||
native = message.native === true;
|
||||
lines = message.lines; frame = { frame_id: message.frame_id, generation: message.generation };
|
||||
}
|
||||
tui.requestRender();
|
||||
} catch { fail("Invalid bridge protocol message"); }
|
||||
}
|
||||
});
|
||||
helloId = send("hello", { version: 1 });
|
||||
const watchdog = setTimeout(() => { if (!ready) fail("Bridge handshake timeout"); }, 5000);
|
||||
processChild.once("exit", () => clearTimeout(watchdog));
|
||||
// Use the host's terminal writer and sequence listener; Pi remains
|
||||
// the sole owner of raw input and screen rendering.
|
||||
let pixelSize: [number, number] | undefined, cellCount: [number, number] | undefined;
|
||||
let directCell = false, queried = false;
|
||||
const updateAspect = (width: number, height: number) => {
|
||||
const ratio = aspectOf(width, height);
|
||||
if (manualAspect !== undefined || ratio === undefined || ratio === cellAspect) return;
|
||||
cellAspect = ratio; lines = []; frame = undefined;
|
||||
if (ready) sendViewport();
|
||||
tui.requestRender();
|
||||
};
|
||||
const removeGeometryListener = tui.addInputListener(data => {
|
||||
// Pi's StdinBuffer assembles split replies before listeners run.
|
||||
const match = /^\x1b\[([468]);([0-9]+);([0-9]+)t$/.exec(data);
|
||||
if (!queried || !match) return;
|
||||
const type = Number(match[1]), height = Number(match[2]), width = Number(match[3]);
|
||||
if (![height, width].every(n => Number.isSafeInteger(n) && n > 0 && n <= 1048576))
|
||||
return { consume: true };
|
||||
if (type === 6) {
|
||||
if (aspectOf(width, height) === undefined) return { consume: true };
|
||||
directCell = true;
|
||||
updateAspect(width, height);
|
||||
return; // Let Pi update its own shared cell-size cache as well.
|
||||
}
|
||||
if (type === 4) pixelSize = [width, height];
|
||||
if (type === 8) cellCount = [width, height];
|
||||
if (!directCell && pixelSize && cellCount)
|
||||
updateAspect(pixelSize[0] / cellCount[0], pixelSize[1] / cellCount[1]);
|
||||
return { consume: true };
|
||||
});
|
||||
let terminalColumns = tui.terminal.columns, terminalRows = tui.terminal.rows;
|
||||
const queryGeometry = () => {
|
||||
if (closed || bridgeStopped) return;
|
||||
queried = true; directCell = false; pixelSize = cellCount = undefined;
|
||||
tui.terminal.write("\x1b[16t\x1b[14t\x1b[18t");
|
||||
};
|
||||
const geometryTimer = setInterval(queryGeometry, 2000);
|
||||
geometryTimer.unref();
|
||||
stopGeometry = () => { clearInterval(geometryTimer); removeGeometryListener(); };
|
||||
queryGeometry();
|
||||
return {
|
||||
render(width: number) {
|
||||
const w = Math.max(1, Math.min(240, width - 1));
|
||||
const h = Math.max(1, Math.min(100, tui.terminal.rows - 5));
|
||||
if (w !== columns || h !== rows) {
|
||||
columns = w; rows = h; lines = []; frame = undefined;
|
||||
if (ready) sendViewport();
|
||||
}
|
||||
if (terminalColumns !== tui.terminal.columns || terminalRows !== tui.terminal.rows) {
|
||||
terminalColumns = tui.terminal.columns; terminalRows = tui.terminal.rows;
|
||||
queryGeometry();
|
||||
}
|
||||
const ack = frame; frame = undefined;
|
||||
if (ack) queueMicrotask(() => send("ack", ack)); // Host consumed the frame; not a physical display timestamp.
|
||||
const hint = bridgeStopped ? "q / Esc / Ctrl+C close"
|
||||
: commandMode ? "Esc/Ctrl+C return | c toggle q quit | arrows/ijkl pan +/- zoom f fit d detail"
|
||||
: control ? "Keys -> remote | Ctrl+] then c release, q quit"
|
||||
: "c control q quit ? help | i up j left k down l right | +/- zoom f fit d detail";
|
||||
return [truncateToWidth(`CrossDesk ${device} | ${status} ${native ? "detail" : "fit"} x${zoom} | Ctrl+] commands`, width),
|
||||
...lines.slice(0, h).map(line => truncateToWidth(line, width)),
|
||||
truncateToWidth(`${hint}${error ? " | " + error : lines.length ? "" : " | Waiting for remote video…"}`, width)];
|
||||
},
|
||||
handleInput(data: string) {
|
||||
data = legacyInput(data);
|
||||
if (!data) return;
|
||||
if ((bridgeStopped || !ready) && (data === "q" || data === "\x1b" || data === "\x03")) { clearTimeout(watchdog); finish(); }
|
||||
else if (ready && Buffer.byteLength(data) <= 16384) send("input", { data });
|
||||
},
|
||||
invalidate() {},
|
||||
dispose() { clearTimeout(watchdog); cleanup(); },
|
||||
};
|
||||
});
|
||||
} finally { password = undefined; cleanup(); stop = undefined; }
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "crossdesk-pi",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Human-only CrossDesk terminal view for Pi",
|
||||
"keywords": ["pi-package"],
|
||||
"pi": { "extensions": ["./index.ts"] },
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^1.4.0",
|
||||
"@earendil-works/pi-tui": "^1.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const cliRoot = path.resolve(__dirname, '..');
|
||||
const root = path.resolve(cliRoot, '../..');
|
||||
const project = require('../package.json');
|
||||
const platforms = require('../npm/platforms.json');
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...options });
|
||||
}
|
||||
function copy(from, to) {
|
||||
fs.cpSync(path.join(root, from), to, {
|
||||
recursive: true,
|
||||
filter: (entry) => !['.git', '.xmake', '.vscode', 'build', 'out', 'node_modules', 'tests', 'test'].includes(path.basename(entry)),
|
||||
});
|
||||
}
|
||||
function json(file, value) {
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
function tarballName(name, version) {
|
||||
// npm pack writes @scope/name as scope-name-version.tgz.
|
||||
return `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz`;
|
||||
}
|
||||
function inspect(binary, target) {
|
||||
const data = fs.readFileSync(binary);
|
||||
const info = { target, sha256: crypto.createHash('sha256').update(data).digest('hex') };
|
||||
if (process.platform === 'darwin') {
|
||||
const cpu = process.arch === 'arm64' ? 0x100000c : 0x1000007;
|
||||
if (data.length < 32 || data.readUInt32LE(0) !== 0xfeedfacf ||
|
||||
data.readUInt32LE(4) !== cpu || data.readUInt32LE(12) !== 2) {
|
||||
throw new Error('Expected a Mach-O executable matching the current CPU.');
|
||||
}
|
||||
info.dependencies = run('otool', ['-L', binary]).split('\n').slice(1)
|
||||
.map((line) => line.trim().split(' (')[0]).filter(Boolean);
|
||||
for (const dependency of info.dependencies) {
|
||||
if (!dependency.startsWith('/usr/lib/') && !dependency.startsWith('/System/Library/'))
|
||||
throw new Error(`Non-system macOS library must be bundled before packaging: ${dependency}`);
|
||||
}
|
||||
const commands = run('otool', ['-l', binary]);
|
||||
info.minimumOS = commands.match(/\bminos\s+([\d.]+)/)?.[1] ||
|
||||
commands.match(/LC_VERSION_MIN_MACOSX[\s\S]*?\bversion\s+([\d.]+)/)?.[1];
|
||||
if (!info.minimumOS) throw new Error('Cannot determine macOS deployment target.');
|
||||
run('codesign', ['--verify', binary]);
|
||||
} else if (process.platform === 'linux') {
|
||||
const machine = process.arch === 'arm64' ? 183 : 62;
|
||||
if (data.length < 64 || data.subarray(0, 4).toString('hex') !== '7f454c46' ||
|
||||
data[4] !== 2 || data[5] !== 1 || data.readUInt16LE(18) !== machine)
|
||||
throw new Error('Expected a 64-bit ELF executable matching the current CPU.');
|
||||
if (!process.report.getReport().header.glibcVersionRuntime)
|
||||
throw new Error('The Linux npm packages currently target glibc, not musl.');
|
||||
info.dependencies = run('ldd', [binary]).trim().split('\n').map((line) => line.trim());
|
||||
const system = /^(linux-vdso|ld-linux|lib(c|m|dl|rt|pthread|gcc_s|stdc\+\+|z|resolv)\.)/;
|
||||
for (const dependency of info.dependencies) {
|
||||
const name = path.basename(dependency.split(/\s/)[0]);
|
||||
if (dependency.includes('not found') || !system.test(name))
|
||||
throw new Error(`Unbundled Linux dependency: ${dependency}`);
|
||||
}
|
||||
const symbols = run('objdump', ['-T', binary]);
|
||||
info.glibc = [...symbols.matchAll(/\bGLIBC_([\d.]+)/g)].map((match) => match[1])
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1);
|
||||
info.glibcxx = [...symbols.matchAll(/\bGLIBCXX_([\d.]+)/g)].map((match) => match[1])
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1);
|
||||
} else {
|
||||
const offset = data.length >= 64 ? data.readUInt32LE(60) : data.length;
|
||||
const machine = 0x8664;
|
||||
if (data.toString('ascii', 0, 2) !== 'MZ' || offset + 6 > data.length ||
|
||||
data.readUInt32LE(offset) !== 0x4550 || data.readUInt16LE(offset + 4) !== machine)
|
||||
throw new Error('Expected a Windows PE executable matching the current CPU.');
|
||||
info.dependencies = [...run('dumpbin.exe', ['/DEPENDENTS', binary])
|
||||
.matchAll(/^\s+([\w.-]+\.dll)\s*$/gim)].map((match) => match[1]);
|
||||
if (!info.dependencies.length) throw new Error('Cannot inspect Windows DLL dependencies.');
|
||||
for (const dependency of info.dependencies) {
|
||||
if (/^(msvcp|vcruntime|concrt)/i.test(dependency) ||
|
||||
(!/^(api-ms-|ext-ms-)/i.test(dependency) &&
|
||||
!fs.existsSync(path.join(process.env.SystemRoot, 'System32', dependency))))
|
||||
throw new Error(`Unbundled Windows runtime: ${dependency}. Build with static runtimes (MT).`);
|
||||
}
|
||||
info.minimumOS = 'Windows 10 (VT console)';
|
||||
}
|
||||
const doctor = JSON.parse(run(binary, ['doctor', '--json'], { timeout: 15000 }));
|
||||
if (doctor.bridge_version !== 1) throw new Error('Native binary does not report the expected bridge version.');
|
||||
return info;
|
||||
}
|
||||
function pack(directory, output) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
|
||||
const expected = tarballName(manifest.name, manifest.version);
|
||||
if (fs.existsSync(path.join(output, expected))) throw new Error(`Output already exists: ${expected}`);
|
||||
const args = ['pack', '--json', '--ignore-scripts', '--pack-destination', output];
|
||||
if (process.platform === 'win32' && !process.env.npm_execpath)
|
||||
throw new Error('On Windows, run this script through npm run pack:npm.');
|
||||
const result = process.env.npm_execpath
|
||||
? run(process.execPath, [process.env.npm_execpath, ...args], { cwd: directory })
|
||||
: run('npm', args, { cwd: directory });
|
||||
// npm 12 uses a name-keyed object instead of the earlier array format.
|
||||
const parsed = JSON.parse(result);
|
||||
const entries = Array.isArray(parsed) ? parsed : Object.values(parsed);
|
||||
if (entries.length !== 1) throw new Error('npm pack must produce exactly one package.');
|
||||
const [{ filename, size }] = entries;
|
||||
if (filename !== expected) throw new Error(`Unexpected npm tarball name: ${filename}; expected ${expected}`);
|
||||
return { filename, size };
|
||||
}
|
||||
|
||||
let staging;
|
||||
const created = [];
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
let output = path.join(root, 'build', 'npm');
|
||||
let binary;
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
if (!args[i + 1]) throw new Error(`Missing value for ${args[i]}`);
|
||||
if (args[i] === '--out-dir') output = path.resolve(args[i + 1]);
|
||||
else if (args[i] === '--binary') binary = path.resolve(args[i + 1]);
|
||||
else throw new Error(`Unknown option: ${args[i]}`);
|
||||
}
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
const platform = platforms[target];
|
||||
if (!platform) throw new Error(`No packaging target for ${target}`);
|
||||
const packageNames = Object.values(platforms).map(({ packageName }) => packageName);
|
||||
if (packageNames.some((name) => typeof name !== 'string' || name.length > 214 ||
|
||||
!/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(name)))
|
||||
throw new Error('Every npm platform must declare a valid scoped packageName.');
|
||||
if (new Set(packageNames).size !== packageNames.length)
|
||||
throw new Error('Each npm platform must have a distinct packageName.');
|
||||
const version = fs.readFileSync(path.join(cliRoot, 'xmake.lua'), 'utf8').match(/set_version\("([^"]+)"\)/)?.[1];
|
||||
if (version !== project.version) throw new Error('apps/cli/package.json and apps/cli/xmake.lua versions must match.');
|
||||
for (const name of [project.name, platform.packageName]) {
|
||||
const filename = tarballName(name, version);
|
||||
if (fs.existsSync(path.join(output, filename))) throw new Error(`Output already exists: ${filename}`);
|
||||
}
|
||||
run('git', ['-C', path.join(root, 'deps/submodules/minirtc'), 'apply', '--reverse', '--check',
|
||||
path.join(root, 'deps/patches/minirtc-credential-logs.patch')]);
|
||||
const executable = process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli';
|
||||
binary ||= path.join(root, 'build', platform.xmakePlatform, platform.xmakeArch, 'release', executable);
|
||||
const info = inspect(binary, target);
|
||||
staging = fs.mkdtempSync(path.join(os.tmpdir(), 'crossdesk-npm-'));
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const native = path.join(staging, 'native');
|
||||
fs.mkdirSync(path.join(native, 'bin'), { recursive: true });
|
||||
fs.copyFileSync(binary, path.join(native, 'bin', executable));
|
||||
fs.chmodSync(path.join(native, 'bin', executable), 0o755);
|
||||
copy('LICENSE', path.join(native, 'LICENSE'));
|
||||
// Include the controller and patched MiniRTC sources used by this checkout.
|
||||
// External Xmake dependency source/licenses still belong to the release's
|
||||
// dependency bundle; see source/apps/cli/docs/source-provenance.md.
|
||||
// Keep desktop build definitions for Xmake's initial option discovery;
|
||||
// desktop application sources and resources are intentionally not bundled.
|
||||
for (const entry of ['apps/cli', 'apps/desktop/xmake',
|
||||
'libs/controller/include',
|
||||
'libs/controller/src', 'libs/terminal', 'libs/wire/include',
|
||||
'libs/wire/src', 'deps/submodules/minirtc',
|
||||
'deps/submodules/xmake.lua', 'deps/thirdparty', 'deps/patches', 'xmake.lua',
|
||||
'LICENSE', '.gitmodules'])
|
||||
copy(entry, path.join(native, 'source', entry));
|
||||
json(path.join(native, 'build-info.json'), { version, ...info });
|
||||
fs.writeFileSync(path.join(native, 'README.md'), `# ${platform.packageName}\n\nNative ${target} dependency for [${project.name}](https://www.npmjs.com/package/${project.name}) ${version}, published under the ${platform.packageName.split('/')[0]} scope.\n\nInstall the cross-platform entry package with \`npm install -g ${project.name}\` or run \`npx ${project.name} doctor --json\`. This platform package contains the native binary and does not register a shell command.\n\nSource repository: [CrossDesk](${project.repository.url.replace(/^git\+/, '').replace(/\.git$/, '')}). Report problems at ${project.bugs.url}.\n\nRuntime requirements and linked libraries: [build-info.json](build-info.json).\n\nController and patched MiniRTC sources are included in source/. From source/, configure with xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release, then run xmake build crossdesk-cli. External dependency notices and corresponding sources must accompany the product release; see source/apps/cli/docs/source-provenance.md.\n`);
|
||||
const metadata = {
|
||||
license: project.license, author: project.author, repository: project.repository,
|
||||
homepage: project.homepage, bugs: project.bugs,
|
||||
publishConfig: { access: 'public', registry: 'https://registry.npmjs.org/' },
|
||||
};
|
||||
json(path.join(native, 'package.json'), {
|
||||
...metadata, name: platform.packageName, version, description: `${project.description} (${target})`,
|
||||
os: [platform.os], cpu: [platform.cpu],
|
||||
...(platform.libc ? { libc: [platform.libc] } : {}),
|
||||
files: ['bin', 'build-info.json', 'source', 'LICENSE', 'README.md'],
|
||||
});
|
||||
const launcher = path.join(staging, 'launcher');
|
||||
fs.mkdirSync(launcher);
|
||||
for (const entry of ['npm', 'docs', 'README.md']) copy(`apps/cli/${entry}`, path.join(launcher, entry));
|
||||
copy('LICENSE', path.join(launcher, 'LICENSE'));
|
||||
fs.chmodSync(path.join(launcher, 'npm', 'cli.cjs'), 0o755);
|
||||
json(path.join(launcher, 'package.json'), {
|
||||
...metadata, name: project.name, version, description: project.description, engines: project.engines,
|
||||
bin: { 'crossdesk-cli': 'npm/cli.cjs' },
|
||||
os: ['darwin', 'linux', 'win32'], cpu: ['arm64', 'x64'],
|
||||
files: ['npm', 'docs', 'README.md', 'LICENSE'],
|
||||
optionalDependencies: Object.fromEntries(packageNames.map((name) => [name, version])),
|
||||
});
|
||||
// Finish both archives before publishing either one to the output directory.
|
||||
const archives = path.join(staging, 'archives');
|
||||
fs.mkdirSync(archives);
|
||||
const packages = [pack(native, archives), pack(launcher, archives)];
|
||||
for (const { filename } of packages) {
|
||||
const destination = path.join(output, filename);
|
||||
fs.copyFileSync(path.join(archives, filename), destination, fs.constants.COPYFILE_EXCL);
|
||||
created.push(destination);
|
||||
}
|
||||
for (const { filename, size } of packages)
|
||||
console.log(`${path.join(output, filename)} (${(size / 1024 / 1024).toFixed(2)} MiB)`);
|
||||
console.log(`Native requirements: ${JSON.stringify(info)}`);
|
||||
} catch (error) {
|
||||
for (const file of created) fs.rmSync(file, { force: true });
|
||||
console.error(`pack:npm: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (staging) fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../../.."
|
||||
git submodule update --init --recursive
|
||||
actual=$(git -C deps/submodules/minirtc rev-parse HEAD)
|
||||
[ "$actual" = b9ee1fa7fe038fdf771f5dec1b1d561d78273f15 ] || { echo 'Unexpected MiniRTC revision' >&2; exit 1; }
|
||||
patch_file="$(pwd)/deps/patches/minirtc-credential-logs.patch"
|
||||
if git -C deps/submodules/minirtc apply --reverse --check "$patch_file" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
git -C deps/submodules/minirtc apply --check "$patch_file"
|
||||
git -C deps/submodules/minirtc apply "$patch_file"
|
||||
@@ -0,0 +1,242 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { setTimeout: sleep } = require('node:timers/promises');
|
||||
const project = require('../package.json');
|
||||
const platforms = require('../npm/platforms.json');
|
||||
const registry = 'https://registry.npmjs.org/';
|
||||
const repository = 'kunkundi/crossdesk';
|
||||
|
||||
function versionParts(version) {
|
||||
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$/.exec(version);
|
||||
assert(match && !(match[4] || '').split('.').some((part) => /^0\d+$/.test(part)),
|
||||
`Invalid release version: ${version}`);
|
||||
return [match.slice(1, 4).map(BigInt), match[4]?.split('.')];
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const [a, preA] = versionParts(left);
|
||||
const [b, preB] = versionParts(right);
|
||||
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;
|
||||
if (!preA || !preB) return preA ? -1 : preB ? 1 : 0;
|
||||
for (let i = 0; i < Math.max(preA.length, preB.length); i++) {
|
||||
if (preA[i] === preB[i]) continue;
|
||||
if (preA[i] === undefined || preB[i] === undefined) return preA[i] === undefined ? -1 : 1;
|
||||
const numericA = /^\d+$/.test(preA[i]);
|
||||
const numericB = /^\d+$/.test(preB[i]);
|
||||
if (numericA !== numericB) return numericA ? -1 : 1;
|
||||
return (numericA ? BigInt(preA[i]) > BigInt(preB[i]) : preA[i] > preB[i]) ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function releasePlan() {
|
||||
assert(['true', 'false'].includes(process.env.RELEASE_DRY_RUN), 'Set RELEASE_DRY_RUN=true or false explicitly.');
|
||||
const dryRun = process.env.RELEASE_DRY_RUN === 'true';
|
||||
const version = project.version;
|
||||
versionParts(version);
|
||||
assert.equal(project.name, '@chunkdash/crossdesk-cli', 'Unexpected entry package scope.');
|
||||
const nativeVersion = fs.readFileSync(path.join(__dirname, '../xmake.lua'), 'utf8')
|
||||
.match(/set_version\("([^"]+)"\)/)?.[1];
|
||||
assert.equal(version, nativeVersion, 'package.json and xmake.lua versions must match.');
|
||||
const ref = process.env.GITHUB_REF || '';
|
||||
if (!dryRun) {
|
||||
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Real publishing must run in GitHub Actions.');
|
||||
assert.equal(process.env.GITHUB_REPOSITORY, repository, 'Unexpected publishing repository.');
|
||||
assert(['push', 'workflow_dispatch'].includes(process.env.GITHUB_EVENT_NAME), 'Unsupported publishing event.');
|
||||
assert.equal(ref, `refs/tags/cli-v${version}`, 'Publish only from the matching cli-vVERSION tag.');
|
||||
} else if (ref.startsWith('refs/tags/')) {
|
||||
assert.equal(ref, `refs/tags/cli-v${version}`, 'Git tag and package version must match, including in a dry run.');
|
||||
}
|
||||
return { version, tag: version.includes('-') ? 'next' : 'latest', dryRun };
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024, ...options });
|
||||
}
|
||||
|
||||
function npm(args, options) {
|
||||
return process.env.npm_execpath
|
||||
? run(process.execPath, [process.env.npm_execpath, ...args], options)
|
||||
: run('npm', args, options);
|
||||
}
|
||||
|
||||
function archiveRead(file, member, encoding = 'utf8') {
|
||||
return run('tar', ['-xOf', file, `package/${member}`], { encoding });
|
||||
}
|
||||
|
||||
function filename(name, version) {
|
||||
return `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz`;
|
||||
}
|
||||
|
||||
function inspect(file, name, version) {
|
||||
const manifest = JSON.parse(archiveRead(file, 'package.json'));
|
||||
assert.equal(manifest.name, name, `Unexpected package in ${file}`);
|
||||
assert.equal(manifest.version, version, `Unexpected version in ${file}`);
|
||||
assert(!manifest.private && !manifest.scripts, `${name}: publish only generated packages without lifecycle scripts.`);
|
||||
assert.equal(manifest.repository?.url, project.repository.url, `${name}: repository mismatch.`);
|
||||
assert.deepEqual(manifest.publishConfig, { access: 'public', registry }, `${name}: publishConfig mismatch.`);
|
||||
const result = JSON.parse(npm(['pack', file, '--dry-run', '--json', '--ignore-scripts']));
|
||||
// npm 12 returns a name-keyed object; npm 11 and earlier return an array.
|
||||
const entries = Array.isArray(result) ? result : Object.values(result);
|
||||
assert.equal(entries.length, 1, `Expected one package in ${file}`);
|
||||
const [packed] = entries;
|
||||
const integrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64')}`;
|
||||
assert.equal(packed.integrity, integrity, `${name}: tarball integrity mismatch.`);
|
||||
return { file, name, version, integrity, manifest, files: packed.files };
|
||||
}
|
||||
|
||||
function collectPackages(directory, version) {
|
||||
const targets = Object.keys(platforms);
|
||||
assert.deepEqual(fs.readdirSync(directory).sort(), targets.map((target) => `npm-${target}`).sort(),
|
||||
'Expected exactly five npm platform artifact directories from the same workflow run.');
|
||||
const natives = [];
|
||||
const launchers = [];
|
||||
const dependencies = Object.fromEntries(Object.values(platforms).map(({ packageName }) => [packageName, version]));
|
||||
for (const [target, platform] of Object.entries(platforms)) {
|
||||
const folder = path.join(directory, `npm-${target}`);
|
||||
const names = [filename(platform.packageName, version), filename(project.name, version)];
|
||||
assert.deepEqual(fs.readdirSync(folder).sort(), [...names].sort(), `${target}: expected native and entry tarballs.`);
|
||||
const native = inspect(path.join(folder, names[0]), platform.packageName, version);
|
||||
assert.deepEqual(native.manifest.os, [platform.os]);
|
||||
assert.deepEqual(native.manifest.cpu, [platform.cpu]);
|
||||
assert.deepEqual(native.manifest.libc, platform.libc ? [platform.libc] : undefined);
|
||||
assert(!native.manifest.bin, `${target}: native packages must not register commands.`);
|
||||
const binary = `bin/crossdesk-cli${platform.os === 'win32' ? '.exe' : ''}`;
|
||||
assert(native.files.some((file) => file.path === binary && (platform.os === 'win32' || (file.mode & 0o111))),
|
||||
`${target}: missing executable binary.`);
|
||||
const info = JSON.parse(archiveRead(native.file, 'build-info.json'));
|
||||
assert.equal(info.version, version);
|
||||
assert.equal(info.target, target);
|
||||
assert.equal(info.sha256, crypto.createHash('sha256').update(archiveRead(native.file, binary, null)).digest('hex'),
|
||||
`${target}: native binary checksum mismatch.`);
|
||||
natives.push(native);
|
||||
|
||||
const launcher = inspect(path.join(folder, names[1]), project.name, version);
|
||||
assert.deepEqual(launcher.manifest.optionalDependencies, dependencies, `${target}: platform dependencies mismatch.`);
|
||||
assert.deepEqual(launcher.manifest.bin, { 'crossdesk-cli': 'npm/cli.cjs' });
|
||||
assert.deepEqual(JSON.parse(archiveRead(launcher.file, 'npm/platforms.json')), platforms);
|
||||
launchers.push({ target, ...launcher });
|
||||
}
|
||||
// Pick a Unix entry so the shebang and executable mode work on Unix as well.
|
||||
const entry = launchers.find(({ target }) => target === 'linux-x64');
|
||||
assert(entry.files.some((file) => file.path === 'npm/cli.cjs' && (file.mode & 0o111)), 'Entry launcher is not executable.');
|
||||
const contents = Object.fromEntries(entry.files.map(({ path: member }) =>
|
||||
[member, archiveRead(entry.file, member).replace(/\r\n/g, '\n')]));
|
||||
for (const launcher of launchers) {
|
||||
assert.deepEqual(launcher.files.map((file) => file.path).sort(), Object.keys(contents).sort(),
|
||||
`${launcher.target}: entry package contents differ.`);
|
||||
for (const [member, content] of Object.entries(contents)) {
|
||||
assert.equal(archiveRead(launcher.file, member).replace(/\r\n/g, '\n'), content,
|
||||
`${launcher.target}: entry file differs: ${member}`);
|
||||
}
|
||||
}
|
||||
return [...natives, entry];
|
||||
}
|
||||
|
||||
async function readRegistry(name) {
|
||||
const response = await fetch(`${registry}${encodeURIComponent(name)}`, {
|
||||
headers: { accept: 'application/json', 'cache-control': 'no-cache' },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
assert(response.ok, `${name}: registry returned HTTP ${response.status}; refusing to treat it as a missing package.`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function checkPublished(pkg, document) {
|
||||
const published = document?.versions?.[pkg.version];
|
||||
if (!published) return false;
|
||||
assert.equal(published.dist?.integrity, pkg.integrity,
|
||||
`${pkg.name}@${pkg.version} already exists with different content. Bump the version; do not overwrite it.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function waitForPackages(packages, tag) {
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
let pending = [...packages];
|
||||
while (pending.length) {
|
||||
const remaining = [];
|
||||
for (const pkg of pending) {
|
||||
const document = await readRegistry(pkg.name);
|
||||
if (!checkPublished(pkg, document) || document['dist-tags']?.[tag] !== pkg.version) remaining.push(pkg);
|
||||
}
|
||||
if (!remaining.length) return;
|
||||
assert(Date.now() < deadline, `npm is still processing: ${remaining.map((pkg) => pkg.name).join(', ')}. Wait, then re-run the failed job with the same artifacts.`);
|
||||
console.log(`Waiting for npm versions and ${tag} tags: ${remaining.map((pkg) => pkg.name).join(', ')}`);
|
||||
pending = remaining;
|
||||
await sleep(15000);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishPackages(packages, { tag, dryRun }) {
|
||||
const existing = new Set();
|
||||
// Preflight every package before making any registry changes.
|
||||
for (const pkg of packages) {
|
||||
if (dryRun) continue;
|
||||
const document = await readRegistry(pkg.name);
|
||||
const current = document?.['dist-tags']?.[tag];
|
||||
assert(!current || compareVersions(current, pkg.version) <= 0,
|
||||
`${pkg.name}: ${tag} already points to newer ${current}; refusing to roll it back.`);
|
||||
if (checkPublished(pkg, document)) {
|
||||
assert.equal(current, pkg.version, `${pkg.name}: existing version matches but ${tag} does not. Review dist-tags manually before retrying.`);
|
||||
existing.add(pkg.name);
|
||||
} else {
|
||||
const versions = Object.keys(document?.versions || {});
|
||||
assert(!(versions.length === 1 && versions[0] === '0.0.0-stage'),
|
||||
`${pkg.name}: npm still has a staging placeholder. Wait for processing before retrying; do not upload it again.`);
|
||||
}
|
||||
}
|
||||
const publish = (pkg) => {
|
||||
if (dryRun) {
|
||||
// collectPackages already ran npm pack --dry-run and inspected the archive.
|
||||
// npm publish --dry-run still exchanges OIDC credentials and rejects
|
||||
// existing versions, so it cannot be used before trust is configured.
|
||||
console.log(`Validated tarball without publishing: ${pkg.name}@${pkg.version} (${tag})`);
|
||||
return;
|
||||
}
|
||||
if (existing.has(pkg.name)) {
|
||||
console.log(`Already published with matching integrity: ${pkg.name}@${pkg.version}`);
|
||||
return;
|
||||
}
|
||||
console.log(`Publishing: ${pkg.name}@${pkg.version} (${tag})`);
|
||||
npm(['publish', pkg.file, '--access', 'public', '--tag', tag, '--ignore-scripts',
|
||||
`--registry=${registry}`, '--fetch-retries=0', '--fetch-timeout=120000',
|
||||
'--provenance'], { stdio: 'inherit' });
|
||||
};
|
||||
const native = packages.slice(0, -1);
|
||||
for (const pkg of native) publish(pkg);
|
||||
if (!dryRun) await waitForPackages(native, tag);
|
||||
const entry = packages.at(-1);
|
||||
publish(entry);
|
||||
if (!dryRun) await waitForPackages([entry], tag);
|
||||
const report = `${dryRun ? 'Validated (no packages published)' : 'Published and verified'} CLI ${entry.version} with npm tag ${tag}.\n\n` +
|
||||
'| Package | Integrity (SHA-512) |\n| --- | --- |\n' + packages.map((pkg) => `| ${pkg.name} | ${pkg.integrity} |`).join('\n') + '\n';
|
||||
console.log(report);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, directory, ...extra] = process.argv.slice(2);
|
||||
assert((command === 'plan' && !directory) || (command === 'publish' && directory && !extra.length),
|
||||
'Usage: release-npm.cjs plan | publish ARTIFACT_DIRECTORY (set RELEASE_DRY_RUN=true or false)');
|
||||
const plan = releasePlan();
|
||||
console.log(`CLI ${plan.version}, npm tag ${plan.tag}, dry run: ${plan.dryRun}`);
|
||||
if (command === 'plan') {
|
||||
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
|
||||
`version=${plan.version}\ntag=${plan.tag}\ndry_run=${plan.dryRun}\n`);
|
||||
return;
|
||||
}
|
||||
await publishPackages(collectPackages(path.resolve(directory), plan.version), plan);
|
||||
}
|
||||
|
||||
if (require.main === module) main().catch((error) => {
|
||||
console.error(`release:npm: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
module.exports = { releasePlan, compareVersions, collectPackages, publishPackages };
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-30
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _TERMINAL_IO_H_
|
||||
#define _TERMINAL_IO_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <csignal>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::cli {
|
||||
static_assert(std::atomic<int>::is_always_lock_free);
|
||||
extern std::atomic<int> interrupted;
|
||||
void InitializeIo();
|
||||
const char *Platform();
|
||||
bool IsTty(int fd);
|
||||
std::ptrdiff_t Read(int fd, void *buffer, size_t size);
|
||||
std::ptrdiff_t Write(int fd, const void *buffer, size_t size);
|
||||
void Close(int fd);
|
||||
int Duplicate(int fd);
|
||||
int Redirect(int from, int to);
|
||||
int OpenLog(const std::string &path);
|
||||
bool WaitReadable(int fd, int timeout_ms);
|
||||
void WaitIo(int input_fd, int output_fd, int timeout_ms);
|
||||
std::string Password();
|
||||
std::string Logs();
|
||||
struct TerminalSize {
|
||||
int columns = 80, rows = 25, pixel_width = 0, pixel_height = 0;
|
||||
};
|
||||
TerminalSize GetTerminalSize(int fd);
|
||||
|
||||
class NonBlocking {
|
||||
public:
|
||||
explicit NonBlocking(int fd, bool output = false);
|
||||
~NonBlocking();
|
||||
NonBlocking(const NonBlocking &) = delete;
|
||||
NonBlocking &operator=(const NonBlocking &) = delete;
|
||||
|
||||
private:
|
||||
struct State;
|
||||
std::unique_ptr<State> state_;
|
||||
};
|
||||
|
||||
class RawTerminal {
|
||||
public:
|
||||
RawTerminal(int input, int output);
|
||||
~RawTerminal();
|
||||
RawTerminal(const RawTerminal &) = delete;
|
||||
RawTerminal &operator=(const RawTerminal &) = delete;
|
||||
|
||||
private:
|
||||
struct State;
|
||||
std::unique_ptr<State> state_;
|
||||
};
|
||||
} // namespace crossdesk::cli
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,162 @@
|
||||
#ifndef _WIN32
|
||||
#include "terminal_io.h"
|
||||
#include <cerrno>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <stdexcept>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/select.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace crossdesk::cli {
|
||||
std::atomic<int> interrupted{0};
|
||||
namespace {
|
||||
void Signal(int value) { interrupted.store(value, std::memory_order_relaxed); }
|
||||
} // namespace
|
||||
void InitializeIo() {
|
||||
struct sigaction action{};
|
||||
action.sa_handler = Signal;
|
||||
sigemptyset(&action.sa_mask);
|
||||
for (int sig : {SIGINT, SIGTERM, SIGHUP})
|
||||
sigaction(sig, &action, nullptr);
|
||||
std::signal(SIGPIPE, SIG_IGN);
|
||||
}
|
||||
const char *Platform() {
|
||||
#ifdef __APPLE__
|
||||
return "macos";
|
||||
#else
|
||||
return "linux";
|
||||
#endif
|
||||
}
|
||||
bool IsTty(int fd) { return isatty(fd); }
|
||||
std::ptrdiff_t Read(int fd, void *buffer, size_t size) {
|
||||
return read(fd, buffer, size);
|
||||
}
|
||||
std::ptrdiff_t Write(int fd, const void *buffer, size_t size) {
|
||||
return write(fd, buffer, size);
|
||||
}
|
||||
void Close(int fd) { close(fd); }
|
||||
int Duplicate(int fd) { return dup(fd); }
|
||||
int Redirect(int from, int to) { return dup2(from, to); }
|
||||
int OpenLog(const std::string &path) {
|
||||
return open(path.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0600);
|
||||
}
|
||||
bool WaitReadable(int fd, int timeout_ms) {
|
||||
pollfd p{fd, POLLIN, 0};
|
||||
int result = poll(&p, 1, timeout_ms);
|
||||
if (result < 0 && errno != EINTR)
|
||||
throw std::runtime_error("cannot poll input descriptor");
|
||||
if (p.revents & POLLNVAL) {
|
||||
// Darwin's /dev/tty may reject poll even though the descriptor is valid.
|
||||
// select supports that controlling-terminal device without a blocking read.
|
||||
if (fd < 0 || fd >= FD_SETSIZE || fcntl(fd, F_GETFL) < 0)
|
||||
throw std::runtime_error("input descriptor unavailable");
|
||||
fd_set readers;
|
||||
FD_ZERO(&readers);
|
||||
FD_SET(fd, &readers);
|
||||
timeval timeout{timeout_ms / 1000, (timeout_ms % 1000) * 1000};
|
||||
result = select(fd + 1, &readers, nullptr, nullptr, &timeout);
|
||||
if (result < 0 && errno != EINTR)
|
||||
throw std::runtime_error("cannot wait for terminal input");
|
||||
return result > 0;
|
||||
}
|
||||
return result > 0 && (p.revents & (POLLIN | POLLHUP | POLLERR));
|
||||
}
|
||||
void WaitIo(int input_fd, int output_fd, int timeout_ms) {
|
||||
pollfd fds[2] = {{input_fd, POLLIN, 0}, {output_fd, POLLOUT, 0}};
|
||||
poll(fds, 2, timeout_ms);
|
||||
}
|
||||
TerminalSize GetTerminalSize(int fd) {
|
||||
winsize ws{};
|
||||
TerminalSize size;
|
||||
if (ioctl(fd, TIOCGWINSZ, &ws) == 0 && ws.ws_col && ws.ws_row) {
|
||||
size.columns = ws.ws_col;
|
||||
size.rows = ws.ws_row;
|
||||
size.pixel_width = ws.ws_xpixel;
|
||||
size.pixel_height = ws.ws_ypixel;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
struct NonBlocking::State {
|
||||
int fd, flags;
|
||||
};
|
||||
NonBlocking::NonBlocking(int fd, bool) : state_(std::make_unique<State>()) {
|
||||
state_->fd = fd;
|
||||
state_->flags = fcntl(fd, F_GETFL);
|
||||
if (state_->flags < 0 || fcntl(fd, F_SETFL, state_->flags | O_NONBLOCK) < 0)
|
||||
throw std::runtime_error("cannot configure nonblocking descriptor");
|
||||
}
|
||||
NonBlocking::~NonBlocking() { fcntl(state_->fd, F_SETFL, state_->flags); }
|
||||
struct RawTerminal::State {
|
||||
int fd, flags;
|
||||
termios saved;
|
||||
};
|
||||
RawTerminal::RawTerminal(int input, int) : state_(std::make_unique<State>()) {
|
||||
state_->fd = input;
|
||||
state_->flags = fcntl(input, F_GETFL);
|
||||
if (state_->flags < 0 || tcgetattr(input, &state_->saved) != 0)
|
||||
throw std::runtime_error("cannot read terminal attributes");
|
||||
termios mode = state_->saved;
|
||||
cfmakeraw(&mode);
|
||||
if (tcsetattr(input, TCSAFLUSH, &mode) != 0)
|
||||
throw std::runtime_error("cannot enter terminal raw mode");
|
||||
if (fcntl(input, F_SETFL, state_->flags | O_NONBLOCK) < 0) {
|
||||
tcsetattr(input, TCSAFLUSH, &state_->saved);
|
||||
throw std::runtime_error("cannot configure terminal input");
|
||||
}
|
||||
}
|
||||
RawTerminal::~RawTerminal() {
|
||||
tcsetattr(state_->fd, TCSAFLUSH, &state_->saved);
|
||||
fcntl(state_->fd, F_SETFL, state_->flags);
|
||||
}
|
||||
std::string Password() {
|
||||
int fd = open("/dev/tty", O_RDWR | O_NOCTTY);
|
||||
if (fd < 0)
|
||||
throw std::runtime_error("password requires an interactive TTY");
|
||||
struct Restore {
|
||||
int fd;
|
||||
termios mode{};
|
||||
bool active = false;
|
||||
~Restore() {
|
||||
if (active) {
|
||||
tcsetattr(fd, TCSAFLUSH, &mode);
|
||||
write(fd, "\n", 1);
|
||||
}
|
||||
close(fd);
|
||||
}
|
||||
} restore{fd};
|
||||
if (tcgetattr(fd, &restore.mode))
|
||||
throw std::runtime_error("cannot read password TTY");
|
||||
auto mode = restore.mode;
|
||||
mode.c_lflag &= ~ECHO;
|
||||
if (tcsetattr(fd, TCSAFLUSH, &mode))
|
||||
throw std::runtime_error("cannot disable password echo");
|
||||
restore.active = true;
|
||||
const char prompt[] = "Remote password: ";
|
||||
write(fd, prompt, sizeof(prompt) - 1);
|
||||
std::string result;
|
||||
while (!interrupted) {
|
||||
if (!WaitReadable(fd, 100))
|
||||
continue;
|
||||
char c;
|
||||
auto n = read(fd, &c, 1);
|
||||
if (n < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (n <= 0 || result.size() > 256)
|
||||
break;
|
||||
if (c == '\n' || c == '\r')
|
||||
return result;
|
||||
result += c;
|
||||
}
|
||||
throw std::runtime_error("password entry cancelled or too long");
|
||||
}
|
||||
std::string Logs() {
|
||||
char path[] = "/tmp/crossdesk-cli-XXXXXX";
|
||||
char *dir = mkdtemp(path);
|
||||
if (!dir)
|
||||
throw std::runtime_error("cannot create private log directory");
|
||||
return dir;
|
||||
}
|
||||
} // namespace crossdesk::cli
|
||||
#endif
|
||||
@@ -0,0 +1,490 @@
|
||||
#ifdef _WIN32
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include "terminal_io.h"
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
#include <random>
|
||||
#include <sddl.h>
|
||||
#include <stdexcept>
|
||||
#include <sys/stat.h>
|
||||
#include <thread>
|
||||
|
||||
namespace crossdesk::cli {
|
||||
std::atomic<int> interrupted{0};
|
||||
namespace {
|
||||
int console_output = -1;
|
||||
std::string input_bytes;
|
||||
wchar_t high_surrogate = 0;
|
||||
DWORD mouse_buttons = 0;
|
||||
HANDLE Handle(int fd) {
|
||||
#ifdef _MSC_VER
|
||||
// A missing optional credential fd should be an error response, not a CRT
|
||||
// invalid-parameter process termination. Keep the handler thread-local.
|
||||
auto previous = _set_thread_local_invalid_parameter_handler(
|
||||
[](const wchar_t *, const wchar_t *, const wchar_t *, unsigned,
|
||||
uintptr_t) {});
|
||||
auto value = _get_osfhandle(fd);
|
||||
_set_thread_local_invalid_parameter_handler(previous);
|
||||
return reinterpret_cast<HANDLE>(value);
|
||||
#else
|
||||
return reinterpret_cast<HANDLE>(_get_osfhandle(fd));
|
||||
#endif
|
||||
}
|
||||
void Signal(int value) { interrupted.store(value, std::memory_order_relaxed); }
|
||||
BOOL WINAPI ConsoleSignal(DWORD value) {
|
||||
if (value == CTRL_C_EVENT || value == CTRL_BREAK_EVENT) {
|
||||
interrupted.store(SIGINT, std::memory_order_relaxed);
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
std::string Utf8(const std::wstring &value) {
|
||||
if (value.empty())
|
||||
return {};
|
||||
int size =
|
||||
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(),
|
||||
int(value.size()), nullptr, 0, nullptr, nullptr);
|
||||
if (!size)
|
||||
throw std::runtime_error("invalid Unicode input");
|
||||
std::string result(size, '\0');
|
||||
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(),
|
||||
int(value.size()), result.data(), size, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
std::wstring Wide(const std::string &value) {
|
||||
int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
|
||||
int(value.size()), nullptr, 0);
|
||||
if (!size)
|
||||
throw std::runtime_error("invalid Unicode path");
|
||||
std::wstring result(size, L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
|
||||
int(value.size()), result.data(), size);
|
||||
return result;
|
||||
}
|
||||
void Character(wchar_t c) {
|
||||
if (c >= 0xd800 && c <= 0xdbff) {
|
||||
high_surrogate = c;
|
||||
return;
|
||||
}
|
||||
if (c >= 0xdc00 && c <= 0xdfff) {
|
||||
if (high_surrogate)
|
||||
input_bytes += Utf8(std::wstring{high_surrogate, c});
|
||||
} else if (c) {
|
||||
input_bytes += Utf8(std::wstring(1, c));
|
||||
}
|
||||
high_surrogate = 0;
|
||||
}
|
||||
void Key(const KEY_EVENT_RECORD &key) {
|
||||
if (!key.bKeyDown)
|
||||
return;
|
||||
const char *sequence = nullptr;
|
||||
switch (key.wVirtualKeyCode) {
|
||||
case VK_UP:
|
||||
sequence = "\033[A";
|
||||
break;
|
||||
case VK_DOWN:
|
||||
sequence = "\033[B";
|
||||
break;
|
||||
case VK_RIGHT:
|
||||
sequence = "\033[C";
|
||||
break;
|
||||
case VK_LEFT:
|
||||
sequence = "\033[D";
|
||||
break;
|
||||
case VK_HOME:
|
||||
sequence = "\033[H";
|
||||
break;
|
||||
case VK_END:
|
||||
sequence = "\033[F";
|
||||
break;
|
||||
case VK_DELETE:
|
||||
sequence = "\033[3~";
|
||||
break;
|
||||
case VK_PRIOR:
|
||||
sequence = "\033[5~";
|
||||
break;
|
||||
case VK_NEXT:
|
||||
sequence = "\033[6~";
|
||||
break;
|
||||
case VK_F1:
|
||||
sequence = "\033OP";
|
||||
break;
|
||||
case VK_F2:
|
||||
sequence = "\033OQ";
|
||||
break;
|
||||
case VK_F3:
|
||||
sequence = "\033OR";
|
||||
break;
|
||||
case VK_F4:
|
||||
sequence = "\033OS";
|
||||
break;
|
||||
}
|
||||
for (int i = 0; i < std::min<int>(key.wRepeatCount, 64); ++i) {
|
||||
if (sequence)
|
||||
input_bytes += sequence;
|
||||
else {
|
||||
// Right Alt + Ctrl is AltGr, not an Alt shortcut.
|
||||
bool alt = key.dwControlKeyState & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED);
|
||||
bool ctrl =
|
||||
key.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED);
|
||||
if (alt && !ctrl && key.uChar.UnicodeChar)
|
||||
input_bytes += '\033';
|
||||
Character(key.uChar.UnicodeChar);
|
||||
}
|
||||
}
|
||||
}
|
||||
void Mouse(const MOUSE_EVENT_RECORD &mouse) {
|
||||
CONSOLE_SCREEN_BUFFER_INFO info{};
|
||||
if (console_output < 0 ||
|
||||
!GetConsoleScreenBufferInfo(Handle(console_output), &info))
|
||||
return;
|
||||
int x = mouse.dwMousePosition.X - info.srWindow.Left + 1;
|
||||
int y = mouse.dwMousePosition.Y - info.srWindow.Top + 1;
|
||||
if (x < 1 || y < 1)
|
||||
return;
|
||||
auto emit = [&](int button, bool release = false) {
|
||||
input_bytes += "\033[<" + std::to_string(button) + ";" + std::to_string(x) +
|
||||
";" + std::to_string(y) + (release ? "m" : "M");
|
||||
};
|
||||
DWORD buttons = mouse.dwButtonState & 0xffff;
|
||||
const DWORD masks[] = {FROM_LEFT_1ST_BUTTON_PRESSED,
|
||||
FROM_LEFT_2ND_BUTTON_PRESSED,
|
||||
RIGHTMOST_BUTTON_PRESSED};
|
||||
for (int i = 0; i < 3; ++i)
|
||||
if ((buttons & masks[i]) != (mouse_buttons & masks[i]))
|
||||
emit(i, !(buttons & masks[i]));
|
||||
if (mouse.dwEventFlags & (MOUSE_WHEELED | MOUSE_HWHEELED)) {
|
||||
int delta = static_cast<SHORT>(HIWORD(mouse.dwButtonState));
|
||||
emit(64 + ((mouse.dwEventFlags & MOUSE_HWHEELED) ? 2 : 0) + (delta < 0));
|
||||
} else if (mouse.dwEventFlags & MOUSE_MOVED) {
|
||||
emit(32 + ((buttons & masks[0]) ? 0
|
||||
: (buttons & masks[1]) ? 1
|
||||
: (buttons & masks[2]) ? 2
|
||||
: 3));
|
||||
}
|
||||
mouse_buttons = buttons;
|
||||
}
|
||||
bool Ready(int fd) {
|
||||
if (fd < 0)
|
||||
return false;
|
||||
HANDLE handle = Handle(fd);
|
||||
if (handle == INVALID_HANDLE_VALUE)
|
||||
throw std::runtime_error("input descriptor unavailable");
|
||||
DWORD mode = 0;
|
||||
if (GetConsoleMode(handle, &mode)) {
|
||||
DWORD count = 0;
|
||||
return !input_bytes.empty() ||
|
||||
(GetNumberOfConsoleInputEvents(handle, &count) && count);
|
||||
}
|
||||
if (GetFileType(handle) == FILE_TYPE_PIPE) {
|
||||
DWORD available = 0;
|
||||
if (PeekNamedPipe(handle, nullptr, 0, nullptr, &available, nullptr))
|
||||
return available != 0;
|
||||
if (GetLastError() == ERROR_BROKEN_PIPE)
|
||||
return true;
|
||||
throw std::runtime_error("cannot inspect input pipe");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
void InitializeIo() {
|
||||
// Preserve LF-delimited JSON and terminal escape bytes through CRT streams.
|
||||
for (int fd : {0, 1, 2})
|
||||
_setmode(fd, _O_BINARY);
|
||||
std::signal(SIGINT, Signal);
|
||||
std::signal(SIGTERM, Signal);
|
||||
SetConsoleCtrlHandler(ConsoleSignal, TRUE);
|
||||
}
|
||||
const char *Platform() { return "windows"; }
|
||||
bool IsTty(int fd) {
|
||||
DWORD mode;
|
||||
return GetConsoleMode(Handle(fd), &mode) != 0;
|
||||
}
|
||||
std::ptrdiff_t Read(int fd, void *buffer, size_t size) {
|
||||
if (!size)
|
||||
return 0;
|
||||
HANDLE handle = Handle(fd);
|
||||
DWORD mode;
|
||||
if (GetConsoleMode(handle, &mode)) {
|
||||
if (input_bytes.empty()) {
|
||||
DWORD count = 0, read = 0;
|
||||
if (!GetNumberOfConsoleInputEvents(handle, &count)) {
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
INPUT_RECORD records[128];
|
||||
if (count && ReadConsoleInputW(handle, records,
|
||||
std::min<DWORD>(count, 128), &read))
|
||||
for (DWORD i = 0; i < read; ++i) {
|
||||
if (records[i].EventType == KEY_EVENT)
|
||||
Key(records[i].Event.KeyEvent);
|
||||
else if (records[i].EventType == MOUSE_EVENT)
|
||||
Mouse(records[i].Event.MouseEvent);
|
||||
else if (records[i].EventType == FOCUS_EVENT &&
|
||||
!records[i].Event.FocusEvent.bSetFocus)
|
||||
input_bytes += "\033[O";
|
||||
}
|
||||
}
|
||||
if (input_bytes.empty()) {
|
||||
errno = EAGAIN;
|
||||
return -1;
|
||||
}
|
||||
size_t count = std::min(size, input_bytes.size());
|
||||
std::copy_n(input_bytes.data(), count, static_cast<char *>(buffer));
|
||||
input_bytes.erase(0, count);
|
||||
return count;
|
||||
}
|
||||
if (GetFileType(handle) == FILE_TYPE_PIPE) {
|
||||
DWORD available = 0;
|
||||
if (!PeekNamedPipe(handle, nullptr, 0, nullptr, &available, nullptr)) {
|
||||
if (GetLastError() == ERROR_BROKEN_PIPE)
|
||||
return 0;
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
if (!available) {
|
||||
errno = EAGAIN;
|
||||
return -1;
|
||||
}
|
||||
size = std::min<size_t>(size, available);
|
||||
}
|
||||
DWORD count = 0;
|
||||
if (!ReadFile(handle, buffer, DWORD(std::min<size_t>(size, 16384)), &count,
|
||||
nullptr)) {
|
||||
if (GetLastError() == ERROR_BROKEN_PIPE)
|
||||
return 0;
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
std::ptrdiff_t Write(int fd, const void *buffer, size_t size) {
|
||||
DWORD count = 0;
|
||||
if (!WriteFile(Handle(fd), buffer, DWORD(std::min<size_t>(size, 16384)),
|
||||
&count, nullptr)) {
|
||||
errno = GetLastError() == ERROR_NO_DATA ? EPIPE : EIO;
|
||||
return -1;
|
||||
}
|
||||
if (!count && size) {
|
||||
errno = EAGAIN;
|
||||
return -1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
void Close(int fd) { _close(fd); }
|
||||
int Duplicate(int fd) { return _dup(fd); }
|
||||
int Redirect(int from, int to) { return _dup2(from, to); }
|
||||
int OpenLog(const std::string &path) {
|
||||
return _wopen(Wide(path).c_str(),
|
||||
_O_CREAT | _O_WRONLY | _O_APPEND | _O_BINARY,
|
||||
_S_IREAD | _S_IWRITE);
|
||||
}
|
||||
bool WaitReadable(int fd, int timeout_ms) {
|
||||
auto until =
|
||||
std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
do {
|
||||
if (Ready(fd))
|
||||
return true;
|
||||
if (interrupted)
|
||||
return false;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
} while (std::chrono::steady_clock::now() < until);
|
||||
return false;
|
||||
}
|
||||
void WaitIo(int input_fd, int, int timeout_ms) {
|
||||
WaitReadable(input_fd, timeout_ms);
|
||||
}
|
||||
TerminalSize GetTerminalSize(int) {
|
||||
TerminalSize size;
|
||||
HANDLE handle = console_output >= 0
|
||||
? Handle(console_output)
|
||||
: CreateFileW(L"CONOUT$", GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
|
||||
OPEN_EXISTING, 0, nullptr);
|
||||
CONSOLE_SCREEN_BUFFER_INFO info{};
|
||||
if (GetConsoleScreenBufferInfo(handle, &info)) {
|
||||
size.columns = info.srWindow.Right - info.srWindow.Left + 1;
|
||||
size.rows = info.srWindow.Bottom - info.srWindow.Top + 1;
|
||||
// ConPTY's console window is only a message window, and its font API
|
||||
// describes the hidden console host, not the user's display terminal.
|
||||
// Only trust native font metrics for a visible classic console.
|
||||
HWND window = GetConsoleWindow();
|
||||
CONSOLE_FONT_INFOEX font{};
|
||||
font.cbSize = sizeof(font);
|
||||
if (window && IsWindowVisible(window) &&
|
||||
GetCurrentConsoleFontEx(handle, FALSE, &font) &&
|
||||
font.dwFontSize.X > 0 && font.dwFontSize.X <= 1024 &&
|
||||
font.dwFontSize.Y > 0 && font.dwFontSize.Y <= 1024) {
|
||||
size.pixel_width = size.columns * font.dwFontSize.X;
|
||||
size.pixel_height = size.rows * font.dwFontSize.Y;
|
||||
}
|
||||
}
|
||||
if (console_output < 0 && handle != INVALID_HANDLE_VALUE)
|
||||
CloseHandle(handle);
|
||||
return size;
|
||||
}
|
||||
struct NonBlocking::State {
|
||||
HANDLE handle;
|
||||
DWORD mode = 0;
|
||||
bool restore = false;
|
||||
};
|
||||
NonBlocking::NonBlocking(int fd, bool output)
|
||||
: state_(std::make_unique<State>()) {
|
||||
state_->handle = Handle(fd);
|
||||
// Reads use PeekNamedPipe; output pipes additionally require bounded writes.
|
||||
if (output && GetFileType(state_->handle) == FILE_TYPE_PIPE) {
|
||||
DWORD flags = 0;
|
||||
if (!GetNamedPipeHandleStateW(state_->handle, &state_->mode, nullptr,
|
||||
nullptr, nullptr, nullptr, 0))
|
||||
throw std::runtime_error("cannot read pipe mode");
|
||||
flags = state_->mode | PIPE_NOWAIT;
|
||||
if (!SetNamedPipeHandleState(state_->handle, &flags, nullptr, nullptr))
|
||||
throw std::runtime_error("cannot configure nonblocking pipe");
|
||||
state_->restore = true;
|
||||
}
|
||||
}
|
||||
NonBlocking::~NonBlocking() {
|
||||
if (state_->restore)
|
||||
SetNamedPipeHandleState(state_->handle, &state_->mode, nullptr, nullptr);
|
||||
}
|
||||
struct RawTerminal::State {
|
||||
HANDLE input, output;
|
||||
DWORD input_mode, output_mode;
|
||||
UINT input_cp, output_cp;
|
||||
State(HANDLE in, HANDLE out, DWORD in_mode, DWORD out_mode)
|
||||
: input(in), output(out), input_mode(in_mode), output_mode(out_mode),
|
||||
input_cp(GetConsoleCP()), output_cp(GetConsoleOutputCP()) {}
|
||||
State(const State &) = delete;
|
||||
State &operator=(const State &) = delete;
|
||||
~State() {
|
||||
SetConsoleMode(input, input_mode);
|
||||
SetConsoleMode(output, output_mode);
|
||||
SetConsoleCP(input_cp);
|
||||
SetConsoleOutputCP(output_cp);
|
||||
console_output = -1;
|
||||
input_bytes.clear();
|
||||
high_surrogate = 0;
|
||||
mouse_buttons = 0;
|
||||
}
|
||||
};
|
||||
RawTerminal::RawTerminal(int input, int output) {
|
||||
DWORD in_mode = 0, out_mode = 0;
|
||||
if (!GetConsoleMode(Handle(input), &in_mode) ||
|
||||
!GetConsoleMode(Handle(output), &out_mode))
|
||||
throw std::runtime_error(
|
||||
"view requires a Windows console or ConPTY terminal");
|
||||
state_ = std::make_unique<State>(Handle(input), Handle(output), in_mode, out_mode);
|
||||
DWORD mode = (in_mode & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT |
|
||||
ENABLE_PROCESSED_INPUT | ENABLE_QUICK_EDIT_MODE |
|
||||
ENABLE_VIRTUAL_TERMINAL_INPUT)) |
|
||||
ENABLE_EXTENDED_FLAGS | ENABLE_WINDOW_INPUT | ENABLE_MOUSE_INPUT;
|
||||
if (!SetConsoleMode(Handle(input), mode) ||
|
||||
!SetConsoleMode(Handle(output), out_mode | ENABLE_PROCESSED_OUTPUT |
|
||||
ENABLE_VIRTUAL_TERMINAL_PROCESSING) ||
|
||||
!SetConsoleCP(CP_UTF8) || !SetConsoleOutputCP(CP_UTF8))
|
||||
throw std::runtime_error(
|
||||
"terminal requires Windows 10 or newer with VT support");
|
||||
console_output = output;
|
||||
}
|
||||
RawTerminal::~RawTerminal() = default;
|
||||
std::string Password() {
|
||||
HANDLE input = CreateFileW(L"CONIN$", GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
|
||||
OPEN_EXISTING, 0, nullptr);
|
||||
HANDLE output =
|
||||
CreateFileW(L"CONOUT$", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
struct Restore {
|
||||
HANDLE input, output;
|
||||
DWORD mode = 0;
|
||||
bool active = false;
|
||||
~Restore() {
|
||||
if (active) {
|
||||
SetConsoleMode(input, mode);
|
||||
DWORD count;
|
||||
WriteConsoleW(output, L"\r\n", 2, &count, nullptr);
|
||||
}
|
||||
if (input != INVALID_HANDLE_VALUE)
|
||||
CloseHandle(input);
|
||||
if (output != INVALID_HANDLE_VALUE)
|
||||
CloseHandle(output);
|
||||
}
|
||||
} restore{input, output};
|
||||
if (input == INVALID_HANDLE_VALUE || output == INVALID_HANDLE_VALUE ||
|
||||
!GetConsoleMode(input, &restore.mode))
|
||||
throw std::runtime_error("password requires an interactive console");
|
||||
if (!SetConsoleMode(
|
||||
input, (restore.mode & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT)) |
|
||||
ENABLE_PROCESSED_INPUT))
|
||||
throw std::runtime_error("cannot disable password echo");
|
||||
restore.active = true;
|
||||
DWORD count;
|
||||
WriteConsoleW(output, L"Remote password: ", 17, &count, nullptr);
|
||||
std::wstring value;
|
||||
while (!interrupted) {
|
||||
if (WaitForSingleObject(input, 100) != WAIT_OBJECT_0)
|
||||
continue;
|
||||
INPUT_RECORD record{};
|
||||
if (!ReadConsoleInputW(input, &record, 1, &count))
|
||||
break;
|
||||
if (!count || record.EventType != KEY_EVENT ||
|
||||
!record.Event.KeyEvent.bKeyDown)
|
||||
continue;
|
||||
wchar_t c = record.Event.KeyEvent.uChar.UnicodeChar;
|
||||
if (c == L'\r') {
|
||||
auto result = Utf8(value);
|
||||
if (result.size() <= 256)
|
||||
return result;
|
||||
break;
|
||||
}
|
||||
if (c == L'\b') {
|
||||
if (!value.empty()) {
|
||||
wchar_t last = value.back();
|
||||
value.pop_back();
|
||||
if (last >= 0xdc00 && last <= 0xdfff && !value.empty())
|
||||
value.pop_back();
|
||||
}
|
||||
} else if (c >= 32) {
|
||||
value += c;
|
||||
if (value.size() > 256)
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw std::runtime_error("password entry cancelled or too long");
|
||||
}
|
||||
std::string Logs() {
|
||||
wchar_t root[MAX_PATH + 1];
|
||||
DWORD size = GetTempPathW(MAX_PATH + 1, root);
|
||||
if (!size || size > MAX_PATH)
|
||||
throw std::runtime_error("cannot locate temporary directory");
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;OW)", SDDL_REVISION_1, &descriptor,
|
||||
nullptr))
|
||||
throw std::runtime_error("cannot secure log directory");
|
||||
struct Release {
|
||||
PSECURITY_DESCRIPTOR p;
|
||||
~Release() { LocalFree(p); }
|
||||
} release{descriptor};
|
||||
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), descriptor, FALSE};
|
||||
std::random_device random;
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
std::wstring path = std::wstring(root) + L"crossdesk-cli-" +
|
||||
std::to_wstring(random()) + L"-" +
|
||||
std::to_wstring(random());
|
||||
if (CreateDirectoryW(path.c_str(), &security))
|
||||
return Utf8(path);
|
||||
if (GetLastError() != ERROR_ALREADY_EXISTS)
|
||||
break;
|
||||
}
|
||||
throw std::runtime_error("cannot create private log directory");
|
||||
}
|
||||
} // namespace crossdesk::cli
|
||||
#endif
|
||||
@@ -0,0 +1,48 @@
|
||||
local source_dir = path.absolute("../..", os.scriptdir())
|
||||
local controller_dir = path.join(source_dir, "libs/controller")
|
||||
local terminal_dir = path.join(source_dir, "libs/terminal")
|
||||
local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc")
|
||||
local credential_patch = path.join(source_dir, "deps/patches/minirtc-credential-logs.patch")
|
||||
|
||||
add_requires("spdlog 1.14.1", {system = false})
|
||||
if is_plat("windows") then
|
||||
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
|
||||
end
|
||||
|
||||
target("crossdesk_controller")
|
||||
set_kind("static")
|
||||
add_deps("minirtc", "crossdesk_wire")
|
||||
add_packages("spdlog")
|
||||
add_includedirs(path.join(controller_dir, "include"),
|
||||
path.join(minirtc_dir, "src/api"), {public = true})
|
||||
add_includedirs(path.join(minirtc_dir, "src/log"))
|
||||
add_files(path.join(controller_dir, "src/*.cpp"))
|
||||
before_build(function ()
|
||||
-- Require the same credential redaction in checkouts and source bundles.
|
||||
os.vrunv("git", {"-C", minirtc_dir, "apply", "--reverse", "--check", credential_patch})
|
||||
end)
|
||||
target_end()
|
||||
|
||||
target("crossdesk_terminal")
|
||||
set_kind("static")
|
||||
add_deps("crossdesk_controller")
|
||||
add_includedirs(path.join(terminal_dir, "include"), {public = true})
|
||||
add_files(path.join(terminal_dir, "src/*.cpp"))
|
||||
if is_plat("windows") then
|
||||
add_packages("zlib", {public = true})
|
||||
else
|
||||
add_syslinks("z", {public = true})
|
||||
end
|
||||
target_end()
|
||||
|
||||
target("crossdesk-cli")
|
||||
set_kind("binary")
|
||||
set_version("0.1.1")
|
||||
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")
|
||||
end
|
||||
target_end()
|
||||
@@ -1,4 +1,4 @@
|
||||
function setup_options_and_dependencies()
|
||||
function setup_options_and_dependencies(enable_dependencies)
|
||||
option("CROSSDESK_UPDATE_PUBLIC_KEY")
|
||||
set_showmenu(true)
|
||||
set_description("Ed25519 public key (64 lowercase hex characters); empty disables automatic installation")
|
||||
@@ -34,6 +34,11 @@ function setup_options_and_dependencies()
|
||||
set_description("Build CrossDesk as a portable package that stores data beside the executable")
|
||||
option_end()
|
||||
|
||||
-- Keep options discoverable when switching back from a CLI-only build.
|
||||
if enable_dependencies == false then
|
||||
return
|
||||
end
|
||||
|
||||
-- set_policy("build.warning", true)
|
||||
-- set_warnings("all", "extra")
|
||||
-- add_cxxflags("/W4", "/WX")
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
diff --git a/src/pc/peer_connection.cpp b/src/pc/peer_connection.cpp
|
||||
index bb9eb49..eda133f 100644
|
||||
--- a/src/pc/peer_connection.cpp
|
||||
+++ b/src/pc/peer_connection.cpp
|
||||
@@ -459,3 +459,3 @@ int PeerConnection::SendJoinRequestLocked(
|
||||
LOG_INFO("[{}] sends join transmission [{}] request", user_id_,
|
||||
- transmission_id);
|
||||
+ transmission_id.substr(0, transmission_id.find('@')));
|
||||
}
|
||||
@@ -481,3 +481,3 @@ int PeerConnection::Join(const std::string& transmission_id) {
|
||||
LOG_INFO("Queue join transmission [{}] until signal login completes",
|
||||
- transmission_id);
|
||||
+ transmission_id.substr(0, transmission_id.find('@')));
|
||||
return 0;
|
||||
@@ -498,3 +498,3 @@ int PeerConnection::Leave(const std::string& transmission_id) {
|
||||
LOG_INFO("Cancel queued join transmission [{}]",
|
||||
- *pending_join_transmission_id_);
|
||||
+ pending_join_transmission_id_->substr(0, pending_join_transmission_id_->find('@')));
|
||||
pending_join_transmission_id_.reset();
|
||||
@@ -508,3 +508,3 @@ int PeerConnection::Leave(const std::string& transmission_id) {
|
||||
LOG_INFO("[{}] leave transmission [{}] notification queued",
|
||||
- user_id_, transmission_id);
|
||||
+ user_id_, transmission_id.substr(0, transmission_id.find('@')));
|
||||
}
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: cdcd0d8fb0...b9ee1fa7fe
@@ -9,6 +9,8 @@
|
||||
|
||||
## 构建依赖
|
||||
|
||||
默认只构建桌面客户端。终端主控可通过 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 单独构建;同时开启两个选项可构建两个客户端。CLI 的依赖准备、运行和 npm 打包见 [终端客户端](../apps/cli/README.md)。
|
||||
|
||||
- Git、[Xmake](https://xmake.io/guide/quick-start.html)(桌面端和移动端均要求 3.1.1 或更新版本)及下文对应平台的编译工具链。
|
||||
- 构建脚本通过 Xmake 管理 **CMake 3.x(≥ 3.21、< 4.0)**、**Rust 1.92.0** 等依赖;首次构建需要下载工具与源码。具体约束见 [Slint 包配置](../deps/thirdparty/slint/xmake.lua)。
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ After cloning, run build and packaging commands from the repository root. The [X
|
||||
|
||||
## Build requirements
|
||||
|
||||
The default configuration builds the desktop client. Use `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` for the terminal controller alone, or enable both options to build both clients. See the [CLI guide](../apps/cli/README.md) (Chinese) for dependency preparation, usage and npm packaging.
|
||||
|
||||
- Git, [Xmake](https://xmake.io/guide/quick-start.html) (3.1.1 or newer for both desktop and mobile), and the platform toolchain listed below.
|
||||
- The build scripts use Xmake to manage **CMake 3.x (≥ 3.21, < 4.0)**, **Rust 1.92.0**, and other dependencies. The first build downloads tools and source archives. See the [Slint package configuration](../deps/thirdparty/slint/xmake.lua) for the constraints.
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-30
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CONTROLLER_DIAGNOSTICS_H_
|
||||
#define _CONTROLLER_DIAGNOSTICS_H_
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk {
|
||||
void InitializeControllerDiagnostics(const std::string &directory);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-30
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CONTROLLER_SESSION_H_
|
||||
#define _CONTROLLER_SESSION_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <minirtc.h>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <optional>
|
||||
#include <remote_action.h>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
struct DecodedFrame {
|
||||
unsigned width = 0, height = 0;
|
||||
uint64_t generation = 0, frame_id = 0;
|
||||
std::chrono::steady_clock::time_point queued;
|
||||
std::vector<uint8_t> nv12;
|
||||
};
|
||||
// One replaceable pending picture; Take transfers ownership to the consumer.
|
||||
// Callback memory is copied synchronously. Native descriptors never escape.
|
||||
class LatestFrame {
|
||||
public:
|
||||
bool Submit(const MiniRtcVideoFrame &, uint64_t generation);
|
||||
// Recycle the caller's old buffer only when a new frame is available.
|
||||
std::optional<DecodedFrame> Take(DecodedFrame *recycle = nullptr);
|
||||
void Reset(uint64_t generation);
|
||||
nlohmann::json Stats() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
std::optional<DecodedFrame> pending_;
|
||||
std::vector<uint8_t> spare_;
|
||||
uint64_t generation_ = 0, received_ = 0, dropped_ = 0, invalid_ = 0;
|
||||
};
|
||||
struct SessionOptions {
|
||||
std::string host = "api.crossdesk.cn", log_directory;
|
||||
int port = 9099, display = 0, fps = 8;
|
||||
int preview_width = 1920, preview_height = 1080;
|
||||
int quality = 2, max_bitrate_bps = 6000000;
|
||||
std::chrono::seconds timeout{30};
|
||||
};
|
||||
// All public methods except the destructor run on one owner thread. Call Tick
|
||||
// regularly. Library callbacks only enqueue bounded events or copy one frame.
|
||||
class ControllerSession {
|
||||
public:
|
||||
ControllerSession() = default;
|
||||
~ControllerSession();
|
||||
void Connect(SessionOptions options, std::string device,
|
||||
std::string password);
|
||||
void Tick();
|
||||
void Disconnect();
|
||||
bool SelectDisplay(int index);
|
||||
void SetPreview(int fps, int width, int height, int quality = -1,
|
||||
int max_bitrate_bps = -1);
|
||||
void SetControl(bool enabled);
|
||||
bool Text(const std::string &utf8);
|
||||
bool Key(int key, bool down);
|
||||
bool Pointer(float x, float y, int flag, int wheel = 0);
|
||||
std::optional<DecodedFrame> TakeFrame(DecodedFrame *recycle = nullptr);
|
||||
nlohmann::json Status() const;
|
||||
bool Connected() const { return state_ == "connected"; }
|
||||
bool Finished() const { return state_ == "failed" || state_ == "closed"; }
|
||||
uint64_t Generation() const { return generation_; }
|
||||
|
||||
private:
|
||||
struct Event {
|
||||
int kind, code;
|
||||
std::string data;
|
||||
};
|
||||
void Enqueue(Event event) noexcept;
|
||||
void Fail(const char *reason);
|
||||
void ClosePeers();
|
||||
Params Parameters();
|
||||
bool Send(const char *stream, const std::string &payload);
|
||||
void Preview();
|
||||
void ReleaseInput();
|
||||
static void Signal(SignalStatus, const char *, size_t, void *);
|
||||
static void Connection(ConnectionStatus, const char *, size_t, void *);
|
||||
static void Data(const char *, size_t, const char *, size_t, const char *,
|
||||
size_t, void *);
|
||||
static void Video(const MiniRtcVideoFrame *, const char *, size_t,
|
||||
const char *, size_t, void *);
|
||||
static void Network(const char *, size_t, TraversalMode,
|
||||
const MiniRtcNetTrafficStats *, const char *, size_t,
|
||||
void *);
|
||||
SessionOptions options_;
|
||||
PeerPtr *peer_ = nullptr;
|
||||
std::string state_ = "idle", error_, device_, password_, client_id_;
|
||||
uint64_t generation_ = 0;
|
||||
uint32_t preview_request_ = 0, preview_ack_ = 0;
|
||||
bool control_ = false, preview_supported_ = false, resolution_ = false;
|
||||
bool fps15_ = false, bandwidth_ = false, text_supported_ = false;
|
||||
bool preview_accepted_ = false;
|
||||
std::vector<HostDisplay> displays_;
|
||||
std::set<int> keys_, buttons_;
|
||||
float pointer_x_ = .5f, pointer_y_ = .5f;
|
||||
std::chrono::steady_clock::time_point deadline_;
|
||||
mutable std::mutex mutex_;
|
||||
std::deque<Event> events_;
|
||||
bool stopping_ = true, overflow_ = false;
|
||||
uint64_t callback_generation_ = 0;
|
||||
int callback_display_ = 0;
|
||||
MiniRtcNetTrafficStats network_{};
|
||||
TraversalMode traversal_ = UnknownMode;
|
||||
LatestFrame frames_;
|
||||
};
|
||||
nlohmann::json ParseBoundedJson(const std::string &text);
|
||||
std::string SafeText(const std::string &text, size_t limit = 160);
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,79 @@
|
||||
#include "controller_diagnostics.h"
|
||||
#include <log.h>
|
||||
#include <string_view>
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
// Signaling errors and SDP can contain credentials. Only audited transport
|
||||
// events reach this sink; arbitrary MiniRTC messages remain suppressed.
|
||||
std::string DiagnosticEvent(const spdlog::details::log_msg &message) {
|
||||
std::string_view source = message.source.filename ? message.source.filename : "";
|
||||
const auto slash = source.find_last_of("/\\");
|
||||
if (slash != std::string_view::npos)
|
||||
source.remove_prefix(slash + 1);
|
||||
const std::string_view text(message.payload.data(), message.payload.size());
|
||||
if (source == "controller_session.cpp")
|
||||
return std::string(text); // Local states/counters only at these call sites.
|
||||
if (source == "ice_transport.cpp") {
|
||||
const auto marker = text.rfind("state_change: ");
|
||||
if (marker != std::string_view::npos) {
|
||||
const auto state = text.substr(marker + 14);
|
||||
for (const auto allowed : {"disconnected", "gathering", "connecting",
|
||||
"connected", "ready", "failed"})
|
||||
if (state == allowed)
|
||||
return "ICE state: " + std::string(state);
|
||||
}
|
||||
for (const auto allowed : {"Traversal using relay server", "Traversal using p2p",
|
||||
"Selected ICE pair is not available yet"})
|
||||
if (text == allowed)
|
||||
return std::string(text);
|
||||
}
|
||||
if (source == "ice_agent.cpp") {
|
||||
for (const auto allowed : {"DTLS handshake started, awaiting peer packets...",
|
||||
"DTLS handshake retransmission failed",
|
||||
"DTLS peer certificate missing",
|
||||
"DTLS peer fingerprint mismatch",
|
||||
"DTLS peer fingerprint verified",
|
||||
"DTLS peer not verified", "No SRTP profile selected",
|
||||
"SSL_export_keying_material failed", "StartDtls failed",
|
||||
"libnice has no relay upgrade extension; rebuild dependencies"})
|
||||
if (text == allowed)
|
||||
return std::string(text);
|
||||
for (const auto prefix : {"DTLS handshake timed out after ",
|
||||
"DTLS handshake start failed, err=",
|
||||
"DTLS packet processing failed, err="})
|
||||
if (text.substr(0, std::char_traits<char>::length(prefix)) == prefix)
|
||||
return std::string(prefix); // Omit variable payload entirely.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
class DiagnosticSink final : public spdlog::sinks::base_sink<std::mutex> {
|
||||
public:
|
||||
explicit DiagnosticSink(const std::string &directory)
|
||||
: file_(directory + "/transport.log", 1024 * 1024, 2) {}
|
||||
|
||||
private:
|
||||
void sink_it_(const spdlog::details::log_msg &message) override {
|
||||
const auto event = DiagnosticEvent(message);
|
||||
if (event.empty())
|
||||
return;
|
||||
auto sanitized = message;
|
||||
sanitized.payload = spdlog::string_view_t(event.data(), event.size());
|
||||
file_.log(sanitized);
|
||||
file_.flush();
|
||||
}
|
||||
void flush_() override { file_.flush(); }
|
||||
spdlog::sinks::rotating_file_sink_mt file_;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
void InitializeControllerDiagnostics(const std::string &directory) {
|
||||
auto sink = std::make_shared<DiagnosticSink>(directory);
|
||||
minirtc::InitLogger(directory);
|
||||
auto logger = minirtc::get_logger();
|
||||
// Connect calls this after the previous peer and its callbacks stop.
|
||||
logger->sinks() = {std::move(sink)};
|
||||
logger->set_level(spdlog::level::info);
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,443 @@
|
||||
#include "controller_session.h"
|
||||
#include "controller_diagnostics.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <display_stream_id.h>
|
||||
#include <log.h>
|
||||
#include <preview_subscription.h>
|
||||
#include <stdexcept>
|
||||
#include <stream_names.h>
|
||||
|
||||
namespace crossdesk {
|
||||
using nlohmann::json;
|
||||
ControllerSession::~ControllerSession() { Disconnect(); }
|
||||
void ControllerSession::Enqueue(Event e) noexcept {
|
||||
try {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (stopping_)
|
||||
return;
|
||||
if (events_.size() == 64) {
|
||||
overflow_ = true;
|
||||
return;
|
||||
}
|
||||
events_.push_back(std::move(e));
|
||||
} catch (...) { /* Allocation failure cannot escape a C callback. */
|
||||
}
|
||||
}
|
||||
void ControllerSession::Signal(SignalStatus s, const char *id, size_t n,
|
||||
void *p) {
|
||||
try {
|
||||
static_cast<ControllerSession *>(p)->Enqueue(
|
||||
{0, s, id && n < 256 ? std::string(id, n) : ""});
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void ControllerSession::Connection(ConnectionStatus s, const char *, size_t,
|
||||
void *p) {
|
||||
auto &session = *static_cast<ControllerSession *>(p);
|
||||
session.Enqueue({1, s, {}});
|
||||
}
|
||||
void ControllerSession::Data(const char *data, size_t n, const char *, size_t,
|
||||
const char *stream, size_t sn, void *p) {
|
||||
auto &session = *static_cast<ControllerSession *>(p);
|
||||
try {
|
||||
if (data && n && n <= 65536 && stream && sn < 64 &&
|
||||
(std::string(stream, sn) == kControlStream ||
|
||||
std::string(stream, sn) == kWebControlStream))
|
||||
session.Enqueue({2, 0, std::string(data, n)});
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void ControllerSession::Video(const MiniRtcVideoFrame *f, const char *, size_t,
|
||||
const char *stream, size_t n, void *p) {
|
||||
auto &s = *static_cast<ControllerSession *>(p);
|
||||
try {
|
||||
std::lock_guard lock(s.mutex_);
|
||||
if (!s.stopping_ && f && stream && n < 64 &&
|
||||
std::string(stream, n) == MakeDisplayStreamId(s.callback_display_))
|
||||
s.frames_.Submit(*f, s.callback_generation_);
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void ControllerSession::Network(const char *, size_t, TraversalMode mode,
|
||||
const MiniRtcNetTrafficStats *stats,
|
||||
const char *, size_t, void *p) {
|
||||
auto &session = *static_cast<ControllerSession *>(p);
|
||||
if (stats) {
|
||||
std::lock_guard lock(session.mutex_);
|
||||
session.network_ = *stats;
|
||||
session.traversal_ = mode;
|
||||
}
|
||||
}
|
||||
Params ControllerSession::Parameters() {
|
||||
Params p{};
|
||||
std::snprintf(p.signal_server_ip, sizeof(p.signal_server_ip), "%s",
|
||||
options_.host.c_str());
|
||||
std::snprintf(p.log_path, sizeof(p.log_path), "%s",
|
||||
options_.log_directory.c_str());
|
||||
p.signal_server_port = options_.port;
|
||||
p.hardware_acceleration = false;
|
||||
p.native_video_output = true;
|
||||
p.av1_encoding = false;
|
||||
p.turn_mode = TurnAutoUdpTcp;
|
||||
p.enable_srtp = true;
|
||||
p.video_content_type = VideoContentType::ScreenContent;
|
||||
p.video_quality = QualityLow;
|
||||
p.video_frame_rate = 30;
|
||||
p.video_degradation_preference =
|
||||
VideoDegradationPreference::MaintainResolution;
|
||||
p.on_signal_status = Signal;
|
||||
p.on_connection_status = Connection;
|
||||
p.on_net_status_report = Network;
|
||||
p.on_receive_data_buffer = Data;
|
||||
p.on_receive_video_frame = Video;
|
||||
// No audio callback, clipboard receiver, file writer or incoming input
|
||||
// handler.
|
||||
p.user_id = "cli";
|
||||
p.user_data = this;
|
||||
return p;
|
||||
}
|
||||
void ControllerSession::Connect(SessionOptions options, std::string device,
|
||||
std::string password) {
|
||||
Disconnect();
|
||||
if (device.size() != 9 ||
|
||||
device.find_first_not_of("0123456789") != std::string::npos)
|
||||
throw std::invalid_argument("device ID must contain 9 digits");
|
||||
if (password.size() > 256 || password.find('\0') != std::string::npos)
|
||||
throw std::invalid_argument("invalid password length or encoding");
|
||||
if (options.host.empty() || options.host.size() >= 256 || options.port < 1 ||
|
||||
options.port > 65535 || options.log_directory.empty() ||
|
||||
options.log_directory.size() >= 256 || options.display < 0 ||
|
||||
options.display > 63)
|
||||
throw std::invalid_argument(
|
||||
"invalid server, log directory or display index");
|
||||
options_ = std::move(options);
|
||||
device_ = std::move(device);
|
||||
password_ = std::move(password);
|
||||
state_ = "registering";
|
||||
error_.clear();
|
||||
displays_.clear();
|
||||
preview_supported_ = resolution_ = fps15_ = bandwidth_ = text_supported_ =
|
||||
false;
|
||||
preview_request_ = preview_ack_ = 0;
|
||||
preview_accepted_ = false;
|
||||
deadline_ = std::chrono::steady_clock::now() + options_.timeout;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
stopping_ = false;
|
||||
overflow_ = false;
|
||||
events_.clear();
|
||||
network_ = {};
|
||||
traversal_ = UnknownMode;
|
||||
callback_generation_ = ++generation_;
|
||||
callback_display_ = options_.display;
|
||||
frames_.Reset(generation_);
|
||||
}
|
||||
// The server assigns a temporary controller identity on this connection.
|
||||
// Never register a desktop identity or persist local credentials.
|
||||
client_id_.clear();
|
||||
InitializeControllerDiagnostics(options_.log_directory);
|
||||
SPDLOG_LOGGER_INFO(minirtc::get_logger(), "Session registering");
|
||||
auto p = Parameters();
|
||||
peer_ = CreatePeer(&p);
|
||||
if (!peer_) {
|
||||
Fail("media_initialization_failed");
|
||||
return;
|
||||
}
|
||||
AddAudioStream(peer_, kAudioStream);
|
||||
AddDataStream(peer_, kDataStream, false);
|
||||
for (auto stream : {kMouseStream, kKeyboardStream, kControlStream})
|
||||
AddDataStream(peer_, stream, true);
|
||||
if (Init(peer_) != 0)
|
||||
Fail("media_initialization_failed");
|
||||
}
|
||||
void ControllerSession::Tick() {
|
||||
std::deque<Event> pending;
|
||||
bool overflow;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
pending.swap(events_);
|
||||
overflow = overflow_;
|
||||
}
|
||||
if (Finished() || state_ == "idle")
|
||||
return;
|
||||
if (overflow) {
|
||||
Fail("control_event_overflow");
|
||||
return;
|
||||
}
|
||||
for (auto &e : pending) {
|
||||
if (Finished())
|
||||
break;
|
||||
if (e.kind == 0) {
|
||||
SPDLOG_LOGGER_INFO(minirtc::get_logger(), "Signal status: {}", e.code);
|
||||
if (e.code == SignalTlsCertError) {
|
||||
Fail("tls_certificate_error");
|
||||
break;
|
||||
}
|
||||
if (e.code == SignalFailed) {
|
||||
Fail("signaling_login_failed");
|
||||
break;
|
||||
}
|
||||
if (e.code == SignalClosed || e.code == SignalServerClosed ||
|
||||
e.code == SignalReconnecting) {
|
||||
Fail("signaling_disconnected");
|
||||
break;
|
||||
}
|
||||
if (e.code != SignalConnected)
|
||||
continue;
|
||||
if (state_ == "registering") {
|
||||
if (e.data.size() != 13 || e.data.rfind("cli-", 0) != 0 ||
|
||||
e.data.find_first_not_of("0123456789", 4) != std::string::npos) {
|
||||
Fail("cli_identity_not_supported");
|
||||
break;
|
||||
}
|
||||
client_id_ = e.data;
|
||||
state_ = "connecting";
|
||||
const auto join = device_ + "@" + password_;
|
||||
if (JoinConnection(peer_, join.c_str()) != 0)
|
||||
Fail("join_request_failed");
|
||||
std::fill(password_.begin(), password_.end(), '\0');
|
||||
password_.clear();
|
||||
}
|
||||
} else if (e.kind == 1) {
|
||||
SPDLOG_LOGGER_INFO(minirtc::get_logger(), "Connection status: {} (session {})",
|
||||
e.code, state_);
|
||||
if (e.code == ::Connected && !Connected()) {
|
||||
state_ = "connected";
|
||||
auto hello = MakeHostInformation("CrossDesk CLI", {}, false);
|
||||
Send(kControlStream, hello.to_json());
|
||||
FreeRemoteAction(hello);
|
||||
RemoteAction mute{};
|
||||
mute.type = audio_capture;
|
||||
mute.a = false;
|
||||
Send(kControlStream, mute.to_json());
|
||||
} else if (e.code == IncorrectPassword)
|
||||
Fail("incorrect_password");
|
||||
else if (e.code == NoSuchTransmissionId)
|
||||
Fail("device_not_found");
|
||||
else if (e.code == RemoteUnavailable)
|
||||
Fail("device_offline_or_unavailable");
|
||||
else if (e.code == Failed)
|
||||
Fail(Connected() ? "connection_lost" : "connection_failed");
|
||||
else if (e.code == Closed || e.code == Disconnected)
|
||||
Fail("remote_disconnected");
|
||||
} else if (e.kind == 2 && Connected()) {
|
||||
auto j = ParseBoundedJson(e.data);
|
||||
if (!j.is_object())
|
||||
continue;
|
||||
if (j.value("type", json{}) == "deskhub_preview_status") {
|
||||
if (j.value("request_id", json{}) == preview_request_ &&
|
||||
j.contains("accepted") && j["accepted"].is_boolean()) {
|
||||
preview_ack_ = preview_request_;
|
||||
preview_accepted_ = j["accepted"];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
RemoteAction a{};
|
||||
if (!a.from_json(e.data))
|
||||
continue;
|
||||
if (a.type == host_infomation) {
|
||||
displays_.clear();
|
||||
for (size_t i = 0; i < std::min(a.i.display_num, size_t(64)); ++i)
|
||||
displays_.push_back({SafeText(a.i.display_list[i]), a.i.left[i],
|
||||
a.i.top[i], a.i.right[i], a.i.bottom[i]});
|
||||
preview_supported_ = a.i.supports_deskhub_preview;
|
||||
resolution_ = a.i.supports_deskhub_resolution;
|
||||
fps15_ = a.i.supports_deskhub_fps15;
|
||||
bandwidth_ = a.i.supports_deskhub_bandwidth;
|
||||
text_supported_ = a.i.supports_text_input;
|
||||
if (!SelectDisplay(options_.display))
|
||||
Fail("display_not_available");
|
||||
else
|
||||
Preview();
|
||||
}
|
||||
FreeRemoteAction(a);
|
||||
}
|
||||
}
|
||||
if (!Connected() && !Finished() &&
|
||||
std::chrono::steady_clock::now() > deadline_)
|
||||
Fail("connection_timeout");
|
||||
}
|
||||
bool ControllerSession::Send(const char *stream, const std::string &payload) {
|
||||
return peer_ && Connected() && !payload.empty() &&
|
||||
SendReliableDataFrame(peer_, payload.data(), payload.size(), stream) ==
|
||||
0;
|
||||
}
|
||||
void ControllerSession::ClosePeers() {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
stopping_ = true;
|
||||
callback_generation_ = ++generation_;
|
||||
frames_.Reset(generation_);
|
||||
}
|
||||
if (peer_) {
|
||||
LeaveConnection(peer_, device_.c_str());
|
||||
DestroyPeer(&peer_);
|
||||
}
|
||||
std::fill(password_.begin(), password_.end(), '\0');
|
||||
password_.clear();
|
||||
}
|
||||
void ControllerSession::Fail(const char *reason) {
|
||||
SPDLOG_LOGGER_INFO(minirtc::get_logger(), "Session failed: {}", reason);
|
||||
ReleaseInput();
|
||||
error_ = reason;
|
||||
state_ = "failed";
|
||||
control_ = false;
|
||||
ClosePeers();
|
||||
}
|
||||
void ControllerSession::Disconnect() {
|
||||
if (state_ == "closed" && !control_)
|
||||
return;
|
||||
ReleaseInput();
|
||||
control_ = false;
|
||||
ClosePeers();
|
||||
state_ = "closed";
|
||||
}
|
||||
bool ControllerSession::SelectDisplay(int index) {
|
||||
if (!Connected() || index < 0 || size_t(index) >= displays_.size())
|
||||
return false;
|
||||
ReleaseInput();
|
||||
options_.display = index;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
callback_display_ = index;
|
||||
callback_generation_ = ++generation_;
|
||||
frames_.Reset(generation_);
|
||||
}
|
||||
RemoteAction a{};
|
||||
a.type = display_id;
|
||||
a.d = index;
|
||||
return Send(kControlStream, a.to_json());
|
||||
}
|
||||
void ControllerSession::SetPreview(int fps, int w, int h, int quality,
|
||||
int bitrate) {
|
||||
if (fps < 0 || fps > 30 || w < 2 || h < 2 || w > 3840 || h > 2160 ||
|
||||
quality < -1 || quality > 2 || bitrate < -1 || bitrate > 100000000)
|
||||
throw std::invalid_argument("preview out of range");
|
||||
quality = quality < 0 ? options_.quality : quality;
|
||||
bitrate = bitrate < 0 ? options_.max_bitrate_bps : bitrate;
|
||||
if (options_.fps == fps && options_.preview_width == w &&
|
||||
options_.preview_height == h && options_.quality == quality &&
|
||||
options_.max_bitrate_bps == bitrate)
|
||||
return;
|
||||
options_.fps = fps;
|
||||
options_.preview_width = w;
|
||||
options_.preview_height = h;
|
||||
options_.quality = quality;
|
||||
options_.max_bitrate_bps = bitrate;
|
||||
Preview();
|
||||
}
|
||||
void ControllerSession::Preview() {
|
||||
if (!Connected() || !preview_supported_)
|
||||
return;
|
||||
PreviewSubscription s;
|
||||
s.request_id = ++preview_request_;
|
||||
preview_accepted_ = false;
|
||||
s.fps = options_.fps == 0 ? 0
|
||||
: options_.fps <= 1 ? 1
|
||||
: options_.fps <= 8 || !fps15_ ? 8
|
||||
: 15;
|
||||
s.audio = false;
|
||||
s.quality = options_.quality;
|
||||
if (resolution_) {
|
||||
s.version = 2;
|
||||
s.width = options_.preview_width;
|
||||
s.height = options_.preview_height;
|
||||
}
|
||||
if (bandwidth_)
|
||||
s.max_bitrate_bps = options_.max_bitrate_bps;
|
||||
Send(kControlStream, s.ToJson().dump());
|
||||
}
|
||||
void ControllerSession::SetControl(bool enabled) {
|
||||
if (control_ != enabled)
|
||||
SPDLOG_LOGGER_INFO(minirtc::get_logger(), "Remote control: {}", enabled);
|
||||
if (!enabled)
|
||||
ReleaseInput();
|
||||
control_ = enabled;
|
||||
}
|
||||
void ControllerSession::ReleaseInput() {
|
||||
auto keys = keys_;
|
||||
for (int k : keys)
|
||||
Key(k, false);
|
||||
auto buttons = buttons_;
|
||||
for (int b : buttons)
|
||||
Pointer(pointer_x_, pointer_y_, b + 1);
|
||||
keys_.clear();
|
||||
buttons_.clear();
|
||||
}
|
||||
bool ControllerSession::Key(int key, bool down) {
|
||||
if (!control_ || !Connected() || key < 0 || key > 255)
|
||||
return false;
|
||||
RemoteAction a{};
|
||||
a.type = keyboard;
|
||||
a.k = {size_t(key), 0, false, down ? key_down : key_up};
|
||||
if (down)
|
||||
keys_.insert(key);
|
||||
else
|
||||
keys_.erase(key);
|
||||
return Send(kKeyboardStream, a.to_json());
|
||||
}
|
||||
bool ControllerSession::Pointer(float x, float y, int flag, int wheel) {
|
||||
if (!control_ || !Connected() || !std::isfinite(x) || !std::isfinite(y) ||
|
||||
x < 0 || y < 0 || x > 1 || y > 1 || flag < 0 || flag > 8 ||
|
||||
std::abs(int64_t(wheel)) > 1200)
|
||||
return false;
|
||||
RemoteAction a{};
|
||||
a.type = mouse;
|
||||
a.m = {x, y, wheel, static_cast<MouseFlag>(flag)};
|
||||
pointer_x_ = x;
|
||||
pointer_y_ = y;
|
||||
if (flag >= 1 && flag <= 6) {
|
||||
if (flag & 1)
|
||||
buttons_.insert(flag);
|
||||
else
|
||||
buttons_.erase(flag - 1);
|
||||
}
|
||||
return Send(kMouseStream, a.to_json());
|
||||
}
|
||||
bool ControllerSession::Text(const std::string &text) {
|
||||
if (!control_ || !Connected() || !text_supported_ || text.empty() ||
|
||||
text.size() > kMaxTextInputBytes)
|
||||
return false;
|
||||
RemoteAction a{};
|
||||
a.type = text_input;
|
||||
a.text = text;
|
||||
try {
|
||||
return Send(kKeyboardStream, a.to_json());
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::optional<DecodedFrame> ControllerSession::TakeFrame(DecodedFrame *recycle) {
|
||||
return frames_.Take(recycle);
|
||||
}
|
||||
json ControllerSession::Status() const {
|
||||
json ds = json::array();
|
||||
for (size_t i = 0; i < displays_.size(); ++i)
|
||||
ds.push_back({{"index", i}, {"name", displays_[i].name}});
|
||||
std::lock_guard lock(mutex_);
|
||||
return {{"state", state_},
|
||||
{"client_id", client_id_},
|
||||
{"error", error_},
|
||||
{"generation", generation_},
|
||||
{"control", control_},
|
||||
{"displays", ds},
|
||||
{"display", options_.display},
|
||||
{"text_input", text_supported_},
|
||||
{"preview_supported", preview_supported_},
|
||||
{"preview_request", preview_request_},
|
||||
{"preview_ack", preview_ack_},
|
||||
{"preview_accepted", preview_accepted_},
|
||||
{"preview_width", options_.preview_width},
|
||||
{"preview_height", options_.preview_height},
|
||||
{"preview_quality", options_.quality},
|
||||
{"preview_bitrate_bps", options_.max_bitrate_bps},
|
||||
{"frames", frames_.Stats()},
|
||||
{"network",
|
||||
{{"video_bps", network_.video_inbound_stats.bitrate},
|
||||
{"rtt_ms", network_.rtt_ms},
|
||||
{"srtp", network_.srtp_active},
|
||||
{"traversal", int(traversal_)}}}};
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "controller_session.h"
|
||||
#include <climits>
|
||||
#include <cstring>
|
||||
|
||||
namespace crossdesk {
|
||||
bool LatestFrame::Submit(const MiniRtcVideoFrame &f, uint64_t generation) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (generation != generation_)
|
||||
return false;
|
||||
const size_t w = f.width, h = f.height;
|
||||
if (!w || !h || (w & 1) || (h & 1) || w > 8192 || h > 8192 ||
|
||||
w * h > 16777216) {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
const size_t size = w * h * 3 / 2;
|
||||
const auto *n = f.native_frame;
|
||||
const bool native = n && n->struct_size >= sizeof(*n) && n->width == w &&
|
||||
n->height == h && n->owner;
|
||||
if (native && n->type == MiniRtcNativeVideoFrameCpuNv12) {
|
||||
const auto &p = n->payload.cpu_nv12;
|
||||
if (!p.y_plane || !p.uv_plane || p.y_stride < w || p.uv_stride < w ||
|
||||
p.y_stride > INT_MAX || p.uv_stride > INT_MAX) {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
spare_.resize(size);
|
||||
for (size_t y = 0; y < h; ++y)
|
||||
std::memcpy(spare_.data() + y * w, p.y_plane + y * p.y_stride, w);
|
||||
for (size_t y = 0; y < h / 2; ++y)
|
||||
std::memcpy(spare_.data() + w * h + y * w, p.uv_plane + y * p.uv_stride,
|
||||
w);
|
||||
} else if (f.data && f.size >= size) {
|
||||
spare_.resize(size);
|
||||
std::memcpy(spare_.data(), f.data, size);
|
||||
} else if (native && n->copy_to_nv12) {
|
||||
spare_.resize(size);
|
||||
if (n->copy_to_nv12(n->owner, spare_.data(), size) != 0) {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
++invalid_;
|
||||
return false;
|
||||
}
|
||||
DecodedFrame frame{f.width,
|
||||
f.height,
|
||||
generation,
|
||||
++received_,
|
||||
std::chrono::steady_clock::now(),
|
||||
std::move(spare_)};
|
||||
// Keep a valid pending frame until the replacement has been copied.
|
||||
if (pending_) {
|
||||
spare_.swap(pending_->nv12);
|
||||
++dropped_;
|
||||
}
|
||||
pending_ = std::move(frame);
|
||||
return true;
|
||||
}
|
||||
std::optional<DecodedFrame> LatestFrame::Take(DecodedFrame *recycle) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!pending_)
|
||||
return {};
|
||||
auto result = std::move(pending_);
|
||||
pending_.reset();
|
||||
if (recycle && recycle->nv12.capacity() > spare_.capacity())
|
||||
spare_.swap(recycle->nv12);
|
||||
return result;
|
||||
}
|
||||
void LatestFrame::Reset(uint64_t generation) {
|
||||
std::lock_guard lock(mutex_);
|
||||
generation_ = generation;
|
||||
pending_.reset();
|
||||
spare_.clear();
|
||||
}
|
||||
nlohmann::json LatestFrame::Stats() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return {{"received", received_},
|
||||
{"replaced", dropped_},
|
||||
{"invalid", invalid_},
|
||||
{"pending", pending_ ? 1 : 0}};
|
||||
}
|
||||
nlohmann::json ParseBoundedJson(const std::string &text) {
|
||||
if (text.size() > 65536)
|
||||
return {};
|
||||
int depth = 0;
|
||||
bool quoted = false, escaped = false;
|
||||
for (char c : text) {
|
||||
if (quoted) {
|
||||
if (escaped)
|
||||
escaped = false;
|
||||
else if (c == '\\')
|
||||
escaped = true;
|
||||
else if (c == '"')
|
||||
quoted = false;
|
||||
} else if (c == '"')
|
||||
quoted = true;
|
||||
else if (c == '{' || c == '[') {
|
||||
if (++depth > 32)
|
||||
return {};
|
||||
} else if (c == '}' || c == ']') {
|
||||
if (--depth < 0)
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return nlohmann::json::parse(text, nullptr, false);
|
||||
}
|
||||
std::string SafeText(const std::string &text, size_t limit) {
|
||||
// ASCII metadata avoids escape injection, C1 controls and bidi spoofing.
|
||||
std::string out;
|
||||
for (unsigned char c : text) {
|
||||
if (out.size() == limit)
|
||||
break;
|
||||
out += c >= 32 && c < 127 ? char(c) : '?';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-30
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _TERMINAL_RENDERER_H_
|
||||
#define _TERMINAL_RENDERER_H_
|
||||
|
||||
#include <controller_session.h>
|
||||
namespace crossdesk::terminal {
|
||||
enum class AnsiDetail { Half, Quadrant };
|
||||
struct Viewport {
|
||||
int columns = 80, rows = 23;
|
||||
double cell_aspect = .5;
|
||||
bool utf8 = true;
|
||||
int colors = 24;
|
||||
AnsiDetail detail = AnsiDetail::Half;
|
||||
double zoom = 1, center_x = .5, center_y = .5;
|
||||
int pixel_width = 0, pixel_height = 0;
|
||||
bool native = false, pixel_mode = false;
|
||||
int row_offset = 0; // Terminal rows reserved above the video area.
|
||||
};
|
||||
// Keep reported cell geometry across queries; never combine partial replies
|
||||
// from different query cycles. Pixel/cell window sizes use the full text area.
|
||||
class TerminalMetrics {
|
||||
public:
|
||||
void BeginQuery();
|
||||
void Report(const std::string &sequence);
|
||||
void Apply(Viewport &) const;
|
||||
bool Known() const { return cell_width_ > 0; }
|
||||
|
||||
private:
|
||||
bool SetCellSize(double width, double height);
|
||||
double cell_width_ = 0, cell_height_ = 0;
|
||||
int pixel_width_ = 0, pixel_height_ = 0, columns_ = 0, rows_ = 0;
|
||||
bool queried_ = false, direct_reply_ = false;
|
||||
};
|
||||
inline constexpr char kTerminalSizeQuery[] = "\033[16t\033[14t\033[18t";
|
||||
struct Rect {
|
||||
int x, y, width, height;
|
||||
};
|
||||
struct Cell {
|
||||
uint32_t top = 0, bottom = 0;
|
||||
uint32_t glyph = 0x2580;
|
||||
bool operator==(const Cell &other) const {
|
||||
return top == other.top && bottom == other.bottom && glyph == other.glyph;
|
||||
}
|
||||
};
|
||||
struct Grid {
|
||||
Viewport viewport;
|
||||
Rect content{};
|
||||
std::vector<Cell> cells;
|
||||
Rect source{};
|
||||
unsigned source_width = 0, source_height = 0;
|
||||
int samples_x = 1, samples_y = 2;
|
||||
};
|
||||
Rect Fit(int width, int height, int columns, int pixel_rows,
|
||||
double pixel_aspect = 1);
|
||||
Grid Layout(const DecodedFrame &, Viewport);
|
||||
Grid Convert(const DecodedFrame &, Viewport);
|
||||
std::optional<std::pair<float, float>> MapPointer(const Grid &, int column,
|
||||
int row);
|
||||
std::vector<std::string> Lines(const Grid &);
|
||||
std::string Difference(const Grid &, const std::optional<Grid> &previous);
|
||||
std::string Kitty(const DecodedFrame &, const Viewport &);
|
||||
inline constexpr char kKittyQuery[] =
|
||||
"\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\";
|
||||
inline constexpr char kKittyDelete[] = "\033_Ga=d,d=I,i=31,q=2\033\\";
|
||||
bool KittyReply(const std::string &response);
|
||||
struct StatusBar {
|
||||
std::string progress, compact_progress, error, view, network;
|
||||
bool control = false, command_mode = false;
|
||||
};
|
||||
// Three padded ASCII rows. Shortcuts are compacted as whole groups to fit.
|
||||
std::vector<std::string> StatusLines(const StatusBar &, int columns);
|
||||
// Incremental parser: protocol replies and unknown escapes are consumed
|
||||
// locally. Key taps always release immediately; paste uses the UTF-8 text
|
||||
// protocol. In command_mode, control records the mode to restore while the
|
||||
// session's remote input is suspended until the user explicitly leaves the mode.
|
||||
class Input {
|
||||
public:
|
||||
void Feed(const std::string &bytes, ControllerSession &, const Grid *grid);
|
||||
bool exit = false, control = false, native = false, command_mode = false;
|
||||
std::string error;
|
||||
double zoom = 1, center_x = .5, center_y = .5;
|
||||
uint64_t view_revision = 0;
|
||||
uint64_t mouse_reports = 0;
|
||||
uint64_t mouse_legacy_reports = 0, mouse_forwarded = 0;
|
||||
uint64_t mouse_send_failures = 0;
|
||||
TerminalMetrics terminal_metrics;
|
||||
|
||||
private:
|
||||
std::string pending_;
|
||||
std::chrono::steady_clock::time_point last_input_{};
|
||||
bool pasting_ = false;
|
||||
unsigned mouse_buttons_ = 0;
|
||||
float pointer_x_ = .5f, pointer_y_ = .5f;
|
||||
void Tap(ControllerSession &, int key, int modifier = 0);
|
||||
bool LocalKey(int key, ControllerSession &, const Grid *);
|
||||
void Navigate(char key, const Grid *);
|
||||
void Mouse(int button, int x, int y, bool release, ControllerSession &,
|
||||
const Grid *);
|
||||
};
|
||||
} // namespace crossdesk::terminal
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,355 @@
|
||||
#include "terminal_renderer.h"
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
namespace crossdesk::terminal {
|
||||
void Input::Tap(ControllerSession &s, int key, int modifier) {
|
||||
if (modifier)
|
||||
s.Key(modifier, true);
|
||||
s.Key(key, true);
|
||||
s.Key(key, false);
|
||||
if (modifier)
|
||||
s.Key(modifier, false);
|
||||
}
|
||||
bool Input::LocalKey(int key, ControllerSession &s, const Grid *grid) {
|
||||
if (key == 29) {
|
||||
if (command_mode) {
|
||||
command_mode = false;
|
||||
s.SetControl(control);
|
||||
if (control)
|
||||
Tap(s, 0xDD, 17); // VK_OEM_6: right bracket, not ASCII ']'.
|
||||
} else {
|
||||
command_mode = true;
|
||||
// Release held buttons and suspend remote input while choosing a command.
|
||||
s.SetControl(false);
|
||||
mouse_buttons_ = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (control && !command_mode)
|
||||
return false;
|
||||
if (key == '?') {
|
||||
command_mode = true;
|
||||
s.SetControl(false);
|
||||
mouse_buttons_ = 0;
|
||||
return true;
|
||||
}
|
||||
if (command_mode && (key == 27 || key == 3)) {
|
||||
command_mode = false;
|
||||
s.SetControl(control);
|
||||
return true;
|
||||
}
|
||||
if (key == 'q' || key == 3) {
|
||||
exit = true;
|
||||
command_mode = false;
|
||||
control = false;
|
||||
} else if (key == 'c') {
|
||||
control = !control;
|
||||
} else if (key == '+' || key == '=' || key == '-' || key == 'f' ||
|
||||
key == 'd' || key == 'i' || key == 'j' || key == 'k' ||
|
||||
key == 'l') {
|
||||
Navigate(char(key), grid);
|
||||
} else if (!command_mode) {
|
||||
return false;
|
||||
} else {
|
||||
error = "Unknown local command; ? for help";
|
||||
}
|
||||
s.SetControl(control && !command_mode);
|
||||
if (!control || command_mode)
|
||||
mouse_buttons_ = 0;
|
||||
return true;
|
||||
}
|
||||
void Input::Navigate(char key, const Grid *grid) {
|
||||
if (key == '+' || key == '=')
|
||||
zoom = std::min(32.0, zoom * 2);
|
||||
if (key == '-')
|
||||
zoom = std::max(1.0, zoom / 2);
|
||||
if (key == 'f') {
|
||||
native = false;
|
||||
zoom = 1;
|
||||
center_x = center_y = .5;
|
||||
}
|
||||
if (key == 'd') {
|
||||
native = true;
|
||||
zoom = 1;
|
||||
}
|
||||
// Use the displayed crop as the pan step, including native pixel mode.
|
||||
const double dx = grid && grid->source_width
|
||||
? double(grid->source.width) / grid->source_width
|
||||
: 1 / zoom;
|
||||
const double dy = grid && grid->source_height
|
||||
? double(grid->source.height) / grid->source_height
|
||||
: 1 / zoom;
|
||||
if (key == 'i' || key == 'j' || key == 'k' || key == 'l') {
|
||||
center_x = std::clamp(center_x, dx / 2, 1 - dx / 2);
|
||||
center_y = std::clamp(center_y, dy / 2, 1 - dy / 2);
|
||||
}
|
||||
if (key == 'j')
|
||||
center_x -= .2 * dx;
|
||||
if (key == 'l')
|
||||
center_x += .2 * dx;
|
||||
if (key == 'i')
|
||||
center_y -= .2 * dy;
|
||||
if (key == 'k')
|
||||
center_y += .2 * dy;
|
||||
center_x = std::clamp(center_x, 0.0, 1.0);
|
||||
center_y = std::clamp(center_y, 0.0, 1.0);
|
||||
++view_revision;
|
||||
}
|
||||
void Input::Mouse(int b, int x, int y, bool release, ControllerSession &s,
|
||||
const Grid *grid) {
|
||||
++mouse_reports;
|
||||
// Extra mouse buttons must not alias the three supported remote buttons.
|
||||
if (!grid || command_mode || b < 0 || b >= 128)
|
||||
return;
|
||||
auto pos = MapPointer(*grid, x - 1, y - 1);
|
||||
if (!control) {
|
||||
if (pos && !release && ((b & 64) || b == 0)) {
|
||||
center_x = pos->first;
|
||||
center_y = pos->second;
|
||||
Navigate((b & 64) ? ((b & 1) ? '-' : '+') : 'd', grid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!pos && !release)
|
||||
return;
|
||||
if (pos) {
|
||||
pointer_x_ = pos->first;
|
||||
pointer_y_ = pos->second;
|
||||
}
|
||||
const auto send = [&](int flag, int wheel = 0) {
|
||||
if (s.Pointer(pointer_x_, pointer_y_, flag, wheel)) {
|
||||
++mouse_forwarded;
|
||||
return true;
|
||||
}
|
||||
++mouse_send_failures;
|
||||
return false;
|
||||
};
|
||||
// Linux/X11 only positions the cursor on move events. A click or wheel
|
||||
// report can arrive without prior motion, including over an SSH terminal.
|
||||
// Always position before a button/scroll event; still release on failure.
|
||||
if (pos && !send(0) && !release)
|
||||
return;
|
||||
if (b & 64) {
|
||||
if (!release)
|
||||
send((b & 2) ? 8 : 7, (b & 1) ? -120 : 120);
|
||||
return;
|
||||
}
|
||||
if (b & 32)
|
||||
return;
|
||||
const int base = b & 3;
|
||||
if (base == 3) {
|
||||
// Legacy reports identify a release, but not which button was released.
|
||||
if (release) {
|
||||
for (unsigned i = 0; i < 3; ++i)
|
||||
if ((mouse_buttons_ & (1u << i)) && send(2 + i * 2))
|
||||
mouse_buttons_ &= ~(1u << i);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const int index = base == 0 ? 0 : base == 1 ? 2 : 1;
|
||||
if (send(1 + index * 2 + int(release))) {
|
||||
if (release)
|
||||
mouse_buttons_ &= ~(1u << index);
|
||||
else
|
||||
mouse_buttons_ |= 1u << index;
|
||||
}
|
||||
}
|
||||
void Input::Feed(const std::string &bytes, ControllerSession &s,
|
||||
const Grid *grid) {
|
||||
if (exit)
|
||||
return;
|
||||
if (pending_.size() + bytes.size() > 32768) {
|
||||
pending_.clear();
|
||||
pasting_ = false;
|
||||
s.SetControl(false);
|
||||
mouse_buttons_ = 0;
|
||||
control = false;
|
||||
return;
|
||||
}
|
||||
if (!bytes.empty()) {
|
||||
last_input_ = std::chrono::steady_clock::now();
|
||||
error.clear();
|
||||
}
|
||||
if (bytes.empty() && pending_ == "\033" &&
|
||||
std::chrono::steady_clock::now() - last_input_ >
|
||||
std::chrono::milliseconds(80)) {
|
||||
if (!LocalKey(27, s, grid))
|
||||
Tap(s, 27);
|
||||
pending_.clear();
|
||||
return;
|
||||
}
|
||||
pending_ += bytes;
|
||||
while (!pending_.empty()) {
|
||||
if (pasting_) {
|
||||
auto end = pending_.find("\033[201~");
|
||||
if (end == std::string::npos)
|
||||
return; // bounded by 32 KiB; one UTF-8 transaction
|
||||
// Drop local-mode paste without leaving the mode or running commands.
|
||||
if (control && !command_mode && !s.Text(pending_.substr(0, end)))
|
||||
error = "Remote UTF-8 text input unavailable";
|
||||
pending_.erase(0, end + 6);
|
||||
pasting_ = false;
|
||||
continue;
|
||||
}
|
||||
unsigned char c = pending_[0];
|
||||
if (c == 29) {
|
||||
LocalKey(c, s, grid);
|
||||
pending_.erase(0, 1);
|
||||
continue;
|
||||
}
|
||||
if (c == 27) {
|
||||
if (pending_.size() < 2)
|
||||
return;
|
||||
if (pending_.compare(0, 6, "\033[200~") == 0) {
|
||||
pasting_ = true;
|
||||
pending_.erase(0, 6);
|
||||
continue;
|
||||
}
|
||||
if (pending_[1] == '_') {
|
||||
auto end = pending_.find("\033\\", 2);
|
||||
if (end == std::string::npos)
|
||||
return;
|
||||
pending_.erase(0, end + 2);
|
||||
continue;
|
||||
}
|
||||
if (pending_[1] == '[' || pending_[1] == 'O') {
|
||||
if (pending_.compare(0, 3, "\033[M") == 0) {
|
||||
// Normal (non-SGR) reports have three raw bytes after CSI M.
|
||||
// Keep the whole packet buffered across reads so its coordinates
|
||||
// cannot become keystrokes or local commands.
|
||||
if (pending_.size() < 6)
|
||||
return;
|
||||
const int b = static_cast<unsigned char>(pending_[3]) - 32;
|
||||
const int x = static_cast<unsigned char>(pending_[4]) - 32;
|
||||
const int y = static_cast<unsigned char>(pending_[5]) - 32;
|
||||
pending_.erase(0, 6);
|
||||
if (b >= 0 && x > 0 && y > 0) {
|
||||
++mouse_legacy_reports;
|
||||
const bool release = (b & 3) == 3 && !(b & (32 | 64));
|
||||
Mouse(b, x, y, release, s, grid);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
size_t end = 2;
|
||||
while (end < pending_.size() &&
|
||||
!(pending_[end] >= '@' && pending_[end] <= '~'))
|
||||
++end;
|
||||
if (end == pending_.size())
|
||||
return;
|
||||
std::string seq = pending_.substr(0, end + 1);
|
||||
pending_.erase(0, end + 1);
|
||||
if (seq.back() == 't') {
|
||||
terminal_metrics.Report(seq);
|
||||
continue;
|
||||
}
|
||||
if (seq == "\033[O") {
|
||||
control = false;
|
||||
s.SetControl(false);
|
||||
mouse_buttons_ = 0;
|
||||
continue;
|
||||
}
|
||||
int key = 0;
|
||||
if (seq == "\033[A" || seq == "\033OA")
|
||||
key = 38;
|
||||
else if (seq == "\033[B" || seq == "\033OB")
|
||||
key = 40;
|
||||
else if (seq == "\033[C" || seq == "\033OC")
|
||||
key = 39;
|
||||
else if (seq == "\033[D" || seq == "\033OD")
|
||||
key = 37;
|
||||
else if (seq == "\033[H")
|
||||
key = 36;
|
||||
else if (seq == "\033[F")
|
||||
key = 35;
|
||||
else if (seq == "\033[3~")
|
||||
key = 46;
|
||||
else if (seq == "\033[5~")
|
||||
key = 33;
|
||||
else if (seq == "\033[6~")
|
||||
key = 34;
|
||||
else if (seq == "\033OP")
|
||||
key = 112;
|
||||
else if (seq == "\033OQ")
|
||||
key = 113;
|
||||
else if (seq == "\033OR")
|
||||
key = 114;
|
||||
else if (seq == "\033OS")
|
||||
key = 115;
|
||||
if (key) {
|
||||
if ((!control || command_mode) && key >= 37 && key <= 40) {
|
||||
LocalKey(key == 37 ? 'j'
|
||||
: key == 38 ? 'i'
|
||||
: key == 39 ? 'l'
|
||||
: 'k',
|
||||
s, grid);
|
||||
continue;
|
||||
}
|
||||
if (command_mode)
|
||||
LocalKey(0, s, grid);
|
||||
else
|
||||
Tap(s, key);
|
||||
continue;
|
||||
}
|
||||
int b = 0, x = 0, y = 0;
|
||||
char action = seq.back();
|
||||
bool mouse =
|
||||
seq.rfind("\033[<", 0) == 0 && (action == 'M' || action == 'm');
|
||||
const char *cur = seq.data() + std::min(size_t(3), seq.size());
|
||||
const char *limit = seq.data() + seq.size() - 1;
|
||||
for (int *value : {&b, &x, &y}) {
|
||||
if (!mouse)
|
||||
break;
|
||||
auto result = std::from_chars(cur, limit, *value);
|
||||
mouse = result.ec == std::errc{} && *value >= 0 && *value <= 10000;
|
||||
cur = result.ptr;
|
||||
if (value != &y) {
|
||||
mouse = mouse && cur < limit && *cur == ';';
|
||||
if (mouse)
|
||||
++cur;
|
||||
} else
|
||||
mouse = mouse && cur == limit;
|
||||
}
|
||||
if (mouse)
|
||||
Mouse(b, x, y, action == 'm', s, grid);
|
||||
continue;
|
||||
}
|
||||
// Alt+ASCII letter: a tap with explicit modifier release.
|
||||
if (command_mode)
|
||||
LocalKey(0, s, grid);
|
||||
else if (pending_[1] >= 'a' && pending_[1] <= 'z')
|
||||
Tap(s, pending_[1] - 'a' + 'A', 18);
|
||||
pending_.erase(0, 2);
|
||||
continue;
|
||||
}
|
||||
if (c < 32 || c == 127) {
|
||||
pending_.erase(0, 1);
|
||||
if (LocalKey(c, s, grid)) {
|
||||
if (exit)
|
||||
return;
|
||||
} else if (c == 13 || c == 10)
|
||||
Tap(s, 13);
|
||||
else if (c == 9)
|
||||
Tap(s, 9);
|
||||
else if (c == 127 || c == 8)
|
||||
Tap(s, 8);
|
||||
else if (c >= 1 && c <= 26)
|
||||
Tap(s, 'A' + c - 1, 17);
|
||||
continue;
|
||||
}
|
||||
size_t n = c < 128 ? 1
|
||||
: (c & 0xe0) == 0xc0 ? 2
|
||||
: (c & 0xf0) == 0xe0 ? 3
|
||||
: (c & 0xf8) == 0xf0 ? 4
|
||||
: 1;
|
||||
if (pending_.size() < n)
|
||||
return;
|
||||
auto text = pending_.substr(0, n);
|
||||
pending_.erase(0, n);
|
||||
if (LocalKey(c, s, grid)) {
|
||||
if (exit)
|
||||
return;
|
||||
} else if (control && !s.Text(text))
|
||||
error = "Remote UTF-8 text input unavailable";
|
||||
}
|
||||
}
|
||||
} // namespace crossdesk::terminal
|
||||
@@ -0,0 +1,67 @@
|
||||
#include "terminal_renderer.h"
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
|
||||
namespace crossdesk::terminal {
|
||||
void TerminalMetrics::BeginQuery() {
|
||||
queried_ = true;
|
||||
direct_reply_ = false;
|
||||
pixel_width_ = pixel_height_ = columns_ = rows_ = 0;
|
||||
}
|
||||
|
||||
bool TerminalMetrics::SetCellSize(double width, double height) {
|
||||
if (width < 1 || height < 1 || width > 1024 || height > 1024 ||
|
||||
width / height < .2 || width / height > 2)
|
||||
return false;
|
||||
cell_width_ = width;
|
||||
cell_height_ = height;
|
||||
return true;
|
||||
}
|
||||
|
||||
void TerminalMetrics::Report(const std::string &sequence) {
|
||||
if (!queried_ || sequence.size() < 9 || sequence.compare(0, 2, "\033[") ||
|
||||
sequence.back() != 't')
|
||||
return;
|
||||
int type = 0, height = 0, width = 0;
|
||||
const char *cur = sequence.data() + 2;
|
||||
const char *end = sequence.data() + sequence.size() - 1;
|
||||
for (int *value : {&type, &height, &width}) {
|
||||
auto result = std::from_chars(cur, end, *value);
|
||||
if (result.ec != std::errc{} || *value <= 0 || *value > 1048576)
|
||||
return;
|
||||
cur = result.ptr;
|
||||
if (value != &width) {
|
||||
if (cur == end || *cur++ != ';')
|
||||
return;
|
||||
} else if (cur != end) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (type == 6) {
|
||||
// CSI 16 t replies with character height, then width (not width/height).
|
||||
if (SetCellSize(width, height))
|
||||
direct_reply_ = true;
|
||||
} else if (type == 4) {
|
||||
pixel_width_ = width;
|
||||
pixel_height_ = height;
|
||||
} else if (type == 8) {
|
||||
columns_ = width;
|
||||
rows_ = height;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// VTE supports text-area pixels/cells even when CSI 16 t is unavailable.
|
||||
// Use reported full rows/columns, not the capped video viewport or status
|
||||
// rows, and retain fractional cell dimensions for scaled terminal fonts.
|
||||
if (!direct_reply_ && pixel_width_ && pixel_height_ && columns_ && rows_)
|
||||
SetCellSize(double(pixel_width_) / columns_, double(pixel_height_) / rows_);
|
||||
}
|
||||
|
||||
void TerminalMetrics::Apply(Viewport &viewport) const {
|
||||
if (!Known())
|
||||
return;
|
||||
viewport.cell_aspect = cell_width_ / cell_height_;
|
||||
viewport.pixel_width = int(std::lround(cell_width_ * viewport.columns));
|
||||
viewport.pixel_height = int(std::lround(cell_height_ * viewport.rows));
|
||||
}
|
||||
} // namespace crossdesk::terminal
|
||||
@@ -0,0 +1,345 @@
|
||||
#include "terminal_renderer.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <zlib.h>
|
||||
namespace crossdesk::terminal {
|
||||
Rect Fit(int w, int h, int cols, int rows, double aspect) {
|
||||
if (w <= 0 || h <= 0 || cols <= 0 || rows <= 0 || !std::isfinite(aspect) ||
|
||||
aspect <= 0)
|
||||
return {};
|
||||
const double scale = std::min(cols * aspect / w, double(rows) / h);
|
||||
const int cw = std::max(1, std::min(cols, int(w * scale / aspect)));
|
||||
const int ch = std::max(1, std::min(rows, int(h * scale)));
|
||||
return {(cols - cw) / 2, (rows - ch) / 2, cw, ch};
|
||||
}
|
||||
static uint32_t Pixel(const DecodedFrame &f, int x, int y) {
|
||||
const size_t offset =
|
||||
size_t(f.width) * f.height + (y / 2) * f.width + (x & ~1);
|
||||
int yy = std::max(0, int(f.nv12[size_t(y) * f.width + x]) - 16);
|
||||
int u = int(f.nv12[offset]) - 128, v = int(f.nv12[offset + 1]) - 128;
|
||||
auto clamp = [](int n) { return uint32_t(std::clamp(n, 0, 255)); };
|
||||
return (clamp((298 * yy + 409 * v + 128) >> 8) << 16) |
|
||||
(clamp((298 * yy - 100 * u - 208 * v + 128) >> 8) << 8) |
|
||||
clamp((298 * yy + 516 * u + 128) >> 8);
|
||||
}
|
||||
static void Validate(const DecodedFrame &f, const Viewport &v) {
|
||||
if (v.columns < 1 || v.rows < 1 || v.columns > 240 || v.rows > 100 ||
|
||||
v.row_offset < 0 || v.row_offset > 100 ||
|
||||
!std::isfinite(v.cell_aspect) || v.cell_aspect < .2 ||
|
||||
v.cell_aspect > 2 || !std::isfinite(v.zoom) || v.zoom < 1 ||
|
||||
v.zoom > 32 || !std::isfinite(v.center_x) || !std::isfinite(v.center_y) ||
|
||||
v.center_x < 0 || v.center_x > 1 || v.center_y < 0 || v.center_y > 1 ||
|
||||
!f.width || !f.height || f.width > 8192 || f.height > 8192 ||
|
||||
(f.width & 1) || (f.height & 1) ||
|
||||
size_t(f.width) * f.height > 16777216 ||
|
||||
f.nv12.size() < size_t(f.width) * f.height * 3 / 2)
|
||||
throw std::invalid_argument("invalid frame or viewport");
|
||||
}
|
||||
static Rect Crop(const DecodedFrame &f, const Viewport &v) {
|
||||
double width = f.width, height = f.height;
|
||||
if (v.native) {
|
||||
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1;
|
||||
const int sy = v.utf8 ? 2 : 1;
|
||||
const int ow = v.pixel_mode ? std::clamp(v.pixel_width > 0 ? v.pixel_width
|
||||
: v.columns * 10,
|
||||
2, 1920)
|
||||
: v.columns * sx;
|
||||
const int oh = v.pixel_mode ? std::clamp(v.pixel_height > 0 ? v.pixel_height
|
||||
: v.rows * 20,
|
||||
2, 1080)
|
||||
: v.rows * sy;
|
||||
const double aspect =
|
||||
double(v.columns) * v.cell_aspect * oh / (v.rows * ow);
|
||||
// Fit square source pixels into non-square character samples without
|
||||
// downsampling either dimension. Native mode may show a small crop.
|
||||
width = std::min(width, std::floor(ow * std::min(1.0, aspect)));
|
||||
height = std::min(height, std::floor(oh * std::min(1.0, 1 / aspect)));
|
||||
}
|
||||
int w = std::max(1, int(width / v.zoom)),
|
||||
h = std::max(1, int(height / v.zoom));
|
||||
int x = std::clamp(int(v.center_x * f.width - w * .5), 0, int(f.width) - w);
|
||||
int y = std::clamp(int(v.center_y * f.height - h * .5), 0, int(f.height) - h);
|
||||
return {x, y, w, h};
|
||||
}
|
||||
// Area resampling integrates the entire source footprint. Thin lines cannot
|
||||
// disappear merely because a nearest-neighbour sample landed beside them.
|
||||
static uint32_t Sample(const DecodedFrame &f, double x0, double y0, double x1,
|
||||
double y1) {
|
||||
double sums[3] = {}, weight = 0;
|
||||
for (int y = int(std::floor(y0));
|
||||
y < std::min(int(f.height), int(std::ceil(y1))); ++y) {
|
||||
double wy = std::min(y1, double(y + 1)) - std::max(y0, double(y));
|
||||
for (int x = int(std::floor(x0));
|
||||
x < std::min(int(f.width), int(std::ceil(x1))); ++x) {
|
||||
double w = wy * (std::min(x1, double(x + 1)) - std::max(x0, double(x)));
|
||||
auto c = Pixel(f, std::max(0, x), std::max(0, y));
|
||||
sums[0] += (c >> 16) * w;
|
||||
sums[1] += ((c >> 8) & 255) * w;
|
||||
sums[2] += (c & 255) * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
if (weight <= 0)
|
||||
return 0;
|
||||
auto round = [&](int i) {
|
||||
return uint32_t(std::clamp(int(std::lround(sums[i] / weight)), 0, 255));
|
||||
};
|
||||
return (round(0) << 16) | (round(1) << 8) | round(2);
|
||||
}
|
||||
static Cell Quadrant(const std::array<uint32_t, 4> &pixels) {
|
||||
// Enumerate all seven two-colour partitions (complements are equivalent).
|
||||
static constexpr uint32_t glyphs[16] = {
|
||||
0x20, 0x2598, 0x259D, 0x2580, 0x2596, 0x258C, 0x259E, 0x259B,
|
||||
0x2597, 0x259A, 0x2590, 0x259C, 0x2584, 0x2599, 0x259F, 0x2588};
|
||||
Cell best{};
|
||||
double best_error = 1e30;
|
||||
for (unsigned mask = 1; mask < 8; ++mask) {
|
||||
double sums[2][3] = {};
|
||||
unsigned count[2] = {};
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
int g = (mask >> i) & 1;
|
||||
++count[g];
|
||||
sums[g][0] += pixels[i] >> 16;
|
||||
sums[g][1] += (pixels[i] >> 8) & 255;
|
||||
sums[g][2] += pixels[i] & 255;
|
||||
}
|
||||
uint32_t colors[2] = {};
|
||||
double error = 0;
|
||||
for (int g = 0; g < 2; ++g)
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sums[g][c] = std::round(sums[g][c] / count[g]);
|
||||
colors[g] |= uint32_t(sums[g][c]) << (16 - 8 * c);
|
||||
}
|
||||
for (int i = 0; i < 4; ++i)
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
double d = double((pixels[i] >> (16 - 8 * c)) & 255) -
|
||||
sums[(mask >> i) & 1][c];
|
||||
error += d * d;
|
||||
}
|
||||
if (error < best_error) {
|
||||
best_error = error;
|
||||
best = {colors[1], colors[0], glyphs[mask]};
|
||||
}
|
||||
}
|
||||
if (best.top == best.bottom)
|
||||
best.glyph = 0x20;
|
||||
return best;
|
||||
}
|
||||
static std::string EncodeUtf8(uint32_t c) {
|
||||
if (c < 128)
|
||||
return std::string(1, char(c));
|
||||
if (c < 2048)
|
||||
return {char(0xc0 | (c >> 6)), char(0x80 | (c & 63))};
|
||||
return {char(0xe0 | (c >> 12)), char(0x80 | ((c >> 6) & 63)),
|
||||
char(0x80 | (c & 63))};
|
||||
}
|
||||
Grid Layout(const DecodedFrame &f, Viewport v) {
|
||||
Validate(f, v);
|
||||
const int sx = v.utf8 && v.detail == AnsiDetail::Quadrant ? 2 : 1,
|
||||
sy = v.utf8 ? 2 : 1;
|
||||
const int width = v.columns * sx, height = v.rows * sy;
|
||||
Rect crop = Crop(f, v);
|
||||
Grid g{v,
|
||||
Fit(crop.width, crop.height, width, height, v.cell_aspect * sy / sx),
|
||||
{}};
|
||||
g.source = crop;
|
||||
g.source_width = f.width;
|
||||
g.source_height = f.height;
|
||||
g.samples_x = sx;
|
||||
g.samples_y = sy;
|
||||
return g;
|
||||
}
|
||||
Grid Convert(const DecodedFrame &f, Viewport v) {
|
||||
auto g = Layout(f, v);
|
||||
const auto crop = g.source;
|
||||
const int sx = g.samples_x, sy = g.samples_y;
|
||||
g.cells.resize(size_t(v.columns) * v.rows);
|
||||
auto pixel = [&](int x, int y) {
|
||||
const auto &r = g.content;
|
||||
if (x < r.x || y < r.y || x >= r.x + r.width || y >= r.y + r.height)
|
||||
return uint32_t(0);
|
||||
double x0 = crop.x + double(x - r.x) * crop.width / r.width;
|
||||
double y0 = crop.y + double(y - r.y) * crop.height / r.height;
|
||||
return Sample(f, x0, y0, x0 + double(crop.width) / r.width,
|
||||
y0 + double(crop.height) / r.height);
|
||||
};
|
||||
for (int y = 0; y < v.rows; ++y)
|
||||
for (int x = 0; x < v.columns; ++x) {
|
||||
auto &c = g.cells[size_t(y) * v.columns + x];
|
||||
if (sx == 2)
|
||||
c = Quadrant({pixel(x * 2, y * 2), pixel(x * 2 + 1, y * 2),
|
||||
pixel(x * 2, y * 2 + 1), pixel(x * 2 + 1, y * 2 + 1)});
|
||||
else {
|
||||
c.top = pixel(x, y * sy);
|
||||
c.bottom = sy == 1 ? c.top : pixel(x, y * sy + sy - 1);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
std::optional<std::pair<float, float>> MapPointer(const Grid &g, int x, int y) {
|
||||
x *= g.samples_x;
|
||||
y -= g.viewport.row_offset;
|
||||
y *= g.samples_y;
|
||||
const auto &r = g.content;
|
||||
if (!r.width || !r.height || !g.source_width || !g.source_height || x < r.x ||
|
||||
y < r.y || x >= r.x + r.width || y >= r.y + r.height)
|
||||
return {};
|
||||
float fx = float(x - r.x) / std::max(1, r.width - 1),
|
||||
fy = float(y - r.y) / std::max(1, r.height - 1);
|
||||
return std::pair<float, float>{
|
||||
(g.source.x + fx * std::max(0, g.source.width - 1)) /
|
||||
std::max(1u, g.source_width - 1),
|
||||
(g.source.y + fy * std::max(0, g.source.height - 1)) /
|
||||
std::max(1u, g.source_height - 1)};
|
||||
}
|
||||
static std::string Color(uint32_t c, bool fg, int depth) {
|
||||
const int r = c >> 16, g = (c >> 8) & 255, b = c & 255;
|
||||
const std::string prefix = fg ? "\033[38;" : "\033[48;";
|
||||
if (depth == 24)
|
||||
return prefix + "2;" + std::to_string(r) + ";" + std::to_string(g) + ";" +
|
||||
std::to_string(b) + "m";
|
||||
if (depth == 256) {
|
||||
constexpr int levels[] = {0, 95, 135, 175, 215, 255};
|
||||
auto index = [&](int value) {
|
||||
int best = 0;
|
||||
for (int i = 1; i < 6; ++i)
|
||||
if (std::abs(levels[i] - value) < std::abs(levels[best] - value))
|
||||
best = i;
|
||||
return best;
|
||||
};
|
||||
int ri = index(r), gi = index(g), bi = index(b);
|
||||
int cube = (r - levels[ri]) * (r - levels[ri]) +
|
||||
(g - levels[gi]) * (g - levels[gi]) +
|
||||
(b - levels[bi]) * (b - levels[bi]);
|
||||
int gray =
|
||||
std::clamp(int(std::lround(((r + g + b) / 3.0 - 8) / 10)), 0, 23),
|
||||
gv = 8 + 10 * gray;
|
||||
int gray_error =
|
||||
(r - gv) * (r - gv) + (g - gv) * (g - gv) + (b - gv) * (b - gv);
|
||||
return prefix + "5;" +
|
||||
std::to_string(gray_error < cube ? 232 + gray
|
||||
: 16 + 36 * ri + 6 * gi + bi) +
|
||||
"m";
|
||||
}
|
||||
int i = (r >= 128 ? 1 : 0) + (g >= 128 ? 2 : 0) + (b >= 128 ? 4 : 0);
|
||||
return "\033[" + std::to_string((fg ? 30 : 40) + i) + "m";
|
||||
}
|
||||
static std::string Line(const Grid &g, int y) {
|
||||
std::string line;
|
||||
uint32_t fg = UINT32_MAX, bg = UINT32_MAX;
|
||||
for (int x = 0; x < g.viewport.columns; ++x) {
|
||||
const auto &c = g.cells[size_t(y) * g.viewport.columns + x];
|
||||
if (g.viewport.utf8 && fg != c.top) {
|
||||
line += Color(c.top, true, g.viewport.colors);
|
||||
fg = c.top;
|
||||
}
|
||||
const auto bottom = g.viewport.utf8 ? c.bottom : c.top;
|
||||
if (bg != bottom) {
|
||||
line += Color(bottom, false, g.viewport.colors);
|
||||
bg = bottom;
|
||||
}
|
||||
line += g.viewport.utf8 ? EncodeUtf8(c.glyph) : " ";
|
||||
}
|
||||
return line + "\033[0m";
|
||||
}
|
||||
std::vector<std::string> Lines(const Grid &g) {
|
||||
std::vector<std::string> lines;
|
||||
lines.reserve(g.viewport.rows);
|
||||
for (int y = 0; y < g.viewport.rows; ++y)
|
||||
lines.push_back(Line(g, y));
|
||||
return lines;
|
||||
}
|
||||
std::string Difference(const Grid &g, const std::optional<Grid> &previous) {
|
||||
std::string out;
|
||||
const bool same = previous &&
|
||||
previous->viewport.columns == g.viewport.columns &&
|
||||
previous->viewport.rows == g.viewport.rows &&
|
||||
previous->viewport.row_offset == g.viewport.row_offset &&
|
||||
previous->viewport.colors == g.viewport.colors &&
|
||||
previous->viewport.utf8 == g.viewport.utf8;
|
||||
for (int y = 0; y < g.viewport.rows; ++y) {
|
||||
auto start = g.cells.begin() + size_t(y) * g.viewport.columns;
|
||||
if (!same ||
|
||||
!std::equal(start, start + g.viewport.columns,
|
||||
previous->cells.begin() + size_t(y) * g.viewport.columns))
|
||||
out += "\033[" + std::to_string(g.viewport.row_offset + y + 1) +
|
||||
";1H" + Line(g, y);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
static std::string Base64(const std::vector<uint8_t> &data) {
|
||||
constexpr char table[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve((data.size() + 2) / 3 * 4);
|
||||
for (size_t i = 0; i < data.size(); i += 3) {
|
||||
unsigned n = unsigned(data[i]) << 16;
|
||||
if (i + 1 < data.size())
|
||||
n |= unsigned(data[i + 1]) << 8;
|
||||
if (i + 2 < data.size())
|
||||
n |= data[i + 2];
|
||||
out += table[n >> 18];
|
||||
out += table[(n >> 12) & 63];
|
||||
out += i + 1 < data.size() ? table[(n >> 6) & 63] : '=';
|
||||
out += i + 2 < data.size() ? table[n & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
std::string Kitty(const DecodedFrame &f, const Viewport &requested) {
|
||||
auto v = requested;
|
||||
v.pixel_mode = true;
|
||||
Validate(f, v);
|
||||
// Use reported terminal pixel dimensions; otherwise a conservative 10x20
|
||||
// cell.
|
||||
int w =
|
||||
std::clamp(v.pixel_width > 0 ? v.pixel_width : v.columns * 10, 2, 1920);
|
||||
int h =
|
||||
std::clamp(v.pixel_height > 0 ? v.pixel_height : v.rows * 20, 2, 1080);
|
||||
const auto crop = Crop(f, v);
|
||||
std::string b64;
|
||||
for (;;) {
|
||||
auto r = Fit(crop.width, crop.height, w, h,
|
||||
double(v.columns) * v.cell_aspect * h / (v.rows * w));
|
||||
std::vector<uint8_t> rgb(size_t(w) * h * 3, 0);
|
||||
for (int y = 0; y < r.height; ++y)
|
||||
for (int x = 0; x < r.width; ++x) {
|
||||
double x0 = crop.x + double(x) * crop.width / r.width,
|
||||
y0 = crop.y + double(y) * crop.height / r.height;
|
||||
auto c = Sample(f, x0, y0, x0 + double(crop.width) / r.width,
|
||||
y0 + double(crop.height) / r.height);
|
||||
auto i = (size_t(y + r.y) * w + x + r.x) * 3;
|
||||
rgb[i] = c >> 16;
|
||||
rgb[i + 1] = c >> 8;
|
||||
rgb[i + 2] = c;
|
||||
}
|
||||
uLongf length = compressBound(rgb.size());
|
||||
std::vector<uint8_t> compressed(length);
|
||||
if (compress2(compressed.data(), &length, rgb.data(), rgb.size(),
|
||||
Z_BEST_SPEED) != Z_OK)
|
||||
throw std::runtime_error("pixel compression failed");
|
||||
compressed.resize(length);
|
||||
b64 = Base64(compressed);
|
||||
// Reserve space for protocol chunk headers; keep the existing 2 MiB cap.
|
||||
if (b64.size() < 1900000)
|
||||
break;
|
||||
w = std::max(2, w * 3 / 4);
|
||||
h = std::max(2, h * 3 / 4);
|
||||
}
|
||||
std::string out = "\033[" + std::to_string(v.row_offset + 1) + ";1H";
|
||||
for (size_t i = 0; i < b64.size(); i += 4096) {
|
||||
out += "\033_G";
|
||||
if (i == 0)
|
||||
out += "a=T,t=d,f=24,o=z,q=2,i=31,p=1,C=1,s=" + std::to_string(w) +
|
||||
",v=" + std::to_string(h) + ",c=" + std::to_string(v.columns) +
|
||||
",r=" + std::to_string(v.rows) + ",";
|
||||
out += "m=" + std::to_string(i + 4096 < b64.size() ? 1 : 0) + ";" +
|
||||
b64.substr(i, 4096) + "\033\\";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
bool KittyReply(const std::string &s) {
|
||||
return s.find("\033_Gi=31;OK\033\\") != std::string::npos;
|
||||
}
|
||||
} // namespace crossdesk::terminal
|
||||
@@ -0,0 +1,151 @@
|
||||
#include "terminal_renderer.h"
|
||||
#include <algorithm>
|
||||
|
||||
namespace crossdesk::terminal {
|
||||
namespace {
|
||||
std::string Clip(const std::string &text, size_t width) {
|
||||
if (text.size() <= width)
|
||||
return text;
|
||||
return width > 3 ? text.substr(0, width - 3) + "..." : text.substr(0, width);
|
||||
}
|
||||
|
||||
std::string Compact(const std::vector<std::string> &choices, size_t width) {
|
||||
for (const auto &text : choices)
|
||||
if (text.size() <= width)
|
||||
return text;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string Label(const std::string &label) {
|
||||
std::string text = label;
|
||||
text.resize(5, ' ');
|
||||
return text + " | ";
|
||||
}
|
||||
|
||||
std::string CompactRow(const std::string &label,
|
||||
const std::vector<std::string> &choices, size_t width) {
|
||||
const auto prefix = width >= 12 ? Label(label) : std::string{};
|
||||
return prefix + Compact(choices, width - prefix.size());
|
||||
}
|
||||
|
||||
std::string Columns(const std::string &label,
|
||||
const std::vector<std::string> &cells, size_t width,
|
||||
size_t count) {
|
||||
std::string line = Label(label);
|
||||
const size_t content = width - line.size() - (count - 1) * 3;
|
||||
for (size_t i = 0; i < cells.size(); ++i) {
|
||||
const size_t start = 8 + i * content / count + i * 3;
|
||||
if (i) {
|
||||
line.resize(start - 3, ' ');
|
||||
line += " | ";
|
||||
}
|
||||
line += cells[i];
|
||||
}
|
||||
return line;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> StatusLines(const StatusBar &bar, int columns) {
|
||||
const size_t width = std::clamp(columns, 1, 240);
|
||||
const size_t margin = width >= 20 ? 1 : 0;
|
||||
const size_t inner = width - 2 * margin;
|
||||
const std::string mode =
|
||||
bar.command_mode
|
||||
? (bar.control
|
||||
? (inner < 36 ? "LOCAL PAUSED" : "LOCAL / CONTROL PAUSED")
|
||||
: (inner < 36 ? "LOCAL READ" : "LOCAL / READ ONLY"))
|
||||
: bar.control ? "CONTROL"
|
||||
: "READ ONLY";
|
||||
const auto progress =
|
||||
bar.error.empty() ? bar.progress : "Error: " + bar.error;
|
||||
auto left = mode + " | " + progress;
|
||||
auto right = bar.view + (bar.network.empty() ? "" : " | " + bar.network);
|
||||
if (left.size() + right.size() + 2 > inner)
|
||||
right = bar.network;
|
||||
if (left.size() + right.size() + 2 > inner && bar.error.empty())
|
||||
left = mode + " | " + bar.compact_progress;
|
||||
if (left.size() + right.size() + 2 > inner)
|
||||
right.clear();
|
||||
std::string status = Clip(left, inner);
|
||||
if (!right.empty()) {
|
||||
status.resize(inner - right.size(), ' ');
|
||||
status += right;
|
||||
}
|
||||
|
||||
std::string keys, view;
|
||||
if (inner >= 63) {
|
||||
const size_t count = inner >= 99 ? 4 : inner >= 77 ? 3 : 2;
|
||||
if (bar.command_mode) {
|
||||
keys =
|
||||
Columns("LOCAL",
|
||||
count >= 3 ? std::vector<std::string>{"<Esc/Ctrl+C> Return",
|
||||
"<c> Toggle control",
|
||||
"<q> Quit"}
|
||||
: std::vector<std::string>{"<Esc/Ctrl+C> Return",
|
||||
"<c> Toggle; <q> Quit"},
|
||||
inner, count);
|
||||
} else if (bar.control) {
|
||||
keys = Columns("KEYS", {"<Ctrl+]> Commands", "Input -> remote"}, inner,
|
||||
count);
|
||||
} else {
|
||||
keys = Columns("KEYS",
|
||||
count >= 3
|
||||
? std::vector<std::string>{"<c> Control", "<q> Quit",
|
||||
"<Ctrl+]> Commands"}
|
||||
: std::vector<std::string>{"<c> Control; <q> Quit",
|
||||
"<Ctrl+]> Commands"},
|
||||
inner, count);
|
||||
}
|
||||
if (bar.control && !bar.command_mode) {
|
||||
view = Columns("THEN", {"<c> Toggle control", "<q> Quit"}, inner, count);
|
||||
} else {
|
||||
view = Columns(
|
||||
"VIEW",
|
||||
count == 4
|
||||
? std::vector<std::string>{"<arrows/ijkl> Pan", "<+/-> Zoom",
|
||||
"<f> Fit", "<d> Detail"}
|
||||
: count == 3
|
||||
? std::vector<std::string>{"<arrows/ijkl> Pan", "<+/-> Zoom",
|
||||
"<f> Fit; <d> Detail"}
|
||||
: std::vector<std::string>{"<arrows> Pan; <+/-> Zoom",
|
||||
"<f> Fit; <d> Detail"},
|
||||
inner, count);
|
||||
}
|
||||
} else {
|
||||
keys = CompactRow(
|
||||
bar.command_mode ? "LOCAL" : "KEYS",
|
||||
bar.command_mode
|
||||
? std::vector<std::string>{"<Esc> Return | <c> Toggle | <q> Quit",
|
||||
"<Esc> Return | <q> Quit",
|
||||
"<Esc> Back <q> Quit", "<q> Quit", "<q>",
|
||||
"q"}
|
||||
: bar.control
|
||||
? std::vector<std::string>{"<Ctrl+]> Commands | Input -> remote",
|
||||
"<Ctrl+]> Commands", "<Ctrl+]> Menu",
|
||||
"<Ctrl+]>"}
|
||||
: std::vector<
|
||||
std::string>{"<c> Control | <q> Quit | <Ctrl+]> Commands",
|
||||
"<c> Control | <q> Quit", "<c> Ctrl <q> Quit",
|
||||
"<q> Quit", "<q>", "q"},
|
||||
inner);
|
||||
view = CompactRow(
|
||||
bar.control && !bar.command_mode ? "THEN" : "VIEW",
|
||||
bar.control && !bar.command_mode
|
||||
? std::vector<std::string>{"<c> Toggle control | <q> Quit",
|
||||
"<c> Toggle; <q> Quit", "<c> / <q>",
|
||||
"<q>", "q"}
|
||||
: std::vector<std::string>{"<arrows> Pan | <+/-> Zoom | <f> Fit | "
|
||||
"<d> Detail",
|
||||
"<+/-> Zoom | <f> Fit | <d> Detail",
|
||||
"<f> Fit | <d> Detail", "<f> Fit", "<f>",
|
||||
"f"},
|
||||
inner);
|
||||
}
|
||||
std::vector<std::string> lines{status, keys, view};
|
||||
for (auto &line : lines) {
|
||||
line.insert(0, margin, ' ');
|
||||
line.resize(width, ' ');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
} // namespace crossdesk::terminal
|
||||
@@ -9,16 +9,42 @@ local desktop_platform = is_plat("windows", "macosx", "linux") or
|
||||
add_rules("mode.release", "mode.debug")
|
||||
set_languages("c++17")
|
||||
set_encodings("utf-8")
|
||||
if is_plat("windows") then
|
||||
-- Shared wire targets and third-party packages must use the same CRT as
|
||||
-- the native applications; a setting inside apps/cli does not reach them.
|
||||
set_runtimes("MT")
|
||||
end
|
||||
add_requires("nlohmann_json 3.11.3")
|
||||
|
||||
option("CROSSDESK_BUILD_DESKTOP")
|
||||
set_default(desktop_platform)
|
||||
set_showmenu(true)
|
||||
set_description("Build the desktop client")
|
||||
option_end()
|
||||
|
||||
option("CROSSDESK_BUILD_CLI")
|
||||
set_default(false)
|
||||
set_showmenu(true)
|
||||
set_description("Build the terminal controller")
|
||||
option_end()
|
||||
|
||||
local build_desktop = desktop_platform and not is_config("CROSSDESK_BUILD_DESKTOP", false)
|
||||
local build_cli = has_config("CROSSDESK_BUILD_CLI")
|
||||
if build_cli and not desktop_platform then
|
||||
raise("CrossDesk CLI supports Windows, macOS and Linux")
|
||||
end
|
||||
|
||||
if desktop_platform then
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/options.lua"))
|
||||
setup_options_and_dependencies(build_desktop)
|
||||
end
|
||||
|
||||
if build_desktop then
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/platform.lua"))
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/rules/slint.lua"))
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/rules/about_resources.lua"))
|
||||
includes(path.join(source_dir, "apps/desktop/xmake/targets.lua"))
|
||||
|
||||
setup_options_and_dependencies()
|
||||
setup_platform_settings()
|
||||
end
|
||||
|
||||
@@ -26,28 +52,42 @@ local wire_dir = path.join(source_dir, "libs/wire")
|
||||
|
||||
target("crossdesk_wire")
|
||||
set_kind("static")
|
||||
add_packages("nlohmann_json")
|
||||
add_packages("nlohmann_json", {public = true})
|
||||
add_files(path.join(wire_dir, "src/*.cpp"))
|
||||
add_headerfiles(path.join(wire_dir,
|
||||
"include/(*.h)"))
|
||||
add_includedirs(path.join(wire_dir, "include"), {public = true})
|
||||
target_end()
|
||||
|
||||
target("crossdesk_wire_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("crossdesk_wire")
|
||||
add_files(path.join(wire_dir, "tests/wire_test.cpp"))
|
||||
target_end()
|
||||
-- CLI source packages omit tests; full checkouts retain these existing targets.
|
||||
if os.isdir(path.join(wire_dir, "tests")) then
|
||||
target("crossdesk_wire_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("crossdesk_wire")
|
||||
add_files(path.join(wire_dir, "tests/wire_test.cpp"))
|
||||
target_end()
|
||||
|
||||
target("keyboard_state_wire_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("crossdesk_wire")
|
||||
add_files(path.join(wire_dir,
|
||||
"tests/keyboard_state_wire_test.cpp"))
|
||||
target_end()
|
||||
target("keyboard_state_wire_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("crossdesk_wire")
|
||||
add_files(path.join(wire_dir,
|
||||
"tests/keyboard_state_wire_test.cpp"))
|
||||
target_end()
|
||||
end
|
||||
|
||||
if desktop_platform then
|
||||
if build_desktop then
|
||||
setup_targets()
|
||||
end
|
||||
|
||||
if build_cli then
|
||||
-- Desktop setup already includes MiniRTC and the shared OpenSSL recipe.
|
||||
if not build_desktop then
|
||||
add_requireconfs("**.openssl", "**.openssl3",
|
||||
{version = "3.3.2", system = false, override = true})
|
||||
includes(path.join(source_dir, "deps/thirdparty/openssl"))
|
||||
includes(path.join(source_dir, "deps/submodules/minirtc"))
|
||||
end
|
||||
includes(path.join(source_dir, "apps/cli/xmake.lua"))
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user