Compare commits

...
20 Commits
Author SHA1 Message Date
dijunkun 28398c0067 [build] bump CLI version to 0.1.5 2026-11-02 12:39:51 +08:00
dijunkun 0188f2e860 [fix] provide host pkg-config for Xcode Cloud dependencies 2026-11-02 12:35:23 +08:00
dijunkun 597c4971cf [feat] add Sixel rendering to CLI 2026-11-02 11:41:25 +08:00
dijunkun b1a0dcdb92 [fix] correct certificate errors and restore automatic reconnection 2026-11-02 11:07:19 +08:00
dijunkun 3382792bfd [fix] prebuild iOS native dependencies before Xcode Cloud archives 2026-11-02 10:34:34 +08:00
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
67 changed files with 2601 additions and 380 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
fail-fast: false
matrix:
include:
- runner: macos-14
- runner: macos-15
target: darwin-arm64
arch: arm64
- runner: macos-15-intel
@@ -97,7 +97,7 @@ jobs:
uses: actions/cache/restore@v5
with:
path: ${{ env.XMAKE_PKG_INSTALLDIR }}
key: cli-deps-v1-${{ matrix.runner }}-${{ matrix.target }}-image${{ steps.runner-image.outputs.version }}-xmake${{ env.XMAKE_VERSION }}-${{ hashFiles('xmake.lua', 'apps/cli/xmake.lua', 'apps/desktop/xmake/options.lua', 'deps/thirdparty/openssl/**', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**', 'deps/patches/**', '.github/workflows/build-cli.yml') }}
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
+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}"
+27 -8
View File
@@ -24,7 +24,7 @@
CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [终端 CLI](#cli) · [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>
@@ -32,10 +32,11 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。
- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty / Sixel 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
- **可信设备:** 桌面端可选开启可信设备限制,在连接密码之外校验设备身份;支持复制、粘贴配对信息,提前添加允许访问本机的设备。
- **部署与运维:** P2P 直连、TURN 中继、SRTP 媒体加密、自托管,以及 Windows 登录前远控和 Linux 无头运行。
<a id="download"></a>
@@ -72,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>
@@ -84,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>
## 界面预览
@@ -121,7 +140,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
## 会话操作与设置
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、可信设备限制、编码、中继、自托管与桌面墙画面预算。
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
@@ -131,7 +150,7 @@ CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也
## 终端客户端 CLI
`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。
`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 或 Sixel 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。
### 画面预览
@@ -183,7 +202,7 @@ xmake run crossdesk-cli 123456789
### 选择画面与连接参数
默认 `--renderer auto` 自动探测 Kitty 图形协议,未获支持时回退为 ANSI。macOS「终端」等普通终端可显式选择 `ansi`;Ghostty 等支持该协议的终端可选择 `kitty`。显式指定 `kitty` 但探测失败时会报错,此时改用 `ansi`。
默认 `--renderer auto` 按 Kitty → Sixel → ANSI 的顺序选择。macOS「终端」等普通终端可显式选择 `ansi`;支持对应图形协议的终端可选择 `kitty` 或 `sixel`。显式像素模式探测失败时会报错,此时改用 `ansi`。Xshell 是否能显示像素图片取决于终端自身的协议支持,CLI 无法单方面补齐。
以下示例使用 npm 全局安装后的命令;从源码构建时,可在仓库根目录将 `crossdesk-cli` 替换为 `xmake run crossdesk-cli`。
@@ -200,7 +219,7 @@ crossdesk-cli 123456789 --quality balanced --fps 8
| 参数 | 用途 |
| --- | --- |
| `--renderer auto\|ansi\|kitty` | 自动选择、字符画面或像素画面;默认 `auto` |
| `--renderer auto\|ansi\|kitty\|sixel` | 自动选择、字符画面或像素画面;默认 `auto` |
| `--zoom native` | 从原始细节模式开始;`--zoom 2` 表示相对全景放大 2 倍 |
| `--quality low\|balanced\|high\|ultra` | 请求最高 360p / 720p / 1080p / 2160p;默认 `high`,实际画质由被控端决定 |
| `--fps 8` | 固定本地输出目标和相应远端预览请求;允许 1–30,默认本地目标为 30,未指定时远端供帧会自适应 |
@@ -237,7 +256,7 @@ pi -e ./apps/cli/plugins/pi/index.ts
其他平台将 `CROSSDESK_CLI` 改为原生可执行文件的绝对路径。在 Pi 中输入 `/crossdesk` 或 `/crossdesk 123456789`,按提示输入密码。安装要求与验证范围见 [Pi 扩展说明](apps/cli/docs/terminal-client.md#pi-扩展)。
CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建与 npm 发布不代表全部平台已完成实机验收。
CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步,也暂不支持连接开启[可信设备限制](#trusted-devices)的被控端。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty / Sixel 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建与 npm 发布不代表全部平台已完成实机验收。
[CLI 文档入口](apps/cli/README.md) · [终端使用与故障排查](apps/cli/docs/terminal-client.md) · [桥接协议](apps/cli/docs/bridge-protocol.md) · [npm 打包与分发](apps/cli/docs/npm-distribution.md)
+27 -8
View File
@@ -24,7 +24,7 @@
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) · [Terminal CLI](#cli) · [Direct IP](#lan-direct) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
[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>
@@ -32,10 +32,11 @@ CrossDesk connects your computers from desktop, browser, mobile, and terminal cl
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
- **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode.
- **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI.
- **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty / Sixel rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI.
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution.
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
- **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>
@@ -72,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>
@@ -84,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
@@ -121,7 +140,7 @@ 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.
@@ -131,7 +150,7 @@ Remote sessions support monitor switching, video quality and frame-rate controls
## Terminal client CLI
`crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities.
`crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty or Sixel graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities.
### Display preview
@@ -183,7 +202,7 @@ For an existing checkout, run the dependency preparation and subsequent commands
### Rendering and connection options
The default `--renderer auto` probes for Kitty graphics support and falls back to ANSI. Use `ansi` explicitly in standard terminals such as macOS Terminal, or `kitty` in compatible terminals such as Ghostty. Explicit `kitty` mode reports an error if the probe fails; use `ansi` in that case.
The default `--renderer auto` selects Kitty, then Sixel, then ANSI according to terminal capability replies. Use `ansi` in standard terminals such as macOS Terminal, or `kitty` / `sixel` in terminals supporting the corresponding protocol. Explicit pixel modes report an error if the probe fails; use `ansi` in that case. Sixel output alone does not guarantee Xshell compatibility: the terminal itself must support the protocol.
These examples use the command installed globally by npm. For a source build, replace `crossdesk-cli` with `xmake run crossdesk-cli` and run it from the repository root.
@@ -200,7 +219,7 @@ crossdesk-cli 123456789 --quality balanced --fps 8
| Option | Purpose |
| --- | --- |
| `--renderer auto\|ansi\|kitty` | Automatic selection, character rendering, or pixel graphics; default: `auto` |
| `--renderer auto\|ansi\|kitty\|sixel` | Automatic selection, character rendering, or pixel graphics; default: `auto` |
| `--zoom native` | Start in native detail mode; `--zoom 2` zooms to twice the full-desktop scale |
| `--quality low\|balanced\|high\|ultra` | Request up to 360p / 720p / 1080p / 2160p; default: `high`. The host determines actual quality |
| `--fps 8` | Fix the local output target and corresponding remote preview request; accepts 1–30. The default local target is 30, with adaptive remote requests when omitted |
@@ -237,7 +256,7 @@ pi -e ./apps/cli/plugins/pi/index.ts
On other platforms, set `CROSSDESK_CLI` to the absolute path of the native executable. Enter `/crossdesk` or `/crossdesk 123456789` in Pi and follow the password prompt. See the [Pi extension guide](apps/cli/docs/terminal-client.md#pi-扩展) (Chinese) for requirements and validation scope.
The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty terminal, and actual Pi TUI checks. Platform builds and npm publication do not establish that every platform has been validated on real devices.
The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. It also cannot yet connect to hosts with [trusted-device restrictions](#trusted-devices) enabled. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty / Sixel terminal, and actual Pi TUI checks. Platform builds and npm publication do not establish that every platform has been validated on real devices.
[CLI documentation](apps/cli/README.md) · [Terminal usage and troubleshooting](apps/cli/docs/terminal-client.md) · [Bridge protocol](apps/cli/docs/bridge-protocol.md) · [npm packaging and distribution](apps/cli/docs/npm-distribution.md) (detailed guides in Chinese)
@@ -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; }
+4 -3
View File
@@ -1,6 +1,6 @@
# CrossDesk CLI
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty 图形后端,以及 Pi 自定义 TUI 扩展。
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty / Sixel 图形后端,以及 Pi 自定义 TUI 扩展。
## 画面预览
@@ -8,7 +8,7 @@ CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通
| --- | --- |
| <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 全景适合查看桌面轮廓,阅读小字需进入细节模式。
图示来自 **2026-10-31** 的实际只读连接,通过伪终端采集并渲染协议输出;来源与边界见 [图示说明](../../docs/images/README.md#cli-captures)。默认 `auto` 依次选择 Kitty、Sixel、ANSI;普通终端可指定 `--renderer ansi`,支持对应协议的终端可指定 `--renderer kitty` 或 `--renderer sixel`。显式像素模式需要通过能力探测;增加 Sixel 输出不代表所有 Xshell 版本均可显示图片,终端本身也必须支持该协议。ANSI 全景适合查看桌面轮廓,阅读小字需进入细节模式。
## 安装与连接
@@ -53,6 +53,7 @@ xmake run crossdesk-cli 123456789 --quality high --zoom native
```sh
crossdesk-cli 123456789 --renderer kitty
crossdesk-cli 123456789 --renderer sixel --fps 8
crossdesk-cli 123456789 --renderer ansi --zoom native
crossdesk-cli 123456789 --quality balanced --fps 8
crossdesk-cli 123456789 --display 1
@@ -82,4 +83,4 @@ crossdesk-cli 123456789 --display 1
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty/Sixel 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。
+3 -3
View File
@@ -39,7 +39,7 @@ npx @chunkdash/crossdesk-cli@latest 123456789
这些是构建与分发目标,不代表均已实机通过。当前完整构建与 npm 运行验证只覆盖 macOS arm64;CLI 主程序与平台 I/O 已通过 Windows x64 与 Linux x64、arm64 的交叉编译检查。其他平台的完整 MiniRTC 链接、实际视频连接、键鼠、终端恢复仍需对应机器验收。
普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty 图形协议是可选增强。macOS Terminal、iTerm2、Linux 常用终端、Windows Terminal 和 VS Code 集成终端属于目标场景。SSH 应分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。不支持 `TERM=dumb` 或把交互观看输出重定向到文件;非交互宿主使用 `bridge --stdio`。Alpine / musl 当前没有预编译包。Windows 独立 mintty 窗口可能不提供 Console / ConPTY,应使用 Windows Terminal 承载 Git Bash。
普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty / Sixel 图形协议是可选增强,需终端自身支持并通过能力探测。macOS Terminal、iTerm2、Linux 常用终端、Windows Terminal 和 VS Code 集成终端属于目标场景。SSH 应分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。不支持 `TERM=dumb` 或把交互观看输出重定向到文件;非交互宿主使用 `bridge --stdio`。Alpine / musl 当前没有预编译包。Windows 独立 mintty 窗口可能不提供 Console / ConPTY,应使用 Windows Terminal 承载 Git Bash。
CI 的 macOS 构建配置指定 `--target_minver=13.0`,但最终必须检查实际产物及依赖。当前开发机的本地二进制最低要求为 **macOS 27.0**,不应将这个本地产物宣称为旧 macOS 通用版。
@@ -57,7 +57,7 @@ codesign --force --sign - build/macosx/arm64/release/crossdesk-cli
npm --prefix apps/cli run pack:npm
```
脚本读取 `apps/cli/package.json` 与 `apps/cli/xmake.lua` 的版本,检查二者一致;确认源码中的 MiniRTC 日志脱敏补丁已应用,再检查当前平台二进制格式、CPU、动态链接依赖,并运行离线 `doctor --json`。macOS 只接受系统动态库,Linux 只接受列明的基础系统运行库,Windows 拒绝未随产品处理的 DLL 和动态 MSVC 运行库。Windows 在含 `dumpbin.exe` 的 Visual Studio 开发者环境中运行打包。脚本预先检查两个输出文件是否存在,并在临时目录中完成两个包后再写入输出目录;写入失败时清理本次已写入的包,已有同名产物不会被覆盖。
脚本读取 `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/`。可指定路径:
@@ -67,7 +67,7 @@ npm --prefix apps/cli run pack:npm -- --binary /absolute/path/crossdesk-cli --ou
每台构建机生成当前平台的原生包与相同版本的通用启动包。以 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/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 标记。
+2 -2
View File
@@ -8,8 +8,8 @@ CLI 从独立开发目录整合到 CrossDesk,入口、打包脚本、npm 启
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
`deps/patches/minirtc-credential-logs.patch` 是 CLI 所需的 MiniRTC 修补:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 按主仓库记录初始化子模块并幂等应用补丁,不另行校验提交哈希;更新 MiniRTC 时无需修改脚本中的版本。CLI 构建检查补丁已存在。共享子模块中的脱敏同样适用于桌面程序;补丁没有改变鉴权、TLS、TURN 或媒体协议。
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` 后构建。
npm 原生包携带 CLI、共享库、构建配置和 MiniRTC 的源码快照,不复制桌面、移动端、测试或本机构建缓存。源码包保留仓库目录结构,在 `source/` 中配置 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 后构建。
LICENSE 沿用 CrossDesk GPL-3.0;MiniRTC 子模块保留其 LGPL-3.0 和第三方声明。外部 Xmake 依赖的许可证、对应源码及重链接材料仍需随正式版本提供。
+15 -7
View File
@@ -11,7 +11,7 @@ xmake build crossdesk-cli
xmake run crossdesk-cli doctor --json
```
`prepare-deps.sh` 按主仓库记录的提交初始化 MiniRTC 子模块,并应用凭据日志脱敏补丁,可重复执行。脚本不另行校验提交哈希,升级 MiniRTC 时只需更新子模块提交;如果补丁与新版源码不兼容,准备步骤仍会失败,需要同步调整补丁。构建会检查补丁,缺失时失败。仓库默认开启桌面构建、关闭 CLI。上述参数只构建 CLI;需要同时构建桌面和 CLI 时,将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。恢复默认桌面配置使用 `xmake f -y --CROSSDESK_BUILD_DESKTOP=y --CROSSDESK_BUILD_CLI=n`。所有命令均从仓库根目录执行。
`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` 作为桥接程序。
@@ -41,7 +41,7 @@ crossdesk-cli 123456789 --server your-server.example --port 9099
保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。
## ANSI、Kitty 与 SSH
## ANSI、Kitty、Sixel 与 SSH
ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝交互连接,不输出全屏转义序列。
@@ -63,7 +63,7 @@ Windows 的可见传统控制台优先读取当前字体尺寸;Windows Termina
ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。
auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没有有效 Kitty 回应就用 ANSI。显式 kitty 模式探测失败时报错。tmux/screen 中 auto 使用 ANSI,显式 kitty 报不支持;不修改用户终端配置。
auto 模式发送 Kitty 查询与主设备属性查询(DA1),等待最多 250ms;优先选择 Kitty,否则在 DA1 的能力参数中找到 `4` 时选择 Sixel,再回退为 ANSI。显式 `kitty` 或 `sixel` 模式探测失败时报错,不强制向未知终端输出图像。tmux/screen 中 auto 使用 ANSI,显式像素模式报不支持;不修改用户终端配置。
本地 macOS/Linux 的 Kitty 模式会另用一个 1×1 像素的共享内存对象探测 `t=s`,最多等待 250ms;探测成功后,将未压缩 RGB 写入权限为 0600 的 POSIX 共享内存,仅通过终端字节流发送名字和显示命令。每次使用唯一名字,最多保留一个待确认的共享缓冲区(1920×1080×3 字节)。收到上传成功确认后才显示新图并释放缓冲区;上传报错、确认超过 500ms 或本地分配失败时,本次会话退回内联传输,继续保留旧画面。终端和 CLI 都会清理共享对象,退出时也会释放未确认的对象。SSH 中不尝试共享内存;Windows 暂用内联传输。
@@ -71,16 +71,24 @@ auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没
依据终端报告的像素尺寸生成画面(未知时按 10×20 像素字符估算),上限 1920×1080。保留面积滤波、黑边、宽高比和裁剪坐标;原始细节模式按像素视口裁取源区域。共享内存路径使用可复用水平行计算的整数面积权重,避免逐像素重复浮点运算,也不再因 Base64 字节预算额外降采样。没有可靠终端像素尺寸时使用估算值,因此不承诺显示设备的物理像素一一对应。内联压缩结果超出单次输出预算时仍逐级降低尺寸;高熵视频的实际帧率可能低于目标。RGB 传输和压缩不丢失像素,不代表面积采样或上游视频编码无损。
Sixel 使用终端字节流传图,无需额外图像库或临时图片文件:每六行像素按颜色平面编码,使用重复压缩。每帧最多 248 色(RGB 色立方体加灰阶),复杂画面超过约 1.9MB 时逐级降低到 43 色、16 色,必要时降到 4 级灰阶,以保留原有 2MiB 单消息上限。Sixel 有调色板量化损失;它没有 Kitty 的图像缩放 placement,因此依据字符像素尺寸限制画面行列,使图像保持在 1920×1080 内,缩放、细节模式和鼠标映射使用相同视口。每帧覆盖整个图像区域(包括黑边),最后不足六行的部分不会绘制到状态栏;通过保存/恢复 DECSDM 模式使图像从视频区光标位置开始,退出时恢复。未收到像素尺寸时按 10×20 字符估算,实际对齐仍取决于终端尺寸报告。高熵画面或慢速 SSH 建议先用 `--fps 8`。
```sh
crossdesk-cli 123456789 --renderer sixel --fps 8
```
CLI 支持 Sixel 不等于 Xshell 必然支持。查阅的 [Xshell 官方完整功能列表](https://www.netsarang.com/en/xshell-all-features/) 与 [更新记录](https://www.netsarang.com/en/xshell-update-history/) 尚未提供 Sixel 支持说明;以终端实际能力回应及显示测试为准,不能仅修改 `TERM` 就获得像素图像支持。无能力回应时可继续使用 `--renderer ansi --zoom native`。Sixel 协议与 DA1 能力位依据 [XTerm 控制序列文档](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Sixel-Graphics)。
```sh
ssh -t your-server
crossdesk-cli 123456789 --renderer ansi --fps 8
```
用户本地终端负责显示 SSH 字节流。ANSI、Kitty over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。
用户本地终端负责显示 SSH 字节流。ANSI、Kitty / Sixel over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。
## 预览与背压
`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。
`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、Sixel、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。
未显式指定 `--fps` 时,远端预览从 30 FPS 开始,根据完整一帧从转换开始到输出完成的耗时自动选择 8/15/30 FPS 请求;共享内存的上传确认等待也计入,等待源帧的空闲时间不计入。使用平滑估计、不同升降档阈值和至少 8 个新帧样本,持续变慢 2 秒才降档、持续恢复 4 秒才升档,保留供帧余量。15 FPS 仅在对端声明支持时使用;预览未获接受时不追加自动调节。视口变化、显示器切换和共享内存回退会重置耗时估计。显式 `--fps` 固定本地目标和对应的远端预览请求,关闭自动调节;例如 `--fps 30` 可用于与自动策略对比。
@@ -99,7 +107,7 @@ crossdesk-cli 123456789 --renderer ansi --fps 8
媒体回调仅同步复制借用的 NV12 数据,校验尺寸、容量、native 描述符与 stride;不保留借用指针。上限单帧 16,777,216 像素、每边 8192、偶数尺寸。最多一个待转换帧,替换前先完成校验与复制,无效帧不会挤掉已有画面;播放器另保留最近转换的一帧用于静止桌面的本地放大/重绘,并在接收新帧时回收上一帧缓冲区供后续复制使用。只丢弃解码后图像。显示器切换/关闭提升 generation,旧帧失效。
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),按令牌预算分块非阻塞写入,突发额度上限仍为 1MiB。本地交互 ANSI 和 Kitty 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,两者保持 1MiB/s,Pi 桥接始终保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分按终端实际使用的颜色与字形比较,忽略量化后相同的颜色和空格不可见的前景色;同一行可输出多个变化片段,并按光标定位、颜色切换和字符的实际字节成本合并短间隔。差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),按令牌预算分块非阻塞写入,突发额度上限仍为 1MiB。本地交互 ANSI、Kitty 和 Sixel 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,三者保持 1MiB/s,Pi 桥接始终保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分按终端实际使用的颜色与字形比较,忽略量化后相同的颜色和空格不可见的前景色;同一行可输出多个变化片段,并按光标定位、颜色切换和字符的实际字节成本合并短间隔。差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
测量像素渲染帧率时使用 release 构建;debug 的 `-O0` 面积采样开销明显更高。共享内存中的像素不经过终端字节预算,但仍受转换和终端消费速度限制;8MiB/s 是内联路径的输出上限,不是帧率保证。解码、像素转换、压缩、终端消费速度和帧大小都会影响结果。退出时,未完成画面最多继续写入 2s;取消图像传输及恢复屏幕的短写最多另重试 500ms。POSIX 立即恢复 termios 并清理输入,不等待输出排空;如果终端始终不消费输出,屏幕清理只能尽力发送。
@@ -169,7 +177,7 @@ Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监
- 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。交互连接的摘要在 client.log,包含收到的终端鼠标报告数 `mouse_reports`、其中的传统报告数 `mouse_legacy_reports`、成功交给传输层的鼠标事件数 `mouse_forwarded`、发送失败数 `mouse_send_failures`。一次点击报告可能生成定位和按键两条事件,所以发送数可能大于报告数;发送成功不表示被控端已执行。transport.log 只记录白名单中的 ICE/DTLS 状态及本地控制开关,不记录密码、SDP 或原始信令。使用完可删除。错误退出信息在恢复终端后显示,并给出本次日志目录。
- `connection_failed` 表示连接建立失败;`connection_lost` 表示连接成功后传输失效。xmake 随后的 `execv ... failed(1)` 表示程序以错误码 1 退出,本身不代表进程崩溃。遇到控制失效时,对照 transport.log 中的控制开关和连接状态;`mouse_reports` 为 0 表示终端没有向 CLI 提供可识别的鼠标事件。报告数增长但 `mouse_forwarded` 为 0 时,检查是否仍为只读/本地命令模式、是否已有画面,以及点击是否落在视频区域;`mouse_send_failures` 增长时检查连接状态。
- 如果整个 macOS Terminal 窗口退出且 CLI 记录 `output closed`,还应检查系统 DiagnosticReports 中的 Terminal 报告。已观察到 Terminal 2.15 在创建标签页并调整窗口尺寸时发生 `TTLogicalScreen expandUnicharOffsetCacheToLine:` 行缓存越界;这与 CLI 的 `connection_failed` 不同。临时可在独立窗口运行,避开该标签页操作,不能据此认为 Terminal 缺陷已修复。
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty / Sixel 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
- 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。
第一轮无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker、IP 直连、MCP 或自动截图。其他宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端或给出播放器命令。
+10 -2
View File
@@ -18,7 +18,7 @@
- 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 真实打包、离线安装及源码包重建通过;工程外故障注入确认已有产物保留、第二个包生成失败时无半套输出、缺失脱敏补丁时拒绝打包。
- npm 真实打包、离线安装及源码包重建通过;工程外故障注入确认已有产物保留、第二个包生成失败时无半套输出。
新增回归代码、基准、假传输和验证产物均位于工程外临时目录,未向工程或子模块添加测试。此次完整构建与运行验证覆盖 macOS arm64,未重新进行 Linux/Windows 实机验证。
@@ -132,8 +132,16 @@
- 暂停 PTY 读取 1.3s,并在暂停期间发送退出命令,恢复读取后完整帧计数与提交计数一致,约 1.17s 内退出,备用屏幕、termios 和非阻塞标志恢复。没有连接真实远端,也未验证真实终端的 GPU 绘制、SSH 或 tmux 实际显示;上述帧率为合成源的完整输出接收率。
- 额外检查源码、脚本、数据和产物均位于工程外系统临时目录,未新增或修改工程内测试文件/用例。
## Sixel 图形输出(2026-11-02)
- 新增 `--renderer sixel`;auto 按 Kitty → Sixel → ANSI 选择,Sixel 通过 DA1 能力参数 `4` 探测。显式像素模式没有有效回应或处于 tmux/screen 时拒绝启动;`doctor --json` 增加 `sixel=probe_on_view`,不主动探测。
- macOS arm64 CLI debug 构建、帮助信息和 `doctor --json` 通过。工程外 ASan/UBSan 检查覆盖能力回复解析、20,000 组随机尺寸的视口限制及重复计算一致性、7 组合成 NV12 图像(纯黑、纯白、渐变、彩色、部分六行、裁剪缩放和大视口)。
- 工程外独立 Sixel 解码器验证完整像素覆盖、颜色量化误差、图像边界和光标保存/恢复;1920×1080 高熵样本触发 4 级灰阶回退,输出约 1.30MB,保持原尺寸且低于单消息上限。
- 生产 CLI/终端 I/O 配合工程外假会话的 11 组 POSIX PTY 检查通过:Kitty 优先、Sixel 自动及显式选择、分片 DA1 回应、错误/无回应回退、显式模式失败、tmux/screen 限制、窗口变化和细节缩放;SSH 字节预算保持 1MiB/s,退出后 termios 与备用屏幕恢复,Sixel 路径发送 DECSDM 恢复命令。
- 未连接真实远端,未进行 Xshell、其他 Sixel 终端或真实 SSH 显示验收,不保证任何 Xshell 版本支持该协议。额外测试源码、脚本、数据及产物均保存在工程外系统临时目录,未新增或修改工程内测试文件/用例。
## 实测边界
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/Sixel/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。
CLI 需要支持 `cli` 临时注册的服务端。`doctor --json` 仅报告本机环境,不会联网登录或证明远端可连接。
+30 -11
View File
@@ -143,6 +143,7 @@ private:
};
struct Tty {
int out;
bool sixel = false;
RawTerminal terminal;
Tty(int input, int output) : out(output), terminal(input, output) {
// Fall back to button/drag reporting if all-motion tracking is unsupported.
@@ -153,6 +154,7 @@ struct Tty {
~Tty() {
// Cancel any incomplete string/control sequence before restoration.
std::string end = "\030\033\\" + std::string(kKittyDelete) +
(sixel ? "\033[?80r" : "") +
"\033[0m\033[?1003l\033[?1002l\033[?1000l"
"\033[?1006l\033[?1004l\033[?2004l\033["
"?7h\033[?25h\033[?1049l";
@@ -332,9 +334,9 @@ int View(SessionOptions options, const std::string &device,
return 130;
Tty tty(0, fd);
Output output(fd);
bool kitty = false;
bool kitty = false, sixel = false;
if (renderer != "ansi" && Env("TMUX").empty() && Env("STY").empty()) {
output.Put(std::string(kKittyQuery) + "\033[c");
output.Put((renderer == "sixel" ? std::string{} : kKittyQuery) + "\033[c");
std::string reply;
auto until = Clock::now() + std::chrono::milliseconds(250);
while (Clock::now() < until && !interrupted) {
@@ -345,11 +347,21 @@ int View(SessionOptions options, const std::string &device,
if (n > 0 && reply.size() < 8192)
reply.append(b, n);
}
kitty = KittyReply(reply);
kitty = renderer != "sixel" && KittyReply(reply);
sixel = renderer != "kitty" && !kitty && SixelReply(reply);
}
if (renderer == "kitty" && !kitty)
throw std::runtime_error(
"Kitty probe failed or multiplexer active; use --renderer ansi");
if (renderer == "sixel" && !sixel)
throw std::runtime_error(
"Sixel probe failed or multiplexer active; use --renderer ansi");
if (sixel) {
// DECSDM reset places graphics at the cursor. Save the user's setting
// and restore it on exit, including interrupted image writes.
output.Put("\033[?80s\033[?80l");
tty.sixel = true;
}
// Both pixel frames and large true-colour character grids need local
// throughput. Keep SSH conservative and retain the one-message/burst limits
// and nonblocking writes so slow terminals still apply backpressure.
@@ -520,10 +532,12 @@ int View(SessionOptions options, const std::string &device,
auto v = Size(&status_rows, &input.terminal_metrics, cell_aspect);
v.detail = detail;
v.native = input.native;
v.pixel_mode = kitty;
v.pixel_mode = kitty || sixel;
v.zoom = input.zoom;
v.center_x = input.center_x;
v.center_y = input.center_y;
if (sixel)
v = SixelViewport(v);
if (input.view_revision != view_revision) {
view_revision = input.view_revision;
redraw = true;
@@ -591,6 +605,7 @@ int View(SessionOptions options, const std::string &device,
bar.command_mode = input.command_mode;
bar.fps = "FPS " + std::to_string(output_fps);
bar.view = std::string(kitty ? "KITTY"
: sixel ? "SIXEL"
: detail == AnsiDetail::Quadrant ? "QUAD"
: "HALF") +
(input.native ? " | Detail x" : " | Fit x") +
@@ -651,15 +666,15 @@ int View(SessionOptions options, const std::string &device,
now - last_frame->queued)
.count();
}
pending = kitty ? Layout(*last_frame, v) : Convert(*last_frame, v);
// Clear waiting text and Kitty margins without erasing the status bars.
pending = v.pixel_mode ? Layout(*last_frame, v) : Convert(*last_frame, v);
// Clear waiting text and image margins without erasing the status bars.
std::string clear;
if (waiting_visible)
for (int row = 0; row < v.rows; ++row)
clear += "\033[" + std::to_string(v.row_offset + row + 1) +
";1H\033[0m\033[2K";
if (shared_enabled) {
const auto pixels = KittyPixels(*last_frame, v);
const auto pixels = ImagePixels(*last_frame, v);
if (shared_image.Assign(pixels.rgb.data(), pixels.rgb.size())) {
output.Put(KittySharedUpload(shared_image.Name(), pixels.width,
pixels.height, submitted % 2 != 0));
@@ -673,7 +688,8 @@ int View(SessionOptions options, const std::string &device,
if (fresh && !frame_started)
frame_started = Clock::now();
output.Put(clear + (kitty ? Kitty(*last_frame, v, submitted % 2 != 0)
: Difference(*pending, displayed)));
: sixel ? Sixel(*last_frame, v)
: Difference(*pending, displayed)));
waiting_visible = false;
pending_frame = true;
}
@@ -729,7 +745,7 @@ int View(SessionOptions options, const std::string &device,
{"reported", input.terminal_metrics.Known()},
{"manual_aspect", cell_aspect > 0}}},
{"submitted", submitted},
{"renderer", kitty ? "kitty" : "ansi"},
{"renderer", kitty ? "kitty" : sixel ? "sixel" : "ansi"},
{"kitty_transport", !kitty ? "none"
: shared_enabled ? "shared_memory"
: "inline"},
@@ -1011,7 +1027,7 @@ int Bridge(int fd, SessionOptions options, int password_fd) {
s.Disconnect();
return interrupted ? 130 : 0;
}
const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty] [--fps 1..30]
const char *help = R"(crossdesk-cli <9-digit-device-id> [--renderer auto|ansi|kitty|sixel] [--fps 1..30]
[--display 0..63] [--control] [--server host] [--port 9099]
[--quality low|balanced|high|ultra] [--ansi-detail quadrant|half] [--zoom 1..32|native]
[--cell-aspect auto|0.2..2]
@@ -1028,6 +1044,7 @@ Ctrl+T and Ctrl+C pass through in control mode; another Ctrl+] leaves local mode
Default ANSI detail: quadrant. Default quality: high (1920x1080); ultra requests up to 3840x2160.
Cell aspect is character width/height: auto detects terminal geometry; use --cell-aspect to calibrate.
Display index is zero-based. ANSI cannot reproduce full desktop text at fit scale.
Auto rendering prefers Kitty, then Sixel, then ANSI. Explicit pixel renderers require a successful probe.
--fps controls local output (default 30). Without it, remote preview adapts to measured output speed.
Explicit --fps keeps a fixed preview request. Kitty transport fallback keeps the local target unchanged.
Preview requests above 15 use the negotiated interactive 30 FPS stream; lower requests use 1/8/15 FPS.
@@ -1064,6 +1081,7 @@ int main(int argc, char **argv) {
{"multiplexer", !Env("TMUX").empty() || !Env("STY").empty()},
{"software_decode", true},
{"kitty", "probe_on_view"},
{"sixel", "probe_on_view"},
{"audio_playback", false},
{"bridge_version", 1}};
std::cout << d.dump(argc == 3 ? -1 : 2) << "\n";
@@ -1142,7 +1160,8 @@ int main(int argc, char **argv) {
throw std::invalid_argument("unsupported option");
}
}
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty")
if (renderer != "auto" && renderer != "ansi" && renderer != "kitty" &&
renderer != "sixel")
throw std::invalid_argument("unknown renderer");
if (command == "bridge" && !stdio)
throw std::invalid_argument("bridge requires --stdio");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chunkdash/crossdesk-cli",
"version": "0.1.3",
"version": "0.1.5",
"private": true,
"description": "CrossDesk remote desktop controller for the terminal",
"license": "GPL-3.0-only",
+3 -5
View File
@@ -132,8 +132,6 @@ try {
const filename = tarballName(name, version);
if (fs.existsSync(path.join(output, filename))) throw new Error(`Output already exists: ${filename}`);
}
run('git', ['-C', path.join(root, 'deps/submodules/minirtc'), 'apply', '--reverse', '--check',
path.join(root, 'deps/patches/minirtc-credential-logs.patch')]);
const executable = process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli';
binary ||= path.join(root, 'build', platform.xmakePlatform, platform.xmakeArch, 'release', executable);
const info = inspect(binary, target);
@@ -144,7 +142,7 @@ try {
fs.copyFileSync(binary, path.join(native, 'bin', executable));
fs.chmodSync(path.join(native, 'bin', executable), 0o755);
copy('LICENSE', path.join(native, 'LICENSE'));
// Include the controller and patched MiniRTC sources used by this checkout.
// 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;
@@ -153,11 +151,11 @@ try {
'libs/controller/include',
'libs/controller/src', 'libs/terminal', 'libs/wire/include',
'libs/wire/src', 'deps/submodules/minirtc',
'deps/submodules/xmake.lua', 'deps/thirdparty', 'deps/patches', 'xmake.lua',
'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 patched MiniRTC sources are included in source/. From source/, configure with xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release, then run xmake build crossdesk-cli. External dependency notices and corresponding sources must accompany the product release; see source/apps/cli/docs/source-provenance.md.\n`);
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,
-6
View File
@@ -2,9 +2,3 @@
set -eu
cd "$(dirname "$0")/../../.."
git submodule update --init --recursive
patch_file="$(pwd)/deps/patches/minirtc-credential-logs.patch"
if git -C deps/submodules/minirtc apply --reverse --check "$patch_file" 2>/dev/null; then
exit 0
fi
git -C deps/submodules/minirtc apply --check "$patch_file"
git -C deps/submodules/minirtc apply "$patch_file"
+1 -6
View File
@@ -2,7 +2,6 @@ local source_dir = path.absolute("../..", os.scriptdir())
local controller_dir = path.join(source_dir, "libs/controller")
local terminal_dir = path.join(source_dir, "libs/terminal")
local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc")
local credential_patch = path.join(source_dir, "deps/patches/minirtc-credential-logs.patch")
add_requires("spdlog 1.14.1", {system = false})
if is_plat("windows") then
@@ -17,10 +16,6 @@ target("crossdesk_controller")
path.join(minirtc_dir, "src/api"), {public = true})
add_includedirs(path.join(minirtc_dir, "src/log"))
add_files(path.join(controller_dir, "src/*.cpp"))
before_build(function ()
-- Require the same credential redaction in checkouts and source bundles.
os.vrunv("git", {"-C", minirtc_dir, "apply", "--reverse", "--check", credential_patch})
end)
target_end()
target("crossdesk_terminal")
@@ -37,7 +32,7 @@ target_end()
target("crossdesk-cli")
set_kind("binary")
set_version("0.1.3")
set_version("0.1.5")
add_deps("crossdesk_terminal")
add_files("main.cpp")
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
+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);
@@ -386,7 +386,7 @@ inline constexpr TranslationRow kTranslationRows[] = {
{"设置(修改后立即保存)", {"设置(修改后立即保存)", "Settings (each change is saved immediately)", "Настройки (изменения сохраняются сразу)", "設定(修改後立即儲存)", "設定 (各変更はすぐに保存されます)", "설정(각 변경 사항은 즉시 저장됩니다)", "Einstellungen (jede Änderung wird sofort gespeichert)", "Paramètres (chaque modification est enregistrée immédiatement)", "Configuración (cada cambio se guarda inmediatamente)", "Configurações (cada alteração é salva imediatamente)"}},
{"访问官网: ", {"访问官网: ", "Access Website: ", "Официальный сайт: ", "訪問官網: ", "ウェブサイトにアクセス: ", "웹사이트 접속: ", "Zugriff auf die Website: ", "Accéder au site Web : ", "Acceder al sitio web: ", "Acesse o site: "}},
{"访问密码必须为 6 位英文字母或数字,区分大小写。", {"访问密码必须为 6 位英文字母或数字,区分大小写。", "Use exactly 6 letters (A–Z, a–z) or digits (0–9). Case-sensitive.", "Пароль: ровно 6 латинских букв или цифр. Регистр учитывается.", "訪問密碼必須為 6 位英文字母或數字,區分大小寫。", "正確に 6 つの文字 (A ~ Z、a ~ z) または数字 (0 ~ 9) を使用してください。大文字と小文字を区別。", "정확히 6개의 문자(A–Z, a–z) 또는 숫자(0–9)를 사용하세요. 대소문자를 구분합니다.", "Verwenden Sie genau 6 Buchstaben (A–Z, a–z) oder Ziffern (0–9). Groß- und Kleinschreibung beachten.", "Utilisez exactement 6 lettres (A–Z, a–z) ou chiffres (0–9). Sensible aux majuscules et minuscules.", "Utilice exactamente 6 letras (A–Z, a–z) o dígitos (0–9). Distingue mayúsculas y minúsculas.", "Use exatamente 6 letras (A–Z, a–z) ou dígitos (0–9). Maiúsculas e minúsculas."}},
{"证书验证失败,请重新安装自托管根证书", {"证书验证失败,请重新安装自托管根证书", "Certificate verification failed. Reinstall the self-hosted root certificate.", "Ошибка проверки сертификата. Переустановите корневой сертификат собственного сервера.", "證書驗證失敗,請重新安裝自託管根證書", "証明書の検証に失敗しました。セルフホスト型ルート証明書を再インストールします。", "인증서 확인에 실패했습니다. 자체 호스팅 루트 인증서를 다시 설치합니다.", "Die Zertifikatsüberprüfung ist fehlgeschlagen. Installieren Sie das selbstgehostete Stammzertifikat neu.", "La vérification du certificat a échoué. Réinstallez le certificat racine auto-hébergé.", "Falló la verificación del certificado. Reinstale el certificado raíz autohospedado.", "Falha na verificação do certificado. Reinstale o certificado raiz auto-hospedado."}},
{"服务器证书无效或已过期,正在自动重试", {"服务器证书无效或已过期,正在自动重试", "Server certificate invalid or expired. Retrying automatically.", "Сертификат сервера недействителен или просрочен. Повторяем подключение автоматически.", "伺服器憑證無效或已過期,正在自動重試", "サーバー証明書が無効か期限切れです。自動的に再試行します。", "서버 인증서가 유효하지 않거나 만료되었습니다. 자동으로 재시도합니다.", "Serverzertifikat ungültig oder abgelaufen. Automatischer Neuversuch.", "Certificat du serveur invalide ou expiré. Nouvelle tentative automatique.", "Certificado del servidor no válido o caducado. Reintentando automáticamente.", "Certificado do servidor inválido ou expirado. Tentando novamente automaticamente."}},
{"该分组暂无近期连接", {"该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений", "該分組暫無近期連線", "このグループには最近の接続がありません", "이 그룹에는 최근 연결이 없습니다.", "Keine aktuellen Verbindungen in dieser Gruppe", "Aucune connexion récente dans ce groupe", "No hay conexiones recientes en este grupo.", "Nenhuma conexão recente neste grupo"}},
{"该应用需要授权以下权限:", {"该应用需要授权以下权限:", "The application requires the following permissions:", "Для работы приложения требуются следующие разрешения:", "該應用需要授權以下許可權:", "アプリケーションには次の権限が必要です。", "애플리케이션에는 다음 권한이 필요합니다.", "Die Anwendung erfordert die folgenden Berechtigungen:", "L'application nécessite les autorisations suivantes :", "La aplicación requiere los siguientes permisos:", "O aplicativo requer as seguintes permissões:"}},
{"该设备正在连接,请稍候", {"该设备正在连接,请稍候", "This device is already connecting", "Устройство уже подключается", "該裝置正在連線,請稍候", "このデバイスはすでに接続されています", "이 장치는 이미 연결되어 있습니다", "Dieses Gerät stellt bereits eine Verbindung her", "Cet appareil est déjà connecté", "Este dispositivo ya se está conectando", "Este dispositivo já está conectado"}},
@@ -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; };
@@ -676,7 +701,7 @@ inline constexpr NamedTranslation kNamedTranslations[] = {
{"signal_disconnected", "未连接服务器"},
{"signal_reconnect", "重新连接"},
{"signal_reconnecting", "正在重新连接..."},
{"signal_tls_cert_error", "证书验证失败,请重新安装自托管根证书"},
{"signal_tls_cert_error", "服务器证书无效或已过期,正在自动重试"},
{"p2p_connected", "对等连接已建立"},
{"p2p_disconnected", "对等连接已断开"},
{"p2p_connecting", "正在建立对等连接..."},
@@ -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";
+41 -9
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";
@@ -93,7 +94,7 @@ export global UiStrings {
in-out property <string> license: "Licensed under GNU GPL v3.";
in-out property <string> signal-connected: "Signal server connected";
in-out property <string> signal-disconnected: "Signal server disconnected";
in-out property <string> tls-error: "Signal server TLS certificate error";
in-out property <string> tls-error: "Server certificate invalid or expired. Retrying automatically.";
in-out property <string> signal-reconnect: "Reconnect";
in-out property <string> signal-reconnecting: "Reconnecting...";
in-out property <string> update-available: "A new version is available";
@@ -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;
}
@@ -1923,7 +1935,7 @@ export component MainWindow inherits Window {
text: root.current-version;
font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center;
}
if !root.lan-mode && !root.signal-connected && !root.signal-tls-error: WorkbenchButton {
if !root.lan-mode && !root.signal-connected: WorkbenchButton {
x: parent.width - self.width - 20px;
y: (parent.height - self.height) / 2;
width: max(64px, reconnect-label-metrics.preferred-width + 22px);
@@ -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();
+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")
@@ -222,6 +222,7 @@
300000000000000000000001 /* Frameworks */,
600000000000000000000002 /* Resources */,
300000000000000000000002 /* Embed Frameworks */,
700000000000000000000002 /* Generate OpenSSL symbols */,
);
buildRules = (
);
@@ -318,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 */
@@ -496,7 +515,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 4;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -548,7 +567,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 4;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -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
@@ -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
+31 -4
View File
@@ -150,11 +150,28 @@ 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
project. It installs Xmake, CMake, pkgconf, 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.
Xmake version and host `pkg-config`, and initializes the pinned MiniRTC submodules.
It exposes both `pkgconf` and `pkg-config` from the Homebrew formula in the host
tool directory, allowing GLib's Meson build to resolve Xmake's iOS PCRE2 and
libffi packages instead of compiling bundled fallback versions. The native build
phase checks and selects these tools again and preserves Cloud's proxy settings
for package downloads. No manual workflow PATH setting or `sudo` command is required.
For **Archive - iOS**, `ci_scripts/ci_pre_xcodebuild.sh` builds the native
dependencies before `xcodebuild`, using `Release / iphoneos / arm64` to match the
shared scheme. It streams verbose build output and prints a heartbeat with
process status every minute, avoiding the archive's 30-minute inactivity timeout
while Xcode buffers Run Script output. The archive's native build phase reuses
the same dependency cache and libraries, retaining its input and toolchain
validation. Other actions keep their existing native build behavior.
The prebuild records its output under `.xmake/xcode-cloud-logs` and copies package
installation and Meson logs there on failure. These files are temporary, so the
script also prints the complete GLib install log and the last 200 lines of other
failed package logs and GLib's Meson log into the retained Cloud step output.
Inspect **Run ci_pre_xcodebuild.sh script** when diagnosing native build failures.
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
@@ -586,6 +603,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
+10 -1
View File
@@ -13,8 +13,9 @@ if ! command -v brew >/dev/null 2>&1; then
fi
echo "Installing Xcode Cloud native build tools"
brew install xmake cmake python@3.13
brew install xmake cmake pkgconf python@3.13
BREW_PREFIX="$(brew --prefix)"
PKGCONF_PREFIX="$(brew --prefix pkgconf)"
PYTHON_PREFIX="$(brew --prefix python@3.13)"
# Match GitHub Actions' host Python dependencies without modifying Homebrew's
@@ -25,6 +26,14 @@ PYTHON_PREFIX="$(brew --prefix python@3.13)"
for tool in xmake xrepo cmake ctest cpack; do
ln -sf "${BREW_PREFIX}/bin/${tool}" "${HOST_TOOLS_DIR}/bin/${tool}"
done
# Meson needs the host pkg-config executable to find Xmake's iOS packages.
# Without it, GLib falls back to bundled PCRE2/libffi sources. Use the formula's
# prefix so an older pkg-config elsewhere on PATH cannot shadow this tool.
for tool in pkgconf pkg-config; do
ln -sf "${PKGCONF_PREFIX}/bin/${tool}" "${HOST_TOOLS_DIR}/bin/${tool}"
done
echo "Verifying Xcode Cloud host pkg-config"
"${HOST_TOOLS_DIR}/bin/pkg-config" --version
"${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" \
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
set -euo pipefail
# The shared CrossDeskMobile scheme archives Release for physical iOS devices.
# Leave other actions to their existing Xcode build phase and SDK selection.
if [[ "${CI_XCODEBUILD_ACTION:-}" != "archive" || "${CI_PRODUCT_PLATFORM:-}" != "iOS" ]]; then
echo "Skipping native prebuild for ${CI_XCODEBUILD_ACTION:-unknown} (${CI_PRODUCT_PLATFORM:-unknown})"
exit 0
fi
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
IOS_DIR="$(dirname "$SCRIPT_DIR")"
LOG_DIR="${IOS_DIR}/.xmake/xcode-cloud-logs"
mkdir -p "$LOG_DIR"
collect_dependency_logs() {
local log_root="$1"
local label="$2"
local log_file relative_path destination
[[ -d "$log_root" ]] || return 0
while IFS= read -r -d '' log_file; do
relative_path="${log_file#"${log_root}/"}"
destination="${LOG_DIR}/dependencies/${label}/${relative_path}"
mkdir -p "$(dirname "$destination")"
cp "$log_file" "$destination"
# Cloud does not publish arbitrary files as downloadable artifacts.
# Include GLib's complete install log in the retained step output, plus
# the tail of Meson and other failed package logs for context.
if [[ "$log_file" == */glib/*/logs/install.txt ]]; then
echo "GLib install log: ${log_file}"
cat "$log_file"
elif [[ "$log_file" == */installdir.failed/logs/* || \
"$log_file" == */glib/*/meson-logs/meson-log.txt ]]; then
echo "Last 200 lines: ${log_file}"
tail -n 200 "$log_file"
fi
done < <(find "$log_root" -type f \( \
-path '*/logs/*.txt' -o -path '*/meson-logs/meson-log.txt' \) -print0)
}
MONITOR_PID=""
finish() {
local build_exit_code=$?
trap - EXIT
set +e
if [[ -n "$MONITOR_PID" ]]; then
kill "$MONITOR_PID" 2>/dev/null
wait "$MONITOR_PID" 2>/dev/null
fi
if [[ "$build_exit_code" -ne 0 ]]; then
echo "iOS native prebuild failed with exit code ${build_exit_code}"
collect_dependency_logs "${XMAKE_GLOBALDIR:-${HOME}}/.xmake/cache/packages" cache
collect_dependency_logs "${IOS_DIR}/.xmake/packages" installed
echo "Native build diagnostics: ${LOG_DIR}"
fi
exit "$build_exit_code"
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Run outside xcodebuild so downloads and compiler output are streamed instead
# of buffered by a Run Script phase. Keep the step active during quiet commands.
(
SLEEP_PID=""
trap 'kill "${SLEEP_PID}" 2>/dev/null || true; wait "${SLEEP_PID}" 2>/dev/null || true; exit 0' TERM INT
while true; do
sleep 60 &
SLEEP_PID=$!
wait "$SLEEP_PID" || exit 0
echo "iOS native prebuild still running at $(date -u +%FT%TZ)"
ps -axo pid,ppid,state,etime,pcpu,comm | \
awk 'NR == 1 || /xmake|clang|cmake|ninja|meson|python|git|curl|make|zsh/' || true
done
) &
MONITOR_PID=$!
echo "Prebuilding iOS native dependencies for Release / iphoneos / arm64"
CONFIGURATION=Release PLATFORM_NAME=iphoneos CURRENT_ARCH=arm64 \
CROSSDESK_NATIVE_VERBOSE=1 \
"${IOS_DIR}/scripts/build_minirtc_ios.sh" 2>&1 | tee "${LOG_DIR}/ios-native-build.log"
echo "iOS native prebuild finished; Archive will reuse the native build products"
+6 -4
View File
@@ -50,10 +50,12 @@ fi
# 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
for host_tool in xmake python3 pkg-config; do
if [[ ! -x "${CLOUD_HOST_BIN}/${host_tool}" ]]; then
print -u2 "Xcode Cloud host tool ${host_tool} is missing. Run ci_scripts/ci_post_clone.sh before building."
exit 69
fi
done
export PATH="${CLOUD_HOST_BIN}:${PATH}"
fi
@@ -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)
+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__":
-24
View File
@@ -1,24 +0,0 @@
diff --git a/src/pc/peer_connection.cpp b/src/pc/peer_connection.cpp
index bb9eb49..eda133f 100644
--- a/src/pc/peer_connection.cpp
+++ b/src/pc/peer_connection.cpp
@@ -459,3 +459,3 @@ int PeerConnection::SendJoinRequestLocked(
LOG_INFO("[{}] sends join transmission [{}] request", user_id_,
- transmission_id);
+ transmission_id.substr(0, transmission_id.find('@')));
}
@@ -481,3 +481,3 @@ int PeerConnection::Join(const std::string& transmission_id) {
LOG_INFO("Queue join transmission [{}] until signal login completes",
- transmission_id);
+ transmission_id.substr(0, transmission_id.find('@')));
return 0;
@@ -498,3 +498,3 @@ int PeerConnection::Leave(const std::string& transmission_id) {
LOG_INFO("Cancel queued join transmission [{}]",
- *pending_join_transmission_id_);
+ pending_join_transmission_id_->substr(0, pending_join_transmission_id_->find('@')));
pending_join_transmission_id_.reset();
@@ -508,3 +508,3 @@ int PeerConnection::Leave(const std::string& transmission_id) {
LOG_INFO("[{}] leave transmission [{}] notification queued",
- user_id_, transmission_id);
+ user_id_, transmission_id.substr(0, transmission_id.find('@')));
}
+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
+6 -1
View File
@@ -67,7 +67,12 @@ struct PixelFrame {
int width = 0, height = 0;
std::vector<uint8_t> rgb;
};
PixelFrame KittyPixels(const DecodedFrame &, const Viewport &);
PixelFrame ImagePixels(const DecodedFrame &, const Viewport &);
// Sixel has no placement scaling. Limit the cell viewport, not the bitmap,
// so the displayed pixels and pointer mapping keep the same geometry.
Viewport SixelViewport(Viewport);
std::string Sixel(const DecodedFrame &, const Viewport &);
bool SixelReply(const std::string &response);
std::string KittySharedUpload(const std::string &name, int width, int height,
bool alternate_buffer, bool query = false);
std::string KittyPresent(const Viewport &, bool alternate_buffer);
+169
View File
@@ -0,0 +1,169 @@
#include "terminal_renderer.h"
#include <algorithm>
#include <array>
#include <charconv>
#include <stdexcept>
namespace crossdesk::terminal {
Viewport SixelViewport(Viewport v) {
if (v.columns < 1 || v.columns > 240 || v.rows < 1 || v.rows > 100)
throw std::invalid_argument("invalid Sixel viewport");
const auto limit = [](int &cells, int &pixels, int fallback, int maximum) {
const int original_cells = cells;
if (pixels <= 0)
pixels = cells * fallback;
cells =
std::max(1, std::min(cells, int(int64_t(maximum) * cells / pixels)));
pixels = std::clamp(
int((int64_t(pixels) * cells + original_cells / 2) / original_cells), 2,
maximum);
};
limit(v.columns, v.pixel_width, 10, 1920);
limit(v.rows, v.pixel_height, 20, 1080);
v.pixel_mode = true;
return v;
}
bool SixelReply(const std::string &response) {
for (size_t pos = 0;
(pos = response.find("\033[?", pos)) != std::string::npos; ++pos) {
const auto end = response.find('c', pos + 3);
if (end == std::string::npos || end - pos > 1024)
continue;
const char *cur = response.data() + pos + 3;
const char *last = response.data() + end;
bool first = true, supported = false;
while (cur < last) {
unsigned value = 0;
const auto parsed = std::from_chars(cur, last, value);
if (parsed.ec != std::errc{})
break;
// The first parameter identifies the terminal, not a capability.
supported = supported || (!first && value == 4);
first = false;
cur = parsed.ptr;
if (cur == last) {
if (supported)
return true;
break;
}
if (*cur++ != ';')
break;
}
}
return false;
}
namespace {
struct Palette {
int levels, grays;
std::vector<std::array<int, 3>> colors;
Palette(int cube_levels, int gray_levels)
: levels(cube_levels), grays(gray_levels) {
for (int r = 0; r < levels; ++r)
for (int g = 0; g < levels; ++g)
for (int b = 0; b < levels; ++b)
colors.push_back({Level(r), Level(g), Level(b)});
for (int i = 0; i < grays; ++i) {
const int value = Gray(i);
colors.push_back({value, value, value});
}
}
int Level(int i) const { return (i * 255 + (levels - 1) / 2) / (levels - 1); }
int Gray(int i) const { return (i * 255 + (grays - 1) / 2) / (grays - 1); }
uint8_t Index(const uint8_t *rgb) const {
const int gray =
((int(rgb[0]) + rgb[1] + rgb[2]) * (grays - 1) + 382) / 765;
const int gray_index = levels * levels * levels + gray;
if (!levels)
return uint8_t(gray_index);
int index = 0, cube_error = 0, gray_error = 0;
for (int c = 0; c < 3; ++c) {
const int step = (rgb[c] * (levels - 1) + 127) / 255;
index = index * levels + step;
const int dc = rgb[c] - Level(step), dg = rgb[c] - Gray(gray);
cube_error += dc * dc;
gray_error += dg * dg;
}
return uint8_t(gray_error < cube_error ? gray_index : index);
}
};
void Run(std::string &out, uint8_t bits, int count) {
if (count > 3) {
out += '!';
out += std::to_string(count);
out += char('?' + bits);
} else {
out.append(size_t(count), char('?' + bits));
}
}
std::string Encode(const PixelFrame &pixels, const Viewport &v,
const Palette &palette) {
constexpr size_t budget = 1900000; // Leave room for terminal/UI commands.
const int width = pixels.width, height = pixels.height;
std::string out = "\0337\033[" + std::to_string(v.row_offset + 1) +
";1H\033P0;1;0q\"1;1;" + std::to_string(width) + ";" +
std::to_string(height);
// Define a complete palette for every frame, including black margins.
// Pb=1 preserves pixels outside the image in its final partial six-row band.
for (size_t i = 0; i < palette.colors.size(); ++i) {
out += '#' + std::to_string(i) + ";2";
for (int c : palette.colors[i])
out += ';' + std::to_string((c * 100 + 127) / 255);
}
std::vector<uint8_t> planes(palette.colors.size() * width);
std::vector<int> ends(palette.colors.size());
for (int y = 0; y < height; y += 6) {
std::fill(planes.begin(), planes.end(), 0);
std::fill(ends.begin(), ends.end(), 0);
for (int dy = 0; dy < std::min(6, height - y); ++dy)
for (int x = 0; x < width; ++x) {
const auto color =
palette.Index(&pixels.rgb[(size_t(y + dy) * width + x) * 3]);
planes[size_t(color) * width + x] |= uint8_t(1 << dy);
ends[color] = std::max(ends[color], x + 1);
}
bool first = true;
for (size_t color = 0; color < ends.size(); ++color) {
if (!ends[color])
continue;
if (!first)
out += '$'; // Return to the left edge without advancing the band.
first = false;
out += '#' + std::to_string(color);
const auto *plane = &planes[color * width];
for (int x = 0; x < ends[color];) {
int end = x + 1;
while (end < ends[color] && plane[end] == plane[x])
++end;
Run(out, plane[x], end - x);
x = end;
}
if (out.size() > budget)
return {};
}
if (y + 6 < height)
out += '-';
}
out += "\033\\\0338";
return out;
}
} // namespace
std::string Sixel(const DecodedFrame &frame, const Viewport &viewport) {
const auto v = SixelViewport(viewport);
const auto pixels = ImagePixels(frame, v);
// Sixel cannot scale a smaller bitmap to a cell placement. Reduce color
// detail on high-entropy frames instead of shrinking or misplacing them.
for (const auto &sizes :
{std::pair{6, 32}, std::pair{3, 16}, std::pair{2, 8}, std::pair{0, 4}}) {
auto out = Encode(pixels, v, Palette(sizes.first, sizes.second));
if (!out.empty())
return out;
}
// Four planes at 1920 x 1080 always fit the budget above.
throw std::runtime_error("Sixel output budget exceeded");
}
} // namespace crossdesk::terminal
+1 -1
View File
@@ -543,7 +543,7 @@ static std::string Base64(const std::vector<uint8_t> &data) {
}
return out;
}
PixelFrame KittyPixels(const DecodedFrame &f, const Viewport &requested) {
PixelFrame ImagePixels(const DecodedFrame &f, const Viewport &requested) {
auto v = requested;
v.pixel_mode = true;
Validate(f, v);
+327 -14
View File
@@ -7980,17 +7980,17 @@
"pt-BR": "Falha na verificação do certificado · Clique para tentar novamente",
"ru": "Не удалось проверить сертификат · Нажмите, чтобы повторить попытку."
},
"证书验证失败,请重新安装自托管根证书": {
"zh-Hans": "证书验证失败,请重新安装自托管根证书",
"en": "Certificate verification failed. Reinstall the self-hosted root certificate.",
"ru": "Ошибка проверки сертификата. Переустановите корневой сертификат собственного сервера.",
"zh-Hant": "證書驗證失敗,請重新安裝自託管根證書",
"ja": "証明書の検証に失敗しました。セルフホスト型ルート証明書を再インストールします。",
"ko": "인증서 확인에 실패했습니다. 자체 호스팅 루트 인증서를 다시 설치합니다.",
"de": "Die Zertifikatsüberprüfung ist fehlgeschlagen. Installieren Sie das selbstgehostete Stammzertifikat neu.",
"fr": "La vérification du certificat a échoué. Réinstallez le certificat racine auto-hébergé.",
"es": "Falló la verificación del certificado. Reinstale el certificado raíz autohospedado.",
"pt-BR": "Falha na verificação do certificado. Reinstale o certificado raiz auto-hospedado."
"服务器证书无效或已过期,正在自动重试": {
"zh-Hans": "服务器证书无效或已过期,正在自动重试",
"en": "Server certificate invalid or expired. Retrying automatically.",
"ru": "Сертификат сервера недействителен или просрочен. Повторяем подключение автоматически.",
"zh-Hant": "伺服器憑證無效或已過期,正在自動重試",
"ja": "サーバー証明書が無効か期限切れです。自動的に再試行します。",
"ko": "서버 인증서가 유효하지 않거나 만료되었습니다. 자동으로 재시도합니다.",
"de": "Serverzertifikat ungültig oder abgelaufen. Automatischer Neuversuch.",
"fr": "Certificat du serveur invalide ou expiré. Nouvelle tentative automatique.",
"es": "Certificado del servidor no válido o caducado. Reintentando automáticamente.",
"pt-BR": "Certificado do servidor inválido ou expirado. Tentando novamente automaticamente."
},
"该分组暂无近期连接": {
"zh-Hans": "该分组暂无近期连接",
@@ -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": {
@@ -10464,7 +10752,7 @@
"signal_disconnected": "未连接服务器",
"signal_reconnect": "重新连接",
"signal_reconnecting": "正在重新连接...",
"signal_tls_cert_error": "证书验证失败,请重新安装自托管根证书",
"signal_tls_cert_error": "服务器证书无效或已过期,正在自动重试",
"p2p_connected": "对等连接已建立",
"p2p_disconnected": "对等连接已断开",
"p2p_connecting": "正在建立对等连接...",
@@ -11154,7 +11442,7 @@
"设置(修改后立即保存)",
"访问官网: ",
"访问密码必须为 6 位英文字母或数字,区分大小写。",
"证书验证失败,请重新安装自托管根证书",
"服务器证书无效或已过期,正在自动重试",
"该分组暂无近期连接",
"该应用需要授权以下权限:",
"该设备正在连接,请稍候",
@@ -11261,7 +11549,32 @@
"(仅在交互终端显示)",
":同步键鼠到所有已连接设备",
"监听所有 IP 接口",
"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。"
"所有接口共用一个端口,包括 VPN。分享时请选择实际 IP;关闭后恢复各 IP 的独立端口。",
"可信设备",
"仅允许可信设备控制",
"管理可信设备",
"可添加的已连接设备",
"信任此设备",
"移除信任",
"无法读取设备身份或可信列表,请修复后再启用。",
"无法保存可信设备设置,请重试。",
"粘贴",
"粘贴控制端的配对信息",
"已在可信列表中",
"无法访问剪贴板,请重试。",
"配对信息无效,请从控制端重新复制。",
"已加入可信设备。",
"无法更新可信设备,请检查列表容量和存储权限后重试。",
"复制配对信息",
"添加可信设备",
"已信任的设备",
"本机配对信息",
"查看指纹",
"收起指纹",
"暂无可信设备",
"请核对控制端指纹。",
"配对信息已复制。",
"无法复制配对信息,请重试。"
],
"android": [
" · 已完成",