Compare commits

...
43 Commits
Author SHA1 Message Date
dijunkun bfa743fd1b [fix] correct mobile viewport safe areas and zoom reset 2026-11-02 03:17:17 +08:00
dijunkun d051dbb278 [fix] avoid X11 None macro collision in credential recovery 2026-11-02 03:03:53 +08:00
dijunkun 425698e22e [feat] authenticate controller sessions with device keys 2026-11-02 03:00:18 +08:00
dijunkun adcb0f17dd [ui] improve scrollbar spacing in the trusted devices dialog 2026-11-02 02:20:31 +08:00
dijunkun 49717f0f68 [feat] use device keys for server login 2026-11-02 01:15:11 +08:00
dijunkun bb48551d59 [docs] document trusted device setup in READMEs 2026-11-01 23:32:58 +08:00
dijunkun ea4db45dbf [ui] remove redundant IP connection hints 2026-11-01 23:27:17 +08:00
dijunkun 489491008a [feat] add trusted device pairing and management UI 2026-11-01 23:27:17 +08:00
dijunkun e15e2c33bd [feat] add application-layer trusted device authentication 2026-11-01 23:26:48 +08:00
dijunkun b999a01e66 [refactor] share private file storage helpers 2026-11-01 23:25:53 +08:00
dijunkun 53a3df6133 [chore] remove merged MiniRTC credential log patch 2026-11-01 03:26:26 +08:00
dijunkun 3597fd4b3e [fix] include OpenSSL resource symbols in iOS archives 2026-11-01 03:11:18 +08:00
dijunkun c2bfce03f2 [fix] update MiniRTC to correct inbound data bitrate 2026-11-01 02:57:42 +08:00
dijunkun 86d3a8cf72 [fix] adapt iOS keyboard switch widths to localized text 2026-11-01 02:49:59 +08:00
dijunkun 4e2e5b74dd [build] migrate CLI macOS runner and release 0.1.4 2026-10-31 05:34:29 +08:00
dijunkun 7bcda7a6fa [build] bump CLI version to 0.1.3 2026-10-31 05:27:31 +08:00
dijunkun baf48120bb [perf] optimize CLI ANSI output and refresh previews 2026-10-31 05:21:10 +08:00
dijunkun 1b716f3682 [docs] clarify platform and client badges in READMEs 2026-10-31 05:09:41 +08:00
dijunkun b361533d70 [docs] refresh CLI previews and clarify FPS capture 2026-10-31 04:58:15 +08:00
dijunkun af60fb2541 [docs] expand CLI guide with npm setup and previews 2026-10-31 04:49:13 +08:00
dijunkun 2279636829 [build] bump CLI version to 0.1.2 2026-10-31 01:16:36 +08:00
dijunkun 84270fe065 [fix] remove hardcoded MiniRTC revision check from CLI setup 2026-10-31 00:36:27 +08:00
dijunkun 915cfe3a04 [feat] add live FPS to CLI status bar 2026-10-31 00:36:27 +08:00
dijunkun d687c0e2ea [perf] adapt CLI preview rate and improve frame pacing 2026-10-31 00:36:27 +08:00
dijunkun 5f2caee0cb [build] update iOS project settings for Xcode 27.1 2026-10-31 00:36:27 +08:00
dijunkun e2cd4959c6 [perf] accelerate Kitty and ANSI rendering 2026-10-31 00:36:27 +08:00
dijunkun cd5e1c95d7 [fix] stop iOS file transfers after read or seek failures 2026-10-31 00:36:27 +08:00
dijunkun dde5b57165 [fix] update MiniRTC to prevent macOS connection hangs 2026-10-31 00:18:01 +08:00
dijunkun bcfc8947c9 [fix] resolve iOS deprecation and archive warnings 2026-10-30 21:56:07 +08:00
dijunkun 12ca90d827 [build] bump iOS version to 1.7.4 2026-10-30 21:43:45 +08:00
dijunkun 765e088f70 [build] configure iOS signing and Xcode Cloud dependencies 2026-10-30 21:37:06 +08:00
dijunkun 8f54be6c70 [ui] update iOS local network permission wording 2026-10-30 21:14:04 +08:00
dijunkun 3ff87d8195 [build] bump CLI version to 0.1.1 2026-10-30 18:27:13 +08:00
dijunkun 7ce974f3d4 [build] integrate scoped CLI publishing with GitHub Actions 2026-10-30 17:48:57 +08:00
dijunkun cf039b8f91 [fix] preserve CLI display aspect ratio across terminals 2026-10-30 17:15:47 +08:00
dijunkun 15092b16d6 [fix] remove deprecated Node.js 20 actions from CLI builds 2026-10-30 16:39:12 +08:00
dijunkun de1a663ab4 [fix] improve CLI mouse reporting and click positioning 2026-10-30 16:38:34 +08:00
dijunkun 7b18937df0 [build] cache CLI toolchain and native dependencies 2026-10-30 16:32:46 +08:00
dijunkun c5ee7d0691 [ui] improve CLI connection status and shortcut layout 2026-10-30 16:21:56 +08:00
dijunkun d984aa55de [build] move CLI platform packages to chunkdash scope 2026-10-30 15:58:27 +08:00
dijunkun e6e7e4c0f0 [feat] improve CLI commands, controls and npm packaging 2026-10-30 13:32:34 +08:00
dijunkun 5d0a8f857f [feat] improve CLI shortcuts and local command controls 2026-10-30 11:22:40 +08:00
dijunkun 699d867956 [feat] integrate CrossDesk CLI with terminal rendering and npm packaging 2026-10-30 10:42:23 +08:00
99 changed files with 8856 additions and 443 deletions
+131
View File
@@ -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-15
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/**', '.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
+5 -4
View File
@@ -276,7 +276,8 @@ jobs:
if [[ "${CROSSDESK_RELEASE_BUILD:-NO}" == "YES" ]]; then
VERIFY_FLAGS+=(--require-release)
fi
python3 apps/ios/scripts/verify_app_bundle.py "${APP_PATH}" "${VERIFY_FLAGS[@]}"
python3 apps/ios/scripts/verify_app_bundle.py "${APP_PATH}" \
--dsym-dir "${DERIVED_DATA}/Build/Products/Release-iphoneos" "${VERIFY_FLAGS[@]}"
xcrun lipo -info "${EXECUTABLE_PATH}"
echo "Minimum iOS version: ${MINIMUM_OS}"
@@ -293,10 +294,10 @@ jobs:
cp apps/ios/licenses/OPEN_SOURCE_NOTICE.txt "${PACKAGE_DIR}/OPEN_SOURCE_NOTICE.txt"
cp "${APP_PATH}/SourceMetadata.json" "${PACKAGE_DIR}/SourceMetadata.json"
DSYM_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app.dSYM"
if [[ -d "${DSYM_PATH}" ]]; then
for DSYM_NAME in CrossDeskMobile.app.dSYM OpenSSL.framework.dSYM; do
DSYM_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/${DSYM_NAME}"
cp -R "${DSYM_PATH}" "${PACKAGE_DIR}/"
fi
done
ditto -c -k --sequesterRsrc --keepParent "${PACKAGE_DIR}" "${PACKAGE_FILE}"
echo "PACKAGE_FILE=${PACKAGE_FILE}" >> "${GITHUB_ENV}"
+1
View File
@@ -9,6 +9,7 @@ on:
- "*"
- "!ios-v*"
- "!android-v*"
- "!cli-v*"
workflow_dispatch:
inputs:
desktop_version:
+72
View File
@@ -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"
+3
View File
@@ -13,3 +13,6 @@ xcuserdata/
# VSCode cache
.vscode
# CLI npm dependencies
node_modules/
+145 -6
View File
@@ -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>
@@ -8,7 +8,10 @@
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Clients](https://img.shields.io/badge/client-Desktop%20%7C%20Mobile%20%7C%20Web%20%7C%20CLI-2ea44f?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![npm](https://img.shields.io/npm/v/%40chunkdash%2Fcrossdesk-cli?style=flat)](https://www.npmjs.com/package/@chunkdash/crossdesk-cli)
[![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
@@ -19,9 +22,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) · [可信设备](#trusted-devices) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
<a id="features"></a>
@@ -29,9 +32,11 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
- **可信设备:** 桌面端可选开启可信设备限制,在连接密码之外校验设备身份;支持复制、粘贴配对信息,提前添加允许访问本机的设备。
- **部署与运维:** P2P 直连、TURN 中继、SRTP 媒体加密、自托管,以及 Windows 登录前远控和 Linux 无头运行。
<a id="download"></a>
@@ -50,8 +55,9 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
| 鸿蒙 PC(开发版) | **控制 / 被控**;API 26,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| 鸿蒙手机 / 平板(开发版) | **仅控制**;API 20+,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| Web | **仅控制**;支持 WebRTC 的浏览器;无需在控制端安装客户端 |
| 终端 CLI(开发版) | **仅控制**;分发目标为 macOS / Linux(x64、arm64)和 Windows(x64);通过 [npm 安装](#cli-npm) 或从源码构建,平台验收范围见 [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)
@@ -67,7 +73,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
使用自建服务器时,两端需要接入同一服务。新版桌面端使用设备密钥登录服务器,请先升级服务端:新 ID 注册时绑定公钥,旧 ID 首次升级时验证原密码后绑定;绑定后,针对该 ID 的错误密码尝试不会阻塞正常签名登录。远程控制仍需输入连接密码;新版控制端也验证设备签名,密码失败按控制端及“控制端 → 目标”计数,不再累计被控端的总失败次数,避免他人的错误尝试锁住目标。身份备份与兼容要求见 [设备登录说明](docs/connection-architecture.md#41-id-模式)。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
<a id="lan-direct"></a>
@@ -79,6 +85,24 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改端口和密码,默认开启所有 IP 接口监听,支持 IPv4 和 IPv6,支持通过 VPN 等接口连接,可在设置的“网络连接”中调整;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。
<a id="trusted-devices"></a>
### 仅允许可信设备连接(可选)
桌面端的可信设备限制**默认关闭**,适用于 ID 连接和 IP 直连。开启后,控制端仍需通过原有密码验证,并持有可信名单中设备的配对私钥;仅知道 ID 和密码不能通过这项限制。
1. **在控制端复制配对信息。** 打开 **设置 → 常规 → 隐私保护 → 管理可信设备**,在 **本机配对信息** 中点击 **复制配对信息**。
2. **在被控端添加控制端。** 打开同一管理界面,将收到的信息粘贴到 **添加可信设备** 输入框,点击 **添加** 即可加入,无需再次确认,也无需先建立远程连接。
3. **在被控端开启限制。** 返回设置,勾选 **仅允许可信设备控制** 并保存。之后照常通过 ID 或 IP 发起连接、验证密码。
配对信息只包含公钥、设备名称和 ID,不含私钥或密码,应通过可信渠道传递。添加后可点击 **查看指纹** 与控制端核对;也允许添加本机,同一公钥不会重复加入。信任是单向的,如需互相控制,应在双方分别添加对方。
添加和移除会立即保存;开启限制时,**移除信任** 会断开对应设备的会话,名单为空则拒绝所有控制端。也可以从管理界面的已连接设备中添加完成身份验证的控制端。
**兼容范围:** 两端均需使用支持可信设备认证的客户端。不支持该协议的旧版客户端无法连接开启限制的被控端;当前 CLI、iOS、Android 和鸿蒙移动端尚未实现该认证。关闭限制时沿用原有连接方式。
设备登录与配对共用缓存目录 `identity.json` 中的私钥,可信名单保存在配置目录的 `trusted-devices/devices.json`。迁移本机身份时应同时备份 ID/密码缓存与 `identity.json`;已绑定的 ID 无法仅凭密码恢复登录。清空整个缓存目录后需重新登记 ID,并让其他设备重新信任本机。备份、迁移及认证机制见 [可信设备技术说明](docs/connection-architecture.md#43-可选可信设备认证)。
<a id="preview"></a>
## 界面预览
@@ -116,12 +140,126 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
## 会话操作与设置
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、可信设备限制、编码、中继、自托管与桌面墙画面预算。
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
- [桌面墙技术说明](docs/deskhub.md):预览订阅、带宽分配、协议与兼容性。
<a id="cli"></a>
## 终端客户端 CLI
`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。
### 画面预览
| ANSI 字符画面 | Kitty 协议像素画面 |
| --- | --- |
| <img src="docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI 的 ANSI 四象限全景:顶部显示只读模式和连接状态,底部显示操作快捷键" /> | <img src="docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI 的 Kitty 协议全景:同一远程桌面以像素画面显示,保留状态栏和操作快捷键" /> |
| 用彩色字符显示桌面轮廓,适合普通终端;查看局部可按 `d` 放大。 | 直接显示像素画面,适合支持 Kitty 图形协议的终端,如 Ghostty。 |
两图采集于 **2026-10-31**,来自同一授权设备的实际只读连接,以 128 × 40 字符视口还原 CLI 的 ANSI / Kitty 协议输出。图中 FPS 是采集时的实际输出计数,静止画面下 ANSI 无变化可显示 `FPS 0`。图示为输出渲染,终端实窗效果受字体、窗口大小及协议支持影响;采集方法见 [图示来源](docs/images/README.md#cli-captures)。
<a id="cli-npm"></a>
### 通过 npm 安装并连接
准备 **Node.js 22 或以上**及 npm,从 [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli) 安装。npm 会按操作系统与 CPU 架构选择原生程序,无需安装 Xmake 或 C++ 编译工具链。
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
将 `123456789` 换成被控电脑的 9 位设备 ID,连接时按提示隐藏输入密码。默认只读、静音;`doctor` 仅检查本机环境,不发起连接。更新到最新版时,重新执行上面的 `npm install -g @chunkdash/crossdesk-cli@latest`。
无需全局安装时,也可使用 `npx`,首次运行会下载对应平台包:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
请安装统一入口 `@chunkdash/crossdesk-cli`,并保留 npm 的可选依赖,不要添加 `--omit=optional`;仅安装平台包不会注册 `crossdesk-cli` 命令。平台运行要求、旧包迁移及故障排查见 [npm 分发说明](apps/cli/docs/npm-distribution.md)。
### 从源码构建并连接
准备 Git、Xmake 3.1.1 或以上、C++17 编译工具链及对应平台依赖。以下以 macOS / Linux shell 为例;Windows 的 Git Bash / 开发者 PowerShell 步骤见 [CLI 构建说明](apps/cli/docs/terminal-client.md#构建与安装)。
```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/`。
### 选择画面与连接参数
默认 `--renderer auto` 自动探测 Kitty 图形协议,未获支持时回退为 ANSI。macOS「终端」等普通终端可显式选择 `ansi`;Ghostty 等支持该协议的终端可选择 `kitty`。显式指定 `kitty` 但探测失败时会报错,此时改用 `ansi`。
以下示例使用 npm 全局安装后的命令;从源码构建时,可在仓库根目录将 `crossdesk-cli` 替换为 `xmake run crossdesk-cli`。
```sh
# 普通终端:以细节模式查看局部
crossdesk-cli 123456789 --renderer ansi --zoom native
# 支持 Kitty 图形协议的终端:显示像素画面
crossdesk-cli 123456789 --renderer kitty
# 降低远端画质与刷新请求
crossdesk-cli 123456789 --quality balanced --fps 8
```
| 参数 | 用途 |
| --- | --- |
| `--renderer auto\|ansi\|kitty` | 自动选择、字符画面或像素画面;默认 `auto` |
| `--zoom native` | 从原始细节模式开始;`--zoom 2` 表示相对全景放大 2 倍 |
| `--quality low\|balanced\|high\|ultra` | 请求最高 360p / 720p / 1080p / 2160p;默认 `high`,实际画质由被控端决定 |
| `--fps 8` | 固定本地输出目标和相应远端预览请求;允许 1–30,默认本地目标为 30,未指定时远端供帧会自适应 |
| `--display 1` | 选择第二个显示器,索引从 `0` 开始 |
| `--control` | 连接后直接启用键鼠控制;省略时默认只读 |
| `--server your-server.example --port 9099` | 使用自建信令服务,两端需接入同一服务 |
ANSI 全景受字符网格限制,提高画质不能让整屏小字变得清晰;可扩大终端、缩小字体,再按 `d` 查看局部,按 `f` 回到全景。顶部状态栏显示模式、连接进度、渲染器、缩放、实时输出 FPS 和网络信息,窄窗口会省略部分字段。通过 SSH 使用时分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。更多显示、鼠标报告和兼容性说明见 [终端使用指南](apps/cli/docs/terminal-client.md)。
### 控制、查看与退出
| 模式 / 按键 | 行为 |
| --- | --- |
| 只读模式 | `c` 开启远端控制;`q` 或 `Ctrl+C` 退出 CLI |
| 控制模式 | 键鼠输入传给远端;`Ctrl+]` 进入本地命令模式并暂停远端输入 |
| 本地命令模式 | 执行命令后持续保持;`c` 选择手动返回后是否启用控制,`q` 退出 CLI |
| `Esc` / `Ctrl+C` | 在本地命令模式中手动返回所选的控制或只读模式 |
| `+` / `=` / `-` | 在只读或本地命令模式中放大 / 缩小 |
| `d` / `f` | 在只读或本地命令模式中切换原始细节 / 完整桌面;只读时也可点击目标进入细节 |
| 方向键 / `i j k l` | 在只读或本地命令模式中平移;`i` 上、`j` 左、`k` 下、`l` 右 |
**控制模式下退出:先按 `Ctrl+]`,再按 `q`。** 普通 `c`、`q` 和 `Ctrl+C` 在控制模式中会发送到远端。
`Ctrl+]` 模式不会因为缩放、平移或按 `c` 自动结束。状态栏中的 `CONTROL PAUSED` / `READ ONLY` 表示返回后是否启用控制;按 `Esc` / `Ctrl+C` 手动返回。再次按 `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 直连、音频播放、文件传输或剪贴板同步,也暂不支持连接开启[可信设备限制](#trusted-devices)的被控端。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 +307,7 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
## 构建与反馈
- [终端客户端 CLI](apps/cli/README.md):独立构建、终端使用、Pi 扩展、桥接协议和 npm 打包。
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
+145 -6
View File
@@ -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>
@@ -8,7 +8,10 @@
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Clients](https://img.shields.io/badge/client-Desktop%20%7C%20Mobile%20%7C%20Web%20%7C%20CLI-2ea44f?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![npm](https://img.shields.io/npm/v/%40chunkdash%2Fcrossdesk-cli?style=flat)](https://www.npmjs.com/package/@chunkdash/crossdesk-cli)
[![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
@@ -19,9 +22,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) · [Trusted devices](#trusted-devices) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
<a id="features"></a>
@@ -29,9 +32,11 @@ 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.
- **Trusted devices:** optionally restrict desktop access to trusted device identities in addition to the connection password. Copy and paste pairing information to approve devices before they connect.
- **Deployment:** direct P2P connections, TURN relay, SRTP media encryption, self-hosting, Windows access before sign-in, and Linux headless operation.
<a id="download"></a>
@@ -50,8 +55,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**. Distribution targets: macOS / Linux (x64, arm64) and Windows (x64). [Install from npm](#cli-npm) or build from source; see the [CLI guide](apps/cli/README.md) (Chinese) for platform validation status |
Requirements follow the current source and build configuration. Check the release notes for the downloaded package. Client capabilities are summarized under [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).
@@ -67,7 +73,7 @@ Requirements follow the current source and build configuration. Check the releas
2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect.
3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems.
For self-hosting, both ends must use the same service. Upgrade the server before using the new desktop client: device login uses a private key, with its public key bound at registration or after a one-time password check for an existing ID. Once bound, wrong-password attempts against that ID cannot block valid signature login. Remote control still requires the connection password. Signed controllers have separate per-controller and controller-to-destination failure budgets; failures from one controller no longer lock the destination for others. See the [device login guide](docs/connection-architecture.md#41-id-模式) (Chinese) for identity backups and compatibility, the [user guide](docs/USER_GUIDE_EN.md#quick-start) for controls, or the [FAQ](docs/FAQ.md#english) for connection problems.
<a id="lan-direct"></a>
@@ -79,6 +85,24 @@ For self-hosting, both ends must use the same service. See the [user guide](docs
Both computers must be reachable over the LAN. Internet access may be available or absent; direct sessions do not depend on a cloud signaling service. The main window lets you select the local IP, edit its port, and change the password. **Listen on all IP interfaces** is enabled by default for IPv4, IPv6, VPN, and other local interfaces on a shared port; change it under **Settings → General → Network**. Disabling **Enable direct IP** stops listeners, disconnects direct IP sessions, and hides the mode button. See the [direct IP guide](docs/USER_GUIDE_EN.md#lan-direct).
<a id="trusted-devices"></a>
### Allow only trusted devices (optional)
The desktop trusted-device restriction is **off by default** and applies to both ID and direct IP connections. When enabled, the controller must pass the existing password verification and prove possession of a pairing private key recognized by the host. Knowing the ID and password alone does not satisfy this restriction.
1. **Copy pairing information on the controller.** Open **Settings → General → Privacy → Manage trusted devices**. Under **This device’s pairing info**, click **Copy pairing info**.
2. **Add the controller on the host.** Open the same management dialog, paste the information into **Add a trusted device**, and click **Add**. It is saved immediately, with no second confirmation or prior remote connection required.
3. **Enable the restriction on the host.** Return to settings, select **Only allow trusted devices**, and save. Connect by ID or IP and verify the password as usual.
Pairing information contains only the public key, device name, and ID, with no private key or password. Exchange it through a trusted channel. After adding a device, use **View fingerprint** to compare it with the controller. You can also add the local device; the same public key is not added twice. Trust is one-way: to control each other, add the other device on both computers.
Adding or removing a device takes effect immediately. With the restriction enabled, **Remove trust** disconnects that device’s sessions, and an empty list rejects all controllers. The dialog also lets you add connected controllers that have completed identity verification.
**Compatibility:** both clients must support trusted-device authentication. Older clients without the protocol cannot connect to a restricted host. The CLI, iOS, Android, and HarmonyOS mobile clients do not currently implement it. With the restriction disabled, the existing connection behavior applies.
Device login and pairing share the private key in the cache directory's `identity.json`; the trusted list is stored in `trusted-devices/devices.json` in the configuration directory. Back up the ID/password cache and `identity.json` together when migrating a device: a bound ID cannot be recovered with its password alone. Clearing the entire cache requires a new ID and renewed trust from other devices. See the [trusted-device technical guide](docs/connection-architecture.md#43-可选可信设备认证) (Chinese) for details.
<a id="preview"></a>
## Preview
@@ -116,12 +140,126 @@ Group control sends input to all connected devices; closing DeskHub disconnects
## Session controls and settings
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure direct IP availability, codecs, relay, self-hosting, and the DeskHub video budget.
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure direct IP availability, trusted-device restrictions, codecs, relay, self-hosting, and the DeskHub video budget.
- [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens.
- [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.
### Display preview
| ANSI character rendering | Kitty pixel graphics |
| --- | --- |
| <img src="docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI ANSI quadrant overview, with read-only mode and connection status at the top and shortcuts at the bottom" /> | <img src="docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI Kitty protocol overview of the same remote desktop, rendered as pixels with the status bar and shortcuts" /> |
| Colored characters show the desktop in standard terminals; press `d` to inspect a smaller region. | Pixel graphics display the desktop in terminals supporting the Kitty graphics protocol, such as Ghostty. |
Both images were captured on **2026-10-31** from real read-only connections to the same authorized host. They render the CLI's ANSI / Kitty protocol output in a 128 × 40 character viewport. FPS counts actual output during capture; an unchanged ANSI view can show `FPS 0`. Actual terminal appearance depends on the font, window size, and protocol support. See [capture notes](docs/images/README.md#cli-captures) for the method.
<a id="cli-npm"></a>
### Install from npm and connect
Install **Node.js 22 or later** and npm, then get [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli). npm selects the native executable for your OS and CPU architecture; Xmake and a C++ toolchain are not required.
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
Replace `123456789` with the host's 9-digit device ID and enter its password at the hidden prompt. Connections start in read-only, silent mode; `doctor` checks the local environment without connecting. To update, run `npm install -g @chunkdash/crossdesk-cli@latest` again.
You can also use `npx` without a global installation. The first run downloads the matching platform package:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
Install the main `@chunkdash/crossdesk-cli` package with npm optional dependencies enabled; do not add `--omit=optional`. Installing a platform package alone does not register the `crossdesk-cli` command. See the [npm distribution guide](apps/cli/docs/npm-distribution.md) (Chinese) for platform requirements, migration from older packages, and troubleshooting.
### Build and connect
Install Git, Xmake 3.1.1 or later, a C++17 toolchain, and the platform dependencies. The following example uses a macOS / Linux shell; Windows Git Bash / Developer PowerShell steps are in the [CLI build guide](apps/cli/docs/terminal-client.md#构建与安装) (Chinese).
```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/`.
### Rendering and connection options
The default `--renderer auto` probes for Kitty graphics support and falls back to ANSI. Use `ansi` explicitly in standard terminals such as macOS Terminal, or `kitty` in compatible terminals such as Ghostty. Explicit `kitty` mode reports an error if the probe fails; use `ansi` in that case.
These examples use the command installed globally by npm. For a source build, replace `crossdesk-cli` with `xmake run crossdesk-cli` and run it from the repository root.
```sh
# Standard terminal: start with a detail view
crossdesk-cli 123456789 --renderer ansi --zoom native
# Terminal supporting the Kitty graphics protocol: display pixel graphics
crossdesk-cli 123456789 --renderer kitty
# Request lower video quality and refresh rate
crossdesk-cli 123456789 --quality balanced --fps 8
```
| Option | Purpose |
| --- | --- |
| `--renderer auto\|ansi\|kitty` | Automatic selection, character rendering, or pixel graphics; default: `auto` |
| `--zoom native` | Start in native detail mode; `--zoom 2` zooms to twice the full-desktop scale |
| `--quality low\|balanced\|high\|ultra` | Request up to 360p / 720p / 1080p / 2160p; default: `high`. The host determines actual quality |
| `--fps 8` | Fix the local output target and corresponding remote preview request; accepts 1–30. The default local target is 30, with adaptive remote requests when omitted |
| `--display 1` | Select the second monitor; indexes start at `0` |
| `--control` | Enable keyboard/mouse control immediately after connecting; omitted by default for read-only viewing |
| `--server your-server.example --port 9099` | Use a self-hosted signaling service; both ends must use the same service |
ANSI overviews are limited by the character grid: higher video quality alone cannot make full-desktop text legible. Enlarge the terminal or reduce its font size, press `d` for detail, and `f` to return to the full desktop. The top bar shows input mode, connection progress, renderer, zoom, live output FPS, and network information; narrow windows omit some fields. Allocate a PTY over SSH (`ssh -t`), and use ANSI inside tmux / screen. See the [terminal guide](apps/cli/docs/terminal-client.md) (Chinese) for rendering, mouse reporting, and compatibility details.
### Control, navigation, and exit
| 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 |
**To quit while controlling the host, press `Ctrl+]`, then `q`.** Ordinary `c`, `q`, and `Ctrl+C` go to the host in control mode.
Zooming, panning, or pressing `c` does not end `Ctrl+]` mode. The status bar shows `CONTROL PAUSED` / `READ ONLY` to indicate whether control will resume; press `Esc` / `Ctrl+C` to return manually. Pressing `Ctrl+]` again also leaves local mode and forwards that shortcut if control resumes.
### Pi extension and feature scope
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. It also cannot yet connect to hosts with [trusted-device restrictions](#trusted-devices) enabled. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty terminal, and actual Pi TUI checks. Platform builds and npm publication do not establish that every platform has been validated on real devices.
[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 +307,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.
@@ -34,7 +34,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private View settingsUpdateDot, aboutUpdateDot, settingsUpdateButton, aboutUpdateRow;
private TextView aboutUpdateLabel;
private boolean navigatingBack;
private FrameLayout canvas;
private FrameLayout canvas, safeArea;
private LinearLayout root;
private EditText remote;
private TextView status, signalBadge, identitySummary;
@@ -133,10 +133,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND);
FrameLayout safeArea=new FrameLayout(this);canvas.addView(safeArea,new FrameLayout.LayoutParams(-1,-1));
safeArea=new FrameLayout(this);canvas.addView(safeArea,new FrameLayout.LayoutParams(-1,-1));
root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720);
safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL));
safeArea.setOnApplyWindowInsetsListener((view,insets)->{
if(dark&&(pictureInPictureUi||isInPictureInPictureMode())){view.setPadding(0,0,0,0);return insets;}
if(dark&&android.os.Build.VERSION.SDK_INT>=30){
android.graphics.Insets bars=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout());
view.setPadding(bars.left,bars.top,bars.right,bars.bottom);
@@ -328,7 +329,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen
dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2);
}
private void sessionScreen(boolean autoLandscape){
installRoot("session",true);remoteOrientation.begin(autoLandscape,!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
installRoot("session",true);remoteOrientation.begin(autoLandscape,!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));
// Video, cursor and touch input must share the area inside system bars
// and display cutouts; fitting to the outer canvas hides image edges.
safeArea.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
controls=new SessionControls(this,ui,new SessionControls.Actions(){
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
@@ -803,6 +807,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(value){releaseKeys();hideKeyboard();pageHost.finish();if(disconnectDialog!=null)disconnectDialog.dismiss();if(remoteUpdateDialog!=null)remoteUpdateDialog.dismiss();}
if(controls!=null)controls.setPictureInPicture(value);
if(video!=null)video.setPictureInPicture(value);
if(safeArea!=null)safeArea.requestApplyInsets();
}
@Override protected void onUserLeaveHint(){
super.onUserLeaveHint();
@@ -42,6 +42,9 @@ final class RemoteViewport {
void zoomBy(float factor, float focusX, float focusY) {
if (baseWidth == 0 || baseHeight == 0 || !Float.isFinite(factor) || factor <= 0) return;
float nextScale = clamp(scale * factor, 1, MAXIMUM_SCALE);
// Returning to fit must clear cursor-edge panning too, including a
// pinch that starts at 1x with an already shifted image.
if (nextScale == 1) { reset(); return; }
float ratio = nextScale / scale;
float focusFromCenterX = focusX - (baseLeft + baseWidth / 2f);
float focusFromCenterY = focusY - (baseTop + baseHeight / 2f);
@@ -75,10 +78,9 @@ final class RemoteViewport {
}
void finishGesture() {
if (!isZoomed()) {
scale = 1;
constrainOffset();
}
// Ordinary pointer gestures at 1x retain edge panning. Only a zoom
// snapping back to its minimum restores the complete centered image.
if (scale > 1 && !isZoomed()) reset();
}
void reset() { scale = 1; offsetX = offsetY = 0; edgePanned = false; }
+85
View File
@@ -0,0 +1,85 @@
# CrossDesk CLI
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty 图形后端,以及 Pi 自定义 TUI 扩展。
## 画面预览
| ANSI 字符画面 | Kitty 协议像素画面 |
| --- | --- |
| <img src="../../docs/images/cli-ansi-overview.png" width="480" alt="CrossDesk CLI 的 ANSI 全景输出,包含只读状态栏与快捷键提示" /> | <img src="../../docs/images/cli-kitty-overview.png" width="480" alt="CrossDesk CLI 的 Kitty 像素全景输出,显示同一远程桌面" /> |
图示来自 **2026-10-31** 的实际只读连接,通过伪终端采集并渲染协议输出;来源与边界见 [图示说明](../../docs/images/README.md#cli-captures)。默认 `auto` 探测 Kitty 支持,失败时使用 ANSI;普通终端可指定 `--renderer ansi`,Ghostty 等支持 Kitty 图形协议的终端可指定 `--renderer kitty`。ANSI 全景适合查看桌面轮廓,阅读小字需进入细节模式。
## 安装与连接
### 通过 npm 安装
需要 **Node.js 22 或以上**和 npm。统一入口为 [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli),分发目标为 macOS / Linux(x64、arm64)和 Windows(x64)。安装时自动选择对应的原生程序,无需 Xmake 或 C++ 编译工具链:
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
将示例 ID 替换为被控电脑的 9 位 ID,按提示隐藏输入密码。`doctor` 仅检查本机环境,不发起连接。更新到最新版时重新执行上述安装命令。
无需全局安装时,使用 `npx`,首次运行会下载对应平台包:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
安装后命令为 `crossdesk-cli`。五个平台依赖使用 `@chunkdash/crossdesk-cli-*`,由启动器自动选择;请保留 npm 的可选依赖,不要使用 `--omit=optional`。旧的无 scope 平台包不属于这套依赖,仅安装平台包不会注册命令。三端实机验收尚未全部完成;运行要求、旧包迁移、打包与发布步骤见 [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
```
构建后可直接运行 `build/<platform>/<arch>/release/crossdesk-cli`,也可将对应目录加入 PATH;Windows 的文件名为 `crossdesk-cli.exe`。
### 常用连接命令
以下示例适用于 npm 全局安装,或已将原生程序加入 PATH 的环境。`crossdesk-cli <设备ID> [选项]` 为默认连接方式,`view <设备ID>` 保留为兼容别名。设备 ID 是格式示例,使用时替换为你有权访问的设备,密码通过隐藏提示输入:
```sh
crossdesk-cli 123456789 --renderer kitty
crossdesk-cli 123456789 --renderer ansi --zoom native
crossdesk-cli 123456789 --quality balanced --fps 8
crossdesk-cli 123456789 --display 1
```
默认请求 `high` 画质(最高 1920 × 1080),`--display` 的索引从 `0` 开始。交互 CLI 默认本地输出目标为 30 FPS,远端供帧会随输出耗时自适应;显式 `--fps` 固定目标和相应预览请求,实际帧率仍取决于设备、终端与链路。更多参数见 [主 README](../../README.md#cli) 和 [终端使用指南](docs/terminal-client.md)。
## 查看、控制与退出
| 操作 | 按键 |
| --- | --- |
| 开启远端控制 | 只读模式下按 `c` |
| 放大局部 / 回到全景 | 只读或本地命令模式下按 `d` / `f`;只读时也可点击目标进入细节 |
| 缩放 / 平移 | 只读或本地命令模式下用 `+/-` 或滚轮缩放;方向键或 `i` 上、`j` 左、`k` 下、`l` 右平移 |
| 暂停远端输入并进入本地命令模式 | `Ctrl+]` |
| 选择返回后的控制状态 | 本地命令模式下按 `c`,再按 `Esc` / `Ctrl+C` 返回 |
| 退出连接 | 只读模式下按 `q` / `Ctrl+C`;控制模式下先按 `Ctrl+]`,再按 `q` |
本地命令模式在缩放、平移和按 `c` 后持续保持,直到手动返回。控制模式中的 `Ctrl+T`、`Ctrl+C` 和普通按键均传给远端。再次按 `Ctrl+]` 也可离开本地命令模式,若返回控制模式则同时向远端发送该组合键。
## 更多文档与功能范围
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
- [私有桥接协议](docs/bridge-protocol.md)
- [实际验证记录](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 服务。
+41
View File
@@ -0,0 +1,41 @@
# 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 关闭视图后直接结束进程。
`viewport.fps` 是本地输出目标。对端支持预览订阅时,超过 15 的目标使用已有交互模式(协议 `fps=60`、编码 `frame_rate=30`),不再折算成 15 FPS;低目标仍按能力选择 1/8/15。bridge 始终输出字符行并要求宿主 ACK,不使用交互 Kitty 的共享内存传图。
帧事件:
```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 命令方法;未知方法报错。模型接入者必须另行取得截图/自动操作授权,不能把持续帧事件附加到聊天记录。
+206
View File
@@ -0,0 +1,206 @@
# npm 分发与平台兼容性
## 使用方式
从 [@chunkdash/crossdesk-cli](https://www.npmjs.com/package/@chunkdash/crossdesk-cli) 安装统一入口,npm 会选择对应系统和架构的原生平台包。需要 Node.js 22 或以上和 npm,无需本机编译工具链。
```sh
npm install -g @chunkdash/crossdesk-cli@latest
crossdesk-cli doctor --json
crossdesk-cli 123456789
```
更新时重新执行同一条 `npm install -g @chunkdash/crossdesk-cli@latest`。将示例 ID 替换为被控电脑的 9 位 ID,密码在连接时隐藏输入;`doctor` 只检查本机环境,不建立远程连接。
无需全局安装时使用 `npx`,首次运行会下载对应平台包:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
启动器根据 Node 的 `process.platform` / `process.arch` 选择同版本的 `@chunkdash` 平台包,直接启动原生 C++ 程序,继承终端、工作目录、环境变量和命令参数。原生可执行文件随 npm 平台包下载,安装过程不执行 postinstall,也不在本机编译。不要使用 `--omit=optional`,否则对应平台包不会安装。
入口包名为 `@chunkdash/crossdesk-cli`,`bin` 声明的终端命令仍为 `crossdesk-cli`。如已全局安装旧的无 scope 入口,先执行 `npm uninstall -g crossdesk-cli`,再安装 scoped 入口,避免两个包争用同一个命令。原来的 `crossdesk-cli@0.1.0` 是独立的旧包,改名不会更新它或自动迁移已有安装。
平台包仅提供原生二进制,不声明 `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` 的版本,检查二者一致;检查当前平台二进制格式、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)。
+15
View File
@@ -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`,版本由主仓库记录的 `deps/submodules/minirtc` 子模块提交固定。整合时采用的 `b9ee1fa7fe038fdf771f5dec1b1d561d78273f15` 基于整合前的 `cdcd0d8fb0e9b688f3bf6416fe52b634cf20d9ca`,仅增加下述四处凭据日志脱敏修改。
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
MiniRTC 已在提交 `b9ee1fa7fe038fdf771f5dec1b1d561d78273f15` 中合入凭据日志脱敏:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 仅按主仓库记录初始化子模块,不再应用独立补丁;更新 MiniRTC 时只需更新子模块提交。共享子模块中的脱敏同样适用于桌面程序;该修改没有改变鉴权、TLS、TURN 或媒体协议。
npm 原生包携带 CLI、共享库、构建配置和 MiniRTC 的源码快照,不复制桌面、移动端、测试或本机构建缓存。源码包保留仓库目录结构,在 `source/` 中配置 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 后构建。
LICENSE 沿用 CrossDesk GPL-3.0;MiniRTC 子模块保留其 LGPL-3.0 和第三方声明。外部 Xmake 依赖的许可证、对应源码及重链接材料仍需随正式版本提供。
+177
View File
@@ -0,0 +1,177 @@
# 终端客户端
## 构建与安装
要求 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 子模块,可重复执行。凭据日志脱敏已合入 MiniRTC,无需额外应用补丁;升级 MiniRTC 时只需更新子模块提交。仓库默认开启桌面构建、关闭 CLI。上述参数只构建 CLI;需要同时构建桌面和 CLI 时,将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。恢复默认桌面配置使用 `xmake f -y --CROSSDESK_BUILD_DESKTOP=y --CROSSDESK_BUILD_CLI=n`。所有命令均从仓库根目录执行。
构建产物在 `build/<platform>/<arch>/<mode>/crossdesk-cli`。可把对应目录加入 PATH 或复制可执行文件到自己的 bin 目录。Pi 需要直接启动该可执行文件,不能使用会输出构建日志的 `xmake run` 作为桥接程序。
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 输出明确拒绝交互连接,不输出全屏转义序列。
四象限模式将每个 RGB 通道变化均不超过 6/255 的低对比单元合并为纯背景色,减少平坦区域的字形接缝。其余单元在独立拟合后参考上下左右邻格的量化误差调整选色,保留源图的真实渐变,并在高对比边缘减弱约束;调整设有失真上限,超过低对比阈值且能精确表示的文字细线和双色边缘保持原样。这些处理自动应用于独立 CLI 和 Pi/bridge 的四象限画面,实际效果仍受终端字体和行距影响。
画面按字符单元比例等比缩放,留黑边及状态栏。交互连接通过终端字节流查询字符尺寸,支持通过 SSH 返回的回复:优先使用 `CSI 16 t` 的字符像素尺寸,否则组合 `CSI 14 t` 的完整文本区像素尺寸和 `CSI 18 t` 的完整行列数计算,兼容 GNOME/VTE 的文本区报告。计算后再扣除状态栏及画面行列上限,不把预留行当作图像高度。窗口改变时重新查询,每两秒刷新一次以覆盖字体/行距变化;回复到达后会重绘静止画面和更新鼠标映射。查询不阻塞连接;没有有效回复时使用系统提供的像素尺寸,再缺失时回退为宽高比 0.5。曾收到有效回复时保留最近有效尺寸,直到下一次有效回复。字符栅格的整数舍入仍可能产生小幅比例误差。格式依据 [XTerm 窗口尺寸报告](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 和 [GNOME VTE 实现](https://github.com/GNOME/vte/blob/master/src/vteseq.cc)。
Windows 的可见传统控制台优先读取当前字体尺寸;Windows Terminal/ConPTY 使用显示终端的尺寸回复,不采用隐藏控制台宿主的字体值。不同终端版本可能不支持查询,或返回虚拟图形尺寸而非实际字体尺寸,此时需要手动校准。
默认 `--cell-aspect auto` 自动确定比例。如果终端屏蔽查询或需要手动校准,可设置 `--cell-aspect 0.45`,数值表示单个字符的**宽/高**,允许 0.2~2;例如字符为 9×20 像素时使用 0.45。画面被压窄时调小此值,被拉宽时调大。此参数优先于自动检测,同时用于全景、细节和鼠标坐标映射。
独立 CLI 每次只输出变化行中首尾变化单元之间的区间,光标或局部视频更新不再重写整行;一帧内复用颜色状态、按实际色板索引合并颜色设置,并省略纯背景空格的前景色指令。Pi/bridge 仍发送可独立绘制的完整字符行,同时使用上述颜色编码优化。四象限初始拟合使用整数子集求和计算同等精度的颜色和误差,边界连续性拟合复用加权样本,减少重复运算。
缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。
快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 `<c>`、`<+/->` 等标签标识。宽窗口中的分组分隔线按列对齐,窄窗口保留标题和完整按键标签,按可用宽度合并或减少分组。
顶部状态栏以整数显示实时帧率(例如 `FPS 30`),约每秒按最近统计区间内完整输出到终端的帧数计算;没有新画面输出时更新为 `FPS 0`。该数值是实际提交速率,不是 `--fps` 目标值或终端 GPU 呈现频率;窄窗口优先省略画面参数和网络信息,保留帧率。
ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。
auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没有有效 Kitty 回应就用 ANSI。显式 kitty 模式探测失败时报错。tmux/screen 中 auto 使用 ANSI,显式 kitty 报不支持;不修改用户终端配置。
本地 macOS/Linux 的 Kitty 模式会另用一个 1×1 像素的共享内存对象探测 `t=s`,最多等待 250ms;探测成功后,将未压缩 RGB 写入权限为 0600 的 POSIX 共享内存,仅通过终端字节流发送名字和显示命令。每次使用唯一名字,最多保留一个待确认的共享缓冲区(1920×1080×3 字节)。收到上传成功确认后才显示新图并释放缓冲区;上传报错、确认超过 500ms 或本地分配失败时,本次会话退回内联传输,继续保留旧画面。终端和 CLI 都会清理共享对象,退出时也会释放未确认的对象。SSH 中不尝试共享内存;Windows 暂用内联传输。
内联 Kitty 通过终端字节流发送 zlib 压缩的 RGB/Base64(每块至多 4096 字符),不使用远端临时图片文件。两条路径都交替使用图像 ID=31/32:以 `a=t` 上传到未显示的 ID,传完后以 `a=p` 创建 placement=1,再删除旧图。仅在整帧显示命令输出完成后交替 ID,上传和短写期间保留旧画面,退出时清理两个 ID。这样避免重传同一 ID 时终端先移除旧图造成的帧间黑屏。
依据终端报告的像素尺寸生成画面(未知时按 10×20 像素字符估算),上限 1920×1080。保留面积滤波、黑边、宽高比和裁剪坐标;原始细节模式按像素视口裁取源区域。共享内存路径使用可复用水平行计算的整数面积权重,避免逐像素重复浮点运算,也不再因 Base64 字节预算额外降采样。没有可靠终端像素尺寸时使用估算值,因此不承诺显示设备的物理像素一一对应。内联压缩结果超出单次输出预算时仍逐级降低尺寸;高熵视频的实际帧率可能低于目标。RGB 传输和压缩不丢失像素,不代表面积采样或上游视频编码无损。
```sh
ssh -t your-server
crossdesk-cli 123456789 --renderer ansi --fps 8
```
用户本地终端负责显示 SSH 字节流。ANSI、Kitty over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。
## 预览与背压
`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。
未显式指定 `--fps` 时,远端预览从 30 FPS 开始,根据完整一帧从转换开始到输出完成的耗时自动选择 8/15/30 FPS 请求;共享内存的上传确认等待也计入,等待源帧的空闲时间不计入。使用平滑估计、不同升降档阈值和至少 8 个新帧样本,持续变慢 2 秒才降档、持续恢复 4 秒才升档,保留供帧余量。15 FPS 仅在对端声明支持时使用;预览未获接受时不追加自动调节。视口变化、显示器切换和共享内存回退会重置耗时估计。显式 `--fps` 固定本地目标和对应的远端预览请求,关闭自动调节;例如 `--fps 30` 可用于与自动策略对比。
本地调度只在有新帧或需要重绘时消耗刷新时隙;短暂迟到保持原刷新节奏,长时间阻塞后跳过旧时隙,不连续补画过时帧。目标帧率不代表终端实际显示帧率。
对端声明 PreviewSubscription 时,预览请求不超过 15 映射到合法 1/8/15 FPS(15 需要对应能力);超过 15 则请求项目已有的交互模式:协议 `fps=60` 是模式标记,编码设置 `frame_rate=30` 才是帧率。分辨率上限使用协商到的 v2,视频预算只在 bandwidth 能力存在时发送。`--quality low|balanced|high|ultra` 对应如下请求:
| 档位 | 分辨率上限 | 质量枚举 | 视频预算 |
|---|---|---|---|
| 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。本地交互 ANSI 和 Kitty 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,两者保持 1MiB/s,Pi 桥接始终保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分按终端实际使用的颜色与字形比较,忽略量化后相同的颜色和空格不可见的前景色;同一行可输出多个变化片段,并按光标定位、颜色切换和字符的实际字节成本合并短间隔。差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
测量像素渲染帧率时使用 release 构建;debug 的 `-O0` 面积采样开销明显更高。共享内存中的像素不经过终端字节预算,但仍受转换和终端消费速度限制;8MiB/s 是内联路径的输出上限,不是帧率保证。解码、像素转换、压缩、终端消费速度和帧大小都会影响结果。退出时,未完成画面最多继续写入 2s;取消图像传输及恢复屏幕的短写最多另重试 500ms。POSIX 立即恢复 termios 并清理输入,不等待输出排空;如果终端始终不消费输出,屏幕清理只能尽力发送。
统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;交互连接结束日志另给平均提交 FPS,以及实际 `renderer` 和 `output_bytes_per_second` 预算。`kitty_transport=shared_memory/inline/none` 标明生效路径,`target_fps` 是本地目标,`preview_fps` 是最近请求的远端预览目标(并非已生效或实测帧率),`adaptive_preview` 表示是否开启自动调节,`preview_adjustments` 记录切档次数;每次切档另记录 `preview_fps` 事件。`frame_service_ms` 是最近完整帧处理与输出耗时的平滑估计,`frame_processing_ms` 是平均本地布局/转换/传输准备耗时,`shared_ack_ms` 是平均上传命令写完到解析成功确认的耗时,`shared_fallbacks` 记录运行时回退次数。`client.log` 摘要的 `startup_ms` 记录从发起连接到连接成功(`connected`)、首个取出用于显示的视频帧入队(`first_frame`)、首帧显示命令完整写入终端(`first_output`)的毫秒数;未到达的阶段为 `null`。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。
## 输入与退出
默认只读、静音。`--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 时打开独立终端或给出播放器命令。
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
+139
View File
@@ -0,0 +1,139 @@
# 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 进行视觉验收。
## ANSI 四象限接缝优化(2026-10-30)
- macOS arm64 的 CLI debug 构建和 `doctor --json` 通过。工程外 ASan/UBSan 检查覆盖低对比合并、横纵细线/斜线/双色图案、等亮度彩色边缘、随机 NV12 输入的失真上限、局部更新范围和重复转换确定性。
- 合成低对比图案中,3,072 个非空白字形全部合并为纯背景。合成渐变纹理的加权接缝误差由约 207,373 降至 180,187(13.1%);仅统计两端均未合并为纯色的边界时,由约 198,007 降至 169,621。指标衡量相邻样本的重建色差相对源图色差的偏离,并降低真实高对比边缘的权重;不代表实机条纹减少的百分比。
- 8/24/256 色输出、half/ASCII 与原实现一致性、不同视口/比例/缩放的布局和鼠标映射、静止网格差分及 Kitty 输出检查通过。
- 本机 `-O2`、1920×1080 输入、240×100 字符视口、每项 12 次转换的合成基准:平滑画面约 7.14 → 6.03 ms/帧,随机亮度噪声约 5.44 → 7.73 ms/帧。仅衡量转换,不包含视频解码、ANSI 输出和终端绘制。
- 额外检查代码、基准和数据均位于工程外系统临时目录;未新增工程内测试,未连接真实远端或进行终端字形/行距的视觉验收。
## ANSI 转换与输出加速(2026-10-30)
- macOS arm64 CLI debug 构建和 `doctor --json` 通过。工程外 ASan/UBSan 与 `-O2` 检查均通过:加速后的转换与此前接缝优化版本逐格颜色/字形一致,覆盖随机 NV12、低对比及平滑纹理、half/quadrant/ASCII、缩放和细节模式;Kitty 数据保持一致。
- ANSI 转义序列模拟器验证最终屏幕一致,覆盖全部四象限字形、8/24/256 色、纯背景及色板复用、首尾单元、局部修改、视口/偏移变化和可独立绘制的 bridge 字符行。未向工程新增测试文件或用例。
- 本机 `-O2`、1920×1080 输入、240×100 字符视口、每项 24 次转换:平滑画面约 6.26 → 4.83 ms/帧,随机亮度噪声约 7.75 → 5.72 ms/帧。保留面积滤波、低对比合并及边界连续性约束。
- 240×100 真彩色合成网格、每项 100 次输出生成:整帧约 2.03 → 1.43 ms;120×50 单元的局部修改约 1.02 → 0.36 ms,输出 461,743 → 231,026 字节;2×3 单元的光标修改约 0.072 → 0.013 ms,输出 27,763 → 262 字节。纯背景色块整帧输出 875,352 → 449,826 字节。
- 这些结果衡量转换、编码及输出量,不包含实际终端绘制或网络传输。整帧高色彩变化图案仍需约 923 KB,仍受 1 MiB/s 输出预算限制;不能将转换耗时的倒数作为远程显示帧率。当时默认本地目标为 8 FPS,后续默认策略见“默认 30 FPS 与供帧自适应”。
- 额外检查代码、模拟器、基准和产物均位于工程外临时目录;未进行跨平台终端或真实远端帧率验收。
## Kitty 帧间黑屏与本地吞吐修复(2026-10-30)
- macOS arm64 debug、release 构建通过,release `doctor --json` 通过。release 使用工程外独立配置构建,保留工程原有 debug 配置;额外检查脚本、伪终端夹具和数据均位于系统临时目录。
- ASan/UBSan 检查及 Kitty 协议模拟验证:ID 31/32 交替上传,所有上传分块完成前旧图保持显示;先创建新图 placement,再释放旧图;最多两个完整图像驻留,切换后只保留一个。新旧输出的解压 RGB 和尺寸逐字节一致,覆盖尺寸、偏移和缩放变化;单帧小于 2MiB,全部分块边界的中断清理通过。
- ANSI 的 half/quadrant/ASCII 和 8/24/256 色共 12 种组合,转换单元及输出字节与此次修改前一致。
- 使用生产 `View`、`Output` 和 Kitty 编码代码、`-O2`、合成 NV12 持续回调的本地 PTY 对比:目标 8 FPS,单帧约 1.25 MB,稳定完整接收速率约 0.84 → 6.71 FPS,帧间隔中位数约 1,192 → 148 ms。只衡量完整输出接收,不包含真实网络、视频解码和 Ghostty GPU 显示,不能作为实机帧率承诺。
- 本地 Kitty 的日志预算为 8MiB/s;分别仅设置 `SSH_TTY`、`SSH_CONNECTION`、`SSH_CLIENT` 时均保持 1MiB/s,ANSI 也保持 1MiB/s。连续帧 ID 仅在完整写入后交替,退出清理两个 ID。
- 慢速 PTY 在第二帧上传中退出:首帧保留、第二帧未提交,约 2.06s 后退出;两个 ID 清理、备用屏幕/光标控制序列和原 termios 均恢复。测试发现原单次清理写入会在满缓冲区时丢失,现增加至多 500ms 的非阻塞短写重试。
- 模拟父 shell 保持会话、终端完全停止读取的 PTY:CLI 约 2.53s 后返回,termios 和非阻塞标志恢复。POSIX 恢复改为 `TCSANOW` 加输入清理,避免 `TCSAFLUSH` 等待输出排空。完全不读取时,屏幕清理字节不保证送达。
- 本机安装 Ghostty 1.4.0,但自动化工具拒绝访问其窗口,未完成 Ghostty 实窗视觉验收。npm 安装的 CLI 不会随本地构建自动更新,复测需要直接运行本次 release 产物。
## 本地 Kitty 共享内存与 30 FPS 目标(2026-10-30)
- 本地 POSIX Kitty 增加共享内存能力探测、唯一名称、0600 权限、上传确认和 500ms 超时回退。单个待确认共享缓冲区不超过 1920×1080×3 字节;确认前保留旧画面,退出释放未确认对象。Windows 暂保留内联路径,SSH 不发共享内存探测。
- 当时共享内存可用且未显式指定 `--fps` 时默认目标 30;自动回退内联恢复 8,显式帧率保持不变。后续默认策略见“默认 30 FPS 与供帧自适应”。目标大于 15 使用现有预览协议的交互模式 `fps=60, frame_rate=30`;没有新增协议取值或修改被控端。
- 工程外 ASan/UBSan 检查通过:110 组随机帧/视口/宽高比/缩放/细节模式,整数面积滤波相对原浮点参考的每通道最大误差为 1/255;内联 Kitty 和 ANSI 保持原输出。覆盖共享内存权限、读取内容、命名、容量限制、双方 unlink、分片确认及非法/溢出/重复 ID 回应。
- 使用实际 release CLI 对象代码、1920×1080 合成噪声 NV12 连续回调、1568×1088 报告视口的 PTY 测试:内联完整接收约 4.06 FPS,共享内存约 29.04 FPS;平均本地处理约 166.6 → 12.3 ms,终端字节流约 1,703,174 → 188 字节/帧。共享内存仍传输完整 RGB,并非总数据量只有 188 字节;数据复制、终端绘制和实际网络另有成本。
- 显式 8 FPS、探测不支持/静默、第三帧上传报错/丢失确认、80ms 延迟确认、SSH 禁用探测均通过。回退前不显示未确认帧,运行时回退仅触发一次,并正确恢复自动目标和协议请求;所有共享对象均清理。第二帧上传后立即退出时,首帧仍是已提交画面,未确认对象及两个图像 ID 均清理。
- 相同合成输入的 debug 构建约 5.91 FPS,本地处理约 163 ms,说明 `-O0` 仍明显影响像素转换;性能验收使用 release。上述精确数字是本地测试接收速率,不是 Ghostty GPU 显示帧率。
- 用户在本机 Ghostty 运行新版 release 后反馈“明显流畅了”。额外测试源码、数据、构建夹具和产物均位于系统临时目录,未在工程或子模块新增测试。
- 该次实机日志确认 `kitty_transport=shared_memory`、目标 30、预览请求已接受、运行时回退 0 次。会话约 13.61s 完整提交 273 帧,平均提交约 20.06 FPS(包含约 1.49s 首帧启动等待);平均本地处理约 10.87ms、共享内存确认约 0.42ms。该日志记录提交速率,不测量终端 GPU 呈现频率。
## 默认 30 FPS 与供帧自适应(2026-10-30)
- 交互 CLI 默认本地目标统一为 30 FPS,Kitty 共享内存回退不再改写该目标;远端请求独立在 8/15/30 FPS 档位间自适应,15 需要对端能力。显式 `--fps` 保留固定请求,bridge 默认仍为 8。
- macOS arm64 debug/release 构建、release `doctor --json` 和帮助输出检查通过。工程外 ASan/UBSan 验证了升降档阈值、迟滞、恢复、15 FPS 能力缺失、显式帧率、源帧稀疏、请求拒绝以及估计重置。
- 使用真实 release CLI 对象代码、支持预览确认和动态供帧的假传输、1920×1080 合成 NV12 与 1568×1088 像素视口进行 PTY 验证。固定 30 FPS 源的共享内存路径完整接收约 30 FPS,未触发自动降档;不再在没有新帧时提前消耗刷新时隙。
- 高熵内联路径实际约 4 FPS 时,自动请求由 30 降为 8,本地目标保持 30;显式 `--fps 30` 在同条件下仍约 4 FPS,远端请求不变。上传报错/超时和探测静默均保持本地 30 目标,随后按完整帧耗时调节供帧。
- 90ms 上传确认延迟使实际接收约 9 FPS,远端请求自动调整为 15;缺少 15 FPS 能力时不发送该档位。200ms 延迟后恢复正常确认,验证远端请求按 30 → 8 → 30 恢复。源仅提供 2 FPS、对端拒绝预览时均未误触发自动降档。
- SSH 环境保持原有 1MiB/s 字节预算且不探测共享内存;慢速输出降远端请求但保留本地目标。ANSI 默认 30/显式 15 和 bridge 默认 8/帧 ACK 通过。所有 Kitty 场景继续验证不显示未确认帧、最多两个终端图像、共享对象释放及退出后终端模式恢复。
- 以上数字是合成源和伪终端的完整输出接收率,不包含真实视频解码、网络或 Ghostty GPU 呈现。全部额外测试代码、数据、构建脚本和产物位于工程外系统临时目录;本次未新增工程内测试,尚未对新版进行 Ghostty 实窗验收。
## ANSI 多片段差分与本地吞吐(2026-10-31)
- 本地交互 ANSI 的持续输出预算由 1MiB/s 提高到 8MiB/s;SSH 与 bridge 仍为 1MiB/s。保留 2MiB 单消息、1MiB 突发、非阻塞短写、最新帧替换和完整输出后提交差分基准的约束。
- 同一行的分散修改按多个片段输出,短间隔按实际编码字节成本合并,比较包含下一单元的颜色设置。差分忽略色板量化后相同的颜色、空格前景色以及 ASCII 模式不显示的字形/背景字段;未修改采样、象限拟合及 Kitty 传输。
- macOS arm64 CLI debug/release 构建及两种产物的 `doctor --json` 通过,完成后工程配置恢复为桌面与 CLI 同时启用的 debug。工程外 ASan/UBSan 检查通过:全部四象限字形、8/24/256 色、ASCII、首尾单元、长短间隔、色板等价、多种尺寸及偏移变化、600 帧连续随机更新与整帧重绘的屏幕一致性;140 组转换逐格一致,bridge 行输出及 Kitty 数据保持一致。
- `-O2`、240×100 合成网格:10 行各两处分散修改由 90,058 降到 931 字节;隔列修改由 919,260 降到 562,601 字节;量化后颜色相同的整屏变化由 72,716 降到 0 字节。高色彩变化整帧仍为 922,929 字节,2×3 光标修改仍为 262 字节。多片段选择会增加部分交错修改场景的编码计算,以减少终端输出量。
- 使用生产 CLI/渲染器的 `-O3` 构建、1920×1080 连续变化合成 NV12、240×100 字符视口、固定目标 30 FPS 的本地 PTY 对比:跳过启动阶段后的完整帧接收速率约 1.64 → 6.42 FPS,约 3.9 倍;平均每帧约 640 KB。分别设置 `SSH_TTY`、`SSH_CONNECTION`、`SSH_CLIENT` 时预算均保持 1MiB/s;设置 `TMUX` 的本地 ANSI 路径使用 8MiB/s。
- 暂停 PTY 读取 1.3s,并在暂停期间发送退出命令,恢复读取后完整帧计数与提交计数一致,约 1.17s 内退出,备用屏幕、termios 和非阻塞标志恢复。没有连接真实远端,也未验证真实终端的 GPU 绘制、SSH 或 tmux 实际显示;上述帧率为合成源的完整输出接收率。
- 额外检查源码、脚本、数据和产物均位于工程外系统临时目录,未新增或修改工程内测试文件/用例。
## 实测边界
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。
CLI 需要支持 `cli` 临时注册的服务端。`doctor --json` 仅报告本机环境,不会联网登录或证明远端可连接。
+1188
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -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;
}
+7
View File
@@ -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" }
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@chunkdash/crossdesk-cli",
"version": "0.1.4",
"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"
}
}
+250
View File
@@ -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; }
},
});
}
+13
View File
@@ -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"
}
}
+200
View File
@@ -0,0 +1,200 @@
'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}`);
}
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 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', '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 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 });
}
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../../.."
git submodule update --init --recursive
+242
View 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 };
+78
View File
@@ -0,0 +1,78 @@
/*
* @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 <cstdint>
#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);
// One private, uniquely named pixel buffer. Keep it alive until the terminal
// acknowledges reading it; Reset also unlinks objects the terminal left behind.
class SharedImage {
public:
SharedImage();
~SharedImage();
bool Assign(const uint8_t *data, size_t size);
void Reset();
const std::string &Name() const;
private:
struct State;
std::unique_ptr<State> state_;
};
class NonBlocking {
public:
explicit NonBlocking(int fd, bool output = false);
~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
+228
View File
@@ -0,0 +1,228 @@
#ifndef _WIN32
#include "terminal_io.h"
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <poll.h>
#include <stdexcept>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/select.h>
#include <termios.h>
#include <unistd.h>
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 SharedImage::State {
std::string name;
void *data = MAP_FAILED;
size_t size = 0;
};
SharedImage::SharedImage() : state_(std::make_unique<State>()) {}
SharedImage::~SharedImage() { Reset(); }
const std::string &SharedImage::Name() const { return state_->name; }
void SharedImage::Reset() {
if (state_->data != MAP_FAILED)
munmap(state_->data, state_->size);
if (!state_->name.empty())
shm_unlink(state_->name.c_str());
state_->data = MAP_FAILED;
state_->size = 0;
state_->name.clear();
}
bool SharedImage::Assign(const uint8_t *data, size_t size) {
Reset();
if (!data || !size || size > 1920 * 1080 * 3)
return false;
static std::atomic<uint32_t> sequence{0};
int fd = -1;
for (int attempt = 0; attempt < 16; ++attempt) {
// Fits Darwin's 31-byte name limit. O_EXCL prevents opening another
// process's object, including a stale name from a recycled process ID.
auto name = "/cdx-" + std::to_string(getpid()) + "-" +
std::to_string(++sequence);
fd = shm_open(name.c_str(), O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd >= 0) {
state_->name = std::move(name);
break;
}
if (errno != EEXIST)
return false;
}
if (fd < 0)
return false;
fcntl(fd, F_SETFD, FD_CLOEXEC);
#ifdef __linux__
// Reserve tmpfs space before touching the mapping; a successful ftruncate
// alone can still SIGBUS later when /dev/shm is full.
if (posix_fallocate(fd, 0, size) != 0) {
close(fd);
Reset();
return false;
}
#endif
if (ftruncate(fd, size) == 0) {
state_->data = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
state_->size = size;
}
close(fd);
if (state_->data == MAP_FAILED) {
Reset();
return false;
}
std::memcpy(state_->data, data, size);
return true;
}
struct NonBlocking::State {
int fd, flags;
};
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() {
// TCSAFLUSH waits for queued output, which can hang exit when the terminal
// stops consuming a partially written image. Restore immediately and flush
// only pending input so protocol replies do not reach the next shell prompt.
tcsetattr(state_->fd, TCSANOW, &state_->saved);
tcflush(state_->fd, TCIFLUSH);
fcntl(state_->fd, F_SETFL, state_->flags);
}
std::string Password() {
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
+499
View File
@@ -0,0 +1,499 @@
#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 SharedImage::State {
std::string name;
};
SharedImage::SharedImage() : state_(std::make_unique<State>()) {}
SharedImage::~SharedImage() = default;
bool SharedImage::Assign(const uint8_t *, size_t) { return false; }
void SharedImage::Reset() {}
const std::string &SharedImage::Name() const { return state_->name; }
struct NonBlocking::State {
HANDLE handle;
DWORD mode = 0;
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
+45
View File
@@ -0,0 +1,45 @@
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")
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"))
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.4")
add_deps("crossdesk_terminal")
add_files("main.cpp")
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
if is_plat("windows") then
add_syslinks("advapi32", "user32")
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
elseif is_plat("linux") then
add_syslinks("rt")
end
target_end()
+244
View File
@@ -0,0 +1,244 @@
#include "private_file.h"
#include <openssl/rand.h>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <vector>
#ifdef _WIN32
#include <windows.h>
#include <Aclapi.h>
#include <sddl.h>
#else
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
namespace crossdesk::private_file {
namespace {
void Require(bool ok, const char* error) {
if (!ok) throw std::runtime_error(error);
}
std::string RandomSuffix() {
unsigned char bytes[16];
Require(RAND_bytes(bytes, sizeof(bytes)) == 1, "Secure random source unavailable");
constexpr char hex[] = "0123456789abcdef";
std::string value;
for (auto byte : bytes) { value += hex[byte >> 4]; value += hex[byte & 15]; }
return value;
}
#ifdef _WIN32
std::vector<unsigned char> CurrentUser() {
HANDLE token = nullptr;
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
"Cannot open user token");
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<unsigned char> data(size);
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
CloseHandle(token);
Require(ok, "Cannot read user token");
return data;
}
PSECURITY_DESCRIPTOR PrivateSecurityDescriptor(bool directory) {
auto user = CurrentUser();
LPWSTR sid = nullptr;
Require(ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
"Cannot read user SID");
// An elevated token can default to Administrators as the owner even when
// its DACL grants access only to TokenUser. Always set both explicitly.
const auto acl = L"O:" + std::wstring(sid) + L"D:P(A;" +
(directory ? L"OICI" : L"") + L";FA;;;" + sid + L")";
LocalFree(sid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
"Cannot protect private file");
return descriptor;
}
#endif
} // namespace
void Write(const std::filesystem::path& path, const std::string& text) {
const auto temporary = path.parent_path() / (".write-" + RandomSuffix());
#ifdef _WIN32
auto descriptor = PrivateSecurityDescriptor(false);
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &security,
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
LocalFree(descriptor);
Require(file != INVALID_HANDLE_VALUE, "Cannot create private file");
DWORD written = 0;
bool ok = WriteFile(file, text.data(), static_cast<DWORD>(text.size()),
&written, nullptr) &&
written == text.size() && FlushFileBuffers(file);
CloseHandle(file);
if (!ok || !MoveFileExW(temporary.c_str(), path.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
DeleteFileW(temporary.c_str());
throw std::runtime_error("Cannot persist private file");
}
#else
int fd =
open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
Require(fd >= 0, "Cannot create private file");
size_t offset = 0;
while (offset < text.size()) {
const auto count = write(fd, text.data() + offset, text.size() - offset);
if (count <= 0) break;
offset += static_cast<size_t>(count);
}
bool ok = offset == text.size() && fsync(fd) == 0;
close(fd);
if (!ok || rename(temporary.c_str(), path.c_str()) != 0) {
unlink(temporary.c_str());
throw std::runtime_error("Cannot persist private file");
}
const int directory = open(path.parent_path().c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
const bool durable = directory >= 0 && fsync(directory) == 0;
if (directory >= 0) close(directory);
Require(durable, "Cannot persist private directory entry");
#endif
}
namespace {
#ifdef _WIN32
using FileHandle = std::unique_ptr<void, decltype(&CloseHandle)>;
FileHandle OpenPrivatePath(const std::filesystem::path& path, DWORD access) {
// Keep the object open without FILE_SHARE_DELETE so validation and legacy
// owner repair cannot operate on a replacement at the same path.
HANDLE file = CreateFileW(
path.c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
nullptr);
Require(file != INVALID_HANDLE_VALUE, "Cannot open private path");
return {file, CloseHandle};
}
void CheckPrivateWindowsPath(const std::filesystem::path& path) {
auto file = OpenPrivatePath(path, READ_CONTROL);
BY_HANDLE_FILE_INFORMATION info{};
Require(GetFileInformationByHandle(file.get(), &info) &&
!(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT),
"private path cannot be a reparse point");
auto user = CurrentUser();
auto sid = reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid;
PSID owner = nullptr;
PACL acl = nullptr;
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(GetSecurityInfo(
file.get(), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS,
"Cannot read private file permissions");
std::unique_ptr<void, decltype(&LocalFree)> security(descriptor, LocalFree);
bool ok = acl && acl->AceCount > 0;
// Only the exact protected, full-access DACL written by older versions is
// eligible for owner repair. Never adopt a shared or foreign-user profile.
SECURITY_DESCRIPTOR_CONTROL control{};
DWORD revision = 0;
bool legacy = owner && IsWellKnownSid(owner, WinBuiltinAdministratorsSid) &&
GetSecurityDescriptorControl(descriptor, &control, &revision) &&
(control & SE_DACL_PROTECTED) && acl && acl->AceCount == 1;
for (DWORD i = 0; ok && i < acl->AceCount; ++i) {
void* raw = nullptr;
if (!GetAce(acl, i, &raw)) {
ok = false;
break;
}
const auto* ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
EqualSid(const_cast<DWORD*>(&ace->SidStart), sid);
const BYTE flags = (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
? OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
: 0;
legacy =
legacy && ace->Mask == FILE_ALL_ACCESS && ace->Header.AceFlags == flags;
}
Require(ok && owner && (EqualSid(owner, sid) || legacy),
"private files must be accessible only by the current user");
if (legacy) {
auto writable = OpenPrivatePath(path, WRITE_OWNER);
BY_HANDLE_FILE_INFORMATION current{};
Require(GetFileInformationByHandle(writable.get(), &current) &&
current.dwVolumeSerialNumber == info.dwVolumeSerialNumber &&
current.nFileIndexHigh == info.nFileIndexHigh &&
current.nFileIndexLow == info.nFileIndexLow &&
SetSecurityInfo(writable.get(), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION, sid, nullptr,
nullptr, nullptr) == ERROR_SUCCESS,
"Cannot repair private file owner");
}
}
void CreatePrivateDirectory(const std::filesystem::path& path, bool protect_existing) {
if (!std::filesystem::exists(path)) {
auto descriptor = PrivateSecurityDescriptor(true);
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
bool ok = CreateDirectoryW(path.c_str(), &security);
LocalFree(descriptor);
Require(ok, "Cannot create private directory");
}
if (protect_existing) {
// The cache may predate identity storage and inherit a normal profile ACL.
// Elevated older builds may have used Administrators as the owner. The
// private-path check below repairs that owner after applying the user ACL.
auto file = OpenPrivatePath(path, READ_CONTROL | WRITE_DAC);
BY_HANDLE_FILE_INFORMATION info{};
Require(GetFileInformationByHandle(file.get(), &info) &&
(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) &&
!(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT),
"Invalid cache directory");
PSID owner = nullptr;
PSECURITY_DESCRIPTOR original = nullptr;
Require(GetSecurityInfo(file.get(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION,
&owner, nullptr, nullptr, nullptr, &original) == ERROR_SUCCESS,
"Cannot read cache directory owner");
std::unique_ptr<void, decltype(&LocalFree)> original_guard(original, LocalFree);
auto user = CurrentUser();
Require(owner && (EqualSid(owner, reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid) ||
IsWellKnownSid(owner, WinBuiltinAdministratorsSid)),
"Cache directory belongs to another user");
auto descriptor = PrivateSecurityDescriptor(true);
std::unique_ptr<void, decltype(&LocalFree)> descriptor_guard(descriptor, LocalFree);
BOOL present = FALSE, defaulted = FALSE;
PACL acl = nullptr;
Require(GetSecurityDescriptorDacl(descriptor, &present, &acl, &defaulted) && present &&
SetSecurityInfo(file.get(), SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
nullptr, nullptr, acl, nullptr) == ERROR_SUCCESS,
"Cannot protect cache directory");
}
CheckPrivateWindowsPath(path);
}
#endif
} // namespace
std::string Read(const std::filesystem::path& path) {
#ifdef _WIN32
CheckPrivateWindowsPath(path);
#else
struct stat status{};
Require(lstat(path.c_str(), &status) == 0 && S_ISREG(status.st_mode) &&
status.st_uid == getuid() && (status.st_mode & 0077) == 0,
"private file must be owned by this user with mode 0600");
#endif
Require(std::filesystem::file_size(path) <= 64 * 1024, "private file too large");
std::ifstream input(path, std::ios::binary);
Require(input.good(), "Cannot read private file");
return {std::istreambuf_iterator<char>(input), {}};
}
void EnsureDirectory(const std::filesystem::path& directory, bool protect_existing) {
std::filesystem::create_directories(directory.parent_path());
#ifdef _WIN32
CreatePrivateDirectory(directory, protect_existing);
#else
std::filesystem::create_directories(directory);
struct stat status{};
Require(lstat(directory.c_str(), &status) == 0 && S_ISDIR(status.st_mode) &&
status.st_uid == getuid(), "Invalid private directory owner or type");
Require(chmod(directory.c_str(), 0700) == 0, "Cannot protect private directory");
#endif
}
} // namespace crossdesk::private_file
+21
View File
@@ -0,0 +1,21 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-01
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVATE_FILE_H_
#define _PRIVATE_FILE_H_
#include <filesystem>
#include <string>
namespace crossdesk::private_file {
// User-only files, atomic replacement, and rejection of shared/linked stores.
void EnsureDirectory(const std::filesystem::path& directory,
bool protect_existing = false);
std::string Read(const std::filesystem::path& path);
void Write(const std::filesystem::path& path, const std::string& text);
}
#endif
@@ -36,6 +36,14 @@ ConfigCenter::~ConfigCenter() {}
int ConfigCenter::Load() {
SI_Error rc = ini_->LoadFile(config_path_.c_str());
if (rc < 0) {
std::error_code error;
if (std::filesystem::exists(config_path_, error) || error) {
// An unreadable existing configuration must not silently disable access
// restrictions or overwrite the user's settings with default values.
trusted_devices_only_.store(true);
LOG_ERROR("Cannot read existing configuration; restricting incoming connections");
return -1;
}
Save();
return -1;
}
@@ -141,6 +149,9 @@ int ConfigCenter::Load() {
ini_->GetBoolValue(section_, "privacy_local_unlock_allowed", true));
lock_on_disconnect_.store(
ini_->GetBoolValue(section_, "lock_on_disconnect", true));
trusted_devices_only_.store(
ini_->GetValue(section_, "trusted_devices_only", nullptr) != nullptr &&
ini_->GetBoolValue(section_, "trusted_devices_only", true));
save_remote_previews_.store(
ini_->GetBoolValue(section_, "save_remote_previews", true));
portable_service_prompt_suppressed_ =
@@ -239,6 +250,7 @@ int ConfigCenter::Save() {
ini_->SetBoolValue(section_, "privacy_local_unlock_allowed",
privacy_local_unlock_allowed_.load());
ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
ini_->SetBoolValue(section_, "trusted_devices_only", trusted_devices_only_.load());
ini_->SetBoolValue(section_, "save_remote_previews",
save_remote_previews_.load());
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
@@ -425,6 +437,13 @@ bool ConfigCenter::IsPrivacyLocalUnlockAllowed() const {
return privacy_local_unlock_allowed_.load();
}
int ConfigCenter::SetTrustedDevicesOnly(bool enabled) {
if (StoreValues({{"trusted_devices_only", enabled ? "true" : "false"}}) != 0) return -1;
trusted_devices_only_.store(enabled);
return 0;
}
bool ConfigCenter::IsTrustedDevicesOnly() const { return trusted_devices_only_.load(); }
int ConfigCenter::SetLockOnDisconnect(bool enabled) {
if (StoreValues({{"lock_on_disconnect", enabled ? "true" : "false"}}) != 0)
return -1;
@@ -55,6 +55,7 @@ class ConfigCenter {
int SetPrivacyScreen(bool enable_privacy_screen);
int SetPrivacyLocalUnlock(bool allowed);
int SetLockOnDisconnect(bool enabled);
int SetTrustedDevicesOnly(bool enabled);
int SetSaveRemotePreviews(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed);
int SetSponsorshipPromptDismissedVersion(const std::string& version);
@@ -80,6 +81,7 @@ class ConfigCenter {
bool IsEnablePrivacyScreen() const;
bool IsPrivacyLocalUnlockAllowed() const;
bool IsLockOnDisconnect() const;
bool IsTrustedDevicesOnly() const;
bool IsSaveRemotePreviews() const;
bool IsPortableServicePromptSuppressed() const;
std::string GetSponsorshipPromptDismissedVersion() const;
@@ -113,6 +115,7 @@ class ConfigCenter {
std::atomic<bool> enable_privacy_screen_{true};
std::atomic<bool> privacy_local_unlock_allowed_{true};
std::atomic<bool> lock_on_disconnect_{true};
std::atomic<bool> trusted_devices_only_{false};
std::atomic<bool> save_remote_previews_{true};
bool portable_service_prompt_suppressed_ = false;
std::string sponsorship_prompt_dismissed_version_;
@@ -366,6 +366,19 @@ slint::SharedString UiText(std::string_view value) {
return slint::SharedString(value);
}
slint::SharedString TrustFingerprint(std::string fingerprint) {
for (size_t i = 8; i < fingerprint.size(); i += 9) fingerprint.insert(i, " ");
return UiText(fingerprint);
}
ui::TrustedDeviceEntry TrustRow(const TrustedDevices::Device& device,
const std::string& remote = {}) {
auto id = device.device_id;
if (id.rfind("C-", 0) == 0) id.erase(0, 2);
return {UiText(remote), UiText(device.public_key),
UiText(device.name.empty() ? id : device.name), UiText(id), {}};
}
void RegisterFontAwesome(slint::Window& window) {
if (const auto error = window.window_handle().register_font_from_data(
fa_solid_900_ttf, fa_solid_900_ttf_len)) {
@@ -1065,6 +1078,11 @@ struct GuiApplication::SlintUi {
std::string tab_model_signature;
std::vector<std::string> controller_ids;
std::string controller_model_signature;
std::string trusted_device_signature;
std::shared_ptr<slint::VectorModel<ui::TrustedDeviceEntry>> trusted_device_model =
std::make_shared<slint::VectorModel<ui::TrustedDeviceEntry>>();
std::shared_ptr<slint::VectorModel<ui::TrustedDeviceEntry>> trust_candidate_model =
std::make_shared<slint::VectorModel<ui::TrustedDeviceEntry>>();
std::string connection_dialog_remote_id;
bool connection_password_initialized = false;
bool connection_dialog_gallery = false;
@@ -1153,6 +1171,7 @@ int GuiApplication::Run() {
announcements_.SetStorageDirectory(path_manager_->GetCachePath() /
"announcement_reads");
config_center_ = std::make_unique<ConfigCenter>(cache_path_ + "/config.ini");
InitializeTrustedDevices(path_manager_->GetConfigPath() / "trusted-devices");
LOG_INFO("CrossDesk version: {} (Slint UI)", CROSSDESK_VERSION);
@@ -1559,6 +1578,7 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
config_center_->IsEnablePrivacyScreen());
main->set_privacy_local_unlock_allowed(config_center_->IsPrivacyLocalUnlockAllowed());
main->set_lock_on_disconnect_enabled(config_center_->IsLockOnDisconnect());
main->set_trusted_devices_only(config_center_->IsTrustedDevicesOnly());
#ifdef _WIN32
main->set_capture_method_visible(true);
main->set_capture_method_index(
@@ -1932,6 +1952,7 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
main->set_file_save_path(folder);
}
});
BindTrustedDeviceCallbacks(gallery_window);
main->on_save_settings([this, main] {
return SaveSettingsFromUi(main->get_gallery_mode());
});
@@ -2472,6 +2493,7 @@ void GuiApplication::Tick() {
return;
}
TickLan();
TickControllerTrust();
if (LanAvailable()) {
const auto address = lan_->identity().settings().value("address", "");
ui_->main->set_lan_listen_all(lan_->identity().listen_all());
@@ -2702,6 +2724,7 @@ void GuiApplication::Tick() {
}
SyncStreamWindow();
SyncServerWindow();
SyncTrustedDevices();
SyncFileTransferNotifications();
}
@@ -2960,6 +2983,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) {
password_change_result_ready_ = false;
password_change_succeeded_ = false;
password_change_result_uncertain_ = false;
password_change_recovery_ = false;
password_change_requested_at_ = std::chrono::steady_clock::now();
pending_password_change_request_id_ = request_id;
pending_local_password_ = password;
@@ -3039,6 +3063,7 @@ void GuiApplication::HandlePasswordChangeResult() {
password_change_result_ready_ = false;
password_change_succeeded_ = false;
password_change_result_uncertain_ = false;
password_change_recovery_ = false;
pending_password_change_request_id_.clear();
pending_local_password_.clear();
password_change_error_.clear();
@@ -3085,23 +3110,53 @@ void GuiApplication::HandlePasswordChangeResult() {
}
void GuiApplication::HandleCredentialRecovery() {
bool retry_active = false;
bool promote_pending = false;
bool clear_pending = false;
CredentialRecoveryAction action;
{
std::lock_guard<std::mutex> lock(password_change_mutex_);
retry_active = credential_recovery_retry_active_;
promote_pending = credential_recovery_promote_pending_;
clear_pending = credential_recovery_clear_pending_;
credential_recovery_retry_active_ = false;
credential_recovery_promote_pending_ = false;
credential_recovery_clear_pending_ = false;
if (retry_active) {
action = std::exchange(credential_recovery_action_, CredentialRecoveryAction::Idle);
if (action == CredentialRecoveryAction::RetryActive) {
credential_recovery_attempt_pending_ = false;
}
}
if (retry_active) {
if (action == CredentialRecoveryAction::ResumePending) {
// Key authentication proves device ownership, not which password committed.
// Replay the durable request and promote only its successful server reply.
const bool self_hosted = config_center_->IsSelfHosted();
std::string request_id;
const auto identity = settings_.PendingPasswordChangeIdentity(self_hosted,
self_hosted ? config_center_->GetSignalServerHost() : config_center_->GetDefaultServerHost(),
self_hosted ? config_center_->GetSignalServerPort() : config_center_->GetDefaultSignalServerPort(),
&request_id);
const auto at = identity.find('@');
if (!peer_ || !signal_connected_ || at == std::string::npos || request_id.empty() ||
identity.substr(0, at) != client_id_) return;
const auto password = identity.substr(at + 1);
{
std::lock_guard lock(password_change_mutex_);
if (password_change_pending_) return;
password_change_pending_ = true;
password_change_result_ready_ = false;
password_change_succeeded_ = false;
password_change_result_uncertain_ = false;
password_change_recovery_ = true;
password_change_requested_at_ = std::chrono::steady_clock::now();
pending_password_change_request_id_ = request_id;
pending_local_password_ = password;
password_change_error_.clear();
}
const auto message = nlohmann::json{{"type", "change_password"},
{"request_id", request_id}, {"new_password", password}}.dump();
if (SendSignalMessage(peer_, message.data(), message.size()) != 0) {
std::lock_guard lock(password_change_mutex_);
password_change_result_ready_ = true;
password_change_result_uncertain_ = true;
password_change_error_ = "Password recovery connection unavailable";
}
return;
}
if (action == CredentialRecoveryAction::RetryActive) {
LOG_INFO("Pending credential was not accepted; retrying active credential");
if (peer_) {
CloseConnectionPeer();
@@ -3109,7 +3164,7 @@ void GuiApplication::HandleCredentialRecovery() {
return;
}
if (promote_pending) {
if (action == CredentialRecoveryAction::PromotePending) {
#if defined(__linux__) && !defined(__OHOS__)
HeadlessConsole::Instance().NotifyKey("console_password_recovered");
#endif
@@ -3118,7 +3173,7 @@ void GuiApplication::HandleCredentialRecovery() {
} else {
LOG_INFO("Recovered password change with pending credential");
}
} else if (clear_pending) {
} else if (action == CredentialRecoveryAction::ClearPending) {
#if defined(__linux__) && !defined(__OHOS__)
HeadlessConsole::Instance().NotifyKey("console_password_retained");
#endif
@@ -4258,6 +4313,113 @@ void GuiApplication::PositionServerWindows() {
}
}
void GuiApplication::BindTrustedDeviceCallbacks(bool gallery_window) {
const auto main = ui_->Window(gallery_window);
main->on_trust_action([this, main](
slint::SharedString action, slint::SharedString id, slint::SharedString key) {
const auto message = [this](const char* text) {
return UiText(localization::Literal(text, localization_language_index_));
};
const bool copying = action == "copy";
const auto set_error = [&](const char* text) {
if (copying) main->set_trust_copy_error(message(text));
else main->set_trusted_devices_error(message(text));
};
if (copying || action == "open" || action == "close") {
main->set_trust_copy_error("");
main->set_trust_copy_notice("");
}
if (!copying) {
main->set_trusted_devices_error("");
main->set_trusted_devices_notice("");
}
if (action == "open" || action == "close") {
main->set_trust_pairing_input("");
SyncTrustedDevices();
return;
}
if (!trusted_devices_) {
set_error("无法读取设备身份或可信列表,请修复后再启用。");
return;
}
if (action == "edit") return;
try {
if (copying) {
const auto text = trusted_devices_->PairingInfo(client_id_, GetHostName());
if (SDL_SetClipboardText(text.c_str()))
main->set_trust_copy_notice(message("配对信息已复制。"));
else set_error("无法访问剪贴板,请重试。");
} else if (action == "import") {
const auto input = std::string(main->get_trust_pairing_input());
if (input.empty()) return;
const auto device = TrustedDevices::ParsePairingInfo(input);
if (!device) {
set_error("配对信息无效,请从控制端重新复制。");
return;
}
// The Add action approves the current input. Import only updates the
// list; a live connection must still complete its own authentication.
if (trusted_devices_->Contains(device->public_key)) {
main->set_trusted_devices_notice(message("已在可信列表中"));
} else {
trusted_devices_->Add(*device);
main->set_trusted_devices_notice(message("已加入可信设备。"));
}
main->set_trust_pairing_input("");
} else if ((action == "add" && !TrustController(std::string(id), std::string(key))) ||
(action == "remove" && !RemoveTrustedDevice(std::string(key)))) {
set_error("无法更新可信设备,请检查列表容量和存储权限后重试。");
}
} catch (const std::exception&) {
set_error(copying ? "无法复制配对信息,请重试。"
: "无法更新可信设备,请检查列表容量和存储权限后重试。");
}
SyncTrustedDevices();
});
}
void GuiApplication::SyncTrustedDevices() {
if (!ui_->main->get_trusted_devices_open() && !ui_->wall->get_trusted_devices_open()) return;
std::vector<ui::TrustedDeviceEntry> saved, candidates;
if (trusted_devices_) {
for (const auto& device : trusted_devices_->List()) saved.push_back(TrustRow(device));
for (const auto& id : ConnectedControllers())
if (const auto device = VerifiedController(id);
device && !trusted_devices_->Contains(device->public_key))
candidates.push_back(TrustRow(*device, id));
}
std::sort(candidates.begin(), candidates.end(), [](const auto& a, const auto& b) {
return std::string(a.remote_id) < std::string(b.remote_id);
});
std::string signature = trusted_devices_ ? "ready" : "unavailable";
for (const auto* rows : {&saved, &candidates}) {
signature += "|";
for (const auto& device : *rows)
signature += std::string(device.remote_id) + '\0' + std::string(device.public_key) + '\0' +
std::string(device.display_name) + '\0' + std::string(device.device_id) + '\0';
}
if (signature != ui_->trusted_device_signature) {
// Tick runs frequently. Hash keys and update Slint only when the rows change.
for (auto* rows : {&saved, &candidates})
for (auto& device : *rows)
device.fingerprint = TrustFingerprint(TrustedDevices::Fingerprint(std::string(device.public_key)));
ui_->trusted_device_model->set_vector(std::move(saved));
ui_->trust_candidate_model->set_vector(std::move(candidates));
ui_->trusted_device_signature = std::move(signature);
const auto fingerprint = TrustFingerprint(trusted_devices_ ? trusted_devices_->Fingerprint() : "");
for (auto& window : {ui_->main, ui_->wall}) {
window->set_trusted_devices(ui_->trusted_device_model);
window->set_trust_candidates(ui_->trust_candidate_model);
window->set_device_fingerprint(fingerprint);
}
}
if (!trusted_devices_) {
for (auto& window : {ui_->main, ui_->wall})
window->set_trusted_devices_error(UiText(localization::Literal(
"无法读取设备身份或可信列表,请修复后再启用。", localization_language_index_)));
}
}
void GuiApplication::SyncServerWindow() {
// The connection map is authoritative. Lifecycle flags record callback
// intent, but callbacks for different controllers can cross each other, so
@@ -4610,6 +4772,11 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
const auto previous_connection_mode = config_center_->GetConnectionMode();
auto& main = ui_->Window(gallery_window);
main->set_settings_save_error("");
if (main->get_trusted_devices_only() && !trusted_devices_) {
main->set_settings_save_error(UiText(localization::Literal(
"无法读取设备身份或可信列表,请修复后再启用。", localization_language_index_)));
return false;
}
const bool has_endpoint = main->get_self_hosted_enabled() ||
!std::string(main->get_server_host()).empty() ||
!std::string(main->get_server_port()).empty();
@@ -4769,6 +4936,12 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
props->preview_needs_sync_ = true;
ui_->gallery_synced = {};
}
if (config_center_->SetTrustedDevicesOnly(main->get_trusted_devices_only()) != 0) {
main->set_settings_save_error(UiText(localization::Literal(
"无法保存可信设备设置,请重试。", localization_language_index_)));
return false;
}
TickControllerTrust();
ApplySettingsFromConfig(previous_server !=
GetSignalServerSettings(*config_center_));
ResetSettingsUi(gallery_window);
@@ -54,6 +54,7 @@ private:
void ShowMainWindow(const std::string& activation_token = {});
bool MinimizeMainWindowToTray();
void BindMainCallbacks(bool gallery_window = false);
void BindTrustedDeviceCallbacks(bool gallery_window = false);
void BindStreamCallbacks();
void BindServerCallbacks();
void Tick();
@@ -92,6 +93,7 @@ private:
void SyncStreamKeyboardFocus();
void SetStreamKeyboardFocus(bool focused);
void SyncServerWindow();
void SyncTrustedDevices();
void SyncFileTransferNotifications();
void CancelFileTransfer(uint32_t file_id);
void SetServerPanelExpanded(bool expanded);
@@ -494,6 +494,31 @@ inline constexpr TranslationRow kTranslationRows[] = {
{":同步键鼠到所有已连接设备", {":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах", ":同步鍵鼠到所有已連線裝置", ": 接続されているすべてのデバイスへのミラー入力", ": 연결된 모든 장치에 대한 미러 입력", ": Eingang auf alle angeschlossenen Geräte spiegeln", ": entrée miroir sur tous les appareils connectés", ": entrada espejo para todos los dispositivos conectados", ": entrada espelhada para todos os dispositivos conectados"}},
{"监听所有 IP 接口", {"监听所有 IP 接口", "Listen on all IP interfaces", "Все IP-интерфейсы", "監聽所有 IP 介面", "すべての IP で待ち受け", "모든 IP 인터페이스 수신", "Alle IP-Schnittstellen", "Toutes les interfaces IP", "Todas las interfaces IP", "Todas as interfaces IP"}},
{"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", {"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。", "Use one port on all interfaces, including VPNs. Select an actual IP to share. Turn off to restore separate ports per IP.", "Один порт для всех интерфейсов, включая VPN. Для подключения выберите реальный IP. Отключение вернёт отдельные порты для каждого IP.", "所有介面共用一個連接埠,包括 VPN。分享時請選擇實際 IP;關閉後恢復各 IP 的獨立連接埠。", "VPN を含む全インターフェースで同じポートを使います。共有には実際の IP を選択してください。オフにすると IP ごとのポートに戻ります。", "VPN을 포함한 모든 인터페이스가 하나의 포트를 사용합니다. 공유할 실제 IP를 선택하세요. 끄면 IP별 포트가 복원됩니다.", "Ein Port für alle Schnittstellen, einschließlich VPN. Zum Teilen eine tatsächliche IP wählen. Ausschalten stellt die Ports pro IP wieder her.", "Un port commun à toutes les interfaces, VPN inclus. Choisissez une IP réelle à partager. Désactivez pour rétablir les ports propres à chaque IP.", "Un puerto para todas las interfaces, incluidas las VPN. Seleccione una IP real para compartir. Desactive para restaurar los puertos de cada IP.", "Uma porta para todas as interfaces, incluindo VPNs. Selecione um IP real para compartilhar. Desative para restaurar as portas de cada IP."}},
{"可信设备", {"可信设备", "Trusted devices", "Доверенные устройства", "可信裝置", "信頼済みデバイス", "신뢰하는 기기", "Vertrauenswürdige Geräte", "Appareils de confiance", "Dispositivos de confianza", "Dispositivos confiáveis"}},
{"仅允许可信设备控制", {"仅允许可信设备控制", "Only allow trusted devices", "Разрешать только доверенные устройства", "僅允許可信裝置控制", "信頼済みデバイスのみ許可", "신뢰하는 기기만 제어 허용", "Nur vertrauenswürdige Geräte zulassen", "Autoriser uniquement les appareils de confiance", "Permitir solo dispositivos de confianza", "Permitir apenas dispositivos confiáveis"}},
{"管理可信设备", {"管理可信设备", "Manage trusted devices", "Управление доверенными устройствами", "管理可信裝置", "信頼済みデバイスを管理", "신뢰하는 기기 관리", "Vertrauenswürdige Geräte verwalten", "Gérer les appareils de confiance", "Administrar dispositivos de confianza", "Gerenciar dispositivos confiáveis"}},
{"可添加的已连接设备", {"可添加的已连接设备", "Connected devices available to trust", "Подключённые устройства для добавления", "可新增的已連線裝置", "追加可能な接続中のデバイス", "추가 가능한 연결된 기기", "Verbundene Geräte zum Hinzufügen", "Appareils connectés à ajouter", "Dispositivos conectados que puedes añadir", "Dispositivos conectados disponíveis"}},
{"信任此设备", {"信任此设备", "Trust this device", "Доверять устройству", "信任此裝置", "このデバイスを信頼", "이 기기 신뢰", "Diesem Gerät vertrauen", "Faire confiance à cet appareil", "Confiar en este dispositivo", "Confiar neste dispositivo"}},
{"移除信任", {"移除信任", "Remove trust", "Отозвать доверие", "移除信任", "信頼を解除", "신뢰 해제", "Vertrauen entfernen", "Retirer la confiance", "Retirar confianza", "Remover confiança"}},
{"无法读取设备身份或可信列表,请修复后再启用。", {"无法读取设备身份或可信列表,请修复后再启用。", "Could not load the device identity or trusted list. Repair the local data before enabling this restriction.", "Не удалось загрузить идентификатор устройства или список доверия. Восстановите локальные данные перед включением ограничения.", "無法讀取裝置身分或可信清單,請修復後再啟用。", "デバイス情報または信頼リストを読み込めません。ローカルデータを修復してから制限を有効にしてください。", "기기 ID 또는 신뢰 목록을 불러올 수 없습니다. 로컬 데이터를 복구한 후 제한을 켜세요.", "Geräteidentität oder Vertrauensliste konnte nicht geladen werden. Lokale Daten vor dem Aktivieren reparieren.", "Impossible de charger l’identité ou la liste de confiance. Réparez les données locales avant d’activer la restriction.", "No se pudo cargar la identidad o la lista de confianza. Repara los datos locales antes de activar la restricción.", "Não foi possível carregar a identidade ou a lista confiável. Repare os dados locais antes de ativar a restrição."}},
{"无法保存可信设备设置,请重试。", {"无法保存可信设备设置,请重试。", "Could not save trusted device settings. Try again.", "Не удалось сохранить настройки доверенных устройств. Повторите попытку.", "無法儲存可信裝置設定,請重試。", "信頼済みデバイスの設定を保存できません。再試行してください。", "신뢰하는 기기 설정을 저장할 수 없습니다. 다시 시도하세요.", "Einstellungen für vertrauenswürdige Geräte konnten nicht gespeichert werden. Erneut versuchen.", "Impossible d’enregistrer les paramètres de confiance. Réessayez.", "No se pudo guardar la configuración de confianza. Inténtalo de nuevo.", "Não foi possível salvar as configurações de confiança. Tente novamente."}},
{"粘贴", {"粘贴", "Paste", "Вставить", "貼上", "貼り付け", "붙여넣기", "Einfügen", "Coller", "Pegar", "Colar"}},
{"粘贴控制端的配对信息", {"粘贴控制端的配对信息", "Paste the controller’s pairing information", "Вставьте данные сопряжения управляющего устройства", "貼上控制端的配對資訊", "操作側のペアリング情報を貼り付け", "제어 장치의 페어링 정보 붙여넣기", "Kopplungsdaten des steuernden Geräts einfügen", "Collez les informations d’association de l’appareil de contrôle", "Pegue la información de emparejamiento del dispositivo de control", "Cole as informações de pareamento do dispositivo controlador"}},
{"已在可信列表中", {"已在可信列表中", "Already trusted", "Уже доверенное", "已在可信清單中", "信頼済み", "이미 신뢰하는 장치", "Bereits vertrauenswürdig", "Déjà approuvé", "Ya es de confianza", "Já é confiável"}},
{"无法访问剪贴板,请重试。", {"无法访问剪贴板,请重试。", "Cannot access the clipboard. Please try again.", "Не удалось получить доступ к буферу обмена. Повторите попытку.", "無法存取剪貼簿,請重試。", "クリップボードにアクセスできません。もう一度お試しください。", "클립보드에 접근할 수 없습니다. 다시 시도하세요.", "Kein Zugriff auf die Zwischenablage. Bitte erneut versuchen.", "Impossible d’accéder au presse-papiers. Réessayez.", "No se puede acceder al portapapeles. Inténtelo de nuevo.", "Não foi possível acessar a área de transferência. Tente novamente."}},
{"配对信息无效,请从控制端重新复制。", {"配对信息无效,请从控制端重新复制。", "Invalid pairing information. Copy it again from the controller.", "Недопустимые данные сопряжения. Скопируйте их заново с управляющего устройства.", "配對資訊無效,請從控制端重新複製。", "ペアリング情報が無効です。操作側からコピーし直してください。", "유효하지 않은 페어링 정보입니다. 제어 장치에서 다시 복사하세요.", "Ungültige Kopplungsdaten. Kopieren Sie sie erneut vom steuernden Gerät.", "Informations d’association invalides. Copiez-les à nouveau depuis l’appareil de contrôle.", "Información de emparejamiento no válida. Cópiela de nuevo del dispositivo de control.", "Informações de pareamento inválidas. Copie-as novamente do dispositivo controlador."}},
{"已加入可信设备。", {"已加入可信设备。", "Device added to the trusted list.", "Устройство добавлено в список доверенных.", "已加入可信裝置。", "信頼済みデバイスに追加しました。", "신뢰 장치 목록에 추가했습니다.", "Gerät zur Vertrauensliste hinzugefügt.", "Appareil ajouté à la liste de confiance.", "Dispositivo añadido a la lista de confianza.", "Dispositivo adicionado à lista de confiança."}},
{"无法更新可信设备,请检查列表容量和存储权限后重试。", {"无法更新可信设备,请检查列表容量和存储权限后重试。", "Cannot update trusted devices. Check the list capacity and storage permissions, then try again.", "Не удалось обновить список доверенных устройств. Проверьте его размер и права доступа к хранилищу.", "無法更新可信裝置,請檢查清單容量和儲存權限後重試。", "信頼済みデバイスを更新できません。リストの上限と保存先の権限を確認してください。", "신뢰 장치를 업데이트할 수 없습니다. 목록 용량과 저장 권한을 확인한 후 다시 시도하세요.", "Vertrauensliste konnte nicht aktualisiert werden. Prüfen Sie die Listenbegrenzung und Speicherberechtigungen.", "Impossible de mettre à jour les appareils de confiance. Vérifiez la capacité de la liste et les autorisations de stockage.", "No se pueden actualizar los dispositivos de confianza. Compruebe la capacidad de la lista y los permisos de almacenamiento.", "Não foi possível atualizar os dispositivos confiáveis. Verifique a capacidade da lista e as permissões de armazenamento."}},
{"复制配对信息", {"复制配对信息", "Copy pairing info", "Копировать данные", "複製配對資訊", "ペアリング情報をコピー", "페어링 정보 복사", "Kopplungsdaten kopieren", "Copier les informations", "Copiar información", "Copiar informações"}},
{"添加可信设备", {"添加可信设备", "Add a trusted device", "Добавить доверенное устройство", "新增可信裝置", "信頼するデバイスを追加", "신뢰 장치 추가", "Vertrauenswürdiges Gerät hinzufügen", "Ajouter un appareil de confiance", "Añadir dispositivo de confianza", "Adicionar dispositivo confiável"}},
{"已信任的设备", {"已信任的设备", "Trusted devices", "Доверенные устройства", "已信任的裝置", "信頼済みデバイス", "신뢰하는 장치", "Vertrauenswürdige Geräte", "Appareils de confiance", "Dispositivos de confianza", "Dispositivos confiáveis"}},
{"本机配对信息", {"本机配对信息", "This device’s pairing info", "Данные этого устройства", "本機配對資訊", "このデバイスのペアリング情報", "이 장치의 페어링 정보", "Kopplungsdaten dieses Geräts", "Informations de cet appareil", "Información de este dispositivo", "Informações deste dispositivo"}},
{"查看指纹", {"查看指纹", "View fingerprint", "Показать отпечаток", "查看指紋", "フィンガープリントを表示", "지문 보기", "Fingerabdruck anzeigen", "Afficher l’empreinte", "Ver huella", "Ver impressão digital"}},
{"收起指纹", {"收起指纹", "Hide fingerprint", "Скрыть отпечаток", "收起指紋", "フィンガープリントを隠す", "지문 숨기기", "Fingerabdruck ausblenden", "Masquer l’empreinte", "Ocultar huella", "Ocultar impressão digital"}},
{"暂无可信设备", {"暂无可信设备", "No trusted devices", "Нет доверенных устройств", "暫無可信裝置", "信頼済みデバイスはありません", "신뢰하는 장치 없음", "Keine vertrauenswürdigen Geräte", "Aucun appareil de confiance", "No hay dispositivos de confianza", "Nenhum dispositivo confiável"}},
{"请核对控制端指纹。", {"请核对控制端指纹。", "Check the fingerprint on the controller.", "Сверьте отпечаток с управляющим устройством.", "請核對控制端指紋。", "操作側のフィンガープリントと照合してください。", "제어 장치의 지문과 대조하세요.", "Mit dem Fingerabdruck des steuernden Geräts vergleichen.", "Vérifiez l’empreinte sur l’appareil de contrôle.", "Verifique la huella en el dispositivo de control.", "Confira a impressão digital no dispositivo controlador."}},
{"配对信息已复制。", {"配对信息已复制。", "Pairing information copied.", "Данные сопряжения скопированы.", "配對資訊已複製。", "ペアリング情報をコピーしました。", "페어링 정보를 복사했습니다.", "Kopplungsdaten kopiert.", "Informations d’association copiées.", "Información de emparejamiento copiada.", "Informações de pareamento copiadas."}},
{"无法复制配对信息,请重试。", {"无法复制配对信息,请重试。", "Unable to copy pairing information. Please try again.", "Не удалось скопировать данные сопряжения. Повторите попытку.", "無法複製配對資訊,請重試。", "ペアリング情報をコピーできません。もう一度お試しください。", "페어링 정보를 복사할 수 없습니다. 다시 시도하세요.", "Kopplungsdaten konnten nicht kopiert werden. Bitte erneut versuchen.", "Impossible de copier les informations d’association. Réessayez.", "No se pudo copiar la información de emparejamiento. Inténtelo de nuevo.", "Não foi possível copiar as informações de pareamento. Tente novamente."}},
};
struct NamedTranslation { const char* name; const char* literal; };
@@ -99,8 +99,9 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
peer = events ? events->DirectPeer() : nullptr;
}
auto session = std::make_shared<file_transfer::Session>(
[peer, events, file, props](const char*, const char* data, size_t size) {
if (!peer || !events || (props && props->closing_)) return -1;
[this, peer, events, file, props](const char*, const char* data, size_t size) {
if (!peer || !events || (props && props->closing_) ||
(!props && !owner_.IsAuthorizedController(file.remote_id))) return -1;
auto callback = events->EnterCallback();
if (!callback || !events->DataAllowed()) return -1;
const auto wire_id = events->WireId(file.remote_id);
@@ -341,7 +342,8 @@ void FileTransferManager::Receive(const char* data, size_t size,
(const file_transfer::ReceiveState& progress) mutable {
UpdateReceiveProgress(progress, notifications, remote);
});
entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) {
entry.session->SetOnSendAck([this, peer, events, remote, props](const FileTransferAck& ack) {
if (!props && !owner_.IsAuthorizedController(remote)) return -1;
auto callback = events->EnterCallback();
if (!callback || !events->DataAllowed()) return -1;
const auto bytes = EncodeFileTransferAck(ack);
@@ -593,14 +593,17 @@ bool SettingsManager::StagePendingPasswordChange(
}
std::string SettingsManager::PendingPasswordChangeIdentity(
bool self_hosted, const std::string &server_host, int server_port) const {
bool self_hosted, const std::string &server_host, int server_port,
std::string* request_id) const {
std::lock_guard<std::mutex> lock(cache_mutex_);
if (request_id) request_id->clear();
if (cache_v3_.pending_identity[0] == '\0' ||
cache_v3_.pending_self_hosted != self_hosted ||
cache_v3_.pending_server_port != server_port ||
server_host != cache_v3_.pending_server_host) {
return {};
}
if (request_id) *request_id = cache_v3_.pending_request_id;
return cache_v3_.pending_identity;
}
@@ -51,7 +51,7 @@ public:
const std::string &request_id);
std::string PendingPasswordChangeIdentity(
bool self_hosted, const std::string &server_host,
int server_port) const;
int server_port, std::string* request_id = nullptr) const;
bool PromotePendingPasswordChange();
bool ClearPendingPasswordChange();
@@ -443,6 +443,12 @@ void GuiRuntime::CloseConnectionPeer() {
announcements_.Reset();
return;
}
{
std::lock_guard lock(controller_trust_mutex_);
std::erase_if(controller_trust_, [this](const auto& item) {
return item.second.events == peer_events_.get();
});
}
peer_events_->StopAccepting();
transfers_.Cancel(peer_events_.get());
announcements_.Reset();
@@ -0,0 +1,97 @@
#include "runtime/device_identity_store.h"
#include <openssl/crypto.h>
#include <stdexcept>
#ifdef _WIN32
#include <windows.h>
#else
#include <fcntl.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
#include "private_file.h"
namespace crossdesk::device_identity_store {
namespace {
void Require(bool ok) {
if (!ok) throw std::runtime_error("Device identity store unavailable or invalid");
}
// Keep the lock file's inode stable; it contains no credentials.
class StoreLock {
public:
explicit StoreLock(const std::filesystem::path& path) {
#ifdef _WIN32
handle_ = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr,
OPEN_ALWAYS, FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
BY_HANDLE_FILE_INFORMATION info{};
if (handle_ == INVALID_HANDLE_VALUE || !GetFileInformationByHandle(handle_, &info) ||
(info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT))) {
if (handle_ != INVALID_HANDLE_VALUE) CloseHandle(handle_);
throw std::runtime_error("Device identity store unavailable");
}
#else
handle_ = open(path.c_str(), O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0600);
struct stat info{};
if (handle_ < 0 || fstat(handle_, &info) != 0 || !S_ISREG(info.st_mode) ||
info.st_uid != getuid() || (info.st_mode & 0077) != 0 ||
flock(handle_, LOCK_EX | LOCK_NB) != 0) {
if (handle_ >= 0) close(handle_);
throw std::runtime_error("Device identity store unavailable");
}
#endif
}
~StoreLock() {
#ifdef _WIN32
CloseHandle(handle_);
#else
close(handle_);
#endif
}
StoreLock(const StoreLock&) = delete;
StoreLock& operator=(const StoreLock&) = delete;
private:
#ifdef _WIN32
HANDLE handle_ = INVALID_HANDLE_VALUE;
#else
int handle_ = -1;
#endif
};
struct PrivateText {
std::string value;
~PrivateText() { OPENSSL_cleanse(value.data(), value.size()); }
};
void Validate(const nlohmann::json& document) {
Require(document.is_object() && document.at("version") == 1);
}
} // namespace
void Update(const std::filesystem::path& cache,
const std::function<void(nlohmann::json&)>& update) {
private_file::EnsureDirectory(cache, true);
StoreLock lock(cache / "identity.json.lock");
const auto path = cache / "identity.json";
const bool exists = std::filesystem::symlink_status(path).type() !=
std::filesystem::file_type::not_found;
nlohmann::json saved = {{"version", 1}};
if (exists) {
PrivateText text{private_file::Read(path)};
saved = nlohmann::json::parse(text.value, nullptr, false);
Require(!saved.is_discarded());
}
Validate(saved);
PrivateText before{saved.dump()};
update(saved);
Validate(saved);
PrivateText after{saved.dump()};
Require(after.value.size() <= 64 * 1024);
if (!exists || before.value != after.value) private_file::Write(path, after.value);
}
} // namespace crossdesk::device_identity_store
@@ -0,0 +1,24 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-01
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DEVICE_IDENTITY_STORE_H_
#define _DEVICE_IDENTITY_STORE_H_
#include <filesystem>
#include <functional>
#include <nlohmann/json.hpp>
namespace crossdesk::device_identity_store {
// The shared device signing key lives in cache/identity.json. Read the latest
// document under a process lock, apply an update, then atomically persist it.
// Throw on corruption, unsafe permissions, lock contention or persistence error.
void Update(const std::filesystem::path& cache,
const std::function<void(nlohmann::json&)>& update);
} // namespace crossdesk::device_identity_store
#endif
@@ -0,0 +1,70 @@
#include "runtime/device_login.h"
#include <algorithm>
#include <cstring>
#include "runtime/trusted_devices.h"
namespace crossdesk {
int DeviceLogin::Exchange(SignalExchangeStage stage, const nlohmann::json& message,
const TrustedDevices* identity, char* response, size_t capacity) {
auto output = [&](const nlohmann::json& reply) {
const auto text = reply.dump();
if (text.empty() || text.size() > capacity) return -1;
std::memcpy(response, text.data(), text.size());
return static_cast<int>(text.size());
};
if (stage == SignalExchangeStage::PrepareLogin) {
phase_ = Phase::Idle;
used_password_ = false;
password_.clear();
request_ = message;
const auto login = message.at("user_id").get<std::string>();
if (!identity) return -1; // Never fall back when local key storage fails.
const auto at = login.find('@');
request_["user_id"] = login.substr(0, at);
if (at != std::string::npos) password_ = login.substr(at + 1);
request_["device_key"] = {{"version", 1}, {"public_key", identity->PublicKey()}};
request_.erase("reconnect_token");
request_.erase("reconnect_version");
phase_ = Phase::AwaitingChallenge;
return output(request_);
}
if (!Pending()) return 0;
const auto type = message.value("type", "");
if (type == "login_challenge") {
if (!identity || phase_ != Phase::AwaitingChallenge || message.at("device_key_version") != 1 ||
message.at("user_id") != request_.at("user_id")) return -1;
const auto nonce = message.at("nonce").get<std::string>();
if (nonce.size() != 64 || !std::all_of(nonce.begin(), nonce.end(),
[](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }))
return -1;
const auto id = request_.at("user_id").get<std::string>();
auto reply = request_;
used_password_ = message.at("password_required").get<bool>();
if (used_password_) {
if (id.empty() || id.rfind("C-", 0) == 0 || password_.empty()) return -1;
reply["user_id"] = id + "@" + password_;
}
const auto transcript = nlohmann::json::array(
{"crossdesk.device-login.v1", id, identity->PublicKey(), nonce}).dump();
reply["device_key"]["nonce"] = nonce;
reply["device_key"]["signature"] = identity->Sign(transcript);
phase_ = Phase::AwaitingResult;
password_.clear();
return output(reply);
}
if (type == "login") {
if (message.at("status") == "success") {
if (phase_ != Phase::AwaitingResult || message.value("device_key_version", 0) != 1) return -1;
const auto assigned = message.at("user_id").get<std::string>();
const auto expected = request_.at("user_id").get<std::string>();
if (assigned.empty() || (!expected.empty() && assigned != expected)) return -1;
}
password_.clear();
request_.clear();
phase_ = Phase::Idle;
}
return 0;
}
} // namespace crossdesk
@@ -0,0 +1,35 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-02
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DEVICE_LOGIN_H_
#define _DEVICE_LOGIN_H_
#include <nlohmann/json.hpp>
#include <string>
#include "minirtc.h"
namespace crossdesk {
class TrustedDevices;
// One signaling connection's application authentication, owned by its callbacks.
class DeviceLogin {
public:
int Exchange(SignalExchangeStage stage, const nlohmann::json& message,
const TrustedDevices* identity, char* response, size_t capacity);
bool Pending() const { return phase_ != Phase::Idle; }
bool UsedPassword() const { return used_password_; }
private:
enum class Phase { Idle, AwaitingChallenge, AwaitingResult };
nlohmann::json request_;
std::string password_;
Phase phase_ = Phase::Idle;
bool used_password_ = false;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,192 @@
#include "runtime/gui_runtime.h"
#include "platform.h"
#include "rd_log.h"
namespace crossdesk {
void GuiRuntime::InitializeTrustedDevices(const std::filesystem::path& directory) {
try {
trusted_devices_ = std::make_unique<TrustedDevices>(directory, cache_path_);
} catch (const std::exception& error) {
LOG_ERROR("Cannot load trusted devices: {}", error.what());
// The independent persisted switch stays enabled on identity/store failure.
}
}
bool GuiRuntime::TrustedControllerAllowed(const std::string& id) {
if (!config_center_ || !config_center_->IsTrustedDevicesOnly()) return true;
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
return it != controller_trust_.end() && it->second.IsTrusted(trusted_devices_.get());
}
bool GuiRuntime::BeginControllerTrust(const std::string& id, PeerEventHandler* events) {
const bool required = config_center_->IsTrustedDevicesOnly();
std::lock_guard lock(controller_trust_mutex_);
const auto previous = controller_trust_.find(id);
// Non-connected statuses clear this state. Duplicate Connected notifications
// in the same application session must not restart the challenge or timeout.
if (previous != controller_trust_.end() && previous->second.events == events) {
auto& state = previous->second;
const bool allowed = !required || state.IsTrusted(trusted_devices_.get());
state.pending = !allowed;
return allowed;
}
ControllerTrust state;
state.events = events;
state.pending = required;
state.deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
try {
if (!trusted_devices_) state.invalid = true;
else state.nonce = TrustedDevices::Nonce();
} catch (...) { state.invalid = true; }
controller_trust_[id] = std::move(state);
return !required;
}
void GuiRuntime::ForgetControllerTrust(const std::string& id, PeerEventHandler* events) {
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
if (it != controller_trust_.end() && it->second.events == events)
controller_trust_.erase(it);
}
int GuiRuntime::SendTrustMessage(const std::string& id, const nlohmann::json& message) {
const auto text = message.dump();
return WithController(id, [&](PeerPtr* peer, const std::string& wire) {
return SendReliableDataFrameToPeer(peer, text.data(), text.size(),
control_data_label_.c_str(), wire.data(), wire.size());
}, false);
}
bool GuiRuntime::HandleDeviceTrustMessage(const std::string& id, PeerEventHandler* events,
const char* data, size_t size) {
if (!data || size > 4096) return false;
const auto message = nlohmann::json::parse(data, data + size, nullptr, false);
if (!message.is_object()) return false;
const auto type_it = message.find("type");
if (type_it == message.end()) return false;
if (*type_it == static_cast<int>(ControlType::host_infomation)) {
// Advertise through an existing message so disabled restrictions send no
// unfamiliar protocol messages to legacy clients. Retain only bounded,
// inert metadata until the connection's proof has been accepted.
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
if (it != controller_trust_.end() && it->second.events == events) {
const auto capability = message.find("device_trust_version");
if (capability != message.end() && *capability == 1) it->second.capable = true;
if (it->second.pending) {
it->second.early_host_info.assign(data, size);
return true;
}
}
return false;
}
if (!type_it->is_string()) return false;
const auto type = type_it->get<std::string>();
if (type.rfind("device_trust_", 0) != 0) return false;
if (!trusted_devices_) return true;
try {
if (type == "device_trust_challenge") {
const auto props = FindRemoteSession(id);
if (!props || props->closing_ || props->peer_events_.get() != events ||
message.at("version") != 1) return true;
const auto wire = events->WireId(id);
const auto proof = trusted_devices_->Prove(message.at("nonce"),
props->local_id_, GetHostName()).dump();
SendReliableDataFrameToPeer(props->peer_, proof.data(), proof.size(),
control_data_label_.c_str(), wire.data(), wire.size());
} else if (type == "device_trust_proof") {
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
if (it == controller_trust_.end() || it->second.events != events) return true;
auto& state = it->second;
if (state.invalid || state.verified || std::chrono::steady_clock::now() >= state.deadline)
return true;
// Accept once per fresh challenge; malformed/replayed proofs grant nothing.
state.verified = TrustedDevices::Verify(message, state.nonce);
if (!state.verified) state.invalid = true;
}
} catch (const std::exception&) {
// Untrusted protocol input never escapes the transport callback.
}
return true;
}
void GuiRuntime::TickControllerTrust() {
struct Action {
std::string id;
PeerEventHandler* events;
std::string nonce;
int kind;
std::string host_info;
};
std::vector<Action> actions;
const auto now = std::chrono::steady_clock::now();
const bool required = config_center_->IsTrustedDevicesOnly();
{
std::lock_guard lock(controller_trust_mutex_);
for (auto& [id, state] : controller_trust_) {
if (state.closing) continue;
const bool trusted = state.IsTrusted(trusted_devices_.get());
if (required && (state.invalid || (state.verified && !trusted) ||
(!state.verified && now >= state.deadline))) {
state.invalid = true;
state.closing = true;
actions.push_back({id, state.events, {}, -1});
} else if (state.pending && (!required || trusted)) {
state.pending = false;
actions.push_back({id, state.events, {}, 1, std::exchange(state.early_host_info, {})});
}
if ((required || state.capable) && !state.invalid && !state.verified &&
now < state.deadline && now >= state.next_challenge) {
state.next_challenge = now + std::chrono::seconds(1);
actions.push_back({id, state.events, state.nonce, 0});
}
}
}
for (const auto& action : actions) {
const auto events = ControllerEvents(action.id);
if (!events || events.get() != action.events) continue;
auto callback = events->EnterCallback();
if (!callback) continue;
if (action.kind < 0) {
LOG_WARN("Reject controller [{}]: trusted device verification required", action.id);
transfers_.Cancel(events.get(), action.id);
remote_input_.Disconnect(action.id);
CloseServerController(action.id);
} else if (action.kind > 0) {
events->ApplyConnectionStatus(ConnectionStatus::Connected, action.id);
if (!action.host_info.empty()) {
const auto wire = events->WireId(action.id);
PeerEventHandler::OnReceiveDataBuffer(action.host_info.data(), action.host_info.size(),
wire.data(), wire.size(), control_data_label_.data(), control_data_label_.size(), events.get());
}
} else {
SendTrustMessage(action.id, {{"type", "device_trust_challenge"},
{"version", 1}, {"nonce", action.nonce}});
}
}
}
std::optional<TrustedDevices::Device> GuiRuntime::VerifiedController(const std::string& id) {
std::lock_guard lock(controller_trust_mutex_);
const auto it = controller_trust_.find(id);
return it == controller_trust_.end() || it->second.invalid
? std::nullopt : it->second.verified;
}
bool GuiRuntime::TrustController(const std::string& id, const std::string& expected_key) {
if (!trusted_devices_ || !IsAuthorizedController(id)) return false;
const auto device = VerifiedController(id);
if (!device || device->public_key != expected_key) return false;
try { trusted_devices_->Add(*device); return true; }
catch (const std::exception& error) { LOG_ERROR("Cannot trust device: {}", error.what()); return false; }
}
bool GuiRuntime::RemoveTrustedDevice(const std::string& public_key) {
if (!trusted_devices_) return false;
try { trusted_devices_->Remove(public_key); }
catch (const std::exception& error) { LOG_ERROR("Cannot remove trusted device: {}", error.what()); return false; }
TickControllerTrust();
return true;
}
} // namespace crossdesk
+2 -3
View File
@@ -190,9 +190,7 @@ int GuiRuntime::CreateConnectionPeer() {
if (pending_identity.empty()) {
credential_recovery_in_progress_ = false;
credential_recovery_attempt_pending_ = false;
credential_recovery_retry_active_ = false;
credential_recovery_promote_pending_ = false;
credential_recovery_clear_pending_ = false;
credential_recovery_action_ = CredentialRecoveryAction::Idle;
} else if (!credential_recovery_in_progress_) {
credential_recovery_in_progress_ = true;
credential_recovery_attempt_pending_ = true;
@@ -270,6 +268,7 @@ int GuiRuntime::CreateConnectionPeer() {
params_.on_signal_status = PeerEventHandler::OnSignalStatus;
params_.on_signal_message = PeerEventHandler::OnSignalMessage;
params_.on_signal_exchange = PeerEventHandler::OnSignalExchange;
params_.on_connection_status = PeerEventHandler::OnConnectionStatus;
params_.on_net_status_report = PeerEventHandler::OnNetStatusReport;
+29 -1
View File
@@ -26,6 +26,7 @@
#include "runtime/announcement_inbox.h"
#include "runtime/peer_event_handler.h"
#include "runtime/privacy_session_policy.h"
#include "runtime/trusted_devices.h"
namespace crossdesk {
@@ -63,7 +64,8 @@ class GuiRuntime : protected gui_detail::GuiState {
std::string password, bool remember_password = false);
int WithController(
const std::string& id,
const std::function<int(PeerPtr*, const std::string&)>& operation);
const std::function<int(PeerPtr*, const std::string&)>& operation,
bool require_trusted = true);
int SendControllerData(const std::string& id, const char* data, size_t size,
const char* label);
std::shared_ptr<PeerEventHandler> ControllerEvents(const std::string& id);
@@ -105,6 +107,32 @@ class GuiRuntime : protected gui_detail::GuiState {
void HandlePrivacy();
void HandleScreenLock();
bool IsAuthorizedController(const std::string& remote_id);
void InitializeTrustedDevices(const std::filesystem::path& directory);
bool TrustedControllerAllowed(const std::string& remote_id);
bool BeginControllerTrust(const std::string& remote_id, PeerEventHandler* events);
void ForgetControllerTrust(const std::string& remote_id, PeerEventHandler* events);
bool HandleDeviceTrustMessage(const std::string& remote_id, PeerEventHandler* events,
const char* data, size_t size);
void TickControllerTrust();
std::optional<TrustedDevices::Device> VerifiedController(const std::string& remote_id);
bool TrustController(const std::string& remote_id, const std::string& expected_key);
bool RemoveTrustedDevice(const std::string& public_key);
int SendTrustMessage(const std::string& remote_id, const nlohmann::json& message);
std::unique_ptr<TrustedDevices> trusted_devices_;
struct ControllerTrust {
PeerEventHandler* events = nullptr;
std::string nonce;
std::optional<TrustedDevices::Device> verified;
std::string early_host_info;
std::chrono::steady_clock::time_point deadline{}, next_challenge{};
bool pending = false, invalid = false, closing = false, capable = false;
bool IsTrusted(const TrustedDevices* devices) const {
return devices && !invalid && !closing && verified &&
devices->Contains(verified->public_key);
}
};
std::mutex controller_trust_mutex_;
std::map<std::string, ControllerTrust> controller_trust_;
void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
// Caller holds connection_status_mutex_ exclusively. Turns privacy off when
// any connected controller lacks support, or on for the first automatic one.
+3 -1
View File
@@ -180,7 +180,9 @@ std::shared_ptr<PeerEventHandler> GuiRuntime::ControllerEvents(
}
int GuiRuntime::WithController(
const std::string& id,
const std::function<int(PeerPtr*, const std::string&)>& operation) {
const std::function<int(PeerPtr*, const std::string&)>& operation,
bool require_trusted) {
if (require_trusted && !TrustedControllerAllowed(id)) return -1;
std::shared_lock lock(remote_sessions_mutex_);
auto it = lan_controllers_.find(id);
auto events = it == lan_controllers_.end() ? peer_events_ : it->second.events;
@@ -133,6 +133,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
GuiRuntime* runtime = &handler->owner_;
std::string source_id = std::string(src_id, src_id_size);
const auto remote_id = handler->ConnectionKey(user_id, user_id_size);
if (source_id == runtime->control_data_label_ &&
runtime->HandleDeviceTrustMessage(remote_id, handler, data, size)) return;
const auto viewer = runtime->FindRemoteSession(remote_id);
if (viewer && viewer->peer_events_.get() != handler) return;
if (!viewer && !runtime->TrustedControllerAllowed(remote_id)) return;
// The service-owned host runs as SYSTEM. Do not expose filesystem or
// clipboard operations in that security context.
if (runtime->unattended_host_ &&
@@ -143,34 +149,30 @@ void PeerEventHandler::OnReceiveDataBuffer(
return;
if (source_id == web_clipboard::kStream) {
runtime->clipboard_.ReceiveWeb(
data, size, handler->ConnectionKey(user_id, user_id_size), handler);
data, size, remote_id, handler);
return;
}
if (source_id == runtime->file_label_) {
runtime->transfers_.Receive(data, size, handler,
handler->ConnectionKey(user_id, user_id_size));
runtime->transfers_.Receive(data, size, handler, remote_id);
return;
} else if (source_id == runtime->clipboard_label_) {
if (size > 0) {
std::string remote_user_id =
handler->ConnectionKey(user_id, user_id_size);
auto props =
runtime->FindRemoteSession(remote_user_id);
if (props && !props->clipboard_allowed_.load()) {
if (viewer && !viewer->clipboard_allowed_.load()) {
return;
}
runtime->clipboard_.QueueRemoteText(
data, size, props ? remote_user_id : std::string{});
data, size, viewer ? remote_id : std::string{}, {},
viewer ? std::function<bool()>{} : [runtime, remote_id] {
return runtime->IsAuthorizedController(remote_id);
});
}
return;
} else if (source_id == runtime->file_feedback_label_) {
runtime->transfers_.HandleAck(
data, size, handler, handler->ConnectionKey(user_id, user_id_size));
runtime->transfers_.HandleAck(data, size, handler, remote_id);
return;
}
const std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
const std::string wire_id = handler->WireId(remote_id);
PeerPtr* bound_peer =
handler->IsDirect() ? handler->DirectPeer() : runtime->peer_;
@@ -84,6 +84,24 @@ PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {
owner_.transfers_.Connected(this, "");
}
int PeerEventHandler::OnSignalExchange(SignalExchangeStage stage, const char* message,
size_t size, char* response, size_t capacity, void* user_data) {
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler || !message) return -1;
auto callback = handler->EnterCallback();
if (!callback) return -1;
if (stage == SignalExchangeStage::IncomingMessage &&
!handler->device_login_.Pending()) return 0;
try {
return handler->device_login_.Exchange(stage,
nlohmann::json::parse(message, message + size),
handler->owner_.trusted_devices_.get(), response, capacity);
} catch (const std::exception&) {
LOG_ERROR("Device login protocol or identity unavailable");
return -1;
}
}
void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
void* user_data) {
auto* handler = static_cast<PeerEventHandler*>(user_data);
@@ -184,13 +202,16 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
j.contains("reason") && j["reason"].is_string()
? j["reason"].get<std::string>()
: "Password change failed";
runtime->password_change_result_uncertain_ = false;
// A rejected replay cannot tell us whether the original request committed.
runtime->password_change_result_uncertain_ = runtime->password_change_recovery_ &&
!runtime->password_change_succeeded_;
runtime->password_change_result_ready_ = true;
}
}
void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
size_t user_id_size, void* user_data) {
using RecoveryAction = GuiRuntime::CredentialRecoveryAction;
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
@@ -212,10 +233,12 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
SendClientInfo(runtime->peer_, client_id);
std::lock_guard<std::mutex> lock(runtime->password_change_mutex_);
if (runtime->credential_recovery_in_progress_) {
if (runtime->credential_recovery_attempt_pending_) {
runtime->credential_recovery_promote_pending_ = true;
if (!handler->device_login_.UsedPassword()) {
runtime->credential_recovery_action_ = RecoveryAction::ResumePending;
} else if (runtime->credential_recovery_attempt_pending_) {
runtime->credential_recovery_action_ = RecoveryAction::PromotePending;
} else {
runtime->credential_recovery_clear_pending_ = true;
runtime->credential_recovery_action_ = RecoveryAction::ClearPending;
}
}
} else if (SignalStatus::SignalFailed == status) {
@@ -223,7 +246,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
std::lock_guard<std::mutex> lock(runtime->password_change_mutex_);
if (runtime->credential_recovery_in_progress_ &&
runtime->credential_recovery_attempt_pending_) {
runtime->credential_recovery_retry_active_ = true;
runtime->credential_recovery_action_ = RecoveryAction::RetryActive;
}
} else if (SignalStatus::SignalClosed == status) {
runtime->signal_connected_ = false;
@@ -300,6 +323,14 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
}
}
if (!props) {
if (status == ConnectionStatus::Connected) {
if (!runtime->BeginControllerTrust(remote_id, handler)) return;
} else {
runtime->ForgetControllerTrust(remote_id, handler);
}
}
if (props) {
runtime->is_client_mode_ = true;
if (!props->wall_managed_) runtime->show_connection_status_window_ = true;
@@ -332,7 +363,9 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]);
}
std::string msg = remote_action.to_json();
auto information = nlohmann::json::parse(remote_action.to_json());
information["device_trust_version"] = 1;
std::string msg = information.dump();
const int ret =
SendReliableDataFrame(props->peer_, msg.data(), msg.size(),
runtime->control_data_label_.c_str());
@@ -15,6 +15,7 @@
#include "minirtc.h"
#include "runtime/session_lifecycle.h"
#include "runtime/device_login.h"
namespace crossdesk {
@@ -63,6 +64,9 @@ public:
size_t user_id_size, void *user_data);
static void OnSignalMessage(const char *message, size_t size,
void *user_data);
static int OnSignalExchange(SignalExchangeStage stage, const char* message,
size_t size, char* response, size_t capacity,
void* user_data);
static void OnConnectionStatus(ConnectionStatus status, const char *user_id,
size_t user_id_size, void *user_data);
static void OnNetStatusReport(const char *client_id, size_t client_id_size,
@@ -85,6 +89,7 @@ private:
size_t early_data_bytes_ = 0;
GuiRuntime& owner_;
DeviceLogin device_login_;
gui_detail::SessionCallbackGate callbacks_;
};
@@ -16,6 +16,7 @@
namespace crossdesk {
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
if (!TrustedControllerAllowed(remote_id)) return false;
if (remote_id.rfind("lan/", 0) == 0) {
const auto events = ControllerEvents(remote_id);
if (!events || !events->DataAllowed()) return false;
@@ -59,6 +60,7 @@ void GuiRuntime::ApplyPrivacyAdmission(bool automatic_enable) {
void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id,
const PrivacyCommand& command) {
if (!TrustedControllerAllowed(remote_id)) return;
std::shared_lock session_lock(connection_status_mutex_);
const auto found = connection_status_.find(remote_id);
if (found == connection_status_.end() ||
@@ -86,6 +88,7 @@ void GuiRuntime::HandlePrivacy() {
commands.swap(privacy_commands_);
}
for (const auto& [remote_id, command] : commands) {
if (!TrustedControllerAllowed(remote_id)) continue;
// Keep admission and activation under the connection lock so an unknown
// controller cannot join between the capability check and enabling.
std::shared_lock lock(connection_status_mutex_);
+5 -3
View File
@@ -167,21 +167,23 @@ struct UserSettingsState {
};
struct PasswordChangeState {
enum class CredentialRecoveryAction {
Idle, RetryActive, PromotePending, ClearPending, ResumePending
};
std::mutex password_change_mutex_;
uint64_t next_password_change_request_id_ = 0;
bool password_change_pending_ = false;
bool password_change_result_ready_ = false;
bool password_change_succeeded_ = false;
bool password_change_result_uncertain_ = false;
bool password_change_recovery_ = false;
std::chrono::steady_clock::time_point password_change_requested_at_;
std::string pending_password_change_request_id_;
std::string pending_local_password_;
std::string password_change_error_;
bool credential_recovery_in_progress_ = false;
bool credential_recovery_attempt_pending_ = false;
bool credential_recovery_retry_active_ = false;
bool credential_recovery_promote_pending_ = false;
bool credential_recovery_clear_pending_ = false;
CredentialRecoveryAction credential_recovery_action_ = CredentialRecoveryAction::Idle;
};
struct ConnectionState {
@@ -0,0 +1,233 @@
#include "runtime/trusted_devices.h"
#include <openssl/crypto.h>
#include <openssl/rand.h>
#include <openssl/sha.h>
#include <algorithm>
#include <array>
#include <stdexcept>
#include "private_file.h"
#include "runtime/device_identity_store.h"
namespace crossdesk {
namespace {
constexpr size_t kMaxTrustedDevices = 128;
void Require(bool ok) {
if (!ok) throw std::runtime_error("Invalid or unavailable trusted device identity");
}
std::string Hex(const uint8_t* data, size_t size) {
constexpr char digits[] = "0123456789abcdef";
std::string result;
result.reserve(size * 2);
for (size_t i = 0; i < size; ++i) {
result += digits[data[i] >> 4]; result += digits[data[i] & 15];
}
return result;
}
bool IsHex(const std::string& value, size_t bytes) {
return value.size() == bytes * 2 && std::all_of(value.begin(), value.end(),
[](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); });
}
std::vector<uint8_t> Unhex(const std::string& text, size_t bytes) {
Require(IsHex(text, bytes));
auto digit = [](char c) { return c <= '9' ? c - '0' : c - 'a' + 10; };
std::vector<uint8_t> result(bytes);
for (size_t i = 0; i < bytes; ++i)
result[i] = (digit(text[2 * i]) << 4) | digit(text[2 * i + 1]);
return result;
}
bool Label(const std::string& value) {
return value.size() <= 128 && std::none_of(value.begin(), value.end(),
[](unsigned char c) { return c < 32 || c == 127; });
}
std::string Transcript(const std::string& nonce, const TrustedDevices::Device& device) {
// Canonical encoding and a domain tag prevent ambiguous or cross-protocol use.
return nlohmann::json::array({"crossdesk.device-trust.v1", nonce, device.public_key,
device.device_id, device.name}).dump();
}
using Context = std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)>;
struct PrivateText {
std::string value;
~PrivateText() { OPENSSL_cleanse(value.data(), value.size()); }
};
}
TrustedDevices::TrustedDevices(const std::filesystem::path& directory,
const std::filesystem::path& cache)
: path_(directory / "devices.json") {
private_file::EnsureDirectory(directory);
const bool exists = std::filesystem::symlink_status(path_).type() !=
std::filesystem::file_type::not_found;
PrivateText legacy_key;
if (exists) {
PrivateText text{private_file::Read(path_)};
auto saved = nlohmann::json::parse(text.value, nullptr, false);
Require(!saved.is_discarded());
const bool legacy = saved.at("version") == 1;
Require((legacy || saved.at("version") == 2) && saved.at("devices").is_array() &&
saved.at("devices").size() <= kMaxTrustedDevices);
if (legacy) {
legacy_key.value = saved.at("private_key").get<std::string>();
auto& encoded = saved["private_key"].get_ref<std::string&>();
OPENSSL_cleanse(encoded.data(), encoded.size());
Require(IsHex(legacy_key.value, 32));
} else Require(!saved.contains("private_key"));
for (const auto& row : saved.at("devices")) {
Device device{row.at("public_key"), row.at("name"), row.at("device_id")};
Require(IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id));
Require(devices_.emplace(device.public_key, device).second);
}
}
device_identity_store::Update(cache, [&](nlohmann::json& identity) {
if (!identity.contains("trust_private_key") && !legacy_key.value.empty())
identity["trust_private_key"] = legacy_key.value;
if (identity.contains("trust_private_key")) {
const auto& encoded = identity.at("trust_private_key").get_ref<const std::string&>();
// Interrupted migration may leave both copies. Never replace either key
// on a mismatch: that would silently invalidate existing pairings.
Require(legacy_key.value.empty() || encoded == legacy_key.value);
auto secret = Unhex(encoded, 32);
key_.reset(EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, nullptr,
secret.data(), secret.size()));
OPENSSL_cleanse(secret.data(), secret.size());
} else {
key_.reset(EVP_PKEY_Q_keygen(nullptr, nullptr, "ED25519"));
Require(bool(key_));
std::array<uint8_t, 32> secret{};
size_t size = secret.size();
Require(EVP_PKEY_get_raw_private_key(key_.get(), secret.data(), &size) == 1 && size == 32);
identity["trust_private_key"] = Hex(secret.data(), secret.size());
OPENSSL_cleanse(secret.data(), secret.size());
}
Require(bool(key_));
});
std::array<uint8_t, 32> public_key{};
size_t size = public_key.size();
Require(EVP_PKEY_get_raw_public_key(key_.get(), public_key.data(), &size) == 1 && size == 32);
public_key_ = Hex(public_key.data(), public_key.size());
// The identity update is durable before removing the legacy secret. If this
// write fails, reopening can safely retry with the same key and device list.
if (!exists || !legacy_key.value.empty()) Save(devices_);
}
std::string TrustedDevices::Fingerprint(const std::string& public_key) {
const auto bytes = Unhex(public_key, 32);
std::array<uint8_t, SHA256_DIGEST_LENGTH> hash{};
SHA256(bytes.data(), bytes.size(), hash.data());
return Hex(hash.data(), hash.size());
}
std::string TrustedDevices::Fingerprint() const { return Fingerprint(public_key_); }
std::string TrustedDevices::PairingInfo(const std::string& device_id,
const std::string& name) const {
Require(Label(device_id) && Label(name));
// Export public information only. Connection admission still requires a fresh
// proof of private-key possession; importing this document grants no bypass.
return nlohmann::json{{"type", "crossdesk-pairing"}, {"version", 1},
{"public_key", public_key_}, {"device_id", device_id}, {"name", name}}.dump();
}
std::optional<TrustedDevices::Device> TrustedDevices::ParsePairingInfo(std::string_view text) {
if (text.empty() || text.size() > kMaxPairingInfoSize) return std::nullopt;
try {
const auto info = nlohmann::json::parse(text.begin(), text.end(), nullptr, false);
if (!info.is_object() || info.size() != 5 || info.at("type") != "crossdesk-pairing" ||
!info.at("version").is_number_integer() || info.at("version") != 1)
return std::nullopt;
Device device{info.at("public_key"), info.at("name"), info.at("device_id")};
if (IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id))
return device;
} catch (const std::exception&) {}
return std::nullopt;
}
std::string TrustedDevices::Nonce() {
std::array<uint8_t, 32> bytes{};
Require(RAND_bytes(bytes.data(), bytes.size()) == 1);
return Hex(bytes.data(), bytes.size());
}
nlohmann::json TrustedDevices::Prove(const std::string& nonce,
const std::string& device_id, const std::string& name) const {
Require(IsHex(nonce, 32) && Label(device_id) && Label(name));
const Device device{public_key_, name, device_id};
const auto text = Transcript(nonce, device);
return {{"type", "device_trust_proof"}, {"version", 1}, {"nonce", nonce},
{"public_key", public_key_}, {"device_id", device_id}, {"name", name},
{"signature", Sign(text)}};
}
std::string TrustedDevices::Sign(std::string_view text) const {
Context context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
std::array<uint8_t, 64> signature{};
size_t size = signature.size();
Require(context && EVP_DigestSignInit(context.get(), nullptr, nullptr, nullptr, key_.get()) == 1 &&
EVP_DigestSign(context.get(), signature.data(), &size,
reinterpret_cast<const uint8_t*>(text.data()), text.size()) == 1 && size == 64);
return Hex(signature.data(), signature.size());
}
std::optional<TrustedDevices::Device> TrustedDevices::Verify(
const nlohmann::json& proof, const std::string& nonce) {
try {
if (proof.at("version") != 1 || proof.at("nonce") != nonce || !IsHex(nonce, 32))
return std::nullopt;
Device device{proof.at("public_key"), proof.at("name"), proof.at("device_id")};
if (!Label(device.name) || !Label(device.device_id)) return std::nullopt;
const auto public_key = Unhex(device.public_key, 32);
const auto signature = Unhex(proof.at("signature"), 64);
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, public_key.data(), public_key.size()),
EVP_PKEY_free);
Context context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
const auto text = Transcript(nonce, device);
if (key && context && EVP_DigestVerifyInit(context.get(), nullptr, nullptr, nullptr, key.get()) == 1 &&
EVP_DigestVerify(context.get(), signature.data(), signature.size(),
reinterpret_cast<const uint8_t*>(text.data()), text.size()) == 1)
return device;
} catch (const std::exception&) {}
return std::nullopt;
}
bool TrustedDevices::Contains(const std::string& public_key) const {
std::lock_guard lock(mutex_);
return devices_.count(public_key) != 0;
}
std::vector<TrustedDevices::Device> TrustedDevices::List() const {
std::lock_guard lock(mutex_);
std::vector<Device> result;
for (const auto& [key, device] : devices_) result.push_back(device);
return result;
}
void TrustedDevices::Save(const std::map<std::string, Device>& devices) const {
nlohmann::json saved{{"version", 2}, {"devices", nlohmann::json::array()}};
for (const auto& [key, device] : devices)
saved["devices"].push_back({{"public_key", key}, {"name", device.name}, {"device_id", device.device_id}});
const auto text = saved.dump(2);
Require(text.size() <= 64 * 1024);
private_file::Write(path_, text);
}
void TrustedDevices::Add(const Device& device) {
Require(IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id));
std::lock_guard lock(mutex_);
const auto existing = devices_.find(device.public_key);
if (existing != devices_.end() && existing->second == device) return;
Require(existing != devices_.end() || devices_.size() < kMaxTrustedDevices);
auto next = devices_;
next[device.public_key] = device;
Save(next);
devices_.swap(next);
}
void TrustedDevices::Remove(const std::string& public_key) {
std::lock_guard lock(mutex_);
if (!devices_.contains(public_key)) return;
auto next = devices_;
next.erase(public_key);
Save(next);
devices_.swap(next);
}
} // namespace crossdesk
@@ -0,0 +1,63 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-01
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _TRUSTED_DEVICES_H_
#define _TRUSTED_DEVICES_H_
#include <openssl/evp.h>
#include <filesystem>
#include <map>
#include <memory>
#include <mutex>
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace crossdesk {
class TrustedDevices {
public:
struct Device {
std::string public_key, name, device_id;
bool operator==(const Device&) const = default;
};
TrustedDevices(const std::filesystem::path& directory,
const std::filesystem::path& cache);
std::string Fingerprint() const;
const std::string& PublicKey() const { return public_key_; }
// Callers supply a canonical, purpose-specific transcript.
std::string Sign(std::string_view transcript) const;
static std::string Fingerprint(const std::string& public_key);
static std::string Nonce();
nlohmann::json Prove(const std::string& nonce,
const std::string& device_id, const std::string& name) const;
static std::optional<Device> Verify(const nlohmann::json& proof,
const std::string& nonce);
static constexpr size_t kMaxPairingInfoSize = 2048;
std::string PairingInfo(const std::string& device_id, const std::string& name) const;
static std::optional<Device> ParsePairingInfo(std::string_view text);
bool Contains(const std::string& public_key) const;
std::vector<Device> List() const;
// Call only after a local add action for a verified peer or valid pairing info.
void Add(const Device& device);
void Remove(const std::string& public_key);
private:
void Save(const std::map<std::string, Device>& devices) const;
std::filesystem::path path_;
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key_{nullptr, EVP_PKEY_free};
std::string public_key_;
mutable std::mutex mutex_;
std::map<std::string, Device> devices_;
};
} // namespace crossdesk
#endif
@@ -12,3 +12,5 @@ export { GalleryStrings } from "device_strings.slint";
export { LanStrings } from "lan_strings.slint";
export { GalleryNetworkDevice } from "gallery_network_dialog.slint";
export { DeviceGroupOption } from "device_group_dialog.slint";
export { TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint";
+39 -7
View File
@@ -1,4 +1,5 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { TrustedDevicesDialog, TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint";
import { LanStrings } from "lan_strings.slint";
import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint";
import { UiRadius } from "ui_radius.slint";
@@ -660,6 +661,17 @@ export component MainWindow inherits Window {
in-out property <bool> privacy-local-unlock-allowed: true;
in property <bool> lock-on-disconnect-available: false;
in-out property <bool> lock-on-disconnect-enabled: true;
in-out property <bool> trusted-devices-only: false;
in-out property <bool> trusted-devices-open: false;
in property <[TrustedDeviceEntry]> trusted-devices;
in property <[TrustedDeviceEntry]> trust-candidates;
in property <string> device-fingerprint;
in property <string> trusted-devices-error;
in property <string> trusted-devices-notice;
in property <string> trust-copy-error;
in property <string> trust-copy-notice;
in-out property <string> trust-pairing-input;
callback trust-action(string, string, string);
in-out property <string> file-save-path: "";
in-out property <string> server-host: "";
in-out property <string> server-port: "";
@@ -1087,7 +1099,7 @@ export component MainWindow inherits Window {
if event.text == Key.Escape { root.messages-open = false; }
return accept;
}
if root.settings-open && !root.self-host-settings-open {
if root.settings-open && !root.self-host-settings-open && !root.trusted-devices-open {
if event.text == Key.Escape { root.cancel-settings(); root.settings-open = false; }
return accept;
}
@@ -2753,15 +2765,21 @@ export component MainWindow inherits Window {
}
}
}
Text {
text: GalleryStrings.texts[119] + "\n" + GalleryStrings.texts[207];
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
}
SettingsGroup {
title: GalleryStrings.texts[120]; rows: 3;
title: GalleryStrings.texts[120]; rows: 5;
SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; }
SettingsToggleRow { label: UiStrings.privacy-local-unlock; checked <=> root.privacy-local-unlock-allowed; enabled: root.privacy-setting-available; }
SettingsToggleRow { label: UiStrings.lock-on-disconnect; checked <=> root.lock-on-disconnect-enabled; enabled: root.lock-on-disconnect-available; divider: false; }
SettingsToggleRow { label: UiStrings.lock-on-disconnect; checked <=> root.lock-on-disconnect-enabled; enabled: root.lock-on-disconnect-available; }
SettingsToggleRow { label: TrustStrings.only; checked <=> root.trusted-devices-only; }
SettingsRow {
label: TrustStrings.title; divider: false;
SettingsButton {
x: parent.width - self.width - 12px; y: 4px;
width: max(104px, self.label-width); height: 28px;
text: TrustStrings.manage;
clicked => { root.trusted-devices-open = true; root.trust-action("open", "", ""); }
}
}
}
Text {
text: GalleryStrings.texts[121];
@@ -2871,6 +2889,20 @@ export component MainWindow inherits Window {
}
}
if root.trusted-devices-open: TrustedDevicesDialog {
z: 51;
devices: root.trusted-devices;
candidates: root.trust-candidates;
local-fingerprint: root.device-fingerprint;
error: root.trusted-devices-error;
notice: root.trusted-devices-notice;
copy-error: root.trust-copy-error;
copy-notice: root.trust-copy-notice;
pairing-input <=> root.trust-pairing-input;
close => { root.trust-action("close", "", ""); root.trusted-devices-open = false; }
action(action, id, key) => { root.trust-action(action, id, key); }
}
if root.self-host-settings-open: Rectangle {
background: #15223835;
z: 49;
@@ -6,11 +6,13 @@ import { WorkbenchStyle } from "workbench_controls.slint";
// Shared visual treatment for the global settings pages.
export component SettingsButton inherits FlatSurface {
in property <string> text;
in property <length> text-size: 12px;
in property <image> icon;
in property <bool> primary: false;
in property <bool> quiet: false;
in property <bool> align-left: false;
in property <bool> enabled: true;
in property <bool> destructive: false;
out property <length> label-width: button-label.preferred-width + 2 * root.content-padding + (root.has-icon ? 24px : 0px);
private property <bool> has-icon: root.icon.width > 0;
private property <length> content-padding: root.has-icon ? 8px : 12px;
@@ -18,7 +20,8 @@ export component SettingsButton inherits FlatSurface {
width: 88px; height: 32px; border-radius: UiRadius.control;
background: !root.enabled ? ControlStyle.disabled
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
: touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover
: touch.pressed ? (root.destructive ? #f6babb : ButtonStyle.pressed)
: touch.has-hover ? (root.destructive ? #fbe0e0 : ButtonStyle.hover)
: root.quiet ? transparent : ButtonStyle.surface;
outline-enabled: !root.quiet && !root.primary;
outline-color: !root.enabled ? ButtonStyle.disabled-border
@@ -44,8 +47,9 @@ export component SettingsButton inherits FlatSurface {
button-label := Text {
x: root.has-icon ? 24px : 0px;
width: parent.width - self.x; height: parent.height;
text: root.text; font-size: 12px; font-weight: root.primary ? 600 : 400;
color: !root.enabled ? #98a2b3 : root.primary ? white : root.quiet ? #65758b : #3c4d63;
text: root.text; font-size: root.text-size; font-weight: root.primary ? 600 : 400;
color: !root.enabled ? #98a2b3 : root.primary ? white
: root.destructive ? #c43c43 : root.quiet ? #65758b : #3c4d63;
horizontal-alignment: root.align-left ? left : center; vertical-alignment: center; overflow: elide;
}
}
@@ -0,0 +1,264 @@
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
import { ScrollView } from "std-widgets.slint";
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint";
export struct TrustedDeviceEntry {
remote-id: string,
public-key: string,
display-name: string,
device-id: string,
fingerprint: string,
}
export global TrustStrings {
in property <string> title: "Trusted devices";
in property <string> only: "Only allow trusted devices";
in property <string> manage: "Manage";
in property <string> copy: "Copy pairing info";
in property <string> import-title: "Add a trusted device";
in property <string> add: "Add";
in property <string> placeholder: "Paste the controller's pairing information";
in property <string> confirm: "Check the fingerprint on the controller.";
in property <string> empty: "No trusted devices";
in property <string> candidates: "Connected devices available to trust";
in property <string> trust: "Trust";
in property <string> remove: "Remove";
in property <string> saved: "Trusted devices";
in property <string> sharing: "This device’s pairing info";
in property <string> show-fingerprint: "View fingerprint";
in property <string> hide-fingerprint: "Hide fingerprint";
in property <string> close: "Close";
}
component FingerprintBlock inherits Rectangle {
in property <string> fingerprint;
background: #f0f4fa; border-radius: UiRadius.control;
height: content.preferred-height;
content := VerticalLayout {
width: parent.width; height: self.preferred-height;
padding: 10px;
Text { text: root.fingerprint; color: #465a76; font-size: 11px; wrap: word-wrap; }
}
}
component DeviceRow inherits FlatSurface {
in property <TrustedDeviceEntry> device;
in property <string> action-text;
in property <bool> confirming: false;
private property <bool> fingerprint-open: false;
callback action;
height: content.preferred-height;
background: white; outline-color: UiSurface.edge;
border-radius: UiRadius.control;
content := VerticalLayout {
width: parent.width; height: self.preferred-height;
padding: 12px; spacing: 8px;
padding-top: root.confirming ? 12px : 8px;
padding-bottom: root.confirming ? 12px : 8px;
HorizontalLayout {
spacing: 6px;
VerticalLayout {
spacing: 2px; horizontal-stretch: 1; min-width: 0px;
Text {
text: root.device.display-name;
font-size: root.confirming ? 13px : 12px; font-weight: root.confirming ? 600 : 500;
color: WorkbenchStyle.text; overflow: elide;
}
Text {
text: "ID: " + (root.device.device-id == "" ? "—" : root.device.device-id);
font-size: 11px; color: WorkbenchStyle.muted; overflow: elide;
}
}
if !root.confirming: SettingsButton {
width: min(144px, self.label-width); height: 28px; quiet: true;
text-size: 11px;
text: root.fingerprint-open ? TrustStrings.hide-fingerprint : TrustStrings.show-fingerprint;
clicked => { root.fingerprint-open = !root.fingerprint-open; }
}
if !root.confirming: SettingsButton {
width: min(128px, max(64px, self.label-width)); height: 28px;
text-size: 11px;
quiet: true; text: root.action-text;
destructive: true;
clicked => { root.action(); }
}
}
if root.confirming || root.fingerprint-open: FingerprintBlock {
fingerprint: root.device.fingerprint;
}
if root.confirming: HorizontalLayout {
spacing: 12px;
Text {
text: TrustStrings.confirm; font-size: 11px;
color: WorkbenchStyle.muted; wrap: word-wrap;
horizontal-stretch: 1; min-width: 0px; vertical-alignment: center;
}
SettingsButton {
width: min(parent.width * 0.6, max(112px, self.label-width)); height: 30px;
primary: true; text: root.action-text;
clicked => { root.action(); }
}
}
}
}
export component TrustedDevicesDialog inherits Rectangle {
in property <[TrustedDeviceEntry]> devices;
in property <[TrustedDeviceEntry]> candidates;
in property <string> local-fingerprint;
in property <string> error;
in property <string> notice;
in property <string> copy-error;
in property <string> copy-notice;
in-out property <string> pairing-input;
private property <bool> local-fingerprint-open: false;
callback close;
callback action(string, string, string);
background: #15223835;
TouchArea { }
forward-focus: focus;
focus := FocusScope {
key-pressed(event) => {
if event.text == Key.Escape { root.close(); return accept; }
return reject;
}
}
dialog := SettingsDialog {
// Reserve the full 14px Fluent scrollbar plus an 8px content gap.
private property <length> scrollbar-gutter: 22px;
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
width: min(540px, parent.width - 32px);
height: min(400px, parent.height - 32px);
min-width: 0px; max-width: parent.width - 32px;
min-height: 0px; max-height: parent.height - 32px;
title: TrustStrings.title; close-label: TrustStrings.close;
close => { root.close(); }
ScrollView {
x: 24px; y: 64px;
// Extend into the right margin to preserve the existing card width.
width: parent.width - 56px + dialog.scrollbar-gutter;
height: parent.height - 84px;
min-width: 0px; min-height: 0px;
horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed;
viewport-width: self.visible-width;
viewport-height: content.preferred-height;
content := VerticalLayout {
x: 0px; y: 0px;
width: max(0px, parent.visible-width - dialog.scrollbar-gutter); height: self.preferred-height;
spacing: 16px; padding: 0px; alignment: start;
FlatSurface {
background: #f1f6ff; outline-color: #d7e3f7; border-radius: UiRadius.surface;
height: add-content.preferred-height;
add-content := VerticalLayout {
width: parent.width; height: self.preferred-height; padding: 14px; spacing: 10px;
Text {
text: TrustStrings.import-title; font-size: 14px; font-weight: 600;
color: WorkbenchStyle.text; min-width: 0px; wrap: word-wrap;
}
HorizontalLayout {
spacing: 8px;
RoundedLineEdit {
horizontal-stretch: 1; height: 32px; text <=> root.pairing-input;
placeholder-text: TrustStrings.placeholder; surrounding-background: #f1f6ff;
edited => { root.action("edit", "", ""); }
accepted => { root.action("import", "", ""); }
}
SettingsButton {
width: max(64px, self.label-width); height: 32px; primary: true;
text: TrustStrings.add; enabled: root.pairing-input != "" && root.local-fingerprint != "";
clicked => { root.action("import", "", ""); }
}
}
if root.error != "": Text {
text: root.error; color: #b33c39; font-size: 11px; wrap: word-wrap;
}
if root.notice != "": Text {
text: root.notice; color: #28734a; font-size: 11px; wrap: word-wrap;
}
}
}
VerticalLayout {
spacing: 6px;
Rectangle {
// Align mixed font sizes by their baselines rather than their top edges.
private property <length> baseline: max(saved-title.font-metrics.ascent, saved-count.font-metrics.ascent);
height: max(saved-title.y + saved-title.preferred-height, saved-count.y + saved-count.preferred-height);
saved-title := Text {
x: 0px; width: self.preferred-width; height: self.preferred-height;
y: parent.baseline - self.font-metrics.ascent;
text: TrustStrings.saved; font-size: 13px; font-weight: 600; color: WorkbenchStyle.text;
}
saved-count := Text {
x: saved-title.preferred-width + 6px;
width: self.preferred-width; height: self.preferred-height;
y: parent.baseline - self.font-metrics.ascent;
text: "" + root.devices.length; font-size: 12px; color: WorkbenchStyle.muted;
}
}
if root.devices.length == 0: FlatSurface {
height: 48px; background: white;
outline-color: UiSurface.edge; border-radius: UiRadius.control;
Text {
x: 14px; width: parent.width - 28px; height: parent.height;
text: TrustStrings.empty; font-size: 12px; color: WorkbenchStyle.muted;
horizontal-alignment: center; vertical-alignment: center; wrap: word-wrap;
}
}
for device in root.devices: DeviceRow {
device: device; action-text: TrustStrings.remove;
action => { root.action("remove", device.remote-id, device.public-key); }
}
}
if root.candidates.length > 0: VerticalLayout {
spacing: 8px;
Text { text: TrustStrings.candidates; font-size: 13px; font-weight: 600; color: WorkbenchStyle.text; wrap: word-wrap; }
for device in root.candidates: DeviceRow {
device: device; confirming: true; action-text: TrustStrings.trust;
action => { root.action("add", device.remote-id, device.public-key); }
}
}
FlatSurface {
height: sharing-content.preferred-height; background: white;
outline-color: UiSurface.edge; border-radius: UiRadius.control;
sharing-content := VerticalLayout {
width: parent.width; height: self.preferred-height; padding: 14px; spacing: 8px;
HorizontalLayout {
spacing: 12px;
Text {
horizontal-stretch: 1; min-width: 0px;
text: TrustStrings.sharing; font-size: 13px; font-weight: 600;
color: WorkbenchStyle.text; wrap: word-wrap; vertical-alignment: center;
}
SettingsButton {
width: min(160px, self.label-width); height: 30px; text: TrustStrings.copy;
enabled: root.local-fingerprint != ""; clicked => { root.action("copy", "", ""); }
}
}
HorizontalLayout {
spacing: 8px;
SettingsButton {
width: self.label-width; height: 22px; quiet: true; align-left: true;
text: root.local-fingerprint-open ? TrustStrings.hide-fingerprint : TrustStrings.show-fingerprint;
enabled: root.local-fingerprint != "";
clicked => { root.local-fingerprint-open = !root.local-fingerprint-open; }
}
Text {
text: root.copy-error != "" ? root.copy-error : root.copy-notice;
color: root.copy-error != "" ? #b33c39 : #28734a; font-size: 11px;
horizontal-stretch: 1; min-width: 0px; wrap: word-wrap;
horizontal-alignment: right; vertical-alignment: center;
}
}
if root.local-fingerprint-open: FingerprintBlock {
fingerprint: root.local-fingerprint;
}
}
}
}
}
}
}
+18
View File
@@ -55,6 +55,24 @@ inline int ApplyMainWindowStrings(
languages.push_back(Text(option.display_name));
languages.push_back(Text(localization::Literal("跟随系统", language)));
window->set_language_options(std::make_shared<slint::VectorModel<slint::SharedString>>(languages));
auto& trust = window->global<ui::TrustStrings>();
trust.set_title(Text(localization::Literal("可信设备", language)));
trust.set_only(Text(localization::Literal("仅允许可信设备控制", language)));
trust.set_manage(Text(localization::Literal("管理可信设备", language)));
trust.set_copy(Text(localization::Literal("复制配对信息", language)));
trust.set_import_title(Text(localization::Literal("添加可信设备", language)));
trust.set_add(Text(localization::Literal("添加", language)));
trust.set_placeholder(Text(localization::Literal("粘贴控制端的配对信息", language)));
trust.set_confirm(Text(localization::Literal("请核对控制端指纹。", language)));
trust.set_saved(Text(localization::Literal("已信任的设备", language)));
trust.set_sharing(Text(localization::Literal("本机配对信息", language)));
trust.set_show_fingerprint(Text(localization::Literal("查看指纹", language)));
trust.set_hide_fingerprint(Text(localization::Literal("收起指纹", language)));
trust.set_empty(Text(localization::Literal("暂无可信设备", language)));
trust.set_candidates(Text(localization::Literal("可添加的已连接设备", language)));
trust.set_trust(Text(localization::Literal("信任此设备", language)));
trust.set_remove(Text(localization::Literal("移除信任", language)));
trust.set_close(Text(localization::message_close[language]));
auto& strings = window->global<ui::UiStrings>();
strings.set_window_minimize(Text(localization::window_minimize[language]));
strings.set_window_close(Text(localization::window_close[language]));
+9 -183
View File
@@ -1,4 +1,5 @@
#include "lan_identity.h"
#include "private_file.h"
#include <openssl/crypto.h>
#include <openssl/pem.h>
@@ -8,7 +9,6 @@
#include <algorithm>
#include <array>
#include <charconv>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <string_view>
@@ -16,23 +16,18 @@
#include "direct_address.h"
#include "lan_password.h"
#ifdef _WIN32
// Winsock must precede windows.h; the networking/ACL headers need its types.
// Winsock must precede windows.h; the networking headers need its types.
// clang-format off
#include <winsock2.h>
#include <windows.h>
#include <ws2tcpip.h>
#include <Aclapi.h>
#include <iphlpapi.h>
#include <netioapi.h>
#include <sddl.h>
// clang-format on
#else
#include <arpa/inet.h>
#include <fcntl.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <sys/stat.h>
#include <unistd.h>
#ifdef __APPLE__
#include <net/if_dl.h>
#include <net/if_types.h>
@@ -59,166 +54,8 @@ bool IsHex(const std::string& text, size_t size) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
});
}
#ifdef _WIN32
std::vector<unsigned char> CurrentUser() {
HANDLE token = nullptr;
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
"Cannot open user token");
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<unsigned char> data(size);
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
CloseHandle(token);
Require(ok, "Cannot read user token");
return data;
}
PSECURITY_DESCRIPTOR PrivateSecurityDescriptor(bool directory) {
auto user = CurrentUser();
LPWSTR sid = nullptr;
Require(ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
"Cannot read user SID");
// An elevated token can default to Administrators as the owner even when
// its DACL grants access only to TokenUser. Always set both explicitly.
const auto acl = L"O:" + std::wstring(sid) + L"D:P(A;" +
(directory ? L"OICI" : L"") + L";FA;;;" + sid + L")";
LocalFree(sid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
"Cannot protect LAN file");
return descriptor;
}
#endif
void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
const auto temporary = path.parent_path() / (".write-" + RandomHex(16));
#ifdef _WIN32
auto descriptor = PrivateSecurityDescriptor(false);
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &security,
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
LocalFree(descriptor);
Require(file != INVALID_HANDLE_VALUE, "Cannot create LAN file");
DWORD written = 0;
bool ok = WriteFile(file, text.data(), static_cast<DWORD>(text.size()),
&written, nullptr) &&
written == text.size() && FlushFileBuffers(file);
CloseHandle(file);
if (!ok || !MoveFileExW(temporary.c_str(), path.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
DeleteFileW(temporary.c_str());
throw std::runtime_error("Cannot persist LAN file");
}
#else
int fd =
open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
Require(fd >= 0, "Cannot create private LAN file");
size_t offset = 0;
while (offset < text.size()) {
const auto count = write(fd, text.data() + offset, text.size() - offset);
if (count <= 0) break;
offset += static_cast<size_t>(count);
}
bool ok = offset == text.size() && fsync(fd) == 0;
close(fd);
if (!ok || rename(temporary.c_str(), path.c_str()) != 0) {
unlink(temporary.c_str());
throw std::runtime_error("Cannot persist LAN file");
}
#endif
}
#ifdef _WIN32
using FileHandle = std::unique_ptr<void, decltype(&CloseHandle)>;
FileHandle OpenLanPath(const std::filesystem::path& path, DWORD access) {
// Keep the object open without FILE_SHARE_DELETE so validation and legacy
// owner repair cannot operate on a replacement at the same path.
HANDLE file = CreateFileW(
path.c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
nullptr);
Require(file != INVALID_HANDLE_VALUE, "Cannot open LAN path");
return {file, CloseHandle};
}
void CheckPrivateWindowsPath(const std::filesystem::path& path) {
auto file = OpenLanPath(path, READ_CONTROL);
BY_HANDLE_FILE_INFORMATION info{};
Require(GetFileInformationByHandle(file.get(), &info) &&
!(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT),
"LAN path cannot be a reparse point");
auto user = CurrentUser();
auto sid = reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid;
PSID owner = nullptr;
PACL acl = nullptr;
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(GetSecurityInfo(
file.get(), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS,
"Cannot read LAN file permissions");
std::unique_ptr<void, decltype(&LocalFree)> security(descriptor, LocalFree);
bool ok = acl && acl->AceCount > 0;
// Only the exact protected, full-access DACL written by older versions is
// eligible for owner repair. Never adopt a shared or foreign-user profile.
SECURITY_DESCRIPTOR_CONTROL control{};
DWORD revision = 0;
bool legacy = owner && IsWellKnownSid(owner, WinBuiltinAdministratorsSid) &&
GetSecurityDescriptorControl(descriptor, &control, &revision) &&
(control & SE_DACL_PROTECTED) && acl && acl->AceCount == 1;
for (DWORD i = 0; ok && i < acl->AceCount; ++i) {
void* raw = nullptr;
if (!GetAce(acl, i, &raw)) {
ok = false;
break;
}
const auto* ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
EqualSid(const_cast<DWORD*>(&ace->SidStart), sid);
const BYTE flags = (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
? OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
: 0;
legacy =
legacy && ace->Mask == FILE_ALL_ACCESS && ace->Header.AceFlags == flags;
}
Require(ok && owner && (EqualSid(owner, sid) || legacy),
"LAN files must be accessible only by the current user");
if (legacy) {
auto writable = OpenLanPath(path, WRITE_OWNER);
BY_HANDLE_FILE_INFORMATION current{};
Require(GetFileInformationByHandle(writable.get(), &current) &&
current.dwVolumeSerialNumber == info.dwVolumeSerialNumber &&
current.nFileIndexHigh == info.nFileIndexHigh &&
current.nFileIndexLow == info.nFileIndexLow &&
SetSecurityInfo(writable.get(), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION, sid, nullptr,
nullptr, nullptr) == ERROR_SUCCESS,
"Cannot repair LAN file owner");
}
}
void CreatePrivateDirectory(const std::filesystem::path& path) {
if (!std::filesystem::exists(path)) {
auto descriptor = PrivateSecurityDescriptor(true);
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
bool ok = CreateDirectoryW(path.c_str(), &security);
LocalFree(descriptor);
Require(ok, "Cannot create LAN directory");
}
CheckPrivateWindowsPath(path);
}
#endif
std::string Read(const std::filesystem::path& path) {
#ifdef _WIN32
CheckPrivateWindowsPath(path);
#else
struct stat status{};
Require(lstat(path.c_str(), &status) == 0 && S_ISREG(status.st_mode) &&
status.st_uid == getuid() && (status.st_mode & 0077) == 0,
"LAN file must be owned by this user with mode 0600");
#endif
Require(std::filesystem::file_size(path) <= 64 * 1024, "LAN file too large");
std::ifstream input(path, std::ios::binary);
Require(input.good(), "Cannot read LAN file");
return {std::istreambuf_iterator<char>(input), {}};
}
using private_file::Read;
using private_file::Write;
std::string BioText(BIO* bio) {
char* data = nullptr;
long size = BIO_get_mem_data(bio, &data);
@@ -245,7 +82,7 @@ std::string PasswordKey(const std::filesystem::path& directory, bool create) {
Require(
RAND_bytes(reinterpret_cast<unsigned char*>(key.value.data()), 32) == 1,
"Cannot generate LAN password storage key");
PrivateWrite(path, key.value);
Write(path, key.value);
}
auto key = Read(path);
Require(key.size() == 32, "Invalid LAN password storage key");
@@ -358,11 +195,11 @@ void CreateCertificate(const std::filesystem::path& key_path,
Require(PEM_write_bio_PrivateKey(bio.get(), key.get(), nullptr, nullptr, 0,
nullptr, nullptr) == 1,
"Cannot encode private key");
PrivateWrite(key_path, BioText(bio.get()));
Write(key_path, BioText(bio.get()));
(void)BIO_reset(bio.get());
Require(PEM_write_bio_X509(bio.get(), cert.get()) == 1,
"Cannot encode certificate");
PrivateWrite(cert_path, BioText(bio.get()));
Write(cert_path, BioText(bio.get()));
}
} // namespace
unsigned ParsePort(const std::string& value) {
@@ -539,18 +376,7 @@ Identity::Identity(std::filesystem::path directory)
: directory_(std::move(directory)) {}
void Identity::Load() {
DevicePassword::CheckSupport();
std::filesystem::create_directories(directory_.parent_path());
#ifdef _WIN32
CreatePrivateDirectory(directory_);
#else
std::filesystem::create_directories(directory_);
struct stat directory_status{};
Require(lstat(directory_.c_str(), &directory_status) == 0 &&
S_ISDIR(directory_status.st_mode) &&
directory_status.st_uid == getuid(),
"Invalid LAN directory owner or type");
Require(chmod(directory_.c_str(), 0700) == 0, "Cannot protect LAN directory");
#endif
private_file::EnsureDirectory(directory_);
const auto store = directory_ / "identity.json";
if (!std::filesystem::exists(store)) {
Require(!std::filesystem::exists(private_key()) &&
@@ -642,7 +468,7 @@ void Identity::Load() {
state_.erase("password_attempts");
}
void Identity::Save(const nlohmann::json& state) {
PrivateWrite(directory_ / "identity.json", state.dump(2));
Write(directory_ / "identity.json", state.dump(2));
}
std::pair<std::string, nlohmann::json> Identity::GeneratePassword() {
constexpr std::string_view alphabet =
@@ -24,6 +24,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
dll_log_path_ = exec_log_path_;
InitializeLogger();
config_center_ = std::make_unique<ConfigCenter>(cache_path_ + "/config.ini");
InitializeTrustedDevices(root / "trusted-devices");
if (config_center_->SetPrivacyScreen(false) != 0 ||
config_center_->SetSaveRemotePreviews(false) != 0)
return 1;
@@ -44,6 +45,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
next_peer_attempt = now + 3000;
if (CreateConnectionPeer() != 0) CloseConnectionPeer();
}
TickControllerTrust();
HandleConnectionStatusChange();
HandleScreenLock();
HandleWindowsServiceIntegration();
+6 -1
View File
@@ -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")
+2 -1
View File
@@ -82,12 +82,13 @@ function setup_targets()
target("desktop_common")
set_kind("object")
add_deps("rd_log", "crossdesk_wire")
add_packages("libyuv")
add_packages("libyuv", "openssl3")
add_packages("concurrentqueue")
add_files("apps/desktop/src/common/*.cpp")
if is_os("windows") then
add_files("apps/desktop/src/platform/windows/system_info.cpp",
"apps/desktop/src/platform/windows/screen_lock.cpp")
add_syslinks("Advapi32", {public = true})
elseif is_os("macosx") then
add_files("apps/desktop/src/platform/macos/system_info.cpp",
"apps/desktop/src/platform/macos/screen_lock.cpp")
@@ -7,13 +7,6 @@
objects = {
/* Begin PBXBuildFile section */
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F20000000000000000001 /* RemotePrivacyScreen.swift */; };
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; };
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; };
100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; };
100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; };
@@ -23,30 +16,51 @@
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000A /* RemoteTouchInputView.swift */; };
100000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000D /* Assets.xcassets */; };
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000E /* ThirdPartyLicensesView.swift */; };
10000000000000000000000A /* ThirdPartyLicenses.json in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000F /* ThirdPartyLicenses.json */; };
10000000000000000000000A /* Resources/ThirdPartyLicenses.json in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000F /* Resources/ThirdPartyLicenses.json */; };
10000000000000000000000B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000010 /* PrivacyInfo.xcprivacy */; };
10000000000000000000000C /* OpenSSL.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 200000000000000000000011 /* OpenSSL.framework */; };
10000000000000000000000D /* OpenSSL.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 200000000000000000000011 /* OpenSSL.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
100000000000000000000012 /* PrivacyPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000012 /* PrivacyPreferences.swift */; };
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000013 /* ThumbnailFileStore.swift */; };
100000000000000000000014 /* PrivacyViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000014 /* PrivacyViews.swift */; };
100000000000000000000015 /* PRIVACY.md in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000015 /* PRIVACY.md */; };
100000000000000000000015 /* ../../../PRIVACY.md in Resources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000015 /* ../../../PRIVACY.md */; };
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; };
100000000000000000000019 /* AnnouncementInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000019 /* AnnouncementInbox.swift */; };
10000000000000000000001A /* AnnouncementText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001A /* AnnouncementText.swift */; };
10000000000000000000001B /* AnnouncementInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001B /* AnnouncementInboxView.swift */; };
10000000000000000000001C /* AppUpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001C /* AppUpdateChecker.swift */; };
3C550ED141D4AEA30950BB7A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5BD6CC3A43B86A2E2267994F; };
4870B14C46F4C4B8F10FEF08 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A0A3C0DA09EE562AE21DF986; };
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
3C550ED141D4AEA30950BB7A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5BD6CC3A43B86A2E2267994F /* Localizable.strings */; };
4870B14C46F4C4B8F10FEF08 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A0A3C0DA09EE562AE21DF986 /* InfoPlist.strings */; };
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F20000000000000000001 /* RemotePrivacyScreen.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
300000000000000000000002 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
10000000000000000000000D /* OpenSSL.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
C10F20000000000000000001 /* RemotePrivacyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePrivacyScreen.swift; sourceTree = "<group>"; };
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = "<group>"; };
200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = "<group>"; };
200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = "<group>"; };
02A713C4B5A1623480A005D6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = Resources/ja.lproj/InfoPlist.strings; sourceTree = "<group>"; };
093D4362C938821DE02B9032 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = Resources/es.lproj/InfoPlist.strings; sourceTree = "<group>"; };
0BB7EC0F2A5A6A313F782DCA /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Resources/fr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
10083BB5398965A4432CEC1E /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Resources/ko.lproj/Localizable.strings; sourceTree = "<group>"; };
10718835138E0FDDFDE9B567 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Resources/ru.lproj/Localizable.strings; sourceTree = "<group>"; };
125E3BF17EC58E43B32F2AC3 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Resources/pt-BR.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
1C906F7AD7B701DB61B512D2 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Resources/pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = "<group>"; };
200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = "<group>"; };
200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -61,53 +75,41 @@
20000000000000000000000C /* build_minirtc_ios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_minirtc_ios.sh; sourceTree = "<group>"; };
20000000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
20000000000000000000000E /* ThirdPartyLicensesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyLicensesView.swift; sourceTree = "<group>"; };
20000000000000000000000F /* ThirdPartyLicenses.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Resources/ThirdPartyLicenses.json; sourceTree = "<group>"; };
20000000000000000000000F /* Resources/ThirdPartyLicenses.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Resources/ThirdPartyLicenses.json; sourceTree = "<group>"; };
200000000000000000000010 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
200000000000000000000011 /* OpenSSL.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OpenSSL.framework; sourceTree = BUILT_PRODUCTS_DIR; };
200000000000000000000012 /* PrivacyPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyPreferences.swift; sourceTree = "<group>"; };
200000000000000000000013 /* ThumbnailFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailFileStore.swift; sourceTree = "<group>"; };
200000000000000000000014 /* PrivacyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViews.swift; sourceTree = "<group>"; };
200000000000000000000015 /* PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = "<group>"; };
200000000000000000000015 /* ../../../PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ../../../PRIVACY.md; sourceTree = "<group>"; };
200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = "<group>"; };
200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = "<group>"; };
200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = "<group>"; };
200000000000000000000019 /* AnnouncementInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInbox.swift; sourceTree = "<group>"; };
20000000000000000000001A /* AnnouncementText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementText.swift; sourceTree = "<group>"; };
20000000000000000000001B /* AnnouncementInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementInboxView.swift; sourceTree = "<group>"; };
20000000000000000000001C /* AppUpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateChecker.swift; sourceTree = "<group>"; };
51B43C654308785E6FC27C98 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en"; path = "Resources/en.lproj/Localizable.strings"; sourceTree = "<group>"; };
C04C96DC6D837D5C45257B37 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "Resources/zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
4E23FD4A4331F613A7BF3C81 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "Resources/zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
10718835138E0FDDFDE9B567 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ru"; path = "Resources/ru.lproj/Localizable.strings"; sourceTree = "<group>"; };
BA8BCB85D00055B796EBB9E1 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ja"; path = "Resources/ja.lproj/Localizable.strings"; sourceTree = "<group>"; };
10083BB5398965A4432CEC1E /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ko"; path = "Resources/ko.lproj/Localizable.strings"; sourceTree = "<group>"; };
8CEF64DB2A646A3C710CAAE7 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "de"; path = "Resources/de.lproj/Localizable.strings"; sourceTree = "<group>"; };
467DE174FF993E9771FAEE5D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr"; path = "Resources/fr.lproj/Localizable.strings"; sourceTree = "<group>"; };
2EA0435DFAEE685F04391B3B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es"; path = "Resources/es.lproj/Localizable.strings"; sourceTree = "<group>"; };
1C906F7AD7B701DB61B512D2 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Resources/pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
B6189259C1AE18847A742A13 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt"; path = "Resources/pt.lproj/Localizable.strings"; sourceTree = "<group>"; };
23D5AD87A9BF0CBF3A6AADDF /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en"; path = "Resources/en.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
23D5AD87A9BF0CBF3A6AADDF /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Resources/en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
2E83770B40B2D299796EB0CD /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "Resources/zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
2EA0435DFAEE685F04391B3B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = Resources/es.lproj/Localizable.strings; sourceTree = "<group>"; };
36B135DFBFBD8F03E16FB09A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "Resources/zh-Hant.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
56DE65D659C5D65945CF834F /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ru"; path = "Resources/ru.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
02A713C4B5A1623480A005D6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ja"; path = "Resources/ja.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
EA650495031B45A6FE5892FD /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ko"; path = "Resources/ko.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
E653CAF5C4E87548BF6D8670 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "de"; path = "Resources/de.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
0BB7EC0F2A5A6A313F782DCA /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr"; path = "Resources/fr.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
093D4362C938821DE02B9032 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es"; path = "Resources/es.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
125E3BF17EC58E43B32F2AC3 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "Resources/pt-BR.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
7DDF05F5D35AB063DB447879 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt"; path = "Resources/pt.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
467DE174FF993E9771FAEE5D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Resources/fr.lproj/Localizable.strings; sourceTree = "<group>"; };
4E23FD4A4331F613A7BF3C81 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "Resources/zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
51B43C654308785E6FC27C98 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Resources/en.lproj/Localizable.strings; sourceTree = "<group>"; };
56DE65D659C5D65945CF834F /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Resources/ru.lproj/InfoPlist.strings; sourceTree = "<group>"; };
7DDF05F5D35AB063DB447879 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = Resources/pt.lproj/InfoPlist.strings; sourceTree = "<group>"; };
8CEF64DB2A646A3C710CAAE7 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = Resources/de.lproj/Localizable.strings; sourceTree = "<group>"; };
B6189259C1AE18847A742A13 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = Resources/pt.lproj/Localizable.strings; sourceTree = "<group>"; };
BA8BCB85D00055B796EBB9E1 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = Resources/ja.lproj/Localizable.strings; sourceTree = "<group>"; };
C04C96DC6D837D5C45257B37 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "Resources/zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
C10F20000000000000000001 /* RemotePrivacyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePrivacyScreen.swift; sourceTree = "<group>"; };
E653CAF5C4E87548BF6D8670 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = Resources/de.lproj/InfoPlist.strings; sourceTree = "<group>"; };
EA650495031B45A6FE5892FD /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Resources/ko.lproj/InfoPlist.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXCopyFilesBuildPhase section */
300000000000000000000002 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (10000000000000000000000D /* OpenSSL.framework in Embed Frameworks */, );
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFrameworksBuildPhase section */
300000000000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -133,7 +135,7 @@
isa = PBXGroup;
children = (
200000000000000000000001 /* CrossDeskMobileApp.swift */,
200000000000000000000015 /* PRIVACY.md */,
200000000000000000000015 /* ../../../PRIVACY.md */,
200000000000000000000014 /* PrivacyViews.swift */,
200000000000000000000013 /* ThumbnailFileStore.swift */,
200000000000000000000012 /* PrivacyPreferences.swift */,
@@ -148,7 +150,7 @@
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
200000000000000000000003 /* ContentView.swift */,
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
20000000000000000000000F /* ThirdPartyLicenses.json */,
20000000000000000000000F /* Resources/ThirdPartyLicenses.json */,
A0A3C0DA09EE562AE21DF986 /* InfoPlist.strings */,
5BD6CC3A43B86A2E2267994F /* Localizable.strings */,
200000000000000000000010 /* PrivacyInfo.xcprivacy */,
@@ -220,6 +222,7 @@
300000000000000000000001 /* Frameworks */,
600000000000000000000002 /* Resources */,
300000000000000000000002 /* Embed Frameworks */,
700000000000000000000002 /* Generate OpenSSL symbols */,
);
buildRules = (
);
@@ -232,18 +235,13 @@
};
/* End PBXNativeTarget section */
/* Begin PBXVariantGroup section */
5BD6CC3A43B86A2E2267994F /* Localizable.strings */ = {isa = PBXVariantGroup; children = (51B43C654308785E6FC27C98, C04C96DC6D837D5C45257B37, 4E23FD4A4331F613A7BF3C81, 10718835138E0FDDFDE9B567, BA8BCB85D00055B796EBB9E1, 10083BB5398965A4432CEC1E, 8CEF64DB2A646A3C710CAAE7, 467DE174FF993E9771FAEE5D, 2EA0435DFAEE685F04391B3B, 1C906F7AD7B701DB61B512D2, B6189259C1AE18847A742A13, ); name = Localizable.strings; sourceTree = "<group>"; };
A0A3C0DA09EE562AE21DF986 /* InfoPlist.strings */ = {isa = PBXVariantGroup; children = (23D5AD87A9BF0CBF3A6AADDF, 2E83770B40B2D299796EB0CD, 36B135DFBFBD8F03E16FB09A, 56DE65D659C5D65945CF834F, 02A713C4B5A1623480A005D6, EA650495031B45A6FE5892FD, E653CAF5C4E87548BF6D8670, 0BB7EC0F2A5A6A313F782DCA, 093D4362C938821DE02B9032, 125E3BF17EC58E43B32F2AC3, 7DDF05F5D35AB063DB447879, ); name = InfoPlist.strings; sourceTree = "<group>"; };
/* End PBXVariantGroup section */
/* Begin PBXProject section */
800000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
LastUpgradeCheck = 2710;
TargetAttributes = {
500000000000000000000001 = {
CreatedOnToolsVersion = 26.6;
@@ -254,7 +252,20 @@
compatibilityVersion = "Xcode 15.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (Base, "en", "zh-Hans", "zh-Hant", "ru", "ja", "ko", "de", "fr", "es", "pt-BR", "pt", );
knownRegions = (
Base,
en,
"zh-Hans",
"zh-Hant",
ru,
ja,
ko,
de,
fr,
es,
"pt-BR",
pt,
);
mainGroup = 400000000000000000000001;
productRefGroup = 400000000000000000000007 /* Products */;
projectDirPath = "";
@@ -271,11 +282,11 @@
buildActionMask = 2147483647;
files = (
100000000000000000000008 /* Assets.xcassets in Resources */,
10000000000000000000000A /* ThirdPartyLicenses.json in Resources */,
10000000000000000000000A /* Resources/ThirdPartyLicenses.json in Resources */,
4870B14C46F4C4B8F10FEF08 /* InfoPlist.strings in Resources */,
3C550ED141D4AEA30950BB7A /* Localizable.strings in Resources */,
10000000000000000000000B /* PrivacyInfo.xcprivacy in Resources */,
100000000000000000000015 /* PRIVACY.md in Resources */,
100000000000000000000015 /* ../../../PRIVACY.md in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -308,6 +319,24 @@
shellPath = /bin/zsh;
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/verify_native_build.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --xmake-version \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-version.txt\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\" --platform \"${PLATFORM_NAME}\"\n";
};
700000000000000000000002 /* Generate OpenSSL symbols */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/OpenSSL.framework/OpenSSL",
"$(PROJECT_DIR)/scripts/generate_openssl_symbols.py",
);
name = "Generate OpenSSL symbols";
outputPaths = (
"$(DWARF_DSYM_FOLDER_PATH)/OpenSSL.framework.dSYM/Contents/Resources/DWARF/OpenSSL",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh;
shellScript = "set -e\nif [[ \"${DEBUG_INFORMATION_FORMAT}\" == \"dwarf-with-dsym\" ]]; then\n python3 -B \"${PROJECT_DIR}/scripts/generate_openssl_symbols.py\" --binary \"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework/OpenSSL\" --output \"${DWARF_DSYM_FOLDER_PATH}/OpenSSL.framework.dSYM\"\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -342,26 +371,94 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
5BD6CC3A43B86A2E2267994F /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
51B43C654308785E6FC27C98 /* en */,
C04C96DC6D837D5C45257B37 /* zh-Hans */,
4E23FD4A4331F613A7BF3C81 /* zh-Hant */,
10718835138E0FDDFDE9B567 /* ru */,
BA8BCB85D00055B796EBB9E1 /* ja */,
10083BB5398965A4432CEC1E /* ko */,
8CEF64DB2A646A3C710CAAE7 /* de */,
467DE174FF993E9771FAEE5D /* fr */,
2EA0435DFAEE685F04391B3B /* es */,
1C906F7AD7B701DB61B512D2 /* pt-BR */,
B6189259C1AE18847A742A13 /* pt */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
A0A3C0DA09EE562AE21DF986 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
23D5AD87A9BF0CBF3A6AADDF /* en */,
2E83770B40B2D299796EB0CD /* zh-Hans */,
36B135DFBFBD8F03E16FB09A /* zh-Hant */,
56DE65D659C5D65945CF834F /* ru */,
02A713C4B5A1623480A005D6 /* ja */,
EA650495031B45A6FE5892FD /* ko */,
E653CAF5C4E87548BF6D8670 /* de */,
0BB7EC0F2A5A6A313F782DCA /* fr */,
093D4362C938821DE02B9032 /* es */,
125E3BF17EC58E43B32F2AC3 /* pt-BR */,
7DDF05F5D35AB063DB447879 /* pt */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
A00000000000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = RJZC936RL7;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -370,15 +467,44 @@
A00000000000000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = RJZC936RL7;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
@@ -387,23 +513,25 @@
A00000000000000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
CURRENT_PROJECT_VERSION = 4;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(BUILT_PRODUCTS_DIR)",
);
GENERATE_INFOPLIST_FILE = NO;
FRAMEWORK_SEARCH_PATHS = ("$(inherited)", "$(BUILT_PRODUCTS_DIR)", );
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../../deps/submodules/minirtc/src/api",
"$(PROJECT_DIR)/../../libs/wire/include",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.6.1;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.7.4;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
@@ -424,7 +552,7 @@
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_BUNDLE_IDENTIFIER = com.chunkdash.crossdesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
@@ -437,23 +565,25 @@
A00000000000000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
CURRENT_PROJECT_VERSION = 4;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(BUILT_PRODUCTS_DIR)",
);
GENERATE_INFOPLIST_FILE = NO;
FRAMEWORK_SEARCH_PATHS = ("$(inherited)", "$(BUILT_PRODUCTS_DIR)", );
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../../deps/submodules/minirtc/src/api",
"$(PROJECT_DIR)/../../libs/wire/include",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.6.1;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.7.4;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/$(PLATFORM_NAME)/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
@@ -474,7 +604,7 @@
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_BUNDLE_IDENTIFIER = com.chunkdash.crossdesk;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
@@ -0,0 +1,9 @@
{
"id" : "528214fb-1170-4293-86d0-859db2b1908b",
"targets" : [
{
"id" : "34a4d067-7914-4e8a-b72a-fda820282321",
"name" : "CrossDeskMobile"
}
]
}
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
LastUpgradeVersion = "2710"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
@@ -41,7 +41,8 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
@@ -57,7 +58,8 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
@@ -67,7 +69,8 @@
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug">
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
@@ -986,9 +986,12 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
return;
}
const off_t end = ftello(input);
rewind(input);
if (end < 0 || fseeko(input, 0, SEEK_SET) != 0) {
finish(true);
return;
}
NSData *name_data = [fileName dataUsingEncoding:NSUTF8StringEncoding];
if (end < 0 || name_data.length == 0 ||
if (name_data.length == 0 ||
name_data.length > std::numeric_limits<uint16_t>::max()) {
finish(true);
return;
@@ -1014,7 +1017,9 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
const size_t bytes_read = to_read > 0
? std::fread(payload.data(), 1, to_read, input)
: 0;
if (to_read > 0 && bytes_read == 0) {
// A short read indicates EOF or an error. Stop before sending a partial
// chunk or reading again from a stream whose position may be invalid.
if (bytes_read != to_read) {
send_result = -1;
break;
}
+17 -5
View File
@@ -8,6 +8,18 @@ enum MobileLayout {
}
extension View {
// Keep change callbacks available on iOS 16 without using the deprecated
// overload on newer systems. Neither branch fires on initial appearance.
@ViewBuilder
func onValueChange<Value: Equatable>(of value: Value,
perform action: @escaping (Value) -> Void) -> some View {
if #available(iOS 17.0, *) {
onChange(of: value) { _, newValue in action(newValue) }
} else {
onChange(of: value, perform: action)
}
}
func readableContentWidth() -> some View {
frame(maxWidth: MobileLayout.readableMaximumWidth)
.frame(maxWidth: .infinity)
@@ -81,7 +93,7 @@ struct ContentView: View {
session.applicationDidBecomeActive()
}
}
.onChange(of: scenePhase) { phase in
.onValueChange(of: scenePhase) { phase in
if phase == .active {
session.applicationDidBecomeActive()
} else if phase == .background {
@@ -304,7 +316,7 @@ private struct ConnectionHomeView: View {
session.dismissDeviceOfflineAlert()
}
}
.onChange(of: session.remoteID) { value in
.onValueChange(of: session.remoteID) { value in
let formatted = String(value.filter { $0.isNumber }.prefix(9))
if formatted != value {
session.remoteID = formatted
@@ -916,16 +928,16 @@ private struct ServerSettingsView: View {
}
.allowsHitTesting(false)
}
.onChange(of: serverConfigurationExpanded) { expanded in
.onValueChange(of: serverConfigurationExpanded) { expanded in
if !expanded {
focusedServerField = nil
validateAndApplyServerConfiguration()
}
}
.onChange(of: usesCustomServer) { isCustom in
.onValueChange(of: usesCustomServer) { isCustom in
if !isCustom { useDefaultServer() }
}
.onChange(of: focusedServerField) { field in
.onValueChange(of: focusedServerField) { field in
if field == nil { validateAndApplyServerConfiguration() }
}
.onDisappear {
+46 -46
View File
@@ -2,56 +2,56 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>CrossDesk</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>CrossDesk</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIFileSharingEnabled</key>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>CrossDesk 需要访问本地网络,以连接同一局域网内的电脑,进行远程控制和文件传输。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -323,6 +323,7 @@ final class RemoteKeyboardInputBar: UIView {
let computerButton = UIButton(type: .system)
computerButton.setTitle(NSLocalizedString("电脑键盘", comment: ""), for: .normal)
computerButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
computerButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
computerButton.accessibilityLabel = NSLocalizedString("切换到电脑键盘", comment: "")
computerButton.addTarget(self, action: #selector(switchToComputer), for: .touchUpInside)
@@ -337,7 +338,8 @@ final class RemoteKeyboardInputBar: UIView {
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
NSLayoutConstraint.activate([
computerButton.widthAnchor.constraint(equalToConstant: 80),
// Keep the minimum touch width while allowing longer translations.
computerButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 80),
dismissButton.widthAnchor.constraint(equalToConstant: RemoteKeyboardDismissButton.sideLength),
row.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4),
row.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4),
@@ -384,6 +384,15 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
let newScale = min(max(oldScale * recognizer.scale, 1),
maximumViewportScale)
recognizer.scale = 1
if newScale == 1 {
// Pinching back to fit also ends any cursor-edge pan, even
// when the gesture starts at 1x with an off-center image.
viewportScale = 1
viewportOffset = .zero
edgePanned = false
publishViewport()
return
}
guard abs(newScale - oldScale) > 0.0001 else { return }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
@@ -402,7 +411,8 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
pinchActive = false
if viewportScale <= 1.001 {
viewportScale = 1
viewportOffset = constrainedViewportOffset(viewportOffset, scale: 1)
viewportOffset = .zero
edgePanned = false
publishViewport()
}
default:
@@ -140,8 +140,11 @@ struct RemoteSessionView: View {
}
.frame(width: proxy.size.width, height: proxy.size.height)
.clipped()
}
.ignoresSafeArea()
// Fit video and input to the same unobscured area. Only the black
// background extends beneath the home indicator and display cutout.
.ignoresSafeArea(.keyboard)
.transaction { transaction in
transaction.animation = nil
transaction.disablesAnimations = true
@@ -197,7 +200,7 @@ struct RemoteSessionView: View {
} message: {
Text(session.remoteUpdateMessage ?? "")
}
.onChange(of: session.privacyScreen.unlockNotice) { visible in
.onValueChange(of: session.privacyScreen.unlockNotice) { visible in
if visible {
mouseInput.releaseAll()
keyboardInputVisible = false
@@ -231,37 +234,37 @@ struct RemoteSessionView: View {
statusMenuVisible = false
statusOrbCenter = nil
}
.onChange(of: session.selectedDisplay) { _ in
.onValueChange(of: session.selectedDisplay) { _ in
systemKeyboardContext.remoteFocusWillChange()
mouseInput.releaseAll()
mouseInput.position(x: 0.5, y: 0.5)
cursorPosition = nil
viewport = RemoteViewportState()
}
.onChange(of: session.mouseControlMode) { _ in
.onValueChange(of: session.mouseControlMode) { _ in
mouseInput.releaseAll()
cursorPosition = session.remoteCursorPosition
if let point = cursorPosition {
mouseInput.position(x: Float(point.x), y: Float(point.y))
}
}
.onChange(of: scenePhase) { phase in
.onValueChange(of: scenePhase) { phase in
if phase != .active { mouseInput.releaseAll() }
if phase == .background { keyboardInputVisible = false }
}
.onChange(of: keyboardInputVisible) { visible in
.onValueChange(of: keyboardInputVisible) { visible in
if !visible {
animateSystemKeyboardPresentation = true
waitingForSystemKeyboard = false
}
}
.onChange(of: session.isConnected) { connected in
.onValueChange(of: session.isConnected) { connected in
if !connected {
mouseInput.releaseAll()
keyboardInputVisible = false
}
}
.onChange(of: session.remoteCursorPositionRevision) { _ in
.onValueChange(of: session.remoteCursorPositionRevision) { _ in
guard !mouseInput.hasVirtualButton, let position = session.remoteCursorPosition else { return }
mouseInput.position(x: Float(position.x), y: Float(position.y))
cursorPosition = position
@@ -481,7 +484,7 @@ struct RemoteSessionView: View {
)
}
.frame(width: containerSize.width, height: containerSize.height)
.onChange(of: containerSize) { _ in mouseInput.releaseAll() }
.onValueChange(of: containerSize) { _ in mouseInput.releaseAll() }
}
}
@@ -855,7 +858,8 @@ private struct RemoteComputerKeyboard: View {
switchKeyboard()
}
.font(.system(size: 15, weight: .medium))
.frame(width: 80, height: 44)
.fixedSize(horizontal: true, vertical: false)
.frame(minWidth: 80, minHeight: 44)
.accessibilityLabel(NSLocalizedString("切换到系统键盘", comment: ""))
if verticalSizeClass == .compact {
pageTabs
@@ -1361,7 +1365,7 @@ private struct ScrollingCodecName: View {
GeometryReader { text in
Color.clear
.onAppear { textWidth = text.size.width }
.onChange(of: text.size.width) { textWidth = $0; startedAt = Date() }
.onValueChange(of: text.size.width) { textWidth = $0; startedAt = Date() }
}
}
.offset(x: offset(in: bounds.size.width, at: context.date))
@@ -1369,8 +1373,8 @@ private struct ScrollingCodecName: View {
.clipped()
}
.onAppear { startedAt = Date() }
.onChange(of: value) { _ in startedAt = Date() }
.onChange(of: bounds.size.width) { _ in startedAt = Date() }
.onValueChange(of: value) { _ in startedAt = Date() }
.onValueChange(of: bounds.size.width) { _ in startedAt = Date() }
}
.frame(height: lineHeight)
.accessibilityElement(children: .ignore)
@@ -1 +1 @@
"NSLocalNetworkUsageDescription" = "CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。";
"NSLocalNetworkUsageDescription" = "CrossDesk 需要访问本地网络,以连接同一局域网内的电脑,进行远程控制和文件传输。";
@@ -142,7 +142,7 @@ struct ThirdPartyLicensesView: View {
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(NSLocalizedString("关于 CrossDesk", comment: ""))
.navigationBarTitleDisplayMode(.inline)
.onChange(of: session.hasNetworkConsent) { allowed in
.onValueChange(of: session.hasNetworkConsent) { allowed in
if allowed { appUpdates.checkNow() }
}
}
+32 -1
View File
@@ -88,7 +88,7 @@ with Xmake precompiled package downloads disabled.
target and scheme.
2. Under **Signing & Capabilities**, select your own development team and set
a bundle identifier available to that team. The checked-in signing team
and `cn.crossdesk.mobile` identifier belong to the project maintainer.
and `com.chunkdash.crossdesk` identifier belong to ChunkDash.
3. Connect and select a physical iPhone or iPad, configure device trust and
Developer Mode as prompted by Xcode, then build and run. See Apple's
[device setup guide](https://developer.apple.com/documentation/xcode/running-your-app-on-simulated-or-physical-devices).
@@ -141,6 +141,27 @@ for that server. Remote control sessions then log in as `C-<identity>` and use
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
`control_data`, `clipboard`, `file`, and `file_feedback` streams.
## Xcode Cloud
Select `apps/ios/CrossDeskMobile.xcodeproj`, the shared `CrossDeskMobile`
scheme, and an **Archive - iOS** action with **App Store Connect** distribution
preparation (called **TestFlight and App Store** in some Xcode versions).
Commit and push the intended signing settings and shared scheme to the branch
selected by the workflow before starting a build.
Xcode Cloud automatically runs `ci_scripts/ci_post_clone.sh` next to the Xcode
project. It installs Xmake, CMake, and Python 3.13 with Homebrew, prepares an
isolated host Python environment with pip/setuptools/wheel, verifies the minimum
Xmake version, and initializes the pinned MiniRTC submodules. The native build
phase selects these tools again and preserves Cloud's proxy settings for package
downloads. No manual workflow PATH setting or `sudo` command is required.
The first archive compiles native dependencies from source and may take longer
than later builds. After changing the setup script, start a build from the new
commit; rebuilding an older commit does not include the fix. App Store releases
still need the source-tag and bundle checks described in **App Store archive
and bundle validation** below.
## GitHub Actions
[`Build iOS`](../../.github/workflows/build-ios.yml) can run independently from
@@ -565,6 +586,16 @@ Xcode removes the static archive from the embedded framework (it may insert a
small resource-framework stub) and preserves `PrivacyInfo.xcprivacy`, source
metadata and license texts. The application does not load a dynamic OpenSSL copy.
For builds using `DWARF with dSYM File`, the **Generate OpenSSL symbols** phase
runs after embedding. It verifies that Xcode's placeholder has no executable
code, generates `OpenSSL.framework.dSYM` from that exact binary, and checks its
UUIDs. This companion describes the empty resource stub, not the statically
linked OpenSSL implementation. Xcode includes it alongside the app's dSYM in
the archive so Organizer can upload symbols for every embedded Mach-O binary.
`verify_app_bundle.py` checks both UUID matches when given an archived app;
for an ordinary Release build, pass `--dsym-dir /path/to/Release-iphoneos`.
The CI ZIP includes both dSYM bundles.
The iOS-specific OpenSSL recipe disables POSIX metadata calls, automatic
`openssl.cnf` loading, and the unused file-URI store provider. It selects the
upstream `getrandom` entropy backend, which uses Darwin's `getentropy` on the
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
IOS_DIR="$(dirname "$SCRIPT_DIR")"
REPO_DIR="$(git -C "$IOS_DIR" rev-parse --show-toplevel)"
HOST_TOOLS_DIR="${IOS_DIR}/.xmake/xcode-cloud-host"
if ! command -v brew >/dev/null 2>&1; then
echo "Xcode Cloud requires Homebrew to install the iOS build tools." >&2
exit 69
fi
echo "Installing Xcode Cloud native build tools"
brew install xmake cmake python@3.13
BREW_PREFIX="$(brew --prefix)"
PYTHON_PREFIX="$(brew --prefix python@3.13)"
# Match GitHub Actions' host Python dependencies without modifying Homebrew's
# externally managed Python. The Xcode build phase selects this environment
# explicitly because exports here do not persist into later Cloud steps.
"${PYTHON_PREFIX}/bin/python3.13" -m venv "$HOST_TOOLS_DIR"
"${HOST_TOOLS_DIR}/bin/python3" -m pip install --upgrade pip setuptools wheel
for tool in xmake xrepo cmake ctest cpack; do
ln -sf "${BREW_PREFIX}/bin/${tool}" "${HOST_TOOLS_DIR}/bin/${tool}"
done
"${HOST_TOOLS_DIR}/bin/xmake" --version > "${HOST_TOOLS_DIR}/xmake-version.txt"
"${HOST_TOOLS_DIR}/bin/python3" -B "${IOS_DIR}/scripts/verify_native_build.py" \
--xmake-version "${HOST_TOOLS_DIR}/xmake-version.txt"
echo "Initializing MiniRTC submodules"
git -C "$REPO_DIR" submodule sync --recursive
git -C "$REPO_DIR" submodule update --init --recursive
echo "Xcode Cloud iOS build tools are ready"
+24 -1
View File
@@ -46,6 +46,17 @@ if [[ "${ARCH_NAME}" != "arm64" ]]; then
exit 64
fi
# Post-clone scripts run in a separate process, so select their installed host
# tools again here instead of relying on exported PATH changes surviving.
if [[ "${CI_XCODE_CLOUD:-}" == "TRUE" ]]; then
CLOUD_HOST_BIN="${IOS_DIR}/.xmake/xcode-cloud-host/bin"
if [[ ! -x "${CLOUD_HOST_BIN}/xmake" || ! -x "${CLOUD_HOST_BIN}/python3" ]]; then
print -u2 "Xcode Cloud build tools are missing. Run ci_scripts/ci_post_clone.sh before building."
exit 69
fi
export PATH="${CLOUD_HOST_BIN}:${PATH}"
fi
XMAKE_BIN="${XMAKE_BIN:-}"
if [[ -z "${XMAKE_BIN}" ]]; then
XMAKE_BIN="$(command -v xmake 2>/dev/null || true)"
@@ -89,6 +100,16 @@ run_xmake() {
if [[ -n "${XMAKE_MAIN_REPO:-}" ]]; then
xmake_env+=("XMAKE_MAIN_REPO=${XMAKE_MAIN_REPO}")
fi
# Cloud dependency downloads use Apple's HTTP proxy. Keep those settings
# while still stripping Xcode's iOS compiler and SDK environment variables.
if [[ "${CI_XCODE_CLOUD:-}" == "TRUE" ]]; then
local proxy_name
for proxy_name in HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy; do
if (( ${+parameters[$proxy_name]} )); then
xmake_env+=("${proxy_name}=${(P)proxy_name}")
fi
done
fi
/usr/bin/env -i \
HOME="${HOME}" \
PATH="${XMAKE_HOST_PATH}" \
@@ -252,7 +273,9 @@ if [[ -f "${OUTPUT_LIBRARY}" ]] && cmp -s "${TEMP_MANIFEST}" "${MERGE_MANIFEST}"
exit 0
fi
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
# Dependencies include empty objects for disabled or other-platform code.
# Suppress only that diagnostic; retain the members and other warnings.
/usr/bin/libtool -static -no_warning_for_no_symbols -o "${TEMP_LIBRARY}" \
"${MINIRTC_LIBRARY}" "${MERGE_ARCHIVES[@]}" \
"${WIRE_LIBRARY}"
verify_no_openh264 "${TEMP_LIBRARY}"
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Generate matching symbols for Xcode's embedded, codeless OpenSSL framework."""
import argparse
from pathlib import Path
import re
import subprocess
import sys
def uuids(path):
output = subprocess.check_output(["xcrun", "dwarfdump", "--uuid", str(path)], text=True)
result = dict((arch, uuid.upper()) for uuid, arch in
re.findall(r"UUID: ([0-9A-Fa-f-]{36}) \(([^)]+)\)", output))
if not result:
raise ValueError(f"No Mach-O UUIDs found: {path}")
return result
def verify_symbols(binary, dsym):
dwarf = dsym / "Contents/Resources/DWARF" / binary.name
if not dwarf.is_file():
raise ValueError(f"Missing dSYM DWARF file: {dwarf}")
if uuids(binary) != uuids(dwarf):
raise ValueError(f"dSYM UUIDs do not match {binary}: {dwarf}")
def generate(binary, output):
# This is only for Xcode's resource placeholder. Real OpenSSL code remains
# statically linked into the app and must not receive an empty dSYM here.
commands = subprocess.check_output(["xcrun", "otool", "-l", str(binary)], text=True)
text_sizes = re.findall(
r"sectname __text\s+segname __TEXT\s+addr \S+\s+size (0x[0-9a-fA-F]+)", commands)
if not text_sizes or any(int(size, 16) for size in text_sizes):
raise ValueError(f"Expected a codeless OpenSSL resource stub: {binary}")
output.parent.mkdir(parents=True, exist_ok=True)
# No function-level debug info exists in this empty stub. dsymutil still
# emits a valid UUID-matched DWARF companion; quiet suppresses that expected
# warning only after verifying that there is no executable code to lose.
subprocess.run(["xcrun", "dsymutil", "--quiet", str(binary), "-o", str(output)], check=True)
verify_symbols(binary, output)
subprocess.run(["xcrun", "dwarfdump", "--verify", str(output)], check=True)
print(f"Generated UUID-matched OpenSSL resource symbols: {output}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
generate(args.binary, args.output)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print(f"OpenSSL symbols: {error}", file=sys.stderr)
sys.exit(1)
+3 -1
View File
@@ -40,7 +40,9 @@ def package(inputs, output, platform="iphoneos"):
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="openssl-framework-", dir=output.parent) as temp:
binary = Path(temp) / "OpenSSL"
subprocess.run(["/usr/bin/libtool", "-static", "-o", str(binary),
# OpenSSL includes empty objects for disabled or platform-specific code.
# Keep those members and all other libtool diagnostics intact.
subprocess.run(["/usr/bin/libtool", "-static", "-no_warning_for_no_symbols", "-o", str(binary),
*(str(archives[name][0]) for name in sorted(archives))], check=True)
subprocess.run(["xcrun", "lipo", str(binary), "-verify_arch", "arm64"], check=True)
symbols = subprocess.check_output(["xcrun", "nm", "-u", str(binary)], text=True)
+13 -2
View File
@@ -10,8 +10,10 @@ import re
import subprocess
import sys
from generate_openssl_symbols import verify_symbols
def verify(app, require_release=False):
def verify(app, require_release=False, dsym_dir=None):
info = plistlib.loads((app / "Info.plist").read_bytes())
binary = app / info["CFBundleExecutable"]
subprocess.run(["xcrun", "lipo", str(binary), "-verify_arch", "arm64"], check=True)
@@ -63,6 +65,14 @@ def verify(app, require_release=False):
sdk_symbols = subprocess.check_output(["xcrun", "nm", "-gU", str(embedded_binary)], text=True)
if re.search(r"\b_(?:SSL_new|OPENSSL_init_crypto)\b", sdk_symbols):
raise ValueError("Unexpected dynamic OpenSSL implementation in resource framework")
# Archive validation includes the exact files that Organizer uploads.
if dsym_dir is None and app.parents[2].suffix == ".xcarchive":
dsym_dir = app.parents[2] / "dSYMs"
if dsym_dir is not None:
verify_symbols(binary, dsym_dir / f"{app.name}.dSYM")
if embedded_binary.exists():
verify_symbols(embedded_binary, dsym_dir / "OpenSSL.framework.dSYM")
print("Verified app and embedded OpenSSL dSYM UUIDs")
linked = subprocess.check_output(["xcrun", "otool", "-L", str(binary)], text=True)
if "OpenSSL.framework" in linked:
raise ValueError("OpenSSL must be statically linked into the application")
@@ -79,8 +89,9 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("app", type=Path)
parser.add_argument("--require-release", action="store_true")
parser.add_argument("--dsym-dir", type=Path)
args = parser.parse_args()
verify(args.app, args.require_release)
verify(args.app.resolve(), args.require_release, args.dsym_dir)
if __name__ == "__main__":
+2
View File
@@ -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)。
+2
View File
@@ -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.
+31 -5
View File
@@ -4,6 +4,8 @@
本文描述当前已实现的桌面客户端架构,代码依据为 CrossDesk `cc48eb65`、MiniRTC `040114d`,核对日期为 **2026-10-25**。ID 模式的服务端行为按客户端协议说明;服务端内部实现由独立的 CrossDesk Server 项目维护。
可信设备认证及配对身份文件的说明于 **2026-11-01** 按当前工作区更新;服务器登录沿用原有 ID、密码及重连令牌流程。
## 1. 两种模式的边界
两种模式复用桌面采集、编码、音视频传输、键鼠、文件和剪贴板处理。差别主要在**如何找到设备、在哪里验证密码,以及通过什么通道交换连接参数**。
@@ -178,11 +180,17 @@ WSS 在连接成功后继续承担心跳和会话关闭消息。当前心跳约
| 场景 | 登录身份及职责 |
| --- | --- |
| 本机常驻 Peer | 使用本机缓存身份;已有 ID 与密码时,普通登录提交 `本机ID@本机密码`,用于本机上线与接受远控 |
| 控制端会话 Peer | 以 `C-<本机ID>` 登录;目标设备的密码在后续 Join 中提交,不附加在该登录身份中 |
| 自托管首次身份分配 | 无缓存身份时,常驻 Peer 以空 `user_id` 请求服务端分配身份,不能假定首次登录时已经具备 ID 和密码 |
| 本机常驻 Peer | 使用缓存 ID 和本地 Ed25519 私钥完成挑战签名,服务端按该 ID 绑定的公钥验签 |
| 控制端会话 Peer | 以 `C-<本机ID>` 完成同一私钥的挑战签名,服务端核对原 ID 的公钥;目标密码在后续 Join 中提交 |
| 首次身份分配 | 无缓存身份时,以空 `user_id` 和本机公钥发起挑战;证明私钥持有权后,服务端原子分配 ID、远控密码并绑定公钥 |
服务端支持重连令牌且客户端持有有效令牌时,后续登录可使用不带密码的 ID 与令牌;令牌被拒绝后可回退到原登录身份。连接目标设备时,控制端会话 Peer 将 `目标ID@目标密码` 放入 Join,由服务端返回成功、凭据错误、限流或繁忙结果。客户端解析结果并驱动密码弹窗与重试;服务端保存密码的具体方式及配额以配套服务端实现为准。
每次登录和重连都先请求 `login_challenge`,再签署规范 JSON 数组 `["crossdesk.device-login.v1", ID, 公钥, 随机挑战]`。挑战为 32 字节随机数,只属于当前 WSS 连接,有效期 10 秒,验证尝试时即消费。已绑定 ID 的签名登录不进入密码计算队列,也不读取密码失败计数;伪造签名不能锁定该 ID。服务端另外限制挑战来源和待处理数量,仍受连接及消息资源上限约束。
旧 ID 首次升级时,客户端先完成签名证明,再按服务端要求提交原有密码;密码验证成功后仅允许绑定一次公钥,保留 ID 和密码。绑定后撤销旧重连令牌并拒绝密码登录,不能用密码覆盖公钥;尚未完成绑定的旧 ID 按来源 IP 及“来源 IP → 目标 ID”限流,不保留目标 ID 的总失败次数。部署时先升级服务端;新桌面客户端要求签名协议,不自动回退到旧服务端的密码登录。混部或回滚到忽略公钥绑定的旧服务端会失去这项保护。
MiniRTC 的可选 `OnSignalExchange` 回调提供登录请求替换、入站消息处理及回复发送能力,不包含设备密钥、可信名单或 CrossDesk 协议字段。应用层负责签名和协议状态;未配置回调的使用方保留原有登录及内存重连令牌行为。改密中断后,签名登录不代表待定密码已生效,客户端重发持久保存的改密请求,收到成功回复后才更新本地密码。
连接目标设备时,控制端会话 Peer 仍将 `目标ID@目标密码` 放入 Join,由服务端返回成功、凭据错误、限流或繁忙结果。设备签名登录仅证明本机 ID 所有权,不授予控制其他设备的权限。服务端按已验证控制端 B 和“B → A”组合累计失败(分别为 20/5 次),不保留 A 的总失败次数;B 的失败不会触发 C 连接 A 的密码冷却。认证队列和计算额度也按已验证控制端隔离。未验证的旧客户端、Web/CLI 临时身份按来源 IP 处理,无法通过自称 `C-B` 获得 B 的预算。已绑定 B 的 `C-B` 登录必须支持签名协议;同一 NAT 下旧客户端仍共享 IP 限制,显式 IP 风控与服务器资源上限仍然适用。
信令 TLS 与远控数据加密属于两条路径。原生远控的 DTLS 握手和 SRTP 处理位于 MiniRTC,不能把“信令使用 TLS”等同于“画面经 TLS WebSocket 传输”。
@@ -196,7 +204,7 @@ WSS 在连接成功后继续承担心跳和会话关闭消息。当前心跳约
该表适用于 `MiniRtcConnection` 原生传输。浏览器使用 `DataChannelConnection` 的 WebRTC 适配路径,不按此表推断其加密行为。
对应代码:[常驻身份初始化](../apps/desktop/src/gui/runtime/gui_runtime.cpp)、[控制端会话登录与 Join](../apps/desktop/src/gui/runtime/connection_runtime.cpp)、[重连令牌](../deps/submodules/minirtc/src/pc/reconnect_login.h)、[SRTP 能力协商](../deps/submodules/minirtc/src/transport/ice_transport.cpp)。
对应代码:[应用层登录认证](../apps/desktop/src/gui/runtime/device_login.cpp)、[常驻身份初始化](../apps/desktop/src/gui/runtime/gui_runtime.cpp)、[控制端会话登录与 Join](../apps/desktop/src/gui/runtime/connection_runtime.cpp)、[通用信令回调](../deps/submodules/minirtc/src/api/minirtc.h)、[SRTP 能力协商](../deps/submodules/minirtc/src/transport/ice_transport.cpp)。
### 4.2 IP 模式
@@ -217,6 +225,24 @@ IP 直连采用 **TLS 内提交密码,由被控端本地验证**:
对应代码:[密码验证](../apps/desktop/src/network/lan/lan_password.cpp)、[本地身份和凭据存储](../apps/desktop/src/network/lan/lan_identity.cpp)、[认证限流](../apps/desktop/src/network/lan/lan_auth_limiter.cpp)、[DTLS/SRTP 就绪处理](../deps/submodules/minirtc/src/transport/ice_transport.cpp)。
### 4.3 可选可信设备认证
可信设备限制默认关闭,认证及授权全部由 CrossDesk 应用层完成。被控端为每次新会话生成 32 字节随机挑战,控制端签署协议域 `crossdesk.device-trust.v1`、挑战随机数、本机公钥、设备 ID 和名称。被控端验证签名后,还必须确认公钥在本地可信名单中,才开放控制权限;相同 ID 或名称不能替代公钥匹配。
首次添加支持离线配对:在控制端复制本机配对信息,通过双方认可的渠道交给被控端;被控端粘贴后点击“添加”即校验并写入可信名单,也支持添加本机,无需预览和二次确认。已存在的公钥不会重复添加,完整指纹可在列表中查看。复制结果显示在“本机配对信息”栏目,添加结果显示在添加区域。配对信息只包含公钥和展示信息,不含私钥或密码,也不需要先建立远程连接或关闭可信设备限制。导入仅更新名单,后续连接仍须完成原有密码验证及实时签名认证;配对信息本身不能证明来源,用户通过传递渠道确认其来源。也可以继续从已连接且完成身份验证的控制端中选择添加。
挑战有效期为 10 秒,只接受一次成功证明。重复的已连接通知沿用当前挑战及截止时间;断开或重新进入连接阶段时清除认证状态,下次连接重新生成挑战。开启限制时,未完成证明、签名无效或公钥不在名单中的设备不能获得控制权限;不支持该应用层协议的旧客户端同样不能通过。关闭限制时保持原连接行为。
MiniRTC 仅通过现有可靠数据通道收发这些应用消息,不提供可信设备专用接口,也不参与名单、签名或授权判断。认证证明不包含 DTLS 连接绑定:随机挑战可防止历史证明重放,但不防护攻击者在两条连接之间实时转发挑战和签名的情形。原有传输加密及其兼容规则仍由第 4.1、4.2 节描述。
设备私钥保存在缓存目录中的 `identity.json`,服务器登录与可信设备认证复用这一份密钥,通过不同协议域区分签名用途。macOS/Linux 文件权限为 `0600`,Windows 使用当前用户 ACL;身份存储通过 `identity.json.lock` 保护读写,并基于最新内容原子更新。锁文件不含凭据,无需备份。
可信列表保存在配置目录的 `trusted-devices/devices.json`,新版格式只含可信设备的公钥、名称和 ID,可包含本机。旧格式中的本机私钥会自动迁入 `identity.json`,持久写入成功后才从列表文件移除,因此原有指纹和配对关系保持不变。迁移中断可重试;发现两处密钥冲突时停止加载,不自动覆盖。Windows 无人值守服务使用其独立资料目录中的同一布局。
只删除普通 ID/密码缓存不会删除私钥,但单凭私钥不能找回已分配的 ID,客户端会请求新 ID。保留旧 ID 而丢失私钥后无法登录已绑定的身份,需要恢复原私钥备份,或清理旧 ID 缓存重新登记。复制 ID 缓存及私钥会复制登录身份,不具备硬件绑定;备份时应一起保护这些文件。清空整个缓存目录还会改变配对指纹,需要其他设备重新信任本机。保留允许控制本机的设备名单还应备份可信列表。身份文件损坏或权限不安全时停止加载和设备登录;已开启的可信设备限制不会自动关闭。
对应代码:[设备签名与名单存储](../apps/desktop/src/gui/runtime/trusted_devices.cpp)、[应用层认证及授权](../apps/desktop/src/gui/runtime/device_trust_runtime.cpp)、[连接状态与认证生命周期](../apps/desktop/src/gui/runtime/peer_event_handler.cpp)。
## 5. 运行时复用、线程与关闭
```mermaid
+17
View File
@@ -35,9 +35,26 @@ On **2026-10-25**, 16 images were rendered from the actual Slint components at `
| `desktop-stream-video-settings-*.png` | 会话工具栏与画面设置 / Session video settings | 1920 × 1200 |
| `desktop-about-*.png` | 关于与检查更新 / About and update check | 1280 × 944 |
| `web-client.png`(中文) | Web 连接首页 / Web connection page | 1280 × 720 |
| `cli-ansi-overview.png` | CLI ANSI 四象限全景 / ANSI quadrant overview | 1280 × 800 |
| `cli-kitty-overview.png` | CLI Kitty 协议像素全景 / Kitty protocol pixel overview | 1280 × 800 |
桌面图片使用 2× 原生渲染缩放,捕获窗口内容区,不包含 macOS 原生标题栏。未放大旧 UI 截图,也未重绘控件;演示远端画面由实际组件按比例显示。Markdown 只设置展示宽度。常规设置可滚动,`desktop-settings` 展示首屏,`desktop-settings-network` 展示网络连接分组;Windows 专属采集方式与便携版服务入口不会出现在这些 macOS 图片中。
<a id="cli-captures"></a>
## CLI 图示 / CLI captures
- **2026-10-31 05:17–05:18(UTC+8)重新采集**,使用在 macOS arm64 上从 `1b716f36` 及当前工作区 ANSI 优化重新构建的 release CLI,连接同一用户授权的被控设备。两次连接均保持只读,展示远端播放动画时的不同画面。
- 沿用工程外的 POSIX 伪终端采集器,视口为 **128 列 × 40 行**,每格 **10 × 20 像素**,输出 PNG 为 **1280 × 800**。顶栏与底部快捷键直接来自 CLI 输出;图示不包含连接密码或真实设备 ID。
- ANSI 图由实际转义序列恢复字符网格及前景/背景色,四象限字符按对应色块渲染;采集器等待完整 ANSI 帧结束后保存,支持本轮新增的行内多片段差分。Kitty 图使用本地共享内存传输:采集器通过 `mmap` 复制完整 RGB 帧后返回确认,按协议 placement 的行列尺寸显示。采集时只读取状态栏所需的字符,保存图片时才做最终缩放和 PNG 渲染。
- 图中的 ANSI `FPS 26`、Kitty `FPS 24` 是对应统计区间的实际完整输出次数。会话日志确认本地输出预算均为 8MiB/s,Kitty 使用 `shared_memory`,未发生回退;两次连接正常退出。源分辨率与画面内容不同,不能用这两张图直接比较后端性能或量化本次优化收益。静止画面下 ANSI 差分为空时不产生新的输出计数,可显示 `FPS 0`。
- 这些图片是**实际 CLI 输出的渲染图**,本轮沿用 PTY 采集,没有取得 Ghostty 或 macOS「终端」的原生窗口截图。字体栅格、图像缩放和显示时序由采集器处理,不能用图中 FPS 推断终端的实际显示性能。
- 采集脚本、原始输出、构建日志、源码/二进制哈希和中间产物均存放在工程外临时目录;工程内只更新最终图示及来源说明。示例命令使用占位 ID,凭据通过隐藏提示输入;构建后已恢复原来的 debug 配置。
Refreshed at **05:17–05:18 (UTC+8) on 2026-10-31** with a freshly rebuilt macOS arm64 release CLI from `1b716f36` plus the working-tree ANSI optimizations, using real read-only connections to the same authorized host during animation playback. The external POSIX PTY collector retains 128 × 40 cells at 10 × 20 pixels and renders the actual CLI output to 1280 × 800 PNGs. The ANSI collector reconstructs character cells and colors, supports multiple changed spans per row, and waits for a complete frame before saving. The Kitty collector copies each complete shared-memory RGB frame through `mmap` before acknowledging it and applies placement dimensions when saving. The captured ANSI `FPS 26` and Kitty `FPS 24` are interval counts of complete outputs. Both sessions used an 8MiB/s local output budget and exited normally; Kitty used shared memory with no fallback. Source resolutions and scene content differ, so these images are not a direct backend comparison or an isolated optimization benchmark. An unchanged ANSI view can show `FPS 0`. These output renderings do not validate native Ghostty or macOS Terminal rendering or GPU performance. Passwords and real device IDs are omitted. Scripts, raw output, build logs, source/binary hashes, and intermediate files stay outside the repository; the original debug configuration was restored.
重新采集时,使用 release CLI,在支持相应协议的终端或 PTY 采集器中分别运行 `crossdesk-cli <设备ID> --renderer ansi --quality high` 和 `crossdesk-cli <设备ID> --renderer kitty --quality high`,通过隐藏提示输入密码,等待顶栏显示 `Displaying` 且统计稳定后捕获完整视口,再按 `q` 退出。若使用 PTY 采集器,须正确处理终端尺寸回复、ANSI 光标与颜色、Kitty 探测、分块上传、共享内存读取与确认,以及 placement 缩放,并在图注中明确采集方式。更新图片前检查敏感信息、画面比例和状态栏完整性,并同步更新本节的日期、版本与验证范围。
## 重新生成 / Regenerate
以下命令在已配置依赖的 macOS 工程根目录执行。生成程序只渲染演示状态;所有临时源码、可执行文件和 PPM 均存放在工程外,确认图片后才将 PNG 复制进 `docs/images`。`sips` 仅做无损格式转换,图片没有重绘或后期修改。
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

@@ -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,122 @@
/*
* @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 PreviewAccepted() const { return preview_accepted_; }
bool SupportsPreviewFps15() const { return fps15_; }
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
+447
View File
@@ -0,0 +1,447 @@
#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 > 15 ? 60
: options_.fps <= 1 ? 1
: options_.fps <= 8 || !fps15_ ? 8
: 15;
// 60 is the existing interactive-mode sentinel, not a 60 FPS request.
// The encoder cadence is independently specified by frame_rate.
s.frame_rate = 30;
s.audio = false;
s.quality = options_.quality;
if (resolution_) {
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
+118
View File
@@ -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
+125
View File
@@ -0,0 +1,125 @@
/*
* @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);
struct PixelFrame {
int width = 0, height = 0;
std::vector<uint8_t> rgb;
};
PixelFrame KittyPixels(const DecodedFrame &, const Viewport &);
std::string KittySharedUpload(const std::string &name, int width, int height,
bool alternate_buffer, bool query = false);
std::string KittyPresent(const Viewport &, bool alternate_buffer);
struct KittyAck {
uint32_t image_id = 0;
bool ok = false;
};
std::optional<KittyAck> ParseKittyAck(const std::string &);
// Toggle alternate_buffer only after the previous output has been written in
// full. Upload to the other image ID before replacing the visible placement.
std::string Kitty(const DecodedFrame &, const Viewport &,
bool alternate_buffer = false);
inline constexpr char kKittyQuery[] =
"\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\";
inline constexpr char kKittyDelete[] =
"\033_Ga=d,d=I,i=31,q=2\033\\\033_Ga=d,d=I,i=32,q=2\033\\";
bool KittyReply(const std::string &response);
struct StatusBar {
std::string progress, compact_progress, error, view, network, fps;
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;
std::optional<KittyAck> kitty_ack;
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
+357
View File
@@ -0,0 +1,357 @@
#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;
if (auto ack = ParseKittyAck(pending_.substr(0, end + 2)))
kitty_ack = ack;
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
+67
View File
@@ -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
+732
View File
@@ -0,0 +1,732 @@
#include "terminal_renderer.h"
#include <algorithm>
#include <array>
#include <charconv>
#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);
}
using QuadrantPixels = std::array<uint32_t, 4>;
struct QuadrantFit {
Cell cell;
QuadrantPixels pixels{};
double error = 0;
};
struct QuadrantEdge {
double weight = 0;
std::array<double, 3> error{};
};
using QuadrantEdges = std::array<QuadrantEdge, 4>;
static int Channel(uint32_t color, int channel) {
return (color >> (16 - 8 * channel)) & 255;
}
// Seven partitions suffice; their complements produce the same pixel colours.
static constexpr uint32_t kQuadrantGlyphs[] = {0x20, 0x2598, 0x259D, 0x2580,
0x2596, 0x258C, 0x259E, 0x259B};
static QuadrantFit Quadrant(const QuadrantPixels &pixels) {
int channels[4][3], total[3] = {}, squares = 0;
uint32_t mean = 0;
bool flat = true;
for (int c = 0; c < 3; ++c) {
int lo = 255, hi = 0;
for (int i = 0; i < 4; ++i) {
int value = channels[i][c] = Channel(pixels[i], c);
lo = std::min(lo, value);
hi = std::max(hi, value);
total[c] += value;
squares += value * value;
}
// Keep the per-channel threshold small to retain faint/coloured detail
// above this range, rather than judging flatness by average luminance.
flat = flat && hi - lo <= 6;
mean |= uint32_t((total[c] + 2) / 4) << (16 - 8 * c);
}
if (flat) {
QuadrantFit fit{
{mean, mean, 0x20}, {mean, mean, mean, mean}, double(squares)};
for (int c = 0; c < 3; ++c) {
int value = Channel(mean, c);
fit.error += 4 * value * value - 2 * value * total[c];
}
return fit;
}
// Reuse subset sums. Integer rounded means and sum-of-squares give exactly
// the original RGB squared error without rescanning four pixels per mask.
static constexpr int counts[] = {0, 1, 1, 2, 1, 2, 2, 3};
int sums[8][3] = {};
QuadrantFit best;
int best_error = INT32_MAX, best_mask = 0;
for (int mask = 1; mask < 8; ++mask) {
int bit = mask & 1 ? 0 : mask & 2 ? 1 : 2;
int rest = mask & (mask - 1), count = counts[mask];
uint32_t fg = 0, bg = 0;
int error = squares;
for (int c = 0; c < 3; ++c) {
int sum = sums[mask][c] = sums[rest][c] + channels[bit][c];
int front = (sum + count / 2) / count;
int back = (total[c] - sum + (4 - count) / 2) / (4 - count);
fg |= uint32_t(front) << (16 - 8 * c);
bg |= uint32_t(back) << (16 - 8 * c);
error += count * front * front - 2 * front * sum +
(4 - count) * back * back - 2 * back * (total[c] - sum);
}
if (error < best_error) {
best_error = error;
best_mask = mask;
best.cell = {fg, bg, kQuadrantGlyphs[mask]};
}
}
best.error = best_error;
for (int i = 0; i < 4; ++i)
best.pixels[i] = (best_mask >> i) & 1 ? best.cell.top : best.cell.bottom;
if (best.cell.top == best.cell.bottom)
best.cell.glyph = 0x20;
return best;
}
static QuadrantFit RefineQuadrant(const QuadrantPixels &pixels,
const QuadrantEdges &edges,
const QuadrantFit &initial) {
int channels[4][3];
double weighted[4][3];
for (int i = 0; i < 4; ++i)
for (int c = 0; c < 3; ++c) {
channels[i][c] = Channel(pixels[i], c);
weighted[i][c] =
(1 + edges[i].weight) * channels[i][c] + edges[i].error[c];
}
auto score = [&](const QuadrantFit &fit) {
double value = fit.error;
for (int i = 0; i < 4; ++i)
for (int c = 0; c < 3; ++c) {
double d = Channel(fit.pixels[i], c) - channels[i][c];
// Match neighbouring quantization errors, preserving the source
// gradient. The neighbour-only constant does not affect selection.
value += edges[i].weight * d * d - 2 * edges[i].error[c] * d;
}
return value;
};
QuadrantFit best = initial;
double best_score = score(best);
const double max_error = initial.error + std::min(48.0, initial.error * .05);
for (unsigned mask = 1; mask < 8; ++mask) {
double sums[2][3] = {};
double weights[2] = {};
for (int i = 0; i < 4; ++i) {
int g = (mask >> i) & 1;
double weight = 1 + edges[i].weight;
weights[g] += weight;
for (int c = 0; c < 3; ++c)
sums[g][c] += weighted[i][c];
}
uint32_t colors[2] = {};
for (int g = 0; g < 2; ++g)
for (int c = 0; c < 3; ++c) {
sums[g][c] =
std::clamp(std::round(sums[g][c] / weights[g]), 0.0, 255.0);
colors[g] |= uint32_t(sums[g][c]) << (16 - 8 * c);
}
QuadrantFit fit{{colors[1], colors[0], kQuadrantGlyphs[mask]}};
for (int i = 0; i < 4; ++i) {
fit.pixels[i] = colors[(mask >> i) & 1];
for (int c = 0; c < 3; ++c) {
double d = channels[i][c] - Channel(fit.pixels[i], c);
fit.error += d * d;
}
}
// Limit the cost of continuity to 5% of the independent fit's error,
// capped at 2 RGB levels RMS over the cell's twelve channel samples.
if (fit.error > max_error)
continue;
double value = score(fit);
if (value < best_score) {
best_score = value;
best = fit;
}
}
if (best.cell.top == best.cell.bottom)
best.cell.glyph = 0x20;
return best;
}
static void AppendUtf8(std::string &out, uint32_t c) {
if (c < 128) {
out += char(c);
} else if (c < 2048) {
out += char(0xc0 | (c >> 6));
out += char(0x80 | (c & 63));
} else {
out += char(0xe0 | (c >> 12));
out += char(0x80 | ((c >> 6) & 63));
out += char(0x80 | (c & 63));
}
}
static void AppendNumber(std::string &out, uint32_t value) {
char digits[10];
auto result = std::to_chars(digits, digits + sizeof(digits), value);
out.append(digits, result.ptr);
}
Grid Layout(const DecodedFrame &f, Viewport v) {
Validate(f, v);
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);
std::vector<QuadrantPixels> samples(sx == 2 ? g.cells.size() : 0);
std::vector<QuadrantFit> fits(samples.size());
auto pixel = [&](int x, int y) {
const auto &r = g.content;
if (x < r.x || y < r.y || x >= r.x + r.width || y >= r.y + r.height)
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) {
const auto index = size_t(y) * v.columns + x;
auto &c = g.cells[index];
if (sx == 2) {
samples[index] = {pixel(x * 2, y * 2), pixel(x * 2 + 1, y * 2),
pixel(x * 2, y * 2 + 1), pixel(x * 2 + 1, y * 2 + 1)};
fits[index] = Quadrant(samples[index]);
c = fits[index].cell;
} else {
c.top = pixel(x, y * sy);
c.bottom = sy == 1 ? c.top : pixel(x, y * sy + sy - 1);
}
}
// Read only independent fits so refinement has no left-to-right/top-to-bottom
// feedback, and a changed cell cannot propagate through the entire frame.
if (sx == 2)
for (int y = 0; y < v.rows; ++y)
for (int x = 0; x < v.columns; ++x) {
const auto index = size_t(y) * v.columns + x;
if (fits[index].cell.glyph == 0x20 || fits[index].error == 0)
continue;
QuadrantEdges edges{};
auto edge = [&](int i, size_t neighbour, int j) {
int contrast = 0;
for (int c = 0; c < 3; ++c)
contrast =
std::max(contrast, std::abs(Channel(samples[index][i], c) -
Channel(samples[neighbour][j], c)));
// Fade out across real edges instead of smoothing text into its
// background. Equal source colours receive the strongest constraint.
double strength = std::max(0.0, 1 - contrast / 24.0);
double weight = .35 * strength * strength;
edges[i].weight += weight;
for (int c = 0; c < 3; ++c)
edges[i].error[c] +=
weight * (Channel(fits[neighbour].pixels[j], c) -
Channel(samples[neighbour][j], c));
};
if (x > 0) {
edge(0, index - 1, 1);
edge(2, index - 1, 3);
}
if (x + 1 < v.columns) {
edge(1, index + 1, 0);
edge(3, index + 1, 2);
}
if (y > 0) {
edge(0, index - v.columns, 2);
edge(1, index - v.columns, 3);
}
if (y + 1 < v.rows) {
edge(2, index + v.columns, 0);
edge(3, index + v.columns, 1);
}
if (edges[0].weight + edges[1].weight + edges[2].weight +
edges[3].weight >
0)
g.cells[index] =
RefineQuadrant(samples[index], edges, fits[index]).cell;
}
return g;
}
std::optional<std::pair<float, float>> MapPointer(const Grid &g, int x, int y) {
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 uint32_t TerminalColor(uint32_t c, int depth) {
const int r = c >> 16, g = (c >> 8) & 255, b = c & 255;
if (depth == 24)
return c;
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 gray_error < cube ? 232 + gray : 16 + 36 * ri + 6 * gi + bi;
}
return (r >= 128 ? 1 : 0) + (g >= 128 ? 2 : 0) + (b >= 128 ? 4 : 0);
}
static void AppendColor(std::string &out, uint32_t c, bool fg, int depth) {
if (depth == 24 || depth == 256) {
out += fg ? "\033[38;" : "\033[48;";
if (depth == 24) {
out += "2;";
AppendNumber(out, c >> 16);
out += ';';
AppendNumber(out, (c >> 8) & 255);
out += ';';
AppendNumber(out, c & 255);
} else {
out += "5;";
AppendNumber(out, c);
}
} else {
out += "\033[";
AppendNumber(out, (fg ? 30 : 40) + c);
}
out += 'm';
}
struct AnsiColors {
uint32_t fg = UINT32_MAX, bg = UINT32_MAX;
};
static void AppendCells(std::string &out, const Grid &g, int y, int begin,
int end, AnsiColors &colors) {
for (int x = begin; x < end; ++x) {
const auto &c = g.cells[size_t(y) * g.viewport.columns + x];
// Spaces need no foreground SGR. Keep backgrounds for block glyphs too,
// since font rasterization can expose them at the cell's edges.
if (g.viewport.utf8 && c.glyph != 0x20) {
auto fg = TerminalColor(c.top, g.viewport.colors);
if (colors.fg != fg) {
AppendColor(out, fg, true, g.viewport.colors);
colors.fg = fg;
}
}
// Track the emitted palette index so quantized colours do not resend SGR.
auto bg =
TerminalColor(g.viewport.utf8 ? c.bottom : c.top, g.viewport.colors);
if (colors.bg != bg) {
AppendColor(out, bg, false, g.viewport.colors);
colors.bg = bg;
}
AppendUtf8(out, g.viewport.utf8 ? c.glyph : 0x20);
}
}
static bool SameTerminalCell(const Cell &a, const Cell &b, const Viewport &v) {
if (a == b)
return true;
if (!v.utf8)
return TerminalColor(a.top, v.colors) == TerminalColor(b.top, v.colors);
return a.glyph == b.glyph &&
TerminalColor(a.bottom, v.colors) == TerminalColor(b.bottom, v.colors) &&
(a.glyph == 0x20 ||
TerminalColor(a.top, v.colors) == TerminalColor(b.top, v.colors));
}
static void AppendCursor(std::string &out, int row, int column) {
out += "\033[";
AppendNumber(out, row + 1);
out += ';';
AppendNumber(out, column + 1);
out += 'H';
}
std::vector<std::string> Lines(const Grid &g) {
std::vector<std::string> lines;
lines.reserve(g.viewport.rows);
for (int y = 0; y < g.viewport.rows; ++y) {
std::string line;
line.reserve(size_t(g.viewport.columns) * 40 + 4);
AnsiColors colors;
AppendCells(line, g, y, 0, g.viewport.columns, colors);
line += "\033[0m";
lines.push_back(std::move(line));
}
return lines;
}
std::string Difference(const Grid &g, const std::optional<Grid> &previous) {
std::string out;
std::string skipped, joined;
AnsiColors colors;
const bool same = previous &&
previous->viewport.columns == g.viewport.columns &&
previous->viewport.rows == g.viewport.rows &&
previous->viewport.row_offset == g.viewport.row_offset &&
previous->viewport.colors == g.viewport.colors &&
previous->viewport.utf8 == g.viewport.utf8 &&
previous->cells.size() == g.cells.size();
for (int y = 0; y < g.viewport.rows; ++y) {
const auto offset = size_t(y) * g.viewport.columns;
const auto unchanged = [&](int x) {
return SameTerminalCell(g.cells[offset + x], previous->cells[offset + x],
g.viewport);
};
int begin = 0, end = g.viewport.columns;
if (same) {
while (begin < end && unchanged(begin))
++begin;
while (end > begin && unchanged(end - 1))
--end;
}
if (begin == end)
continue;
AppendCursor(out, g.viewport.row_offset + y, begin);
if (!same) {
AppendCells(out, g, y, begin, end, colors);
continue;
}
AppendCells(out, g, y, begin, begin + 1, colors);
for (int x = begin + 1; x < end;) {
const int start = x;
if (!unchanged(x)) {
while (++x < end && !unchanged(x)) {}
AppendCells(out, g, y, start, x, colors);
continue;
}
while (++x < end && unchanged(x)) {}
// end already excludes the unchanged suffix, so x is a changed cell.
// Compare actual bytes, including the next cell's SGR: crossing a short
// gap can be cheaper than moving the cursor and restoring its colours.
skipped.clear();
auto skip_colors = colors;
AppendCursor(skipped, g.viewport.row_offset + y, x);
AppendCells(skipped, g, y, x, x + 1, skip_colors);
joined.clear();
auto join_colors = colors;
for (int gap = start; gap <= x && joined.size() <= skipped.size(); ++gap)
AppendCells(joined, g, y, gap, gap + 1, join_colors);
if (joined.size() <= skipped.size()) {
out += joined;
colors = join_colors;
} else {
out += skipped;
colors = skip_colors;
}
++x;
}
}
if (!out.empty())
out += "\033[0m";
return out;
}
static std::string Base64(const std::vector<uint8_t> &data) {
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;
}
PixelFrame KittyPixels(const DecodedFrame &f, const Viewport &requested) {
auto v = requested;
v.pixel_mode = true;
Validate(f, v);
PixelFrame pixels;
pixels.width =
std::clamp(v.pixel_width > 0 ? v.pixel_width : v.columns * 10, 2, 1920);
pixels.height =
std::clamp(v.pixel_height > 0 ? v.pixel_height : v.rows * 20, 2, 1080);
pixels.rgb.resize(size_t(pixels.width) * pixels.height * 3, 0);
const auto crop = Crop(f, v);
const auto r = Fit(crop.width, crop.height, pixels.width, pixels.height,
double(v.columns) * v.cell_aspect * pixels.height /
(v.rows * pixels.width));
if (r.width == crop.width && r.height == crop.height) {
for (int y = 0; y < r.height; ++y)
for (int x = 0; x < r.width; ++x) {
const auto c = Pixel(f, crop.x + x, crop.y + y);
auto *dst = &pixels.rgb[(size_t(y + r.y) * pixels.width + x + r.x) * 3];
dst[0] = c >> 16;
dst[1] = c >> 8;
dst[2] = c;
}
return pixels;
}
// Separable area integration. Integer overlap weights avoid per-pixel
// floor/ceil and repeated NV12 conversion in overlapping vertical samples.
// Adjacent output rows can share only their boundary source row, so one
// cached horizontal row suffices, even when upscaling.
struct Span {
int first, last, begin, end;
};
std::vector<Span> spans;
for (int x = 0; x < r.width; ++x) {
const int begin = x * crop.width, end = begin + crop.width;
spans.push_back(
{begin / r.width, (end + r.width - 1) / r.width, begin, end});
}
std::vector<uint32_t> source(crop.width), horizontal(size_t(r.width) * 3);
std::vector<uint64_t> sums(size_t(r.width) * 3);
const uint64_t area = uint64_t(crop.width) * crop.height;
int cached_y = -1;
for (int y = 0; y < r.height; ++y) {
std::fill(sums.begin(), sums.end(), 0);
const int begin = y * crop.height, end = begin + crop.height;
for (int sy = begin / r.height; sy < (end + r.height - 1) / r.height;
++sy) {
if (cached_y != sy) {
for (int x = 0; x < crop.width; ++x)
source[x] = Pixel(f, crop.x + x, crop.y + sy);
std::fill(horizontal.begin(), horizontal.end(), 0);
for (int x = 0; x < r.width; ++x) {
const auto &span = spans[x];
for (int sx = span.first; sx < span.last; ++sx) {
const unsigned weight = std::min(span.end, (sx + 1) * r.width) -
std::max(span.begin, sx * r.width);
const auto c = source[sx];
horizontal[x * 3] += (c >> 16) * weight;
horizontal[x * 3 + 1] += ((c >> 8) & 255) * weight;
horizontal[x * 3 + 2] += (c & 255) * weight;
}
}
cached_y = sy;
}
const unsigned weight =
std::min(end, (sy + 1) * r.height) - std::max(begin, sy * r.height);
for (size_t i = 0; i < sums.size(); ++i)
sums[i] += uint64_t(horizontal[i]) * weight;
}
auto *dst = &pixels.rgb[(size_t(y + r.y) * pixels.width + r.x) * 3];
for (size_t i = 0; i < sums.size(); ++i)
dst[i] = (sums[i] + area / 2) / area;
}
return pixels;
}
std::string KittySharedUpload(const std::string &name, int width, int height,
bool alternate_buffer, bool query) {
if (name.empty() || name.size() > 128 || width < 1 || width > 1920 ||
height < 1 || height > 1080)
throw std::invalid_argument("invalid shared image");
return "\033_Ga=" + std::string(query ? "q" : "t") + ",t=s,f=24,q=0,i=" +
std::to_string(query ? 33
: alternate_buffer ? 32
: 31) +
",s=" + std::to_string(width) + ",v=" + std::to_string(height) +
",S=" + std::to_string(size_t(width) * height * 3) + ";" +
Base64(std::vector<uint8_t>(name.begin(), name.end())) + "\033\\";
}
std::string KittyPresent(const Viewport &v, bool alternate_buffer) {
return "\033[" + std::to_string(v.row_offset + 1) +
";1H\033_Ga=p,i=" + std::to_string(alternate_buffer ? 32 : 31) +
",p=1,C=1,q=2,c=" + std::to_string(v.columns) +
",r=" + std::to_string(v.rows) +
"\033\\\033_Ga=d,d=I,i=" + std::to_string(alternate_buffer ? 31 : 32) +
",q=2\033\\";
}
std::optional<KittyAck> ParseKittyAck(const std::string &s) {
if (s.size() > 1024 || s.size() < 10 || s.compare(0, 3, "\033_G") != 0 ||
s.compare(s.size() - 2, 2, "\033\\") != 0)
return {};
const auto separator = s.find(';', 3);
if (separator == std::string::npos)
return {};
uint32_t image_id = 0;
for (size_t pos = 3; pos < separator;) {
const auto end = std::min(s.find(',', pos), separator);
if (s.compare(pos, 2, "i=") == 0) {
if (image_id)
return {};
const auto parsed =
std::from_chars(s.data() + pos + 2, s.data() + end, image_id);
if (parsed.ec != std::errc{} || parsed.ptr != s.data() + end || !image_id)
return {};
} else if (s.compare(pos, 2, "p=") == 0 &&
s.substr(pos, end - pos) != "p=0") {
return {}; // Only upload/query acknowledgements are relevant here.
}
pos = end + 1;
}
if (!image_id)
return {};
return KittyAck{image_id,
s.substr(separator + 1, s.size() - separator - 3) == "OK"};
}
std::string Kitty(const DecodedFrame &f, const Viewport &requested,
bool alternate_buffer) {
auto v = requested;
v.pixel_mode = true;
Validate(f, v);
// 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);
}
const auto image_id = std::to_string(alternate_buffer ? 32 : 31);
std::string out;
out.reserve(b64.size() + (b64.size() / 4096 + 1) * 12 + 256);
for (size_t i = 0; i < b64.size(); i += 4096) {
out += "\033_G";
if (i == 0)
out += "a=t,t=d,f=24,o=z,q=2,i=" + image_id + ",s=" +
std::to_string(w) + ",v=" + std::to_string(h) + ",";
out += i + 4096 < b64.size() ? "m=1;" : "m=0;";
out.append(b64, i, 4096);
out += "\033\\";
}
// Re-transmitting a visible image ID can remove its placement as soon as
// upload starts. Keep the old image throughout chunking/short writes, then
// place the fully uploaded image before freeing the previous one.
out += KittyPresent(v, alternate_buffer);
return out;
}
bool KittyReply(const std::string &s) {
return s.find("\033_Gi=31;OK\033\\") != std::string::npos;
}
} // namespace crossdesk::terminal
+156
View File
@@ -0,0 +1,156 @@
#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 metrics = bar.fps;
if (!bar.network.empty())
metrics += (metrics.empty() ? "" : " | ") + bar.network;
auto right = bar.view + (metrics.empty() ? "" : " | " + metrics);
if (left.size() + right.size() + 2 > inner)
right = metrics;
if (left.size() + right.size() + 2 > inner && bar.error.empty())
left = mode + " | " + bar.compact_progress;
if (left.size() + right.size() + 2 > inner)
right = bar.fps;
if (left.size() + right.size() + 2 > inner)
right.clear();
std::string status = Clip(left, inner);
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
+314 -1
View File
@@ -10283,6 +10283,294 @@
"fr": "Un port commun à toutes les interfaces, VPN inclus. Choisissez une IP réelle à partager. Désactivez pour rétablir les ports propres à chaque IP.",
"es": "Un puerto para todas las interfaces, incluidas las VPN. Seleccione una IP real para compartir. Desactive para restaurar los puertos de cada IP.",
"pt-BR": "Uma porta para todas as interfaces, incluindo VPNs. Selecione um IP real para compartilhar. Desative para restaurar as portas de cada IP."
},
"可信设备": {
"zh-Hans": "可信设备",
"en": "Trusted devices",
"ru": "Доверенные устройства",
"zh-Hant": "可信裝置",
"ja": "信頼済みデバイス",
"ko": "신뢰하는 기기",
"de": "Vertrauenswürdige Geräte",
"fr": "Appareils de confiance",
"es": "Dispositivos de confianza",
"pt-BR": "Dispositivos confiáveis"
},
"仅允许可信设备控制": {
"zh-Hans": "仅允许可信设备控制",
"en": "Only allow trusted devices",
"ru": "Разрешать только доверенные устройства",
"zh-Hant": "僅允許可信裝置控制",
"ja": "信頼済みデバイスのみ許可",
"ko": "신뢰하는 기기만 제어 허용",
"de": "Nur vertrauenswürdige Geräte zulassen",
"fr": "Autoriser uniquement les appareils de confiance",
"es": "Permitir solo dispositivos de confianza",
"pt-BR": "Permitir apenas dispositivos confiáveis"
},
"管理可信设备": {
"zh-Hans": "管理可信设备",
"en": "Manage trusted devices",
"ru": "Управление доверенными устройствами",
"zh-Hant": "管理可信裝置",
"ja": "信頼済みデバイスを管理",
"ko": "신뢰하는 기기 관리",
"de": "Vertrauenswürdige Geräte verwalten",
"fr": "Gérer les appareils de confiance",
"es": "Administrar dispositivos de confianza",
"pt-BR": "Gerenciar dispositivos confiáveis"
},
"可添加的已连接设备": {
"zh-Hans": "可添加的已连接设备",
"en": "Connected devices available to trust",
"ru": "Подключённые устройства для добавления",
"zh-Hant": "可新增的已連線裝置",
"ja": "追加可能な接続中のデバイス",
"ko": "추가 가능한 연결된 기기",
"de": "Verbundene Geräte zum Hinzufügen",
"fr": "Appareils connectés à ajouter",
"es": "Dispositivos conectados que puedes añadir",
"pt-BR": "Dispositivos conectados disponíveis"
},
"信任此设备": {
"zh-Hans": "信任此设备",
"en": "Trust this device",
"ru": "Доверять устройству",
"zh-Hant": "信任此裝置",
"ja": "このデバイスを信頼",
"ko": "이 기기 신뢰",
"de": "Diesem Gerät vertrauen",
"fr": "Faire confiance à cet appareil",
"es": "Confiar en este dispositivo",
"pt-BR": "Confiar neste dispositivo"
},
"移除信任": {
"zh-Hans": "移除信任",
"en": "Remove trust",
"ru": "Отозвать доверие",
"zh-Hant": "移除信任",
"ja": "信頼を解除",
"ko": "신뢰 해제",
"de": "Vertrauen entfernen",
"fr": "Retirer la confiance",
"es": "Retirar confianza",
"pt-BR": "Remover confiança"
},
"无法读取设备身份或可信列表,请修复后再启用。": {
"zh-Hans": "无法读取设备身份或可信列表,请修复后再启用。",
"en": "Could not load the device identity or trusted list. Repair the local data before enabling this restriction.",
"ru": "Не удалось загрузить идентификатор устройства или список доверия. Восстановите локальные данные перед включением ограничения.",
"zh-Hant": "無法讀取裝置身分或可信清單,請修復後再啟用。",
"ja": "デバイス情報または信頼リストを読み込めません。ローカルデータを修復してから制限を有効にしてください。",
"ko": "기기 ID 또는 신뢰 목록을 불러올 수 없습니다. 로컬 데이터를 복구한 후 제한을 켜세요.",
"de": "Geräteidentität oder Vertrauensliste konnte nicht geladen werden. Lokale Daten vor dem Aktivieren reparieren.",
"fr": "Impossible de charger l’identité ou la liste de confiance. Réparez les données locales avant d’activer la restriction.",
"es": "No se pudo cargar la identidad o la lista de confianza. Repara los datos locales antes de activar la restricción.",
"pt-BR": "Não foi possível carregar a identidade ou a lista confiável. Repare os dados locais antes de ativar a restrição."
},
"无法保存可信设备设置,请重试。": {
"zh-Hans": "无法保存可信设备设置,请重试。",
"en": "Could not save trusted device settings. Try again.",
"ru": "Не удалось сохранить настройки доверенных устройств. Повторите попытку.",
"zh-Hant": "無法儲存可信裝置設定,請重試。",
"ja": "信頼済みデバイスの設定を保存できません。再試行してください。",
"ko": "신뢰하는 기기 설정을 저장할 수 없습니다. 다시 시도하세요.",
"de": "Einstellungen für vertrauenswürdige Geräte konnten nicht gespeichert werden. Erneut versuchen.",
"fr": "Impossible d’enregistrer les paramètres de confiance. Réessayez.",
"es": "No se pudo guardar la configuración de confianza. Inténtalo de nuevo.",
"pt-BR": "Não foi possível salvar as configurações de confiança. Tente novamente."
},
"粘贴控制端的配对信息": {
"zh-Hans": "粘贴控制端的配对信息",
"en": "Paste the controller’s pairing information",
"ru": "Вставьте данные сопряжения управляющего устройства",
"zh-Hant": "貼上控制端的配對資訊",
"ja": "操作側のペアリング情報を貼り付け",
"ko": "제어 장치의 페어링 정보 붙여넣기",
"de": "Kopplungsdaten des steuernden Geräts einfügen",
"fr": "Collez les informations d’association de l’appareil de contrôle",
"es": "Pegue la información de emparejamiento del dispositivo de control",
"pt-BR": "Cole as informações de pareamento do dispositivo controlador"
},
"已在可信列表中": {
"zh-Hans": "已在可信列表中",
"en": "Already trusted",
"ru": "Уже доверенное",
"zh-Hant": "已在可信清單中",
"ja": "信頼済み",
"ko": "이미 신뢰하는 장치",
"de": "Bereits vertrauenswürdig",
"fr": "Déjà approuvé",
"es": "Ya es de confianza",
"pt-BR": "Já é confiável"
},
"无法访问剪贴板,请重试。": {
"zh-Hans": "无法访问剪贴板,请重试。",
"en": "Cannot access the clipboard. Please try again.",
"ru": "Не удалось получить доступ к буферу обмена. Повторите попытку.",
"zh-Hant": "無法存取剪貼簿,請重試。",
"ja": "クリップボードにアクセスできません。もう一度お試しください。",
"ko": "클립보드에 접근할 수 없습니다. 다시 시도하세요.",
"de": "Kein Zugriff auf die Zwischenablage. Bitte erneut versuchen.",
"fr": "Impossible d’accéder au presse-papiers. Réessayez.",
"es": "No se puede acceder al portapapeles. Inténtelo de nuevo.",
"pt-BR": "Não foi possível acessar a área de transferência. Tente novamente."
},
"配对信息无效,请从控制端重新复制。": {
"zh-Hans": "配对信息无效,请从控制端重新复制。",
"en": "Invalid pairing information. Copy it again from the controller.",
"ru": "Недопустимые данные сопряжения. Скопируйте их заново с управляющего устройства.",
"zh-Hant": "配對資訊無效,請從控制端重新複製。",
"ja": "ペアリング情報が無効です。操作側からコピーし直してください。",
"ko": "유효하지 않은 페어링 정보입니다. 제어 장치에서 다시 복사하세요.",
"de": "Ungültige Kopplungsdaten. Kopieren Sie sie erneut vom steuernden Gerät.",
"fr": "Informations d’association invalides. Copiez-les à nouveau depuis l’appareil de contrôle.",
"es": "Información de emparejamiento no válida. Cópiela de nuevo del dispositivo de control.",
"pt-BR": "Informações de pareamento inválidas. Copie-as novamente do dispositivo controlador."
},
"已加入可信设备。": {
"zh-Hans": "已加入可信设备。",
"en": "Device added to the trusted list.",
"ru": "Устройство добавлено в список доверенных.",
"zh-Hant": "已加入可信裝置。",
"ja": "信頼済みデバイスに追加しました。",
"ko": "신뢰 장치 목록에 추가했습니다.",
"de": "Gerät zur Vertrauensliste hinzugefügt.",
"fr": "Appareil ajouté à la liste de confiance.",
"es": "Dispositivo añadido a la lista de confianza.",
"pt-BR": "Dispositivo adicionado à lista de confiança."
},
"无法更新可信设备,请检查列表容量和存储权限后重试。": {
"zh-Hans": "无法更新可信设备,请检查列表容量和存储权限后重试。",
"en": "Cannot update trusted devices. Check the list capacity and storage permissions, then try again.",
"ru": "Не удалось обновить список доверенных устройств. Проверьте его размер и права доступа к хранилищу.",
"zh-Hant": "無法更新可信裝置,請檢查清單容量和儲存權限後重試。",
"ja": "信頼済みデバイスを更新できません。リストの上限と保存先の権限を確認してください。",
"ko": "신뢰 장치를 업데이트할 수 없습니다. 목록 용량과 저장 권한을 확인한 후 다시 시도하세요.",
"de": "Vertrauensliste konnte nicht aktualisiert werden. Prüfen Sie die Listenbegrenzung und Speicherberechtigungen.",
"fr": "Impossible de mettre à jour les appareils de confiance. Vérifiez la capacité de la liste et les autorisations de stockage.",
"es": "No se pueden actualizar los dispositivos de confianza. Compruebe la capacidad de la lista y los permisos de almacenamiento.",
"pt-BR": "Não foi possível atualizar os dispositivos confiáveis. Verifique a capacidade da lista e as permissões de armazenamento."
},
"复制配对信息": {
"zh-Hans": "复制配对信息",
"en": "Copy pairing info",
"ru": "Копировать данные",
"zh-Hant": "複製配對資訊",
"ja": "ペアリング情報をコピー",
"ko": "페어링 정보 복사",
"de": "Kopplungsdaten kopieren",
"fr": "Copier les informations",
"es": "Copiar información",
"pt-BR": "Copiar informações"
},
"添加可信设备": {
"zh-Hans": "添加可信设备",
"en": "Add a trusted device",
"ru": "Добавить доверенное устройство",
"zh-Hant": "新增可信裝置",
"ja": "信頼するデバイスを追加",
"ko": "신뢰 장치 추가",
"de": "Vertrauenswürdiges Gerät hinzufügen",
"fr": "Ajouter un appareil de confiance",
"es": "Añadir dispositivo de confianza",
"pt-BR": "Adicionar dispositivo confiável"
},
"已信任的设备": {
"zh-Hans": "已信任的设备",
"en": "Trusted devices",
"ru": "Доверенные устройства",
"zh-Hant": "已信任的裝置",
"ja": "信頼済みデバイス",
"ko": "신뢰하는 장치",
"de": "Vertrauenswürdige Geräte",
"fr": "Appareils de confiance",
"es": "Dispositivos de confianza",
"pt-BR": "Dispositivos confiáveis"
},
"本机配对信息": {
"zh-Hans": "本机配对信息",
"en": "This device’s pairing info",
"ru": "Данные этого устройства",
"zh-Hant": "本機配對資訊",
"ja": "このデバイスのペアリング情報",
"ko": "이 장치의 페어링 정보",
"de": "Kopplungsdaten dieses Geräts",
"fr": "Informations de cet appareil",
"es": "Información de este dispositivo",
"pt-BR": "Informações deste dispositivo"
},
"查看指纹": {
"zh-Hans": "查看指纹",
"en": "View fingerprint",
"ru": "Показать отпечаток",
"zh-Hant": "查看指紋",
"ja": "フィンガープリントを表示",
"ko": "지문 보기",
"de": "Fingerabdruck anzeigen",
"fr": "Afficher l’empreinte",
"es": "Ver huella",
"pt-BR": "Ver impressão digital"
},
"收起指纹": {
"zh-Hans": "收起指纹",
"en": "Hide fingerprint",
"ru": "Скрыть отпечаток",
"zh-Hant": "收起指紋",
"ja": "フィンガープリントを隠す",
"ko": "지문 숨기기",
"de": "Fingerabdruck ausblenden",
"fr": "Masquer l’empreinte",
"es": "Ocultar huella",
"pt-BR": "Ocultar impressão digital"
},
"暂无可信设备": {
"zh-Hans": "暂无可信设备",
"en": "No trusted devices",
"ru": "Нет доверенных устройств",
"zh-Hant": "暫無可信裝置",
"ja": "信頼済みデバイスはありません",
"ko": "신뢰하는 장치 없음",
"de": "Keine vertrauenswürdigen Geräte",
"fr": "Aucun appareil de confiance",
"es": "No hay dispositivos de confianza",
"pt-BR": "Nenhum dispositivo confiável"
},
"请核对控制端指纹。": {
"zh-Hans": "请核对控制端指纹。",
"en": "Check the fingerprint on the controller.",
"ru": "Сверьте отпечаток с управляющим устройством.",
"zh-Hant": "請核對控制端指紋。",
"ja": "操作側のフィンガープリントと照合してください。",
"ko": "제어 장치의 지문과 대조하세요.",
"de": "Mit dem Fingerabdruck des steuernden Geräts vergleichen.",
"fr": "Vérifiez l’empreinte sur l’appareil de contrôle.",
"es": "Verifique la huella en el dispositivo de control.",
"pt-BR": "Confira a impressão digital no dispositivo controlador."
},
"配对信息已复制。": {
"zh-Hans": "配对信息已复制。",
"en": "Pairing information copied.",
"ru": "Данные сопряжения скопированы.",
"zh-Hant": "配對資訊已複製。",
"ja": "ペアリング情報をコピーしました。",
"ko": "페어링 정보를 복사했습니다.",
"de": "Kopplungsdaten kopiert.",
"fr": "Informations d’association copiées.",
"es": "Información de emparejamiento copiada.",
"pt-BR": "Informações de pareamento copiadas."
},
"无法复制配对信息,请重试。": {
"zh-Hans": "无法复制配对信息,请重试。",
"en": "Unable to copy pairing information. Please try again.",
"ru": "Не удалось скопировать данные сопряжения. Повторите попытку.",
"zh-Hant": "無法複製配對資訊,請重試。",
"ja": "ペアリング情報をコピーできません。もう一度お試しください。",
"ko": "페어링 정보를 복사할 수 없습니다. 다시 시도하세요.",
"de": "Kopplungsdaten konnten nicht kopiert werden. Bitte erneut versuchen.",
"fr": "Impossible de copier les informations d’association. Réessayez.",
"es": "No se pudo copiar la información de emparejamiento. Inténtelo de nuevo.",
"pt-BR": "Não foi possível copiar as informações de pareamento. Tente novamente."
}
},
"desktop": {
@@ -11261,7 +11549,32 @@
"(仅在交互终端显示)",
":同步键鼠到所有已连接设备",
"监听所有 IP 接口",
"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。"
"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。",
"可信设备",
"仅允许可信设备控制",
"管理可信设备",
"可添加的已连接设备",
"信任此设备",
"移除信任",
"无法读取设备身份或可信列表,请修复后再启用。",
"无法保存可信设备设置,请重试。",
"粘贴",
"粘贴控制端的配对信息",
"已在可信列表中",
"无法访问剪贴板,请重试。",
"配对信息无效,请从控制端重新复制。",
"已加入可信设备。",
"无法更新可信设备,请检查列表容量和存储权限后重试。",
"复制配对信息",
"添加可信设备",
"已信任的设备",
"本机配对信息",
"查看指纹",
"收起指纹",
"暂无可信设备",
"请核对控制端指纹。",
"配对信息已复制。",
"无法复制配对信息,请重试。"
],
"android": [
" · 已完成",
+56 -16
View File
@@ -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