Compare commits

..
16 Commits
Author SHA1 Message Date
dijunkun bdd04603c7 [feat] enable screen lock on disconnect by default 2026-10-25 03:07:13 +08:00
dijunkun f3d8689b55 [fix] allow alphanumeric password input on iOS, fixes #127 2026-10-25 03:01:45 +08:00
dijunkun 0fb4b031c7 [fix] adapt recent connection thumbnails to display DPI 2026-10-25 02:57:33 +08:00
dijunkun 7fc21f01bc [ui] add a compact edit button for self-hosted settings 2026-10-25 02:43:24 +08:00
dijunkun 86bae6e1f4 [ui] remove TURN service toggle and simplify relay settings 2026-10-25 02:40:21 +08:00
dijunkun c03c8a63d4 [fix] avoid Windows interface macro collision 2026-10-25 02:32:42 +08:00
dijunkun ec9ac6a7cd [chore] simplify issue templates and reduce required fields 2026-10-25 01:51:48 +08:00
dijunkun 2a0b14035d [fix] preserve Unicode device names, fixes #126 2026-10-25 01:47:10 +08:00
dijunkun 652378afa8 [fix] add missing stream header and update libnice recipe checksum 2026-10-25 01:39:49 +08:00
dijunkun 0cbdbb0c96 [feat] add lock screen on disconnect option, refs #125 2026-10-25 01:35:40 +08:00
dijunkun 63bc5648c0 [chore] update MiniRTC for IP direct API grouping 2026-10-25 01:24:39 +08:00
dijunkun 859aaa29d4 [docs] document ID and direct IP connection architecture 2026-10-25 01:19:04 +08:00
dijunkun cc48eb651f [docs] document IP direct mode and refresh desktop screenshots 2026-10-25 01:05:43 +08:00
dijunkun ae6f81e83a [feat] integrate IP direct mode with desktop controls and history 2026-10-25 00:54:13 +08:00
dijunkun 2445f79f92 [feat] add authenticated LAN transport and per-IP listeners 2026-10-25 00:54:10 +08:00
dijunkun bd7f9505d9 [docs] document update public key build option 2026-10-24 21:03:55 +08:00
84 changed files with 5342 additions and 695 deletions
+12 -104
View File
@@ -7,124 +7,32 @@ body:
- type: markdown
attributes:
value: |
请先查看 [常见问题 / FAQ](https://github.com/kunkundi/crossdesk/blob/deskhub/docs/FAQ.md) 并搜索 [已有问题 / Existing issues](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
Please check the FAQ and existing issues first. You can write in Chinese or English.
只需描述问题,其余选填。中文或英文均可。
Only a problem description is required. Chinese or English is welcome.
- type: textarea
id: problem
attributes:
label: 问题与预期 / Problem and expected behavior
description: 描述实际发生的情况、错误提示,以及期望的行为。Describe what happened, any error messages, and what you expected.
placeholder: |
实际结果 / Actual behavior:
预期结果 / Expected behavior:
label: 问题描述 / Problem description
description: 遇到了什么问题?如方便,也请说明触发操作或错误提示。What went wrong? If possible, include what you were doing or any error message.
validations:
required: true
- type: textarea
id: reproduction
- type: input
id: environment
attributes:
label: 复现步骤 / Steps to reproduce
description: 说明操作步骤和出现频率;偶发问题可描述发生前的操作。Include the steps and frequency. For intermittent issues, describe what you were doing.
placeholder: |
1.
2.
3.
出现频率 / Frequency:
validations:
required: true
- type: dropdown
id: clients
attributes:
label: 涉及客户端 / Affected clients
description: 可多选;Web 与原生移动端请分别选择。Select all that apply; distinguish Web from native mobile apps.
multiple: true
options:
- 桌面客户端(Windows / macOS / Linux) / Desktop client
- iOS / iPadOS(原生 / Native)
- Android(原生 / Native)
- 鸿蒙 PC / HarmonyOS PC
- 鸿蒙手机或平板 / HarmonyOS mobile
- Web 客户端 / Web client
- 服务端 / Server
- 其他 / Other
validations:
required: true
- type: textarea
id: controller
attributes:
label: 控制端或本机环境 / Controller or local environment
description: |
控制端是发起远程操作的设备。请填写完整系统和客户端版本;Web 用户另填浏览器及其版本。自行构建请附提交号。
仅涉及安装、启动或服务端时,在此填写发生问题的本机环境。
The controller initiates remote access. Include full OS and client versions; Web users should also include the browser and its version. For source builds, include the commit.
For installation, startup, or server-only issues, describe the affected local environment here.
placeholder: |
操作系统及版本 / OS and version:
CPU 架构(x64 / arm64 等) / CPU architecture:
CrossDesk 版本或提交号 / CrossDesk version or commit:
浏览器及版本(仅 Web) / Browser and version (Web only):
validations:
required: true
- type: textarea
id: host
attributes:
label: 被控端环境 / Remote host environment
description: |
被控端是接受远程操作的电脑。连接或会话问题请补充这一端的信息;不涉及远程设备时可留空。
The host is the computer being controlled. Include its details for connection or session issues; leave empty if no remote device is involved.
placeholder: |
操作系统及版本 / OS and version:
CPU 架构(x64 / arm64 等) / CPU architecture:
CrossDesk 版本或提交号 / CrossDesk version or commit:
validations:
required: false
- type: dropdown
id: service
attributes:
label: 服务部署方式 / Server deployment
options:
- 公共服务 / Public service
- 自托管 / Self-hosted
- 不确定 / Unknown
- 不适用 / Not applicable
validations:
required: false
- type: dropdown
id: connection
attributes:
label: 连接方式 / Connection type
description: 可在会话的网络状态中查看;无法确认时选择“不确定”。Check the session's network status, or choose Unknown.
options:
- P2P 直连 / Direct P2P
- TURN 中继 / TURN relay
- 尚未建立连接 / Connection not established
- 不确定 / Unknown
- 不适用 / Not applicable
validations:
required: false
- type: textarea
id: context
attributes:
label: 补充环境 / Additional environment
description: |
按问题补充相关信息,例如普通会话或桌面墙、自托管服务端版本、局域网或公网、VPN;画面问题可补充显卡、编码格式、硬件编解码设置和显示器情况。
Include relevant details such as a normal session or DeskHub, self-hosted server version, LAN or Internet, and VPN use. For video issues, add GPU, codec, hardware codec settings, and display configuration.
label: 使用环境(选填) / Environment (optional)
description: 操作系统和 CrossDesk 版本;连接问题可填写两端信息。OS and CrossDesk version; include both devices for connection issues.
placeholder: Windows 11 → macOS 15; CrossDesk x.y.z
validations:
required: false
- type: textarea
id: attachments
attributes:
label: 日志与截图 / Logs and screenshots
label: 截图或日志(选填) / Screenshots or logs (optional)
description: |
可粘贴相关日志,或拖入日志文件、截图和录屏。上传前请隐去连接密码等敏感信息。
Paste relevant logs or attach log files, screenshots, and recordings. Redact connection passwords and other private information before uploading.
可直接拖入截图、录屏或日志文件,请隐去密码等敏感信息。
Drag in screenshots, recordings, or logs. Hide passwords and other sensitive information.
validations:
required: false
+6 -51
View File
@@ -7,66 +7,21 @@ body:
- type: markdown
attributes:
value: |
提交前请搜索 [已有建议 / Existing requests](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
Please search existing requests before submitting. You can write in Chinese or English.
- type: textarea
id: use-case
attributes:
label: 使用场景与问题 / Use case and problem
description: 你希望完成什么?目前遇到什么限制?What are you trying to do, and what limitation do you encounter?
validations:
required: true
只需描述你的建议,其余选填。中文或英文均可。
Only a description of your idea is required. Chinese or English is welcome.
- type: textarea
id: proposal
attributes:
label: 功能建议与预期效果 / Proposal and expected outcome
description: 描述希望新增或改进的行为,以及理想的使用方式。Describe the behavior you would like to add or improve and how you expect to use it.
label: 你的建议 / Your idea
description: 希望新增或改进什么?如方便,也请说明使用场景。What would you like to add or improve? If possible, describe when you would use it.
validations:
required: true
- type: dropdown
id: scope
attributes:
label: 适用范围 / Scope
description: 可多选;通用建议可选择“跨平台或通用”。Select all that apply, or choose Cross-platform or general.
multiple: true
options:
- 跨平台或通用 / Cross-platform or general
- 桌面客户端(Windows / macOS / Linux) / Desktop client
- iOS / iPadOS
- Android
- 鸿蒙 PC / HarmonyOS PC
- 鸿蒙手机或平板 / HarmonyOS mobile
- Web 客户端 / Web client
- 桌面墙 / DeskHub
- 自托管服务 / Self-hosted services
- 文档或安装体验 / Documentation or installation
- 其他 / Other
validations:
required: false
- type: textarea
id: impact
attributes:
label: 影响范围与使用频率 / Impact and frequency
description: 哪些用户或场景会受益?你会多频繁地使用?Who would benefit, in which situations, and how often would you use this?
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: 替代方案 / Alternatives
description: 目前如何处理这个需求?是否考虑过其他实现方式?How do you handle this today, and what other approaches have you considered?
validations:
required: false
- type: textarea
id: references
attributes:
label: 参考资料与补充说明 / References and additional context
description: 可提供参考链接、截图、示意图或其他要求。Include links, screenshots, mockups, or any additional requirements.
label: 补充说明(选填) / Additional context (optional)
description: 可附截图、参考链接或其他说明。Add screenshots, links, or any other details.
validations:
required: false
+29 -5
View File
@@ -22,13 +22,14 @@
CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [IP 直连](#lan-direct) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
<a id="features"></a>
## 主要功能
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
@@ -61,22 +62,42 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
## 快速连接
1. **准备被控电脑。** 安装并运行 CrossDesk,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
### 通过设备 ID 连接
1. **准备被控电脑。** 安装并运行 CrossDesk,切换至 ID 连接模式,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
<a id="lan-direct"></a>
### 通过局域网 IP 直连
1. **切换模式。** 两台电脑均运行支持 IP 直连的桌面客户端,点击左上角模式按钮切换到 **IP 直连模式**。若按钮未显示,在 **设置 → 常规 → 网络连接** 中勾选 **启用 IP 直连** 并保存。
2. **填写地址。** 在控制端输入被控电脑显示的 `IP:端口`,例如 `192.168.1.20:52888`,点击“连接”。只输入 IP 时使用默认端口 **52888**;请以对方界面显示的实际端口为准。
3. **输入密码。** 输入被控电脑在 IP 直连模式下显示的 **6 位英文字母或数字密码**,可勾选“记住密码”。切换模式时,“近期连接”同步切换为对应模式的设备记录。
两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改各 IP 的端口和密码;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。
<a id="preview"></a>
## 界面预览
### 桌面客户端
本机凭据、远程连接和近期设备集中在主窗口,左上角进入桌面墙。
本机凭据、远程连接和近期设备集中在主窗口,左上角提供连接模式切换和桌面墙入口。
<p align="center">
<img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk 主窗口:本机凭据、远程连接入口和近期设备;数据为演示" />
<img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk ID 连接模式:模式切换、本机 ID、远程连接入口和近期设备;数据为演示" />
</p>
### IP 直连模式
切换本机 IP、修改端口与密码,通过对方地址连接;下方展示独立的局域网近期设备。
<p align="center">
<img src="docs/images/desktop-lan-zh.png" width="800" alt="CrossDesk IP 直连模式:本机 IP 和端口编辑入口、六位密码、远端地址输入和局域网近期设备;数据为演示" />
</p>
<a id="deskhub"></a>
@@ -96,7 +117,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
## 会话操作与设置
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置编码、中继、自托管与桌面墙画面预算。
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
@@ -151,9 +172,12 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
桌面端的 `CROSSDESK_UPDATE_PUBLIC_KEY` 是可选编译参数,用于验证应用内升级清单的 Ed25519 签名。不配置仍可正常编译、运行和检查版本,但更新只能手动下载;需要应用内自动安装更新时,须配置与发布私钥匹配的公钥。配置方式见 [更新公钥](docs/BUILD.md#update-public-key),密钥生成与发布流程见 [桌面端应用内升级](docs/UPDATER.md)。
<a id="sponsor"></a>
## 支持 CrossDesk
+29 -5
View File
@@ -22,13 +22,14 @@
CrossDesk connects your computers from desktop, browser, and mobile clients, with **DeskHub** for viewing and controlling multiple devices together. It is built on [MiniRTC](https://github.com/kunkundi/minirtc) and supports self-hosting.
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [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) · [Direct IP](#lan-direct) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
<a id="features"></a>
## Features
- **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.
- **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.
@@ -61,22 +62,42 @@ Requirements follow the current source and build configuration. Check the releas
## Quick start
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the server-connected status, then find its ID and password under **Local Desktop**.
### Connect by device ID
1. **Prepare the host.** Install and run CrossDesk on the computer to control, select ID mode, and wait for the server-connected status, then find its ID and password under **Local Desktop**.
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.
<a id="lan-direct"></a>
### Connect directly over LAN
1. **Switch modes.** Run a desktop client with direct IP support on both computers. Click the top-left mode button to select **Direct IP mode**. If the button is hidden, select **Enable direct IP** under **Settings → General → Network** and save.
2. **Enter the address.** On the controller, enter the host's displayed `IP:port`, for example `192.168.1.20:52888`, and click **Connect**. Omitting the port uses **52888**; use the actual port shown on the host.
3. **Enter the password.** Use the host's **six-character ASCII letter/digit password** shown in Direct IP mode. You can select **Remember password**. Switching modes also switches the recent device list.
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. 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="preview"></a>
## Preview
### Desktop client
Local credentials, remote connections, and recent devices share the main window. Open DeskHub from the top-left corner.
Local credentials, remote connections, and recent devices share the main window. The top-left controls switch connection modes and open DeskHub.
<p align="center">
<img src="docs/images/desktop-main-en.png" width="800" alt="CrossDesk main window with local credentials, remote connection controls, and recent devices; demonstration data" />
<img src="docs/images/desktop-main-en.png" width="800" alt="CrossDesk ID mode with the mode switch, local ID, remote connection controls, and recent devices; demonstration data" />
</p>
### Direct IP mode
Select the local IP, edit its port and password, and connect using the other desktop's address. Recent LAN devices have their own list below.
<p align="center">
<img src="docs/images/desktop-lan-en.png" width="800" alt="CrossDesk Direct IP mode with local IP and port editing, a six-character password, remote address input, and recent LAN devices; demonstration data" />
</p>
<a id="deskhub"></a>
@@ -96,7 +117,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 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, 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.
@@ -151,9 +172,12 @@ Host your own signaling and TURN services, then enable them under **Settings →
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
- [Connection architecture](docs/connection-architecture.md) (Chinese): discovery, authentication, negotiation, data paths, and lifecycle for ID and direct IP modes.
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
- [Report an issue or suggestion](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
The optional desktop build parameter `CROSSDESK_UPDATE_PUBLIC_KEY` verifies Ed25519 signatures on in-app update manifests. Without it, the app still builds, runs, and checks for new versions, but updates require manual download. In-app update installation requires a public key matching the release signing private key. See [Update public key](docs/BUILD_EN.md#update-public-key) for configuration and the [desktop updater guide](docs/UPDATER.md) (Chinese) for key generation and publishing.
<a id="sponsor"></a>
## Support CrossDesk
+18
View File
@@ -0,0 +1,18 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SCREEN_LOCK_H_
#define _SCREEN_LOCK_H_
namespace crossdesk {
// Called on the application thread. True means the OS accepted the request,
// not that the asynchronous transition to the lock screen has finished.
bool LockScreen();
} // namespace crossdesk
#endif
@@ -68,6 +68,13 @@ int ConfigCenter::Load() {
language_ = static_cast<LANGUAGE>(language_value);
}
enable_ip_direct_ = ini_->GetBoolValue(section_, "enable_ip_direct", true);
connection_mode_ =
enable_ip_direct_ &&
std::strcmp(ini_->GetValue(section_, "connection_mode", "id"), "ip") == 0
? CONNECTION_MODE::DIRECT_IP
: CONNECTION_MODE::DEVICE_ID;
const long screen_capture_method_value = ini_->GetLongValue(
section_, "screen_capture_method",
static_cast<long>(ScreenCaptureMethod::Auto));
@@ -130,6 +137,8 @@ int ConfigCenter::Load() {
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
enable_privacy_screen_.store(
ini_->GetBoolValue(section_, "enable_privacy_screen", true));
lock_on_disconnect_.store(
ini_->GetBoolValue(section_, "lock_on_disconnect", true));
save_remote_previews_.store(
ini_->GetBoolValue(section_, "save_remote_previews", true));
portable_service_prompt_suppressed_ =
@@ -201,6 +210,9 @@ int ConfigCenter::StoreValues(
int ConfigCenter::Save() {
ini_->SetLongValue(section_, "language", static_cast<long>(language_));
ini_->SetValue(section_, "connection_mode",
connection_mode_ == CONNECTION_MODE::DIRECT_IP ? "ip" : "id");
ini_->SetBoolValue(section_, "enable_ip_direct", enable_ip_direct_);
ini_->SetLongValue(section_, "screen_capture_method",
static_cast<long>(screen_capture_method_));
ini_->SetLongValue(section_, "video_encode_format",
@@ -222,6 +234,7 @@ int ConfigCenter::Save() {
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
ini_->SetBoolValue(section_, "enable_privacy_screen",
enable_privacy_screen_.load());
ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
ini_->SetBoolValue(section_, "save_remote_previews",
save_remote_previews_.load());
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
@@ -249,6 +262,34 @@ int ConfigCenter::SetLanguage(LANGUAGE language) {
return 0;
}
int ConfigCenter::SetConnectionMode(CONNECTION_MODE mode) {
if (mode != CONNECTION_MODE::DEVICE_ID && mode != CONNECTION_MODE::DIRECT_IP) {
return -1;
}
if (mode == CONNECTION_MODE::DIRECT_IP && !enable_ip_direct_) return -1;
if (mode == connection_mode_) return 0;
if (StoreValues({{"connection_mode",
mode == CONNECTION_MODE::DIRECT_IP ? "ip" : "id"}}) != 0) {
return -1;
}
connection_mode_ = mode;
return 0;
}
int ConfigCenter::SetIpDirectEnabled(bool enabled) {
if (enabled == enable_ip_direct_) return 0;
const auto mode = enabled ? connection_mode_ : CONNECTION_MODE::DEVICE_ID;
// Persist availability and the fallback mode together, so a failed save
// leaves both the current runtime and the next launch unchanged.
if (StoreValues({{"enable_ip_direct", enabled ? "true" : "false"},
{"connection_mode", mode == CONNECTION_MODE::DIRECT_IP
? "ip" : "id"}}) != 0)
return -1;
enable_ip_direct_ = enabled;
connection_mode_ = mode;
return 0;
}
int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
return -1;
@@ -278,10 +319,7 @@ int ConfigCenter::SetTurnMode(TURN_MODE mode) {
return 0;
}
int ConfigCenter::SetTurn(bool enable_turn, bool force_relay) {
if (!enable_turn) {
return SetTurnMode(TURN_MODE::DISABLED);
}
int ConfigCenter::SetForceRelay(bool force_relay) {
if (!force_relay) {
return SetTurnMode(TURN_MODE::AUTO_UDP_TCP);
}
@@ -371,6 +409,17 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
return 0;
}
int ConfigCenter::SetLockOnDisconnect(bool enabled) {
if (StoreValues({{"lock_on_disconnect", enabled ? "true" : "false"}}) != 0)
return -1;
lock_on_disconnect_.store(enabled);
return 0;
}
bool ConfigCenter::IsLockOnDisconnect() const {
return lock_on_disconnect_.load();
}
int ConfigCenter::SetSaveRemotePreviews(bool enabled) {
if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) {
return -1;
@@ -409,6 +458,12 @@ int ConfigCenter::SetSponsorshipPromptDismissedVersion(
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; }
ConfigCenter::CONNECTION_MODE ConfigCenter::GetConnectionMode() const {
return connection_mode_;
}
bool ConfigCenter::IsIpDirectEnabled() const { return enable_ip_direct_; }
ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
return screen_capture_method_;
}
@@ -421,10 +476,6 @@ ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
return turn_mode_;
}
bool ConfigCenter::IsEnableTurn() const {
return turn_mode_ != TURN_MODE::DISABLED;
}
bool ConfigCenter::IsForceRelay() const {
return turn_mode_ == TURN_MODE::FORCE_UDP || turn_mode_ == TURN_MODE::FORCE_TCP;
}
+11 -2
View File
@@ -21,6 +21,7 @@ namespace crossdesk {
class ConfigCenter {
public:
enum class LANGUAGE { CHINESE = 0, ENGLISH = 1, RUSSIAN = 2 };
enum class CONNECTION_MODE { DEVICE_ID = 0, DIRECT_IP = 1 };
enum class VIDEO_ENCODE_FORMAT { H264 = 0, AV1 = 1 };
enum class TURN_MODE {
DISABLED = 0,
@@ -35,10 +36,12 @@ class ConfigCenter {
// write config
int SetLanguage(LANGUAGE language);
int SetConnectionMode(CONNECTION_MODE mode);
int SetIpDirectEnabled(bool enabled);
int SetScreenCaptureMethod(ScreenCaptureMethod method);
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
int SetTurnMode(TURN_MODE turn_mode);
int SetTurn(bool enable_turn, bool force_relay);
int SetForceRelay(bool force_relay);
int SetServerHost(const std::string& signal_server_host);
int SetServerPort(int signal_server_port);
int SetServerEndpoint(const std::string& address, int port);
@@ -46,6 +49,7 @@ class ConfigCenter {
int SetAutostart(bool enable_autostart);
int SetDaemon(bool enable_daemon);
int SetPrivacyScreen(bool enable_privacy_screen);
int SetLockOnDisconnect(bool enabled);
int SetSaveRemotePreviews(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed);
int SetSponsorshipPromptDismissedVersion(const std::string& version);
@@ -54,10 +58,11 @@ class ConfigCenter {
// read config
LANGUAGE GetLanguage() const;
CONNECTION_MODE GetConnectionMode() const;
bool IsIpDirectEnabled() const;
ScreenCaptureMethod GetScreenCaptureMethod() const;
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
TURN_MODE GetTurnMode() const;
bool IsEnableTurn() const;
bool IsForceRelay() const;
bool IsEnableSrtp() const;
std::string GetSignalServerHost() const;
@@ -68,6 +73,7 @@ class ConfigCenter {
bool IsEnableAutostart() const;
bool IsEnableDaemon() const;
bool IsEnablePrivacyScreen() const;
bool IsLockOnDisconnect() const;
bool IsSaveRemotePreviews() const;
bool IsPortableServicePromptSuppressed() const;
std::string GetSponsorshipPromptDismissedVersion() const;
@@ -86,6 +92,8 @@ class ConfigCenter {
const char* section_ = "Settings";
LANGUAGE language_ = LANGUAGE::CHINESE;
CONNECTION_MODE connection_mode_ = CONNECTION_MODE::DEVICE_ID;
bool enable_ip_direct_ = true;
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
@@ -97,6 +105,7 @@ class ConfigCenter {
bool enable_autostart_ = false;
bool enable_daemon_ = false;
std::atomic<bool> enable_privacy_screen_{true};
std::atomic<bool> lock_on_disconnect_{true};
std::atomic<bool> save_remote_previews_{true};
bool portable_service_prompt_suppressed_ = false;
std::string sponsorship_prompt_dismissed_version_;
@@ -42,6 +42,7 @@
#include "features/gallery/gallery_window_state.h"
#include "features/gallery/gallery_workspace.h"
#include "features/gallery/preview_bandwidth.h"
#include "features/gallery/preview_resolution.h"
#include "filesystem_utf8.h"
#include "localization.h"
#include "platform.h"
@@ -1034,6 +1035,9 @@ struct GuiApplication::SlintUi {
bool portable_service_dialog_initialized = false;
bool sponsorship_prompt_pending = false;
std::string recent_model_signature;
gallery::PreviewResolutionDebouncer recent_preview_resolution;
gallery::PreviewResolution recent_preview_size;
std::unordered_map<std::string, slint::Image> recent_preview_images;
std::optional<std::string> recent_group_filter;
std::vector<std::string> recent_filter_groups;
slint::Timer timer;
@@ -1162,6 +1166,7 @@ int GuiApplication::Run() {
updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath());
#endif
InitializeUi();
InitializeLan();
#ifndef __OHOS__
ui_->activation_receiver = std::move(activation_receiver);
#endif
@@ -1296,6 +1301,10 @@ void GuiApplication::InitializeUi() {
slint::set_xdg_app_id("crossdesk");
#endif
ui_ = std::make_unique<SlintUi>();
for (const auto& window : {ui_->main, ui_->wall})
window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled());
ui_->main->set_lan_mode(config_center_->GetConnectionMode() ==
ConfigCenter::CONNECTION_MODE::DIRECT_IP);
#if _WIN32
ui_->shutdown_guard =
std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_);
@@ -1436,7 +1445,6 @@ void GuiApplication::InitializeSystemTray() {
};
auto exit_app = [this] {
exit_ = true;
slint::quit_event_loop();
};
#if _WIN32
ui_->tray = std::make_unique<WinTray>(
@@ -1484,7 +1492,8 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
auto& main = ui_->Window(gallery_window);
main->set_language_index(language_button_value_);
main->set_codec_index(video_encode_format_button_value_);
main->set_turn_enabled(enable_turn_);
main->set_ip_direct_enabled_draft(config_center_->IsIpDirectEnabled());
main->set_settings_save_error("");
main->set_force_relay_enabled(config_center_->IsForceRelay());
main->set_self_hosted_enabled(enable_self_hosted_);
main->set_autostart_enabled(enable_autostart_);
@@ -1495,6 +1504,7 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
main->set_privacy_on_connect_enabled(
config_center_->IsEnablePrivacyScreen());
main->set_lock_on_disconnect_enabled(config_center_->IsLockOnDisconnect());
#ifdef _WIN32
main->set_capture_method_visible(true);
main->set_capture_method_index(
@@ -1502,8 +1512,10 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
#endif
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
main->set_privacy_setting_available(true);
main->set_lock_on_disconnect_available(true);
#else
main->set_privacy_setting_available(false);
main->set_lock_on_disconnect_available(false);
#endif
#if defined(__OHOS__)
// The sandbox default is not the system desktop. Show the actual receive
@@ -1544,6 +1556,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta);
});
main->on_open_gallery([this] { ShowGalleryWindow(); });
main->on_connection_mode_changed([this, main](bool lan_mode) {
const auto mode = lan_mode ? ConfigCenter::CONNECTION_MODE::DIRECT_IP
: ConfigCenter::CONNECTION_MODE::DEVICE_ID;
if (config_center_->SetConnectionMode(mode) != 0) {
LOG_WARN("Could not save connection mode preference");
main->set_offline_warning(UiText(
localization_language_index_ == 0
? "无法保存连接模式,下次启动可能不会恢复当前模式。"
: localization_language_index_ == 2
? "Не удалось сохранить режим подключения. При следующем запуске он может не восстановиться."
: "Could not save the connection mode. It may not be restored on the next launch."));
}
});
main->on_toggle_gallery_fullscreen(
[this] { ToggleGalleryFullscreen(); });
main->on_gallery_action(
@@ -1678,8 +1703,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
show_password_ = !show_password_;
main->set_password_visible(show_password_);
});
main->on_reset_password([this](slint::SharedString value) {
return RequestPasswordChange(std::string(value));
main->on_reset_password([this, main](slint::SharedString value) {
const std::string password(value);
if (!main->get_lan_mode()) return RequestPasswordChange(password);
if (!lan::ValidPassword(password)) return false;
if (!LanAvailable()) return true; // The initialization error is shown on the main view.
try {
lan_error_.clear();
lan_->SetPassword(password);
main->set_lan_password_pending(lan_->password_pending());
} catch (const std::exception& error) {
lan_error_ = error.what();
}
return true;
});
main->on_refresh_password([this] {
const auto password = GenerateRandomPassword(password_saved_);
@@ -1691,6 +1727,106 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
}
RequestPasswordChange(*password);
});
main->on_lan_select_address([this, main](slint::SharedString address) {
if (!LanAvailable()) {
main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_));
return false;
}
try {
lan_->identity().SelectAddress(std::string(address));
const auto port =
UiText(std::to_string(lan_->identity().PortFor(std::string(address))));
main->set_lan_listener_ip(address);
main->set_lan_listener_port(port);
main->set_lan_local_port(port);
main->set_lan_port_dirty(false);
main->set_lan_endpoint_error("");
return true;
} catch (const std::exception& error) {
main->set_lan_endpoint_error(UiText(error.what()));
return false;
}
});
main->on_lan_configure([this, main](slint::SharedString address,
slint::SharedString port) {
if (!LanAvailable()) {
main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_));
return false;
}
try {
const auto port_number = lan::ParsePort(std::string(port));
lan_->Configure(std::string(address), port_number);
const auto normalized_port = UiText(std::to_string(port_number));
main->set_lan_listener_ip(address);
main->set_lan_listener_port(normalized_port);
main->set_lan_local_port(normalized_port);
main->set_lan_port_dirty(false);
lan_error_.clear();
main->set_lan_endpoint_error("");
return true;
} catch (const std::exception& error) {
main->set_lan_endpoint_error(UiText(error.what()));
return false;
}
});
main->on_lan_reset_password([this] {
if (LanAvailable()) {
lan_error_.clear();
lan_->ResetPassword();
}
});
main->on_lan_copy_text([this](slint::SharedString value) {
if (!SDL_SetClipboardText(std::string(value).c_str())) {
lan_error_ = "Cannot copy text";
return false;
}
return true;
});
main->on_lan_prepare_connect([this, main](slint::SharedString endpoint) {
try {
const auto [address, port] = lan::ParseEndpoint(Trim(std::string(endpoint)), 52888);
if (!LanAvailable()) return UiText(lan_error_.empty() ? "Busy" : lan_error_);
main->set_lan_remote_address(UiText(address));
main->set_lan_remote_port(UiText(std::to_string(port)));
main->set_lan_retry_after(lan_->RetryAfter(address, port));
// History is newest first. Match both address and port, and only use
// the latest matching record when an endpoint has been reused.
for (const auto& [_, recent] : recent_connections_) {
if (recent.lan.device_id.empty() || recent.lan.address != address ||
recent.lan.port != port)
continue;
if (recent.remember_password && lan::ValidPassword(recent.password)) {
main->set_lan_access_password(UiText(recent.password));
main->set_lan_remember_password(true);
}
break;
}
lan_error_.clear();
main->set_lan_message("");
return slint::SharedString{};
} catch (const std::exception& error) {
return UiText(error.what());
}
});
main->on_lan_cancel_connect([this, main] {
if (lan_ && lan_connecting_) lan_->Close(lan_connecting_);
lan_connecting_.reset();
main->set_lan_connecting(false);
lan_error_.clear();
main->set_lan_message("");
});
main->on_lan_connect([this, main](slint::SharedString address,
slint::SharedString port,
slint::SharedString password, bool remember) {
try {
ConnectLan(std::string(address), lan::ParsePort(std::string(port)),
std::string(password), remember);
} catch (const std::exception& error) {
lan_error_ = error.what();
}
main->set_lan_connecting(lan_connecting_ != nullptr);
main->set_lan_message(UiText(lan_error_));
});
main->on_connect_requested(
[this](slint::SharedString id) { ConnectFromUi(std::string(id)); });
main->on_format_remote_id([](slint::SharedString value) {
@@ -1703,7 +1839,7 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
if (auto props = FindRemoteSession(remote_id);
props && props->connection_status_ == ConnectionStatus::Connected &&
!props->closing_) {
CloseStreamTab(remote_id);
CloseStreamTab(props->remote_id_);
SyncMainWindow();
}
});
@@ -2266,9 +2402,101 @@ void GuiApplication::BindServerCallbacks() {
void GuiApplication::Tick() {
if (exit_) {
slint::quit_event_loop();
BeginShutdown();
if (ShutdownDrained()) slint::quit_event_loop();
return;
}
TickLan();
if (LanAvailable()) {
const auto address = lan_->identity().settings().value("address", "");
ui_->main->set_lan_password_pending(lan_->password_pending());
ui_->main->set_lan_listening(lan_->listening());
if (lan_connecting_ && lan_connecting_->authorized) {
ui_->main->set_lan_open(false);
lan_connecting_.reset();
} else if (lan_connecting_ && lan_connecting_->closing) {
lan_connecting_.reset();
}
ui_->main->set_lan_connecting(lan_connecting_ != nullptr);
int retry_after = 0;
if (ui_->main->get_lan_open()) {
try {
retry_after = lan_->RetryAfter(
std::string(ui_->main->get_lan_remote_address()),
lan::ParsePort(std::string(ui_->main->get_lan_remote_port())));
} catch (const std::exception&) {
// An incomplete address has no cooldown; input validation handles it.
}
}
ui_->main->set_lan_retry_after(retry_after);
if (auto password = lan_->TakeNewPassword(); !password.empty())
ui_->main->set_lan_new_password(UiText(password));
const auto& available_interfaces = lan_->interfaces();
const auto previous = ui_->main->get_lan_interfaces();
bool changed = !previous || previous->row_count() != available_interfaces.size();
for (size_t i = 0; !changed && i < available_interfaces.size(); ++i)
changed = std::string_view(previous->row_data(i).value()) !=
available_interfaces[i].address;
if (changed) {
std::vector<slint::SharedString> addresses;
addresses.reserve(available_interfaces.size());
for (const auto& network_interface : available_interfaces)
addresses.push_back(UiText(network_interface.address));
ui_->main->set_lan_interfaces(
std::make_shared<slint::VectorModel<slint::SharedString>>(std::move(addresses)));
}
// Keep the current displayed address while it remains available, including
// when a previously missing preferred address returns during a port edit.
const auto previous_address = std::string(ui_->main->get_lan_listener_ip());
auto display_address = previous_address.empty() ? address : previous_address;
int interface_index = -1;
for (size_t i = 0; i < available_interfaces.size(); ++i)
if (available_interfaces[i].address == display_address) {
interface_index = static_cast<int>(i);
break;
}
if (interface_index < 0) {
interface_index = available_interfaces.empty() ? -1 : 0;
display_address = interface_index == 0
? available_interfaces.front().address : "";
}
const auto port =
UiText(std::to_string(lan_->identity().PortFor(display_address)));
if (previous_address != display_address) {
// A disappearing address must not transfer its unfinished port edit to
// another interface. Explicit selection commits the old edit first.
if (!previous_address.empty() && ui_->main->get_lan_port_dirty()) {
ui_->main->set_lan_access_warning_endpoint(UiText(previous_address));
ui_->main->set_lan_access_warning("Selected LAN interface is unavailable");
}
ui_->main->set_lan_port_dirty(false);
ui_->main->set_lan_endpoint_error("");
ui_->main->set_lan_local_port(port);
}
ui_->main->set_lan_listener_ip(UiText(display_address));
ui_->main->set_lan_listener_port(port);
ui_->main->set_lan_interface_index(interface_index);
ui_->main->set_lan_interface_draft_index(interface_index);
if (!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty())
ui_->main->set_lan_local_port(port);
ui_->main->set_lan_listener_error(UiText(
display_address.empty() ? "Selected LAN interface is unavailable"
: lan_->ListenerError(display_address)));
// Show failures one address at a time, without interrupting edits or another
// dialog. A working listener must not clear another address's error.
if (ui_->main->get_lan_mode() && ui_->main->get_can_show_sponsorship() &&
!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty()) {
if (auto issue = lan_->TakeListenerWarning()) {
ui_->main->set_lan_access_warning_endpoint(
UiText(issue->address + ":" + std::to_string(issue->port)));
ui_->main->set_lan_access_warning(UiText(issue->error));
}
}
}
const auto lan_message = lan_error_.rfind("ConnectionFailed", 0) == 0
? std::string("ConnectionFailed")
: lan_error_;
ui_->main->set_lan_message(UiText(lan_message));
#if _WIN32
if (ui_->shutdown_guard) {
ui_->shutdown_guard->Update(ui_->shutdown_reason);
@@ -2356,11 +2584,13 @@ void GuiApplication::Tick() {
HandleRecentConnections();
if (was_reloading_recent_connections && !reload_recent_connections_) {
ui_->recent_model_signature.clear();
ui_->recent_preview_images.clear();
}
HandleConnectionStatusChange();
HandlePendingPresenceProbe();
HandlePresenceProbeTimeout();
HandlePrivacy();
HandleScreenLock();
HandleWindowsServiceIntegration();
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
SyncXWaylandWindowActivation();
@@ -2461,7 +2691,7 @@ void GuiApplication::ShareLocalCursorState() {
}
}
}
if (!is_server_mode_ || !peer_ || connected_controllers.empty()) {
if (!is_server_mode_ || connected_controllers.empty()) {
cursor_delivery_states_.clear();
return;
}
@@ -2587,9 +2817,8 @@ void GuiApplication::ShareLocalCursorState() {
action.type = ControlType::cursor_state;
action.cs = outgoing;
const std::string message = action.to_json();
const int result = SendReliableDataFrameToPeer(
peer_, message.c_str(), message.size(), mouse_label_.c_str(),
recipient.remote_id.c_str(), recipient.remote_id.size());
const int result = SendControllerData(recipient.remote_id, message.c_str(),
message.size(), mouse_label_.c_str());
if (result != 0) {
LOG_WARN("Send cursor state to [{}] failed, ret={}",
recipient.remote_id, result);
@@ -3008,8 +3237,9 @@ void GuiApplication::SyncMainWindow() {
std::vector<slint::SharedString> recent_groups{
tr_group("全部设备", "All devices", "Все устройства"),
tr_group("未分组", "Ungrouped", "Без группы")};
const bool lan_mode = ui_->main->get_lan_mode();
int recent_group_index = 0;
if (ui_->recent_group_filter) {
if (!lan_mode && ui_->recent_group_filter) {
if (ui_->recent_group_filter->empty()) recent_group_index = 1;
else {
const auto found = std::find(ui_->gallery.groups.begin(),
@@ -3046,12 +3276,26 @@ void GuiApplication::SyncMainWindow() {
return props && props->connection_status_ == ConnectionStatus::Connected &&
!props->closing_;
};
const auto preview_size = ui_->recent_preview_resolution.Update(
gallery::PreviewResolution::Viewport(
ui_->main->get_recent_preview_width(),
ui_->main->get_recent_preview_height(),
ui_->main->window().scale_factor()),
gallery::PreviewResolutionDebouncer::Clock::now());
const bool save_previews = config_center_->IsSaveRemotePreviews();
if (!(preview_size == ui_->recent_preview_size) || !save_previews) {
ui_->recent_preview_size = preview_size;
ui_->recent_preview_images.clear();
}
std::ostringstream signature_builder;
signature_builder << config_center_->IsSaveRemotePreviews() << '\n'
signature_builder << save_previews << '\n'
<< preview_size.width << ':' << preview_size.height << '\n'
<< lan_mode << '\n'
<< bool(ui_->recent_group_filter) << '\0'
<< ui_->recent_group_filter.value_or("") << '\n';
for (const auto& [key, connection] : recent_connections_) {
signature_builder << key << '\0' << connection.remote_id << '\0'
<< connection.lan.address << ':' << connection.lan.port << '\0'
<< connection.remote_host_name << '\0'
<< static_cast<int>(connection.platform) << '\0'
<< settings_.RecentConnectionDisplayName(connection)
@@ -3072,7 +3316,9 @@ void GuiApplication::SyncMainWindow() {
std::vector<ui::RecentConnection> model;
model.reserve(recent_connections_.size());
for (const auto& [_, connection] : recent_connections_) {
if (!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter))
if (lan_mode != !connection.lan.device_id.empty()) continue;
if (!lan_mode &&
!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter))
continue;
// Slint-generated scalar fields have no defaults. With no image,
// has_thumbnail must remain false so the remote platform is rendered.
@@ -3082,21 +3328,34 @@ void GuiApplication::SyncMainWindow() {
item.group_names = UiText(group_labels[connection.remote_id]);
item.display_name =
UiText(settings_.RecentConnectionDisplayName(connection));
item.online = device_presence_cache_.IsOnline(connection.remote_id);
item.lan = !connection.lan.device_id.empty();
item.endpoint = UiText(item.lan ? connection.lan.address + ":" +
std::to_string(connection.lan.port)
: "");
item.online =
!item.lan && device_presence_cache_.IsOnline(connection.remote_id);
item.connected = is_connected(connection.remote_id);
item.platform = UiText(HostPlatformName(connection.platform));
std::vector<unsigned char> rgba;
int image_width = 0;
int image_height = 0;
if (config_center_->IsSaveRemotePreviews() &&
!connection.image_path.empty() &&
thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width,
&image_height)) {
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
image_height);
std::memcpy(pixels.begin(), rgba.data(), rgba.size());
item.thumbnail = slint::Image(std::move(pixels));
item.has_thumbnail = true;
if (save_previews && !connection.image_path.empty()) {
const auto path = connection.image_path.string();
auto cached = ui_->recent_preview_images.find(path);
if (cached == ui_->recent_preview_images.end()) {
std::vector<unsigned char> rgba;
int image_width = 0, image_height = 0;
if (thumbnail_->DecodeImage(connection.image_path, preview_size.width,
preview_size.height, &rgba, &image_width,
&image_height)) {
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
image_height);
std::memcpy(pixels.begin(), rgba.data(), rgba.size());
cached = ui_->recent_preview_images.emplace(
path, slint::Image(std::move(pixels))).first;
}
}
if (cached != ui_->recent_preview_images.end()) {
item.thumbnail = cached->second;
item.has_thumbnail = true;
}
}
model.push_back(std::move(item));
}
@@ -3163,7 +3422,9 @@ void GuiApplication::SyncConnectionDialog() {
} else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) {
text = localization::connection_not_authenticated[localization_language_index_];
} else {
text = ConnectionStatusText(status, localization_language_index_);
text = props->lan_session_ && !props->lan_session_->error.empty()
? props->lan_session_->error
: ConnectionStatusText(status, localization_language_index_);
}
if (auth.retry_after > 0) {
char retry_text[256];
@@ -4260,6 +4521,7 @@ bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) {
bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
const auto previous_server = GetSignalServerSettings(*config_center_);
auto& main = ui_->Window(gallery_window);
main->set_settings_save_error("");
const bool has_endpoint = main->get_self_hosted_enabled() ||
!std::string(main->get_server_host()).empty() ||
!std::string(main->get_server_port()).empty();
@@ -4298,10 +4560,29 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
main->set_self_host_settings_open(true);
return false;
}
if (config_center_->SetIpDirectEnabled(main->get_ip_direct_enabled_draft()) != 0) {
if (wall_changed && !ui_->gallery.Save(ui_->gallery_path))
LOG_ERROR("Failed to restore DeskHub settings after IP direct save failure");
main->set_settings_save_error(UiText(
localization_language_index_ == 0 ? "无法保存 IP 直连设置,请重试。"
: localization_language_index_ == 2 ? "Не удалось сохранить настройки IP. Повторите попытку."
: "Could not save direct IP settings. Try again."));
return false;
}
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
if (config_center_->SetLockOnDisconnect(main->get_lock_on_disconnect_enabled()) != 0) {
if (wall_changed && !ui_->gallery.Save(ui_->gallery_path))
LOG_ERROR("Failed to restore DeskHub settings after lock preference save failure");
main->set_settings_save_error(UiText(
localization_language_index_ == 0 ? "无法保存断开后锁屏设置,请重试。"
: localization_language_index_ == 2 ? "Не удалось сохранить настройку блокировки. Повторите попытку."
: "Could not save lock screen settings. Try again."));
return false;
}
#endif
language_button_value_ =
localization::detail::ClampLanguageIndex(main->get_language_index());
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
enable_turn_ = main->get_turn_enabled();
enable_self_hosted_ = main->get_self_hosted_enabled();
#if !defined(__OHOS__)
enable_autostart_ = main->get_autostart_enabled();
@@ -4317,7 +4598,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
config_center_->SetVideoEncodeFormat(
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
video_encode_format_button_value_));
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
config_center_->SetForceRelay(main->get_force_relay_enabled());
main->set_force_relay_enabled(config_center_->IsForceRelay());
#if !defined(__OHOS__)
config_center_->SetAutostart(enable_autostart_);
@@ -4383,7 +4664,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
localization_language_ = config_center_->GetLanguage();
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
enable_turn_ = config_center_->IsEnableTurn();
enable_self_hosted_ = config_center_->IsSelfHosted();
enable_autostart_ = config_center_->IsEnableAutostart();
enable_daemon_ = config_center_->IsEnableDaemon();
@@ -4397,7 +4677,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
language_button_value_last_ = language_button_value_;
video_encode_format_button_value_last_ = video_encode_format_button_value_;
enable_turn_last_ = enable_turn_;
enable_self_hosted_last_ = enable_self_hosted_;
enable_autostart_last_ = enable_autostart_;
enable_daemon_last_ = enable_daemon_;
@@ -4407,9 +4686,12 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
#endif
if (ui_) {
ui_->localized_language = -1;
for (const auto& window : {ui_->main, ui_->wall})
window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled());
if (!ui_->main->get_settings_open()) ResetSettingsUi();
if (!ui_->wall->get_settings_open()) ResetSettingsUi(true);
}
ApplyLanSettings();
if (reconnect && !HasActiveSession()) {
// Tick recreates the peer after asynchronous cleanup, using the committed
// configuration. Keep the application and its console running throughout.
@@ -4434,6 +4716,26 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
void GuiApplication::ConnectFromUi(const std::string& input) {
ui_->gallery_auth_id.clear();
if (input.rfind("lan-", 0) == 0) {
if (!LanAvailable()) return;
if (auto existing = FindRemoteSession(input);
existing && existing->connection_established_) {
existing->tab_opened_ = true;
focused_remote_id_ = existing->remote_id_;
need_to_create_stream_window_ = true;
return;
}
for (const auto& [_, recent] : recent_connections_)
if (recent.remote_id == input && !recent.lan.device_id.empty()) {
ui_->main->invoke_request_lan_connection(
UiText(recent.lan.address +
(recent.lan.port == 52888
? ""
: ":" + std::to_string(recent.lan.port))));
return;
}
return;
}
const std::string remote_id = CompactPeerId(input);
if (!gallery::Workspace::ValidId(remote_id)) {
ui_->main->set_connection_input_error(
@@ -5209,6 +5511,7 @@ void GuiApplication::GalleryAction(const std::string& action,
std::set<std::string> seen;
int available = 0;
for (const auto& [_, recent] : recent_connections_) {
if (!recent.lan.device_id.empty()) continue;
if (!gallery::Workspace::ValidId(recent.remote_id) ||
!seen.insert(recent.remote_id).second)
continue;
@@ -5697,8 +6000,9 @@ void GuiApplication::SyncGallery() {
};
const auto name = [this](const std::string& id) {
for (const auto& [_, recent] : recent_connections_)
if (recent.remote_id == id)
return settings_.RecentConnectionDisplayName(recent);
if (recent.lan.device_id.empty())
if (recent.remote_id == id)
return settings_.RecentConnectionDisplayName(recent);
Thumbnail::RecentConnection device{};
device.remote_id = id;
if (auto props = FindRemoteSession(id)) {
@@ -5716,7 +6020,7 @@ void GuiApplication::SyncGallery() {
{
std::shared_lock lock(remote_sessions_mutex_);
for (const auto& [id, props] : remote_sessions_)
if (props && !props->closing_ &&
if (props && !props->lan_session_ && !props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected)
connected_ids.push_back(id);
}
@@ -5816,11 +6120,12 @@ void GuiApplication::SyncGallery() {
bool remember = operation.remember_password;
if (!operation.password)
for (const auto& [_, recent] : recent_connections_)
if (recent.remote_id == operation.id) {
password = recent.password;
remember = recent.remember_password;
break;
}
if (recent.lan.device_id.empty())
if (recent.remote_id == operation.id) {
password = recent.password;
remember = recent.remember_password;
break;
}
// Join itself determines availability. Background probes must never
// overwrite the interactive page's single pending presence probe.
ConnectTo(operation.id, password.c_str(), remember, true, true);
@@ -5970,6 +6275,7 @@ void GuiApplication::SyncGallery() {
tile.selected = wall.selected.count(id);
auto props = FindRemoteSession(id);
for (const auto& [_, recent] : recent_connections_) {
if (!recent.lan.device_id.empty()) continue;
if (recent.remote_id != id) continue;
tile.host_name = UiText(recent.remote_host_name);
break;
@@ -6205,6 +6511,9 @@ void GuiApplication::SyncGallery() {
}
void GuiApplication::Cleanup() {
BeginShutdown();
while (!ShutdownDrained())
std::this_thread::sleep_for(std::chrono::milliseconds(2));
remote_input_.Stop();
#if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
@@ -6245,6 +6554,7 @@ void GuiApplication::Cleanup() {
keyboard_.ReleaseAllRemotePressedKeys("application_exit");
devices_.DestroyDevices();
privacy_.Shutdown();
HandleScreenLock();
devices_.DestroyFactories();
CloseAllRemoteSessions();
WaitForSessionCleanup();
@@ -37,7 +37,7 @@ struct TranslationRow {
X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \
X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk") \
X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \
X(console_help, u8"命令: status 查看ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status, settings, password, random, help, quit", u8"Команды: status, settings, password, random, help, quit") \
X(console_help, u8"命令: status 查看 ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status, settings, password, random, help, quit", u8"Команды: status, settings, password, random, help, quit") \
X(console_status, u8"状态", "Status", u8"Состояние") \
X(console_online, u8"在线", "Online", u8"В сети") \
X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
@@ -100,7 +100,7 @@ struct TranslationRow {
X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \
X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \
X(local_id, u8"本机ID", "Local ID", u8"Локальный ID") \
X(local_id, u8"本机 ID", "Local ID", u8"Локальный ID") \
X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
u8"Скопировано в буфер обмена") \
X(password, u8"密码", "Password", u8"Пароль") \
@@ -110,7 +110,7 @@ struct TranslationRow {
X(remote_desktop, u8"远程桌面", "Remote Desktop", \
u8"Удаленный рабочий стол") \
X(device_name, u8"设备名称", "Device Name", u8"Имя устройства") \
X(remote_id, u8"对端ID", "Remote ID", u8"Удаленный ID") \
X(remote_id, u8"对端 ID", "Remote ID", u8"Удаленный ID") \
X(connect, u8"连接", "Connect", u8"Подключиться") \
X(recent_connections, u8"近期连接", "Recent Connections", \
u8"Недавние подключения") \
@@ -145,16 +145,16 @@ struct TranslationRow {
X(audio_capture, u8"播放声音", "Audio Capture", u8"Воспроизведение звука") \
X(mute, u8" 静音", " Mute", u8"Без звука") \
X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Сочетания клавиш") \
X(send_sas, u8"发送SAS", "Send SAS", u8"Отправить SAS") \
X(send_sas, u8"发送 SAS", "Send SAS", u8"Отправить SAS") \
X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать") \
X(remote_password_box_visible, u8"远端密码框已出现", \
"Remote password box visible", u8"Окно ввода пароля видно") \
X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送SAS", \
X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送 SAS", \
"Remote lock screen visible, send SAS", \
u8"Видна блокировка, отправьте SAS") \
X(remote_secure_desktop_active, u8"远端已进入安全桌面", \
"Remote secure desktop active", u8"Активен защищенный рабочий стол") \
X(remote_service_unavailable, u8"远端Windows服务不可用", \
X(remote_service_unavailable, u8"远端 Windows 服务不可用", \
"Remote Windows service unavailable", \
u8"Служба Windows на удаленной стороне недоступна") \
X(windows_service_setup_title, u8"安装 CrossDesk Service", \
@@ -237,8 +237,6 @@ struct TranslationRow {
"Video Encode Format:", u8"Формат кодека видео:") \
X(av1, u8"AV1", "AV1", "AV1") \
X(h264, u8"H.264", "H.264", "H.264") \
X(enable_turn, u8"启用中继服务:", \
"Enable TURN Service:", u8"Включить TURN-сервис:") \
X(force_relay, u8"强制中继连接:", "Force Relay:", u8"Только через реле:") \
X(self_hosted_server_config, u8"自托管配置", "Self-Hosted Config", \
u8"Конфигурация self-hosted") \
@@ -288,7 +286,7 @@ struct TranslationRow {
X(connection_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", u8"Сеанс входа истёк. Подключитесь заново.") \
X(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \
X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \
X(no_such_id, u8"无此 ID", "No such ID", u8"ID не найден") \
X(about, u8"关于", "About", u8"О программе") \
X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \
X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
@@ -339,6 +337,8 @@ struct TranslationRow {
X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on connect:", \
u8"Приватность при подключении:") \
X(lock_on_disconnect, u8"连接断开后锁屏:", "Lock screen on disconnect:", \
u8"Блокировка при отключении:") \
X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \
u8"Вступит в силу после перезапуска") \
X(select_file, u8"选择文件发送", "Select File to Send", \
@@ -226,15 +226,11 @@ int ClipboardController::SendToPeers(const std::string &text) {
}
}
if (owner_.peer_) {
const int ret =
SendReliableDataFrame(owner_.peer_, text.data(), text.size(),
owner_.clipboard_label_.c_str());
if (ret != 0) {
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
owner_.remote_id_display_, ret);
return ret;
}
lock.unlock();
for (const auto& id : owner_.ConnectedControllers()) {
const int ret = owner_.SendControllerData(id, text.data(), text.size(),
owner_.clipboard_label_.c_str());
if (ret != 0) return ret;
}
return 0;
@@ -43,18 +43,17 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
SpeakerCapturerFactory().Create());
},
[this](unsigned char *data, size_t size, const char *) {
if (!owner_.peer_) return;
MiniRtcAudioFrame frame{};
frame.data = reinterpret_cast<const char *>(data);
frame.size = size;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
std::shared_lock lock(owner_.connection_status_mutex_);
for (const auto& [id, status] : owner_.connection_status_) {
if (status == ConnectionStatus::Connected)
SendAudioFrameToPeer(owner_.peer_, &frame,
owner_.audio_label_.c_str(), id.data(),
id.size());
}
for (const auto& id : owner_.ConnectedControllers())
owner_.WithController(
id, [&](PeerPtr* peer, const std::string& wire) {
frame.captured_timestamp = GetSystemTimeMicros(peer);
return SendAudioFrameToPeer(peer, &frame,
owner_.audio_label_.c_str(),
wire.data(), wire.size());
});
}) {}
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
@@ -132,6 +131,10 @@ void SessionDeviceManager::Initialize() {
DeviceControllerFactory::Device::Keyboard));
}
int SessionDeviceManager::EnsureCaptureReady() {
return screen_capturer_ ? 0 : InitializeScreenCapturer();
}
int SessionDeviceManager::InitializeScreenCapturer() {
#ifdef __APPLE__
if (!owner_.EnsureMacScreenRecordingPermission()) {
@@ -193,16 +196,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
.count()
: 0;
std::vector<std::string> connected_remote_ids;
{
std::shared_lock lock(owner_.connection_status_mutex_);
connected_remote_ids.reserve(owner_.connection_status_.size());
for (const auto &[remote_id, status] : owner_.connection_status_) {
if (status == ConnectionStatus::Connected) {
connected_remote_ids.push_back(remote_id);
}
}
}
const auto connected_remote_ids = owner_.ConnectedControllers();
// Capture can still deliver frames while ICE is gathering or after
// the final controller disconnects. Do not broadcast those frames to
@@ -232,28 +226,27 @@ int SessionDeviceManager::InitializeScreenCapturer() {
has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs;
const bool stream_changed = !last_video_frame_stream_id_.empty() &&
last_video_frame_stream_id_ != stream_id;
if (resumed_after_gap || stream_changed) {
if (RequestVideoKeyFrame(owner_.peer_, stream_id.c_str()) == 0) {
LOG_DEBUG("Request video key frame before sending captured frame, "
"stream='{}', gap_ms={}, stream_changed={}",
stream_id, duration_ms, stream_changed);
}
}
MiniRtcVideoFrame frame{};
frame.data = reinterpret_cast<const char *>(data);
frame.size = size;
frame.width = width;
frame.height = height;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
frame.native_frame = native_frame;
const auto send_started = std::chrono::steady_clock::now();
uint64_t send_errors = 0;
for (const std::string &remote_id : connected_remote_ids) {
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
stream_id)) continue;
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
remote_id.data(), remote_id.size()) != 0)
if (owner_.WithController(
remote_id, [&](PeerPtr* peer, const std::string& wire) {
if (resumed_after_gap || stream_changed)
RequestVideoKeyFrame(peer, stream_id.c_str());
frame.captured_timestamp = GetSystemTimeMicros(peer);
return SendVideoFrameToPeer(peer, &frame, stream_id.c_str(),
wire.data(), wire.size());
}) != 0)
++send_errors;
}
const auto send_us =
@@ -54,6 +54,7 @@ public:
void DestroyFactories();
int InitializeScreenCapturer();
int EnsureCaptureReady();
int StartScreenCapturer();
int StopScreenCapturer();
bool ReleaseRemoteMouseButtons();
@@ -94,13 +94,19 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_;
}
if (!props && file.remote_id.rfind("lan/", 0) == 0) {
events = owner_.ControllerEvents(file.remote_id);
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;
auto callback = events->EnterCallback();
if (!callback) return -1;
return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(),
file.remote_id.c_str(), file.remote_id.size());
if (!callback || !events->DataAllowed()) return -1;
const auto wire_id = events->WireId(file.remote_id);
return SendReliableDataFrameToPeer(
peer, data, size, file.file_label.c_str(),
wire_id.data(), wire_id.size());
}, file_transfer::Session::Progress{});
state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0;
state.current_file_id_ = id;
@@ -313,6 +319,10 @@ void FileTransferManager::Receive(const char* data, size_t size,
peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_;
}
if (!props && remote.rfind("lan/", 0) == 0) {
events = owner_.ControllerEvents(remote);
peer = events ? events->DirectPeer() : nullptr;
}
if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
std::shared_ptr<FileReceiver> receiver;
std::filesystem::path default_directory;
@@ -333,10 +343,12 @@ void FileTransferManager::Receive(const char* data, size_t size,
});
entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) {
auto callback = events->EnterCallback();
if (!callback) return -1;
if (!callback || !events->DataAllowed()) return -1;
const auto bytes = EncodeFileTransferAck(ack);
return SendReliableDataFrameToPeer(peer, bytes.data(), bytes.size(), kFileFeedbackStream,
remote.c_str(), remote.size());
const auto wire_id = events->WireId(remote);
return SendReliableDataFrameToPeer(
peer, bytes.data(), bytes.size(), kFileFeedbackStream,
wire_id.data(), wire_id.size());
});
}
receiver = entry.session;
@@ -465,7 +465,6 @@ int SettingsManager::Load() {
static_cast<int>(owner_.config_center_->GetLanguage()));
owner_.video_encode_format_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat());
owner_.enable_turn_ = owner_.config_center_->IsEnableTurn();
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
@@ -484,7 +483,6 @@ int SettingsManager::Load() {
owner_.language_button_value_last_ = owner_.language_button_value_;
owner_.video_encode_format_button_value_last_ =
owner_.video_encode_format_button_value_;
owner_.enable_turn_last_ = owner_.enable_turn_;
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
owner_.enable_autostart_last_ = owner_.enable_autostart_;
@@ -744,6 +742,8 @@ std::string SettingsManager::RecentConnectionDisplayName(
connection.remote_host_name != "unknown") {
return connection.remote_host_name;
}
if (!connection.lan.device_id.empty())
return connection.lan.address + ":" + std::to_string(connection.lan.port);
return connection.remote_id;
}
@@ -14,6 +14,7 @@
#include "platform.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#include "screen_lock.h"
#include "runtime/device_presence_request.h"
#include "runtime/gui_runtime.h"
@@ -26,6 +27,7 @@ constexpr auto kPresenceRetryInterval = std::chrono::seconds(1);
} // namespace
bool GuiRuntime::HasActiveSession() {
if (lan_ && !lan_->Quiescent()) return true;
if (stream_window_inited_) return true;
std::shared_lock lock(connection_status_mutex_);
return std::any_of(connection_status_.begin(), connection_status_.end(),
@@ -188,7 +190,8 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
void GuiRuntime::HandleServerControllerDisconnected(
const std::string& remote_id, const char* reason) {
transfers_.Cancel(peer_events_.get(), remote_id);
if (auto events = ControllerEvents(remote_id))
transfers_.Cancel(events.get(), remote_id);
preview_subscriptions_.Remove(remote_id);
bool has_connected_controller = false;
@@ -196,6 +199,8 @@ void GuiRuntime::HandleServerControllerDisconnected(
{
std::unique_lock lock(connection_status_mutex_);
connection_status_.erase(remote_id);
disconnect_lock_.Disconnected(remote_id,
config_center_->IsLockOnDisconnect());
remote_input_.Disconnect(remote_id);
devices_.OnControllerConnectionsChanged();
connection_host_names_.erase(remote_id);
@@ -243,11 +248,30 @@ void GuiRuntime::HandleServerControllerDisconnected(
#endif
devices_.StopSpeakerCapturer();
start_mouse_controller_ = false;
start_keyboard_capturer_ = false;
{
std::shared_lock lock(remote_sessions_mutex_);
start_keyboard_capturer_ = std::any_of(
remote_sessions_.begin(), remote_sessions_.end(), [](const auto& item) {
const auto& session = item.second;
return session && !session->closing_ &&
session->connection_established_ && !session->wall_managed_;
});
}
remote_client_id_.clear();
audio_capture_ = false;
}
void GuiRuntime::HandleScreenLock() {
// Serialize request consumption with incoming admission so a reconnect
// cancels a pending lock before it can interrupt the new controller.
std::unique_lock lock(connection_status_mutex_);
if (!disconnect_lock_.TakeRequest(config_center_->IsLockOnDisconnect()))
return;
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
if (LockScreen()) LOG_INFO("Requested screen lock after last controller disconnected");
#endif
}
int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
const char* password,
bool remember_password) {
@@ -294,10 +318,15 @@ int GuiRuntime::ForceSignalReconnect() {
}
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (!props || props->closing_.exchange(true)) return;
if (!props) return;
if (props->lan_session_ && !props->lan_session_->closing) {
if (lan_) lan_->Close(props->lan_session_);
return;
}
if (props->closing_.exchange(true)) return;
MuteRemoteAudio(props);
if (props->peer_events_) {
props->peer_events_->Deactivate();
props->peer_events_->StopAccepting();
transfers_.Cancel(props->peer_events_.get());
}
props->connection_established_ = false;
@@ -350,7 +379,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask(
[props, peer, frame_snapshot, video_width, video_height, platform,
thumbnail = thumbnail_, queued_at]() mutable {
if (peer) {
if (props->peer_events_) props->peer_events_->Deactivate();
if (peer && !props->lan_session_) {
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
props->remote_id_);
LeaveConnection(peer, props->remote_id_.c_str());
@@ -359,11 +389,20 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (thumbnail && props->was_connected_) {
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr;
const auto direct = props->lan_session_;
const Thumbnail::LanTarget target =
direct ? Thumbnail::LanTarget{direct->target.address,
direct->target.device_id,
direct->target.port}
: Thumbnail::LanTarget{};
const auto record_id = direct
? Thumbnail::LanRecordId(target.device_id)
: props->remote_id_;
if (thumbnail->SaveToThumbnail(
nv12, video_width, video_height, props->remote_id_,
nv12, video_width, video_height, record_id,
props->remote_host_name_,
props->remember_password_ ? props->remote_password_ : "",
platform) != 0) {
platform, direct ? &target : nullptr) != 0) {
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
}
}
@@ -375,6 +414,17 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
}
void GuiRuntime::CloseServerController(const std::string& remote_id) {
if (remote_id.rfind("lan/", 0) == 0) {
std::shared_ptr<lan::Session> session;
{
std::shared_lock lock(remote_sessions_mutex_);
if (auto it = lan_controllers_.find(remote_id);
it != lan_controllers_.end())
session = it->second.session;
}
if (session && lan_) lan_->Close(session);
return;
}
if (!peer_ || remote_id.empty() || controller_cleanup_tasks_.count(remote_id))
return;
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
@@ -392,20 +442,17 @@ void GuiRuntime::CloseConnectionPeer() {
announcements_.Reset();
return;
}
peer_events_->Deactivate();
peer_events_->StopAccepting();
transfers_.Cancel(peer_events_.get());
announcements_.Reset();
std::vector<std::string> controllers;
{
std::shared_lock lock(connection_status_mutex_);
for (const auto& [id, status] : connection_status_) controllers.push_back(id);
for (const auto& [id, status] : connection_status_)
if (id.rfind("lan/", 0) != 0) controllers.push_back(id);
}
for (const auto& id : controllers)
HandleServerControllerDisconnected(id, "peer_closed");
devices_.StopMouseController();
devices_.StopScreenCapturer();
devices_.StopSpeakerCapturer();
devices_.StopKeyboardCapturer();
signal_connected_ = false;
device_presence_cache_.SetSignalConnected(false);
signal_status_ = SignalStatus::SignalClosed;
@@ -418,6 +465,7 @@ void GuiRuntime::CloseConnectionPeer() {
// these finish before the shared peer is destroyed.
connection_peer_cleanup_ = session_cleanup_queue_.PostTask(
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
handler->Deactivate();
LOG_INFO("[{}] Background connection peer cleanup", id);
LeaveConnection(peer, id.c_str());
DestroyPeer(&peer);
@@ -551,6 +599,12 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
std::shared_lock lock(remote_sessions_mutex_);
auto it = remote_sessions_.find(remote_id);
if (it == remote_sessions_.end()) {
if (remote_id.rfind("lan-", 0) == 0)
for (const auto& [_, props] : remote_sessions_)
if (props->lan_session_ && !props->closing_ &&
Thumbnail::LanRecordId(props->lan_session_->target.device_id) ==
remote_id)
return props;
return nullptr;
}
return it->second;
@@ -22,6 +22,7 @@ inline nlohmann::json BuildDevicePresenceRequest(
const auto append = [&](const std::string& id) {
// Recent connection keys contain Y/N plus display settings after the ID.
const std::string pure_id = id.substr(0, id.find_first_of("YN"));
if (pure_id.rfind("lan-", 0) == 0 || pure_id.rfind("lan/", 0) == 0) return;
if (!pure_id.empty() &&
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
devices.push_back(pure_id);
@@ -0,0 +1,41 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DISCONNECT_LOCK_POLICY_H_
#define _DISCONNECT_LOCK_POLICY_H_
#include <string>
#include <unordered_set>
#include <utility>
namespace crossdesk {
// Guarded by GuiRuntime's connection_status_mutex_. Only incoming sessions
// that reached Connected can request a lock; terminal callbacks may repeat.
class DisconnectLockPolicy {
public:
void Connected(const std::string& remote_id) {
controllers_.insert(remote_id);
pending_ = false;
}
void Disconnected(const std::string& remote_id, bool enabled) {
if (controllers_.erase(remote_id) && controllers_.empty() && enabled)
pending_ = true;
}
bool TakeRequest(bool enabled) {
return std::exchange(pending_, false) && enabled && controllers_.empty();
}
private:
std::unordered_set<std::string> controllers_;
bool pending_ = false;
};
} // namespace crossdesk
#endif
+15 -7
View File
@@ -70,6 +70,7 @@ GuiRuntime::~GuiRuntime() {
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
if (windows_service_worker_) windows_service_worker_->Reset();
#endif
lan_.reset();
transfers_.Shutdown();
WaitForSessionCleanup();
}
@@ -292,7 +293,7 @@ int GuiRuntime::CreateConnectionPeer() {
LOG_INFO("Create peer [{}] instance failed", client_id_);
}
if (0 == devices_.InitializeScreenCapturer()) {
if (0 == devices_.EnsureCaptureReady()) {
const auto &displays = devices_.display_info_list();
for (size_t index = 0; index < displays.size(); ++index) {
const std::string stream_id = MakeDisplayStreamId(index);
@@ -334,10 +335,18 @@ void GuiRuntime::HandleRecentConnections() {
// Record a successful connection before a preview or host info arrives.
// The close path still updates its final thumbnail. Remember password
// controls only credential storage, not whether this device is listed.
const auto direct = props->lan_session_;
const Thumbnail::LanTarget target =
direct ? Thumbnail::LanTarget{direct->target.address,
direct->target.device_id,
direct->target.port}
: Thumbnail::LanTarget{};
const auto record_id =
direct ? Thumbnail::LanRecordId(target.device_id) : props->remote_id_;
if (thumbnail_->SaveToThumbnail(
nullptr, 0, 0, props->remote_id_, host_name,
nullptr, 0, 0, record_id, host_name,
props->remember_password_ ? props->remote_password_ : "",
platform) == 0)
platform, direct ? &target : nullptr) == 0)
reload_recent_connections_ = true;
else
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
@@ -346,9 +355,7 @@ void GuiRuntime::HandleRecentConnections() {
if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) {
uint32_t now_time = SDL_GetTicks();
if (now_time - recent_connection_image_save_time_ >= 50) {
int ret = thumbnail_->LoadThumbnail(recent_connections_,
&recent_connection_image_width_,
&recent_connection_image_height_);
int ret = thumbnail_->LoadThumbnail(recent_connections_);
if (!ret) {
LOG_INFO("Load recent connection thumbnails");
}
@@ -356,7 +363,8 @@ void GuiRuntime::HandleRecentConnections() {
recent_connection_ids_.clear();
for (const auto &conn : recent_connections_) {
recent_connection_ids_.push_back(conn.first);
if (conn.second.lan.device_id.empty())
recent_connection_ids_.push_back(conn.first);
}
need_to_send_recent_connections_ = true;
}
@@ -19,8 +19,10 @@
#include "features/input/keyboard_controller.h"
#include "features/input/remote_input_dispatcher.h"
#include "features/settings/settings_manager.h"
#include "network/lan/lan_session_manager.h"
#include "privacy_controller.h"
#include "runtime/gui_state.h"
#include "runtime/disconnect_lock_policy.h"
#include "runtime/announcement_inbox.h"
#include "runtime/peer_event_handler.h"
#include "runtime/privacy_session_policy.h"
@@ -50,6 +52,35 @@ class GuiRuntime : protected gui_detail::GuiState {
static void SdlCaptureAudioIn(void* userdata, uint8_t* stream, int len);
static void SdlCaptureAudioOut(void* userdata, uint8_t* stream, int len);
void InitializeLan();
void ApplyLanSettings();
bool LanAvailable() const;
void TickLan();
void BeginShutdown();
bool ShutdownDrained();
void RegisterStreams(PeerPtr* peer, size_t display_count);
void ConnectLan(const std::string& address, unsigned port,
std::string password, bool remember_password = false);
int WithController(
const std::string& id,
const std::function<int(PeerPtr*, const std::string&)>& operation);
int SendControllerData(const std::string& id, const char* data, size_t size,
const char* label);
std::shared_ptr<PeerEventHandler> ControllerEvents(const std::string& id);
std::vector<std::string> ConnectedControllers();
std::unique_ptr<lan::SessionManager> lan_;
std::string lan_error_;
std::shared_ptr<lan::Session> lan_connecting_;
bool lan_stopping_ = false;
bool stopping_ = false;
std::chrono::steady_clock::time_point shutdown_report_at_{};
struct LanController {
std::shared_ptr<lan::Session> session;
std::shared_ptr<PeerEventHandler> events;
};
// Protected by remote_sessions_mutex_; snapshots acquire callback leases
// before releasing that lock, so removal cannot race a raw Peer use.
std::map<std::string, LanController> lan_controllers_;
int CreateConnectionPeer();
int ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check = false,
@@ -72,6 +103,7 @@ class GuiRuntime : protected gui_detail::GuiState {
const char* reason);
void HandleWindowsServiceIntegration();
void HandlePrivacy();
void HandleScreenLock();
bool IsAuthorizedController(const std::string& remote_id);
void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
// Caller holds connection_status_mutex_ exclusively. Turns privacy off when
@@ -127,6 +159,7 @@ class GuiRuntime : protected gui_detail::GuiState {
bool unattended_host_ = false;
AnnouncementInbox announcements_;
PrivacySessionPolicy privacy_sessions_;
DisconnectLockPolicy disconnect_lock_;
std::mutex privacy_commands_mutex_;
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
uint32_t last_privacy_revision_ = 0;
@@ -0,0 +1,236 @@
#include <display_stream_id.h>
#include "rd_log.h"
#include "runtime/gui_runtime.h"
#if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h"
#endif
namespace crossdesk {
void GuiRuntime::RegisterStreams(PeerPtr* peer, size_t displays) {
for (size_t i = 0; i < displays; ++i) {
const auto id = MakeDisplayStreamId(i);
AddVideoStream(peer, id.c_str());
}
AddAudioStream(peer, audio_label_.c_str());
AddDataStream(peer, data_label_.c_str(), false);
for (const auto& label :
{mouse_label_, keyboard_label_, control_data_label_, file_label_,
file_feedback_label_, clipboard_label_})
AddDataStream(peer, label.c_str(), true);
}
void GuiRuntime::InitializeLan() {
if (unattended_host_ || stopping_ || lan_ ||
!config_center_->IsIpDirectEnabled()) return;
#if defined(__linux__) && !defined(__OHOS__)
if (HeadlessConsole::Instance().active()) return;
#endif
lan_error_.clear();
lan::SessionManager::Hooks hooks;
hooks.error = [this](const std::string& error) {
if (!lan_stopping_ && !stopping_) lan_error_ = error;
};
hooks.prepare = [this](const std::shared_ptr<lan::Session>& session) {
if (stopping_ || lan_stopping_ || devices_.EnsureCaptureReady() != 0)
throw std::runtime_error("Capture initialization failed");
const size_t displays = devices_.display_info_list().size();
auto events = std::make_shared<PeerEventHandler>(*this);
events->BindDirect(session);
// Direct initialization must not depend on cloud login/configuration having
// successfully populated the managed peer's Params snapshot.
Params params{};
std::snprintf(params.log_path, sizeof(params.log_path), "%s",
dll_log_path_.c_str());
params.hardware_acceleration = true;
params.native_video_output = true;
params.av1_encoding = config_center_->GetVideoEncodeFormat() ==
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
params.enable_srtp = true;
params.turn_mode = TurnMode::TurnDisabled;
params.video_content_type = VideoContentType::ScreenContent;
params.video_quality = VideoQuality::QualityHigh;
params.video_frame_rate = 60;
params.video_degradation_preference =
VideoDegradationPreference::MaintainResolution;
params.on_receive_video_frame = PeerEventHandler::OnReceiveVideoBuffer;
params.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
params.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
params.on_connection_status = PeerEventHandler::OnConnectionStatus;
params.on_net_status_report = PeerEventHandler::OnNetStatusReport;
params.user_id = session->local_id.c_str();
params.user_data = events.get();
if (!session->incoming) {
auto props = FindRemoteSession(session->key);
if (!props) throw std::runtime_error("LAN session cancelled");
props->peer_events_ = events;
props->local_id_ = session->local_id;
}
return [this, session, events, params, displays] {
lan::SessionManager::Prepared result;
result.callback_owner = events;
result.drain_callbacks = [events] { events->Deactivate(); };
result.peer = CreatePeer(&params);
events->SetDirectPeer(result.peer);
if (result.peer) RegisterStreams(result.peer, displays);
return result;
};
};
hooks.attached = [this](const std::shared_ptr<lan::Session>& session) {
auto events =
std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
std::unique_lock lock(remote_sessions_mutex_);
if (session->incoming)
lan_controllers_[session->key] = {session, events};
else if (auto it = remote_sessions_.find(session->key);
it != remote_sessions_.end())
it->second->peer_ = session->peer;
};
hooks.connected = [this](const std::shared_ptr<lan::Session>& session) {
auto events =
std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
events->ApplyConnectionStatus(ConnectionStatus::Connected, session->key);
events->FlushEarlyData();
};
hooks.closed = [this](const std::shared_ptr<lan::Session>& session) {
auto events =
std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
bool attached = !session->incoming;
if (session->incoming) {
std::shared_lock lock(remote_sessions_mutex_);
attached = lan_controllers_.count(session->key) != 0;
}
if (events) {
if (attached)
events->ApplyConnectionStatus(ConnectionStatus::Closed, session->key);
events->StopAccepting();
transfers_.Cancel(events.get());
}
if (session->incoming) {
std::unique_lock lock(remote_sessions_mutex_);
lan_controllers_.erase(session->key);
} else if (auto props = FindRemoteSession(session->key))
CloseRemoteSession(props);
};
try {
lan_ = std::make_unique<lan::SessionManager>(
path_manager_->GetConfigPath() / "lan", std::move(hooks));
} catch (const std::exception& error) {
lan_error_ = error.what();
LOG_ERROR("LAN initialization failed: {}", error.what());
}
}
void GuiRuntime::ApplyLanSettings() {
if (config_center_->IsIpDirectEnabled()) {
InitializeLan();
} else {
lan_connecting_.reset();
lan_error_.clear();
if (lan_ && !lan_stopping_) {
lan_stopping_ = true;
lan_->Stop();
}
}
}
bool GuiRuntime::LanAvailable() const {
return lan_ && !lan_stopping_ && !stopping_;
}
void GuiRuntime::TickLan() {
if (lan_) lan_->Tick();
if (lan_ && lan_stopping_ && lan_->Quiescent()) {
// Drain async authentication, media preparation and callbacks before
// releasing the manager. A quick off/on waits here before rebinding.
lan_.reset();
lan_stopping_ = false;
InitializeLan();
}
}
void GuiRuntime::ConnectLan(const std::string& address, unsigned port,
std::string password, bool remember_password) {
if (!LanAvailable() || (lan_connecting_ && !lan_connecting_->closing))
return;
try {
auto props = std::make_shared<RemoteSession>();
props->remember_password_ = remember_password;
if (remember_password && lan::ValidPassword(password))
std::memcpy(props->remote_password_, password.data(), password.size());
auto session = lan_->Connect(address, port, std::move(password));
lan_connecting_ = session;
props->lan_session_ = session;
props->remote_id_ = session->key;
props->connection_status_ = ConnectionStatus::Connecting;
props->connection_auth_.Complete();
{
std::unique_lock lock(remote_sessions_mutex_);
remote_sessions_[session->key] = props;
}
focused_remote_id_ = session->key;
show_connection_status_window_ = false;
lan_error_.clear();
} catch (const std::exception& error) {
lan_error_ = error.what();
}
}
std::shared_ptr<PeerEventHandler> GuiRuntime::ControllerEvents(
const std::string& id) {
std::shared_lock lock(remote_sessions_mutex_);
auto it = lan_controllers_.find(id);
return it == lan_controllers_.end()
? (id.rfind("lan/", 0) == 0 ? nullptr : peer_events_)
: it->second.events;
}
int GuiRuntime::WithController(
const std::string& id,
const std::function<int(PeerPtr*, const std::string&)>& operation) {
std::shared_lock lock(remote_sessions_mutex_);
auto it = lan_controllers_.find(id);
auto events = it == lan_controllers_.end() ? peer_events_ : it->second.events;
if (it == lan_controllers_.end() && id.rfind("lan/", 0) == 0) return -1;
PeerPtr* peer =
it == lan_controllers_.end() ? peer_ : it->second.session->peer;
if (!peer || !events || !events->DataAllowed()) return -1;
auto callback = events->EnterCallback();
if (!callback) return -1;
const auto wire_id = events->WireId(id);
lock.unlock();
return operation(peer, wire_id);
}
int GuiRuntime::SendControllerData(const std::string& id, const char* data,
size_t size, const char* label) {
return WithController(id, [&](PeerPtr* peer, const std::string& wire) {
return SendReliableDataFrameToPeer(peer, data, size, label, wire.data(),
wire.size());
});
}
std::vector<std::string> GuiRuntime::ConnectedControllers() {
std::vector<std::string> ids;
std::shared_lock lock(connection_status_mutex_);
ids.reserve(connection_status_.size());
for (const auto& [id, status] : connection_status_)
if (status == ConnectionStatus::Connected) ids.push_back(id);
return ids;
}
void GuiRuntime::BeginShutdown() {
if (stopping_) return;
stopping_ = true;
shutdown_report_at_ =
std::chrono::steady_clock::now() + std::chrono::seconds(5);
if (lan_) lan_->Stop();
CloseAllRemoteSessions();
}
bool GuiRuntime::ShutdownDrained() {
TickLan();
HandleSessionCleanup();
const bool drained =
(!lan_ || lan_->Quiescent()) && !connection_peer_cleanup_.valid() &&
session_cleanup_tasks_.empty() && controller_cleanup_tasks_.empty();
if (!drained && std::chrono::steady_clock::now() >= shutdown_report_at_) {
LOG_WARN(
"Shutdown waiting: LAN [{}], cloud peer={}, viewers={}, controllers={}",
lan_ ? lan_->PendingWork() : "none", connection_peer_cleanup_.valid(),
session_cleanup_tasks_.size(), controller_cleanup_tasks_.size());
shutdown_report_at_ =
std::chrono::steady_clock::now() + std::chrono::seconds(5);
}
return drained;
}
} // namespace crossdesk
@@ -84,17 +84,52 @@ void LogSecureDesktopInputBlocked(uint32_t *last_tick, const char *side,
} // namespace
bool PeerEventHandler::QueueEarlyData(const char* data, size_t size,
const char* label, size_t label_size) {
if (!direct_bound_) return false;
auto session = direct_.lock();
if (!session || session->closing) return true;
std::lock_guard lock(early_data_mutex_);
if (session->authorized) return false;
// Keep bounded reliable metadata until the manager commits Connected. Mouse,
// keyboard, clipboard and files never enter this pre-authorization queue.
if (std::string_view(label, label_size) != owner_.control_data_label_)
return true;
if (size > 8192 || early_data_.size() >= 16 ||
early_data_bytes_ + size > 65536) {
session->failed = true;
return true;
}
early_data_bytes_ += size;
early_data_.emplace_back(data, size);
return true;
}
void PeerEventHandler::FlushEarlyData() {
std::vector<std::string> queued;
{
std::lock_guard lock(early_data_mutex_);
queued.swap(early_data_);
early_data_bytes_ = 0;
}
const auto session = direct_.lock();
if (!session || !session->authorized || session->closing) return;
const auto& label = owner_.control_data_label_;
for (const auto& data : queued)
OnReceiveDataBuffer(data.data(), data.size(), session->remote_id.data(),
session->remote_id.size(), label.data(), label.size(),
this);
}
void PeerEventHandler::OnReceiveDataBuffer(
const char *data, size_t size, const char *user_id, size_t user_id_size,
const char *src_id, size_t src_id_size, void *user_data) {
auto *handler = static_cast<PeerEventHandler *>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime *runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) {
if (!callback || handler->QueueEarlyData(data, size, src_id, src_id_size) ||
!handler->DataAllowed())
return;
}
GuiRuntime* runtime = &handler->owner_;
std::string source_id = std::string(src_id, src_id_size);
// The service-owned host runs as SYSTEM. Do not expose filesystem or
@@ -105,11 +140,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
source_id == runtime->clipboard_label_))
return;
if (source_id == runtime->file_label_) {
runtime->transfers_.Receive(data, size, handler, std::string(user_id, user_id_size));
runtime->transfers_.Receive(data, size, handler,
handler->ConnectionKey(user_id, user_id_size));
return;
} else if (source_id == runtime->clipboard_label_) {
if (size > 0) {
std::string remote_user_id(user_id, user_id_size);
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()) {
@@ -121,11 +158,15 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
return;
} else if (source_id == runtime->file_feedback_label_) {
runtime->transfers_.HandleAck(data, size, handler, std::string(user_id, user_id_size));
runtime->transfers_.HandleAck(
data, size, handler, handler->ConnectionKey(user_id, user_id_size));
return;
}
const std::string remote_id(user_id, user_id_size);
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_;
std::string json_str(data, size);
const auto preview_json =
json_str.find("\"deskhub_preview") != std::string::npos
@@ -145,29 +186,31 @@ void PeerEventHandler::OnReceiveDataBuffer(
if (!previous || subscription->IsNewerThan(*previous)) {
if (subscription->fps > 0)
accepted = UpdateVideoSettings(
runtime->peer_, remote_id.data(), remote_id.size(),
static_cast<VideoQuality>(subscription->quality),
subscription->frame_rate,
static_cast<VideoDegradationPreference>(
subscription->EncoderPreference())) == 0;
bound_peer, wire_id.data(), wire_id.size(),
static_cast<VideoQuality>(subscription->quality),
subscription->frame_rate,
static_cast<VideoDegradationPreference>(
subscription->EncoderPreference())) == 0;
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
subscription->max_bitrate_bps.value_or(0)))
accepted = UpdateVideoBitrateLimit(
runtime->peer_, remote_id.data(), remote_id.size(),
bound_peer, wire_id.data(), wire_id.size(),
subscription->max_bitrate_bps.value_or(0)) == 0;
// Rebalancing bandwidth alone must not force every device to send a
// keyframe. The pause API requests its own keyframes on resume.
if (accepted && (!previous || previous->width != subscription->width ||
previous->height != subscription->height))
accepted = UpdateVideoResolution(
runtime->peer_, remote_id.data(), remote_id.size(),
subscription->width, subscription->height) == 0;
accepted = UpdateVideoResolution(bound_peer, wire_id.data(),
wire_id.size(), subscription->width,
subscription->height) == 0;
if (accepted)
accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), subscription->fps == 0) == 0;
accepted =
SetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(),
subscription->fps == 0) == 0;
if (accepted)
accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), !subscription->audio) == 0;
accepted =
SetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(),
!subscription->audio) == 0;
if (accepted && subscription->audio)
runtime->devices_.StartSpeakerCapturer();
}
@@ -178,9 +221,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
{"request_id", subscription->request_id},
{"accepted",
accepted}}.dump();
SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(),
SendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(),
runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size());
wire_id.data(), wire_id.size());
return;
}
if (preview_json.is_object() &&
@@ -253,7 +296,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
const auto& settings = remote_action.vs;
remote_action.vs.accepted =
UpdateVideoSettings(
runtime->peer_, remote_id.data(), remote_id.size(),
bound_peer, wire_id.data(), wire_id.size(),
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
@@ -262,9 +305,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
settings.frame_rate, settings.preference, remote_action.vs.accepted);
remote_action.type = ControlType::video_settings_status;
const auto message = remote_action.to_json();
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
SendReliableDataFrameToPeer(bound_peer, message.data(), message.size(),
runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size());
wire_id.data(), wire_id.size());
return;
}
if (remote_action.type == ControlType::privacy_status) {
@@ -407,8 +450,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
props->video_settings_request_tick_ = SDL_GetTicks();
settings.vs = props->video_settings_;
const auto message = settings.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(),
props->control_data_label_.c_str());
SendReliableDataFrame(
handler->IsDirect() ? handler->DirectPeer() : props->peer_,
message.data(), message.size(),
props->control_data_label_.c_str());
}
}
// client mode
@@ -456,8 +501,8 @@ void PeerEventHandler::OnReceiveDataBuffer(
// command arrives after it (e.g. when unmuting an inactive window).
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
const bool paused = subscription ? !subscription->audio : !remote_action.a;
const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), paused);
const int ret = SetAudioSendPaused(bound_peer, wire_id.data(),
wire_id.size(), paused);
if (remote_action.a)
runtime->devices_.StartSpeakerCapturer();
else if (ret != 0)
@@ -49,6 +49,22 @@ constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
} // namespace
std::string PeerEventHandler::ConnectionKey(const char* id, size_t size) const {
if (auto session = direct_.lock()) return session->key;
return direct_bound_ ? std::string{} : std::string(id, size);
}
std::string PeerEventHandler::WireId(const std::string& key) const {
if (auto session = direct_.lock()) return session->remote_id;
return key;
}
bool PeerEventHandler::DataAllowed() const {
if (!direct_bound_) return true;
auto session = direct_.lock();
return session && session->authorized && !session->closing;
}
void PeerEventHandler::SendClientInfo(PeerPtr* peer,
const std::string& client_id) {
if (!peer) {
@@ -74,8 +90,8 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime || !message || size == 0) {
GuiRuntime* runtime = &handler->owner_;
if (!message || size == 0) {
return;
}
std::string s(message, size);
@@ -179,10 +195,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) {
return;
}
GuiRuntime* runtime = &handler->owner_;
std::string client_id(user_id, user_id_size);
if (client_id == runtime->client_id_) {
@@ -251,17 +264,30 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
}
void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
const char* user_id,
const size_t user_id_size,
void* user_data) {
const char* user_id, size_t size,
void* context) {
auto* handler = static_cast<PeerEventHandler*>(context);
if (!handler) return;
if (handler->direct_bound_) {
auto callback = handler->EnterCallback();
if (callback)
if (auto session = handler->direct_.lock()) session->Status(status);
return;
}
DispatchConnectionStatus(status, user_id, size, context);
}
void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
const char* user_id,
const size_t user_id_size,
void* user_data) {
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) return;
GuiRuntime* runtime = &handler->owner_;
std::string remote_id(user_id, user_id_size);
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
@@ -432,6 +458,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
{
std::unique_lock lock(runtime->connection_status_mutex_);
if (status == ConnectionStatus::Connected) {
runtime->disconnect_lock_.Connected(remote_id);
// Local privacy requires explicit desktop capability. Host info may
// arrive before Connected; consume that saved capability here.
const bool automatic_enable = runtime->privacy_sessions_.Connected(
@@ -474,8 +501,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
}
std::string msg = remote_action.to_json();
const int ret =
SendReliableDataFrame(runtime->peer_, msg.data(), msg.size(),
runtime->control_data_label_.c_str());
runtime->SendControllerData(remote_id, msg.data(), msg.size(),
runtime->control_data_label_.c_str());
if (ret != 0) {
LOG_WARN("[{}] host information send failed, ret={}", remote_id,
ret);
@@ -516,21 +543,12 @@ void PeerEventHandler::OnNetStatusReport(
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) {
return;
}
GuiRuntime* runtime = &handler->owner_;
if (strchr(client_id, '@') != nullptr && strchr(user_id, '-') == nullptr) {
std::string id, password;
const char* at_pos = strchr(client_id, '@');
if (at_pos == nullptr) {
id = client_id;
password.clear();
} else {
id.assign(client_id, at_pos - client_id);
password = at_pos + 1;
}
if (const char* at_pos = handler->IsDirect() ? nullptr : strchr(client_id, '@');
at_pos && strchr(user_id, '-') == nullptr) {
const std::string id(client_id, at_pos - client_id);
const std::string password(at_pos + 1);
bool is_self_hosted = runtime->config_center_->IsSelfHosted();
@@ -578,7 +596,7 @@ void PeerEventHandler::OnNetStatusReport(
#endif
}
std::string remote_id(user_id, user_id_size);
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
auto props = runtime->FindRemoteSession(remote_id);
if (!props) {
bool report_codecs = false;
@@ -597,9 +615,8 @@ void PeerEventHandler::OnNetStatusReport(
net_traffic_stats->video_encoder);
const auto message = action.to_json();
// Report only to this controller, after releasing the status lock.
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size());
runtime->SendControllerData(remote_id, message.data(), message.size(),
runtime->control_data_label_.c_str());
}
return;
}
@@ -8,7 +8,10 @@
#define _PEER_EVENT_HANDLER_H_
#include <cstddef>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "minirtc.h"
#include "runtime/session_lifecycle.h"
@@ -16,12 +19,30 @@
namespace crossdesk {
class GuiRuntime;
namespace lan {
struct Session;
}
// Adapts MiniRTC C callbacks to the GUI runtime.
class PeerEventHandler {
public:
explicit PeerEventHandler(GuiRuntime &owner);
void BindDirect(const std::shared_ptr<lan::Session>& session) {
direct_ = session;
direct_bound_ = true;
}
std::string ConnectionKey(const char* id, size_t size) const;
std::string WireId(const std::string& key) const;
bool DataAllowed() const;
bool IsDirect() const { return direct_bound_; }
void SetDirectPeer(PeerPtr* peer) { direct_peer_ = peer; }
PeerPtr* DirectPeer() const { return direct_peer_; }
void FlushEarlyData();
void ApplyConnectionStatus(ConnectionStatus status, const std::string& id) {
DispatchConnectionStatus(status, id.data(), id.size(), this);
}
void StopAccepting() { callbacks_.StopAccepting(); }
bool IsActive() const { return callbacks_.IsActive(); }
auto EnterCallback() { return callbacks_.Enter(); }
void Deactivate() { callbacks_.Close(); }
@@ -51,12 +72,22 @@ public:
void *user_data);
private:
static void SendClientInfo(PeerPtr *peer, const std::string &client_id);
static void DispatchConnectionStatus(ConnectionStatus status, const char* id,
size_t size, void* context);
static void SendClientInfo(PeerPtr* peer, const std::string& client_id);
bool QueueEarlyData(const char* data, size_t size, const char* label,
size_t label_size);
std::weak_ptr<lan::Session> direct_;
bool direct_bound_ = false;
PeerPtr* direct_peer_ = nullptr;
std::mutex early_data_mutex_;
std::vector<std::string> early_data_;
size_t early_data_bytes_ = 0;
GuiRuntime &owner_;
GuiRuntime& owner_;
gui_detail::SessionCallbackGate callbacks_;
};
} // namespace crossdesk
#endif
#endif
@@ -38,20 +38,21 @@ void PeerEventHandler::OnReceiveVideoBuffer(
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime || !video_frame) {
if (!callback || !handler->DataAllowed()) return;
GuiRuntime* runtime = &handler->owner_;
if (!video_frame) {
return;
}
std::string remote_id(user_id, user_id_size);
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
auto props = runtime->FindRemoteSession(remote_id);
if (!props) return;
if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now();
// Pair the clocks before preview conversion so its cost is included.
const auto local_now_us = GetSystemTimeMicros(props->peer_);
const auto local_now_us = GetSystemTimeMicros(
handler->IsDirect() ? handler->DirectPeer() : props->peer_);
const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record(
received_native ? received_native->width : video_frame->width,
@@ -274,15 +275,15 @@ void PeerEventHandler::OnReceiveAudioBuffer(
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime || runtime->unattended_host_) {
if (!callback || !handler->DataAllowed()) return;
GuiRuntime* runtime = &handler->owner_;
if (runtime->unattended_host_) {
return;
}
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
const auto session =
runtime->FindRemoteSession(std::string(user_id, user_id_size));
runtime->FindRemoteSession(handler->ConnectionKey(user_id, user_id_size));
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
runtime->audio_buffer_fresh_ = true;
@@ -15,6 +15,10 @@
namespace crossdesk {
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
if (remote_id.rfind("lan/", 0) == 0) {
const auto events = ControllerEvents(remote_id);
if (!events || !events->DataAllowed()) return false;
}
// Connections appear here only after the existing password/signalling/RTC
// admission path. Outgoing/viewer sessions live in remote_sessions_ instead.
std::shared_lock lock(connection_status_mutex_);
@@ -109,9 +113,9 @@ void GuiRuntime::HandlePrivacy() {
"A connected controller does not support privacy screen");
}
}
if (!peer_ || (last_privacy_status_tick_ != 0 &&
status.revision == last_privacy_revision_ &&
now - last_privacy_status_tick_ < 1000))
if (last_privacy_status_tick_ != 0 &&
status.revision == last_privacy_revision_ &&
now - last_privacy_status_tick_ < 1000)
return;
RemoteAction action{};
action.type = ControlType::privacy_status;
@@ -119,9 +123,8 @@ void GuiRuntime::HandlePrivacy() {
const auto message = action.to_json();
bool sent = true;
for (const auto& id : controllers) {
const int result = SendReliableDataFrameToPeer(
peer_, message.data(), message.size(), control_data_label_.c_str(),
id.data(), id.size());
const int result = SendControllerData(id, message.data(), message.size(),
control_data_label_.c_str());
if (result != 0) {
sent = false;
LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result);
@@ -31,7 +31,12 @@
#include "runtime/received_video_stats.h"
#include "runtime/connection_auth_state.h"
namespace crossdesk { class PeerEventHandler; }
namespace crossdesk {
class PeerEventHandler;
namespace lan {
struct Session;
}
} // namespace crossdesk
namespace crossdesk::gui_detail {
@@ -105,6 +110,7 @@ struct RemoteSession {
// Decoder-thread scratch memory, never shared with the UI.
std::mutex gallery_conversion_mutex_;
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
std::shared_ptr<lan::Session> lan_session_;
Params params_;
PeerPtr* peer_ = nullptr;
std::shared_ptr<PeerEventHandler> peer_events_;
@@ -59,8 +59,6 @@ struct RecentConnectionsState {
recent_connections_;
std::vector<std::string> recent_connection_ids_;
std::vector<std::string> gallery_presence_ids_;
int recent_connection_image_width_ = 160;
int recent_connection_image_height_ = 90;
uint32_t recent_connection_image_save_time_ = 0;
DevicePresenceCache device_presence_cache_;
std::atomic<bool> need_to_send_recent_connections_{true};
@@ -150,13 +148,11 @@ struct UserSettingsState {
char connection_login_identity_[17] = "";
int language_button_value_ = 0;
int video_encode_format_button_value_ = 0;
bool enable_turn_ = true;
char signal_server_ip_[256] = "api.crossdesk.cn";
char signal_server_port_[6] = "9099";
bool enable_self_hosted_ = false;
int language_button_value_last_ = 0;
int video_encode_format_button_value_last_ = 0;
bool enable_turn_last_ = true;
bool enable_self_hosted_last_ = false;
bool enable_autostart_ = false;
bool enable_autostart_last_ = false;
@@ -37,6 +37,11 @@ void SessionCallbackGate::EndCallback() {
}
}
void SessionCallbackGate::StopAccepting() {
std::lock_guard lock(mutex_);
accepting_callbacks_ = false;
}
void SessionCallbackGate::Close() {
std::unique_lock lock(mutex_);
accepting_callbacks_ = false;
@@ -39,6 +39,7 @@ class SessionCallbackGate {
CallbackGuard Enter();
// Called by the session owner, never from within this session's callbacks.
void StopAccepting();
void Close();
private:
+28 -10
View File
@@ -7,10 +7,19 @@ export component GroupFilter inherits Rectangle {
in property <[string]> model;
in property <int> system-count: 2;
in property <bool> enabled: true;
// An embedded picker shares its enclosing field's border and focus ring.
in property <bool> embedded: false;
in property <bool> show-indicator: true;
in property <length> popup-min-width: 0px;
in property <length> text-size: 12px;
in property <int> text-weight: 400;
in property <color> text-color: #30343b;
in property <color> normal-border-color: #c9cdd2;
in property <color> hover-border-color: root.normal-border-color;
in property <color> focus-border-color: #8db5df;
in-out property <int> current-index: 0;
out property <bool> has-focus: trigger-focus.has-focus || popup.is-open;
out property <bool> has-hover: root.enabled && trigger-touch.has-hover && !popup.is-open;
callback selected(int);
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
@@ -25,8 +34,9 @@ export component GroupFilter inherits Rectangle {
}
width: 155px; height: 28px;
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
border-width: 1px;
background: root.embedded ? (root.enabled && trigger-touch.has-hover ? #eef3fb : transparent)
: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
border-width: root.embedded ? 0px : 1px;
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color;
border-radius: UiRadius.control;
@@ -52,16 +62,18 @@ export component GroupFilter inherits Rectangle {
root.reveal-selection();
root.selected(root.current-index);
}
function open-menu() {
public function open-menu() {
if root.enabled && root.model.length > 0 {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0;
root.reveal-selection();
trigger-focus.focus();
popup.show();
popup-open-timer.running = true;
}
}
public function close-menu() { popup.close(); }
// Defer until the popup has its initial frame; animate its contents only,
// keeping the native popup bounds and scrolling geometry stable.
popup-open-timer := Timer {
@@ -87,14 +99,19 @@ export component GroupFilter inherits Rectangle {
return reject;
}
Text {
x: 10px; width: parent.width - 36px; height: parent.height;
x: root.embedded ? 8px : 10px;
width: parent.width - (root.show-indicator
? (root.embedded ? 26px : 36px) : (root.embedded ? 16px : 20px));
height: parent.height;
text: root.model[root.current-index];
color: root.enabled ? #30343b : #92979f;
font-size: 12px; overflow: elide; vertical-alignment: center;
color: root.enabled ? root.text-color : #92979f;
font-size: root.text-size; font-weight: root.text-weight;
overflow: elide; vertical-alignment: center;
accessible-role: none;
}
Text {
x: parent.width - 25px; width: 16px; height: parent.height;
if root.show-indicator: Text {
x: parent.width - (root.embedded ? 16px : 25px);
width: root.embedded ? 10px : 16px; height: parent.height;
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #858c96;
horizontal-alignment: center; vertical-alignment: center;
@@ -107,12 +124,13 @@ export component GroupFilter inherits Rectangle {
key-pressed(event) => { return root.handle-key(event); }
trigger-touch := TouchArea {
enabled: root.enabled;
clicked => { trigger-focus.focus(); root.open-menu(); }
mouse-cursor: pointer;
clicked => { root.open-menu(); }
}
}
popup := PopupWindow {
x: 0px; y: root.height + 4px;
width: root.width; height: root.visible-height + 8px;
width: max(root.width, root.popup-min-width); height: root.visible-height + 8px;
close-policy: close-on-click-outside;
forward-focus: popup-focus;
popup-focus := FocusScope {
+47
View File
@@ -0,0 +1,47 @@
import { GalleryStrings } from "device_strings.slint";
export global LanStrings {
// Short localized lines let the warning dialog add leading even though
// Slint Text does not expose a line-height property.
public pure function access-error-lines(code: string) -> [string] {
if code == "LanPortInUse" { return [
GalleryStrings.tr("此端口已被占用,", "This port is in use.", "Порт занят."),
GalleryStrings.tr("请更换端口。", "Choose another port.", "Выберите другой порт."),
GalleryStrings.tr("原设置已保留。", "Your previous settings are unchanged.", "Прежние настройки сохранены.")
]; }
if code == "LanPortCheckFailed" { return [
GalleryStrings.tr("无法确认此端口是否可用,", "Could not check this port.", "Не удалось проверить порт."),
GalleryStrings.tr("请重试或检查本机防火墙。", "Retry or check the local firewall.", "Повторите попытку или проверьте брандмауэр."),
GalleryStrings.tr("原设置已保留。", "Your previous settings are unchanged.", "Прежние настройки сохранены.")
]; }
if code == "LanPortPermissionDenied" { return [
GalleryStrings.tr("无法使用此端口,", "Cannot use this port.", "Порт недоступен."),
GalleryStrings.tr("请选择其他端口或检查系统权限。", "Choose another port or check system permissions.", "Смените порт или проверьте разрешения."),
GalleryStrings.tr("原设置已保留。", "Your previous settings are unchanged.", "Прежние настройки сохранены.")
]; }
if code == "Selected LAN interface is unavailable" { return [
GalleryStrings.tr("未找到可用的局域网连接。", "Connect to Wi-Fi or Ethernet,", "Подключитесь к Wi-Fi или Ethernet"),
GalleryStrings.tr("请连接 Wi-Fi 或网线,", "or select a local IP", "либо выберите IP"),
GalleryStrings.tr("或在主界面选择本机 IP。", "on the main screen.", "в главном окне.")
]; }
// Keep all branches the same size for Slint's C++ model generator.
return ["", "", ""];
}
public pure function error-message(code: string) -> string {
let lines = LanStrings.access-error-lines(code);
if lines[0] != "" {
let separator = GalleryStrings.tr("", " ", " ");
return lines[0] + separator + lines[1] + separator + lines[2];
}
if code == "InvalidCredentials" { return GalleryStrings.tr("密码不正确,或对方密码已更新。请查看对方的局域网访问密码后重试。", "Check the other desktop's current LAN password and try again.", "Проверьте текущий пароль LAN другого компьютера и повторите попытку."); }
if code == "AuthenticationRateLimited" { return GalleryStrings.tr("尝试次数过多,请等待倒计时结束后重试。", "Too many attempts. Try again when the countdown ends.", "Слишком много попыток. Дождитесь окончания обратного отсчёта."); }
if code == "CredentialsChanged" { return GalleryStrings.tr("对方密码已更新,请使用新密码重试。", "The password changed. Try again with the new password.", "Пароль изменился. Повторите с новым паролем."); }
if code == "Busy" { return GalleryStrings.tr("设备正忙,请稍后重试。", "The desktop is busy. Try again shortly.", "Компьютер занят. Повторите позже."); }
if code == "LAN password must contain exactly 6 ASCII letters or digits" { return GalleryStrings.tr("访问密码必须为 6 位英文字母或数字,区分大小写。", "Use exactly 6 letters (A–Z, a–z) or digits (0–9). Case-sensitive.", "Пароль: ровно 6 латинских букв или цифр. Регистр учитывается."); }
if code == "ConnectionFailed" || code == "ConnectionTimeout" || code == "Timeout" || code == "ConnectionClosed" { return GalleryStrings.tr("无法连接,请检查对方 IP、程序是否运行,以及防火墙是否放行。", "Check the IP, whether the other desktop is running CrossDesk, and its firewall settings.", "Проверьте IP, работу CrossDesk на другом компьютере и брандмауэр."); }
if code == "Enter a numeric IPv4 address or IPv4:port" { return GalleryStrings.tr("请输入有效的 IP,例如 192.168.1.20;指定端口可用 IP:端口,端口范围 1–65535。", "Enter a valid IP such as 192.168.1.20, or IP:port (1–65535).", "Введите IP, например 192.168.1.20, или IP:порт (1–65535)."); }
if code == "Invalid LAN port" { return GalleryStrings.tr("请输入 1–65535 之间的整数端口。", "Enter a port number from 1 to 65535.", "Введите целый номер порта от 1 до 65535."); }
if code == "LAN password is not ready" { return GalleryStrings.tr("请点击主界面密码旁的修改按钮设置密码。", "Set a password using Edit next to the password on the main screen.", "Задайте пароль кнопкой редактирования в главном окне."); }
return code;
}
}
+561 -75
View File
@@ -1,3 +1,4 @@
import { LanStrings } from "lan_strings.slint";
import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint";
import { UiRadius } from "ui_radius.slint";
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint";
@@ -22,6 +23,8 @@ export struct RecentConnection {
host-name: string,
group-names: string,
online: bool,
lan: bool,
endpoint: string,
connected: bool,
thumbnail: image,
has-thumbnail: bool,
@@ -69,7 +72,6 @@ export global UiStrings {
in-out property <string> capture-method: "Capture method";
in-out property <string> capture-method-auto: "Auto";
in-out property <string> codec: "Video codec";
in-out property <string> turn-relay: "TURN relay";
in-out property <string> force-relay: "Force relay";
in-out property <string> self-hosted: "Self-hosted server";
in-out property <string> autostart: "Start with system";
@@ -77,6 +79,7 @@ export global UiStrings {
in-out property <string> save-remote-previews: "Save remote screen previews:";
in-out property <string> unknown-platform: "Unknown platform";
in-out property <string> privacy-on-connect: "Privacy screen on connect";
in-out property <string> lock-on-disconnect: "Lock screen on disconnect";
in-out property <string> file-save-path: "File save path";
in-out property <string> default-desktop: "Desktop";
in-out property <string> server-host: "Server host";
@@ -372,6 +375,172 @@ export component MainWindow inherits Window {
private property <length> wayland-corner-radius: 12px;
in property <string> local-id: "";
in property <string> local-password: "";
in property <bool> ip-direct-enabled: true;
changed ip-direct-enabled => {
if !root.ip-direct-enabled {
root.lan-port-dirty = false;
root.lan-port-editing = false;
root.lan-apply-pending = false;
root.lan-local-port = root.lan-listener-port;
root.lan-endpoint-error = "";
root.lan-access-warning = "";
root.lan-access-warning-endpoint = "";
root.lan-listener-error = "";
root.lan-open = false;
root.lan-connecting = false;
root.lan-listening = false;
root.lan-password-pending = false;
root.lan-retry-after = 0;
root.lan-access-password = "";
root.lan-new-password = "";
root.lan-message = "";
lan-ip-picker.close-menu();
if root.lan-mode {
root.reset-password-open = false;
root.set-connection-mode(false);
}
}
}
in-out property <bool> lan-open;
changed lan-open => {
if !root.lan-open { root.lan-access-password = ""; }
if root.lan-open { root.gallery-action("release", ""); }
}
in-out property <bool> lan-mode;
callback connection-mode-changed(bool);
changed lan-mode => {
root.recent-tooltip-visible = false;
recent-scroll.viewport-x = 0px;
if !root.lan-mode {
root.lan-port-editing = false;
if root.lan-port-dirty && root.lan-endpoint-error == "" { root.lan-apply-pending = true; }
}
}
private property <string> id-connection-draft;
private property <string> ip-connection-draft;
private property <bool> lan-password-visible: true;
private property <bool> active-password-visible: root.lan-mode ? root.lan-password-visible : root.password-visible;
private property <string> active-password: root.lan-mode ? root.lan-new-password : root.local-password;
function set-connection-mode(lan: bool) {
recent-group-filter.close-menu();
if root.lan-mode { root.ip-connection-draft = root.remote-id-input; }
else { root.id-connection-draft = root.remote-id-input; }
root.lan-mode = lan;
root.remote-id-input = root.lan-mode ? root.ip-connection-draft : root.id-connection-draft;
root.connection-input-error = "";
root.copy-feedback = CopyFeedback.idle;
root.menu-open = false;
focus-sink.focus();
}
function switch-connection-mode() {
if !root.ip-direct-enabled { return; }
root.set-connection-mode(!root.lan-mode);
root.connection-mode-changed(root.lan-mode);
}
function submit-connection() {
if root.connection-pending || root.lan-connecting { return; }
if root.lan-mode {
root.request-lan-connection(root.remote-id-input);
} else { root.connect-requested(root.remote-id-input); }
}
public function request-lan-connection(address: string) {
if !root.ip-direct-enabled || root.connection-pending || root.lan-connecting || root.lan-open { return; }
root.lan-access-password = "";
root.lan-remember-password = false;
let error = root.lan-prepare-connect(address);
root.connection-input-error = error == "Enter a numeric IPv4 address or IPv4:port"
? GalleryStrings.tr("请输入有效的 IPv4 或 IPv4:端口", "Enter a valid IPv4 or IPv4:port", "Укажите IPv4 или IPv4:порт")
: LanStrings.error-message(error);
if error == "" {
root.lan-open = true;
if root.lan-remember-password && root.lan-access-password != "" && root.lan-retry-after == 0 {
root.submit-connection-password();
}
}
}
in-out property <bool> lan-listening;
in-out property <string> lan-listener-ip;
in-out property <string> lan-listener-port: "52888";
out property <string> lan-listener-address: root.lan-listener-ip == "" ? "" : root.lan-listener-ip + ":" + root.lan-listener-port;
in-out property <[string]> lan-interfaces;
in-out property <int> lan-interface-index: 0;
in-out property <int> lan-interface-draft-index: 0;
in-out property <string> lan-local-port: "52888";
in-out property <bool> lan-port-editing;
in-out property <bool> lan-port-dirty;
in-out property <string> lan-endpoint-error;
in-out property <string> lan-access-warning;
in-out property <string> lan-access-warning-endpoint;
in-out property <string> lan-listener-error;
function dismiss-lan-access-warning() {
root.lan-access-warning = "";
root.lan-access-warning-endpoint = "";
// Keep the failed draft ready to edit and validate it on every new blur.
if root.lan-mode && root.lan-port-dirty && root.sponsorship-unobstructed {
lan-port-editor.focus();
} else { focus-sink.focus(); }
}
private property <bool> lan-apply-pending;
callback lan-configure(string, string) -> bool;
callback lan-select-address(string) -> bool;
function apply-lan-endpoint() -> bool {
root.lan-apply-pending = false;
if !root.ip-direct-enabled || root.lan-password-pending { return false; }
if root.lan-configure(root.lan-listener-ip, root.lan-local-port) {
root.lan-port-dirty = false;
return true;
}
root.lan-access-warning-endpoint = root.lan-listener-ip
+ (root.lan-endpoint-error == "Invalid LAN port" ? "" : ":" + root.lan-local-port);
root.lan-access-warning = root.lan-endpoint-error;
return false;
}
Timer {
interval: 1ms;
running: root.lan-apply-pending;
triggered => {
root.lan-apply-pending = false;
if root.lan-port-dirty { root.apply-lan-endpoint(); }
}
}
in-out property <string> lan-remote-address;
in-out property <string> lan-remote-port: "52888";
in-out property <bool> lan-password-pending;
in-out property <bool> lan-connecting;
in-out property <int> lan-retry-after;
in-out property <string> lan-access-password;
in-out property <bool> lan-remember-password;
in-out property <string> lan-new-password;
in-out property <string> lan-message;
callback lan-reset-password;
callback lan-copy-text(string) -> bool;
callback lan-cancel-connect;
callback lan-prepare-connect(string) -> string;
callback lan-connect(string, string, string, bool);
private property <bool> prompt-password-required: root.lan-open || root.connection-password-required;
private property <bool> prompt-validating: root.lan-open ? root.lan-connecting : root.connection-validating;
private property <bool> prompt-pending: root.lan-open ? root.lan-connecting : root.connection-pending;
private property <string> prompt-status-text: !root.lan-open ? root.connection-status-text
: root.lan-connecting ? GalleryStrings.tr("正在连接…", "Connecting…", "Подключение…")
: root.lan-retry-after > 0 ? GalleryStrings.tr("请在 " + root.lan-retry-after + " 秒后重试", "Try again in " + root.lan-retry-after + "s", "Повторите через " + root.lan-retry-after + " с")
: root.lan-message == "InvalidCredentials" ? UiStrings.reinput-password
: root.lan-message == "" || root.lan-message == "AuthenticationRateLimited" ? UiStrings.input-password
: LanStrings.error-message(root.lan-message);
function submit-connection-password() {
if root.lan-open {
if root.lan-connecting || root.lan-retry-after > 0 || root.lan-access-password.character-count != 6 { return; }
root.lan-connect(root.lan-remote-address, root.lan-remote-port, root.lan-access-password, root.lan-remember-password);
root.lan-access-password = "";
} else { root.connection-submit-password(root.connection-password, root.connection-remember-password); }
}
function cancel-connection-prompt() {
if root.lan-open {
root.lan-cancel-connect();
root.lan-access-password = "";
root.lan-open = false;
focus-sink.focus();
} else { root.connection-cancel(); }
}
in-out property <string> remote-id-input: "";
in property <bool> password-visible: true;
in property <bool> password-change-pending: false;
@@ -379,6 +548,8 @@ export component MainWindow inherits Window {
in property <bool> signal-reconnecting: false;
in property <bool> signal-tls-error: false;
in property <[RecentConnection]> recent-connections;
out property <length> recent-preview-width: max(1px, (recent-scroll.width - 24px) / 3);
out property <length> recent-preview-height: 80px;
in property <[string]> recent-groups: [GalleryStrings.tr("全部设备", "All devices", "Все устройства"), GalleryStrings.tr("未分组", "Ungrouped", "Без группы")];
in-out property <int> recent-group-index: 0;
callback recent-group-selected(int);
@@ -455,11 +626,9 @@ export component MainWindow inherits Window {
in property <bool> capture-method-visible: false;
in-out property <int> capture-method-index: 0;
in-out property <int> codec-index: 0;
in-out property <bool> turn-enabled: true;
in-out property <bool> ip-direct-enabled-draft: true;
in property <string> settings-save-error;
in-out property <bool> force-relay-enabled: false;
changed turn-enabled => {
if !root.turn-enabled { root.force-relay-enabled = false; }
}
in-out property <bool> self-hosted-enabled: false;
in-out property <bool> autostart-enabled: false;
in property <bool> autostart-system-managed: false;
@@ -477,6 +646,8 @@ export component MainWindow inherits Window {
// Show the preference on every platform; only adapted hosts can change it.
in property <bool> privacy-setting-available: false;
in-out property <bool> privacy-on-connect-enabled: true;
in property <bool> lock-on-disconnect-available: false;
in-out property <bool> lock-on-disconnect-enabled: true;
in-out property <string> file-save-path: "";
in-out property <string> server-host: "";
in-out property <string> server-port: "";
@@ -555,11 +726,11 @@ export component MainWindow inherits Window {
&& !root.update-confirm-open
&& !root.reset-password-open && !root.alias-open && !root.delete-open
&& !root.disconnect-open
&& !root.device-group-open && !root.connection-dialog-open
&& !root.device-group-open && !root.connection-dialog-open && !root.lan-open
&& !root.permission-dialog-open && !root.wayland-notice-open
&& !root.portable-service-dialog-open
&& !root.portable-service-suppressed-notice-open
&& root.offline-warning == "";
&& root.offline-warning == "" && root.lan-access-warning == "";
out property <bool> can-show-sponsorship: !root.sponsorship-open
&& root.sponsorship-unobstructed;
// Follow the first available update. Dismissing the dialog replaces this
@@ -570,6 +741,10 @@ export component MainWindow inherits Window {
}
in-out property <bool> update-confirm-open: false;
in-out property <bool> reset-password-open: false;
changed reset-password-open => {
root.new-password-input = "";
root.reset-password-invalid = false;
}
in-out property <bool> reset-password-invalid: false;
in-out property <string> new-password-input: "";
in-out property <bool> alias-open: false;
@@ -926,9 +1101,21 @@ export component MainWindow inherits Window {
// A compact application toolbar keeps global actions outside device cards.
// Center the 28px buttons above the cards with 6px of space on each side.
connection-mode-button := WorkbenchButton {
visible: !root.gallery-mode && root.ip-direct-enabled;
x: 20px; y: 6px;
width: 120px; height: 28px;
text: root.lan-mode ? GalleryStrings.tr("IP 直连模式", "Direct IP mode", "Режим IP") : GalleryStrings.tr("ID 连接模式", "ID mode", "Режим ID");
tooltip: root.lan-mode ? GalleryStrings.tr("切换到 ID 连接模式", "Switch to ID mode", "Переключить в режим ID") : GalleryStrings.tr("切换到 IP 直连模式", "Switch to direct IP mode", "Переключить в режим IP");
accessible-label: self.text + " · " + self.tooltip;
enabled: !root.connection-pending && !root.lan-connecting;
z: 8;
clicked => { root.switch-connection-mode(); }
}
open-gallery-button := WorkbenchButton {
visible: !root.gallery-mode;
x: 20px; y: 6px;
x: root.ip-direct-enabled ? connection-mode-button.x + connection-mode-button.width + 8px : 20px;
y: connection-mode-button.y;
width: 80px; height: 28px;
text: GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub");
z: 8;
@@ -1024,22 +1211,166 @@ export component MainWindow inherits Window {
x: 0px; y: 0px;
width: (parent.width - 16px) / 2; height: parent.height;
Text { x: 16px; y: 14px; width: parent.width - 32px; height: 24px; overflow: elide; text: UiStrings.local-desktop; font-size: 16px; font-weight: 600; color: WorkbenchStyle.text; }
Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: GalleryStrings.tr("将 ID 和密码分享给连接方", "Share your ID and password to get connected", "Передайте ID и пароль для подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; }
Text { x: 16px; y: 68px; height: 18px; text: UiStrings.local-id; font-size: 12px; color: WorkbenchStyle.muted; }
Rectangle {
Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: root.lan-mode ? GalleryStrings.tr("将 IP 和密码分享给连接方", "Share your IP and password to get connected", "Передайте IP и пароль для управления") : GalleryStrings.tr("将 ID 和密码分享给连接方", "Share your ID and password to get connected", "Передайте ID и пароль для подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; }
local-address-label := Text { x: 16px; y: 68px; height: 18px; text: root.lan-mode ? GalleryStrings.tr("本机 IP / 端口", "Local IP / Port", "IP / Порт") : UiStrings.local-id; font-size: 12px; color: WorkbenchStyle.muted; vertical-alignment: top; }
lan-switch-ip-metrics := Text {
visible: false; accessible-role: none;
text: GalleryStrings.tr("切换 IP", "Switch IP", "Сменить IP");
font-size: 12px; font-weight: 500;
}
lan-edit-port-metrics := Text {
visible: false; accessible-role: none;
text: GalleryStrings.tr("修改端口", "Edit port", "Изменить порт");
font-size: 12px; font-weight: 500;
}
lan-switch-ip-action := WorkbenchButton {
visible: root.lan-mode;
x: local-address-label.x + local-address-label.preferred-width + 4px;
y: local-address-label.y - 5px;
width: lan-switch-ip-metrics.preferred-width + 20px; height: 22px;
quiet: true; text: lan-switch-ip-metrics.text;
text-size: 12px; text-color: WorkbenchStyle.accent;
enabled: root.lan-mode && !root.lan-password-pending && root.lan-interfaces.length > 0;
clicked => {
if root.lan-port-dirty && !root.apply-lan-endpoint() { return; }
lan-ip-picker.open-menu();
}
}
WorkbenchButton {
visible: root.lan-mode;
x: lan-switch-ip-action.x + lan-switch-ip-action.width + 2px;
y: lan-switch-ip-action.y;
width: lan-edit-port-metrics.preferred-width + 20px; height: 22px;
quiet: true; text: lan-edit-port-metrics.text;
text-size: 12px; text-color: WorkbenchStyle.accent;
enabled: root.lan-mode && !root.lan-password-pending && root.lan-interfaces.length > 0;
clicked => {
// Moving from the editor to this action is still editing.
root.lan-apply-pending = false;
lan-port-editor.focus();
lan-port-editor.select-all();
}
}
local-address-field := Rectangle {
x: 16px; y: 90px; width: parent.width - 32px; height: 40px;
// Size the endpoint as one line; reserve five port digits so
// editing never shifts the IP or changes the font size.
private property <float> lan-font-scale: min(1,
(self.width - 80px) / (lan-ip-metrics.preferred-width + lan-port-metrics.preferred-width));
lan-ip-metrics := Text {
visible: false; accessible-role: none;
text: root.lan-interfaces[root.lan-interface-draft-index];
font-size: 18px; font-weight: 600;
}
lan-port-metrics := Text {
visible: false; accessible-role: none;
text: "65535"; font-size: 18px; font-weight: 600;
}
// Raise the whole field: a child's z cannot escape its
// parent's stacking order above the following password row.
z: 1;
background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
border-width: 1px; border-color: WorkbenchStyle.border;
Text { x: 12px; width: parent.width - 64px; height: parent.height; text: root.local-id; font-size: 24px; font-weight: 600; letter-spacing: 1px; color: WorkbenchStyle.text; vertical-alignment: center; }
border-width: 1px;
border-color: !root.lan-mode ? WorkbenchStyle.border
: root.lan-endpoint-error != "" ? #d92d20
: lan-ip-picker.has-focus || root.lan-port-editing ? WorkbenchStyle.accent
: WorkbenchStyle.border;
animate border-color { duration: 120ms; }
if !root.lan-mode: Text { x: 12px; width: parent.width - 64px; height: parent.height; text: root.local-id; font-size: 24px; font-weight: 600; letter-spacing: 1px; overflow: elide; color: WorkbenchStyle.text; vertical-alignment: center; }
Rectangle { x: parent.width - 48px; y: 10px; width: 1px; height: parent.height - 20px; background: WorkbenchStyle.border; }
lan-ip-picker := GroupFilter {
visible: root.lan-mode;
x: 4px; y: 2px;
width: lan-ip-metrics.preferred-width * local-address-field.lan-font-scale + 16px;
height: parent.height - 4px;
embedded: true; text-size: 18px * local-address-field.lan-font-scale;
text-weight: 600; text-color: WorkbenchStyle.text;
show-indicator: false;
popup-min-width: 160px;
model: root.lan-interfaces; system-count: 0;
current-index <=> root.lan-interface-draft-index;
enabled: root.lan-mode && !root.lan-password-pending && root.lan-interfaces.length > 0;
normal-border-color: WorkbenchStyle.control-border;
hover-border-color: WorkbenchStyle.hover-border;
focus-border-color: WorkbenchStyle.accent;
accessible-label: GalleryStrings.tr("切换本机 IP", "Switch local IP", "Сменить локальный IP");
selected(index) => {
root.copy-feedback = CopyFeedback.idle;
// Save the draft for its original IP before changing
// the display. Selecting an IP never rebinds sockets.
if root.lan-port-dirty && !root.apply-lan-endpoint() {
root.lan-interface-draft-index = root.lan-interface-index;
return;
}
if root.lan-select-address(root.lan-interfaces[index]) {
root.lan-interface-index = index;
} else {
root.lan-access-warning-endpoint = root.lan-interfaces[index];
root.lan-access-warning = root.lan-endpoint-error;
root.lan-interface-draft-index = root.lan-interface-index;
}
}
}
if root.lan-mode: Text {
x: lan-ip-picker.x + lan-ip-picker.width - 4px; width: 6px; height: parent.height;
text: ":"; font-size: 18px * local-address-field.lan-font-scale;
font-weight: 600; color: WorkbenchStyle.text;
horizontal-alignment: center; vertical-alignment: center;
}
lan-port-field := Rectangle {
visible: root.lan-mode;
x: lan-ip-picker.x + lan-ip-picker.width + 2px; y: 2px;
width: lan-port-metrics.preferred-width * local-address-field.lan-font-scale + 4px;
height: parent.height - 4px;
background: lan-port-editor.has-focus ? WorkbenchStyle.surface
: lan-port-hover.has-hover ? WorkbenchStyle.hover-surface : transparent;
border-radius: UiRadius.control;
border-width: 1px;
border-color: lan-port-editor.has-focus ? WorkbenchStyle.accent : transparent;
animate background, border-color { duration: 120ms; }
lan-port-hover := TouchArea {
enabled: root.lan-mode && !root.lan-password-pending;
mouse-cursor: text;
FocusScope {
width: parent.width; height: parent.height;
key-pressed(event) => {
if event.text == Key.Escape {
root.lan-apply-pending = false;
root.lan-port-dirty = false;
root.lan-local-port = root.lan-listener-port;
root.lan-endpoint-error = "";
focus-sink.focus();
return accept;
}
return reject;
}
lan-port-editor := TextInput {
x: 2px; width: parent.width - 4px; height: parent.height;
text <=> root.lan-local-port;
font-size: 18px * local-address-field.lan-font-scale; font-weight: 600;
input-type: InputType.number; color: WorkbenchStyle.text;
enabled: root.lan-mode && !root.lan-password-pending;
accessible-label: GalleryStrings.tr("局域网端口", "LAN port", "Порт ЛВС");
vertical-alignment: center; horizontal-alignment: left;
selection-foreground-color: white; selection-background-color: WorkbenchStyle.accent;
changed has-focus => {
root.lan-port-editing = self.has-focus;
// Opening the warning also moves focus: do not
// treat that as another attempt to save.
if !self.has-focus && root.lan-port-dirty && root.lan-access-warning == "" { root.lan-apply-pending = true; }
}
edited => { root.lan-port-dirty = true; root.lan-endpoint-error = ""; root.copy-feedback = CopyFeedback.idle; }
accepted => { if root.apply-lan-endpoint() { focus-sink.focus(); } }
}
}
}
}
copy-button := WorkbenchButton {
x: parent.width - 42px; y: 2px; width: 36px; quiet: true;
icon: root.copy-feedback == CopyFeedback.success ? FontAwesomeIcons.check : FontAwesomeIcons.copy;
icon-color: root.copy-feedback == CopyFeedback.success ? WorkbenchStyle.success : WorkbenchStyle.muted;
tooltip: GalleryStrings.tr("复制本机 ID", "Copy local ID", "Копировать ID");
tooltip: root.lan-mode ? GalleryStrings.tr("复制 IP 和端口", "Copy IP and port", "Копировать IP и порт") : GalleryStrings.tr("复制本机 ID", "Copy local ID", "Копировать ID");
enabled: !root.lan-mode || root.lan-listener-address != "";
tooltip-suppressed: root.copy-feedback != CopyFeedback.idle;
// Let other controls receive the original click; moving
// focus away from Copy dismisses its transient feedback.
@@ -1047,35 +1378,61 @@ export component MainWindow inherits Window {
if !self.has-focus { root.copy-feedback = CopyFeedback.idle; }
}
clicked => {
root.copy-feedback = root.copy-local-id() ? CopyFeedback.success : CopyFeedback.failed;
if root.lan-mode && root.lan-port-dirty && !root.apply-lan-endpoint() { return; }
let copied = root.lan-mode ? root.lan-copy-text(root.lan-listener-address) : root.copy-local-id();
root.copy-feedback = copied ? CopyFeedback.success : CopyFeedback.failed;
copy-timer.restart();
}
}
private property <string> edit-hint: !root.lan-mode || !root.sponsorship-unobstructed
|| root.lan-password-pending ? ""
: lan-ip-picker.has-hover ? GalleryStrings.tr("点击切换本机 IP", "Click to switch the local IP", "Нажмите, чтобы сменить IP")
: lan-port-hover.has-hover && !lan-port-editor.has-focus ? GalleryStrings.tr("点击修改端口", "Click to edit the port", "Нажмите, чтобы изменить порт") : "";
private property <bool> edit-hint-visible;
changed edit-hint => { local-address-field.edit-hint-visible = false; }
Timer {
interval: 450ms;
running: local-address-field.edit-hint != "" && !local-address-field.edit-hint-visible;
triggered => { local-address-field.edit-hint-visible = true; }
}
if local-address-field.edit-hint != "" && local-address-field.edit-hint-visible: Rectangle {
x: clamp((lan-ip-picker.has-hover ? lan-ip-picker.x + lan-ip-picker.width / 2
: lan-port-field.x + lan-port-field.width / 2) - self.width / 2, 0px, parent.width - self.width);
y: parent.height + 6px;
width: min(parent.width, edit-hint-text.preferred-width + 20px);
height: max(28px, edit-hint-text.preferred-height + 12px);
background: #24324a; border-radius: UiRadius.control; z: 20;
edit-hint-text := Text {
x: 10px; y: 6px; width: parent.width - 20px; height: parent.height - 12px;
text: local-address-field.edit-hint; font-size: 12px; color: white;
wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center;
}
}
}
Text { x: 16px; y: 138px; height: 18px; text: UiStrings.password; font-size: 12px; color: WorkbenchStyle.muted; }
Rectangle {
x: 16px; y: 158px; width: parent.width - 32px; height: 36px;
background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
border-width: 1px; border-color: WorkbenchStyle.border;
Text { x: 12px; width: parent.width - 132px; height: parent.height; text: root.password-visible ? root.local-password : "••••••"; font-size: 20px; font-weight: 500; letter-spacing: 2px; color: WorkbenchStyle.text; vertical-alignment: center; }
Text { x: 12px; width: parent.width - 132px; height: parent.height; text: root.active-password == "" ? "—" : root.active-password-visible ? root.active-password : "••••••"; font-size: 20px; font-weight: 500; letter-spacing: 2px; color: WorkbenchStyle.text; vertical-alignment: center; }
Rectangle { x: parent.width - 118px; y: 8px; width: 1px; height: parent.height - 16px; background: WorkbenchStyle.border; }
WorkbenchButton {
x: parent.width - 112px; y: 0px; width: 36px; quiet: true;
icon: root.password-visible ? FontAwesomeIcons.eye : FontAwesomeIcons.eye-slash;
tooltip: root.password-visible ? GalleryStrings.tr("隐藏密码", "Hide password", "Скрыть пароль") : GalleryStrings.tr("显示密码", "Show password", "Показать пароль");
clicked => { root.toggle-password-visibility(); }
icon: root.active-password-visible ? FontAwesomeIcons.eye : FontAwesomeIcons.eye-slash;
tooltip: root.active-password-visible ? GalleryStrings.tr("隐藏密码", "Hide password", "Скрыть пароль") : GalleryStrings.tr("显示密码", "Show password", "Показать пароль");
clicked => { if root.lan-mode { root.lan-password-visible = !root.lan-password-visible; } else { root.toggle-password-visibility(); } }
}
WorkbenchButton {
x: parent.width - 74px; y: 0px; width: 36px; quiet: true;
icon: FontAwesomeIcons.rotate; tooltip: UiStrings.refresh-password;
enabled: !root.password-change-pending;
clicked => { root.refresh-password(); }
enabled: root.lan-mode ? !root.lan-password-pending : !root.password-change-pending;
clicked => { if root.lan-mode { root.lan-reset-password(); } else { root.refresh-password(); } }
}
WorkbenchButton {
x: parent.width - 36px; y: 0px; width: 36px; quiet: true;
icon: FontAwesomeIcons.pen;
tooltip: GalleryStrings.tr("修改密码", "Change password", "Изменить пароль");
enabled: !root.password-change-pending;
enabled: root.lan-mode ? !root.lan-password-pending : !root.password-change-pending;
clicked => { root.reset-password-open = true; }
}
}
@@ -1083,8 +1440,8 @@ export component MainWindow inherits Window {
WorkbenchCard {
x: local-card.width + 16px; y: 0px; width: local-card.width; height: parent.height;
Text { x: 16px; y: 14px; width: parent.width - 32px; height: 24px; overflow: elide; text: UiStrings.remote-desktop; font-size: 16px; font-weight: 600; color: WorkbenchStyle.text; }
Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: GalleryStrings.tr("输入设备 ID,连接远程桌面", "Enter a device ID to start a remote session", "Введите ID для удалённого подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; }
Text { x: 16px; y: 68px; height: 18px; text: UiStrings.remote-id; font-size: 12px; color: WorkbenchStyle.muted; }
Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: root.lan-mode ? GalleryStrings.tr("输入 IP:端口,通过局域网连接", "Enter IP:port to connect over LAN", "Введите IP:порт для подключения по LAN") : GalleryStrings.tr("输入设备 ID,连接远程桌面", "Enter a device ID to start a remote session", "Введите ID для удалённого подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; }
Text { x: 16px; y: 68px; height: 18px; text: root.lan-mode ? GalleryStrings.tr("远端 IP / 端口", "Remote IP / Port", "Удалённый IP / Порт") : UiStrings.remote-id; font-size: 12px; color: WorkbenchStyle.muted; }
Rectangle {
x: 16px; y: 90px; width: parent.width - 32px; height: 40px;
background: white; border-radius: UiRadius.control; border-width: 1px;
@@ -1092,40 +1449,42 @@ export component MainWindow inherits Window {
animate border-color { duration: 120ms; }
if root.remote-id-input == "": Text {
x: 12px; width: parent.width - 24px; height: parent.height;
text: GalleryStrings.tr("请输入 9 位设备 ID", "Enter a 9-digit device ID", "Введите ID из 9 цифр");
text: root.lan-mode ? "192.168.1.20:52888" : GalleryStrings.tr("请输入 9 位设备 ID", "Enter a 9-digit device ID", "Введите ID из 9 цифр");
font-size: 13px; color: #8290a4; vertical-alignment: center;
}
remote-id-editor := TextInput {
x: 12px; width: parent.width - 24px; height: parent.height;
text <=> root.remote-id-input; font-size: 24px; font-weight: 600; letter-spacing: 1px;
input-type: InputType.number; color: WorkbenchStyle.text;
accessible-label: UiStrings.remote-id;
text <=> root.remote-id-input; font-size: root.lan-mode ? 18px : 24px; font-weight: 600; letter-spacing: root.lan-mode ? 0px : 1px;
input-type: root.lan-mode ? InputType.text : InputType.number; color: WorkbenchStyle.text;
accessible-label: root.lan-mode ? GalleryStrings.tr("远端 IP / 端口", "Remote IP / Port", "Удалённый IP / Порт") : UiStrings.remote-id;
enabled: !root.connection-pending && !root.lan-connecting;
vertical-alignment: center;
selection-foreground-color: white; selection-background-color: WorkbenchStyle.accent;
edited => {
root.connection-input-error = "";
// Enforce the digit limit before the next frame can
// render the text from typing, paste, or replacement.
let formatted = root.format-remote-id(self.text);
let formatted = root.lan-mode ? self.text : root.format-remote-id(self.text);
self.text = formatted;
// Formatted IDs are ASCII, so these are UTF-8 offsets.
self.set-selection-offsets(
formatted.character-count, formatted.character-count);
}
accepted => { if !root.connection-pending { root.connect-requested(root.remote-id-input); } }
accepted => { root.submit-connection(); }
}
}
Text {
x: 16px; y: 133px; width: parent.width - 32px; height: 24px;
text: root.connection-input-error;
font-size: 11px; color: #be3d42; wrap: word-wrap;
text: root.connection-input-error != "" ? root.connection-input-error
: root.lan-mode ? GalleryStrings.tr("不填端口时,默认使用 52888", "Port defaults to 52888 if omitted", "Без порта используется 52888") : "";
font-size: 11px; color: root.connection-input-error != "" ? #be3d42 : WorkbenchStyle.muted; wrap: word-wrap;
}
WorkbenchButton {
x: 16px; y: 158px; width: parent.width - 32px; height: 36px;
primary: true; icon: FontAwesomeIcons.arrow-right-long;
text: root.connection-pending ? GalleryStrings.tr("正在连接…", "Connecting…", "Подключение…") : UiStrings.connect;
enabled: !root.connection-pending;
clicked => { root.connect-requested(root.remote-id-input); }
text: root.connection-pending || root.lan-connecting ? GalleryStrings.tr("正在连接…", "Connecting…", "Подключение…") : UiStrings.connect;
enabled: !root.connection-pending && !root.lan-connecting;
clicked => { root.submit-connection(); }
}
}
}
@@ -1137,6 +1496,7 @@ export component MainWindow inherits Window {
Text { x: 20px; y: 0px; width: recent-group-filter.x - self.x - 12px; height: 32px; text: UiStrings.recent-connections; font-size: 15px; font-weight: 600; color: WorkbenchStyle.text; vertical-alignment: center; }
recent-group-filter := GroupFilter {
visible: !root.lan-mode;
x: parent.width - self.width - 20px; y: 0px;
width: 155px; height: 32px;
normal-border-color: WorkbenchStyle.control-border;
@@ -1169,14 +1529,18 @@ export component MainWindow inherits Window {
}
Text {
x: 84px; y: 36px; width: parent.width - 104px; height: 24px;
text: root.recent-group-index == 0
text: root.lan-mode
? GalleryStrings.tr("暂无 IP 直连设备", "No recent IP connections", "Нет недавних IP-подключений")
: root.recent-group-index == 0
? GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений")
: GalleryStrings.tr("该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений");
color: WorkbenchStyle.text; font-size: 14px; font-weight: 600;
}
Text {
x: 84px; y: 68px; width: parent.width - 104px; height: 20px;
text: root.recent-group-index == 0
text: root.lan-mode
? GalleryStrings.tr("通过 IP 连接过的设备会显示在这里。", "Devices you connect to by IP will appear here.", "Здесь появятся устройства, подключённые по IP.")
: root.recent-group-index == 0
? GalleryStrings.tr("连接过的设备会显示在这里,方便下次快速访问。", "Devices you connect to will appear here for quick access.", "Устройства появятся здесь после первого подключения.")
: GalleryStrings.tr("切换设备分组,查看其他连接记录。", "Choose another group to see your other connections.", "Выберите другую группу для просмотра подключений.");
color: WorkbenchStyle.muted; font-size: 12px;
@@ -1199,10 +1563,10 @@ export component MainWindow inherits Window {
spacing: 12px;
alignment: start;
for connection[index] in root.recent-connections: connection-card := Rectangle {
width: (recent-scroll.width - 24px) / 3;
width: root.recent-preview-width;
height: 126px;
background: transparent;
private property <bool> available: connection.online || connection.connected;
private property <bool> available: connection.lan || connection.online || connection.connected;
private property <length> content-width: self.width;
private property <length> scroll-offset: recent-scroll.viewport-x;
private property <string> device-id: connection.remote-id;
@@ -1222,7 +1586,7 @@ export component MainWindow inherits Window {
x: 0px;
y: 0px;
width: parent.width;
height: 80px;
height: root.recent-preview-height;
background: !connection-card.available ? DeviceCardStyle.offline-preview :
connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) :
connection.platform == "macos" ? @linear-gradient(135deg, #6e4aad, #8c63c4) :
@@ -1263,7 +1627,7 @@ export component MainWindow inherits Window {
}
presence-label := Text {
x: 17px; width: self.preferred-width; height: parent.height;
text: connection-card.available ? UiStrings.online : UiStrings.offline;
text: connection.lan ? "LAN" : connection-card.available ? UiStrings.online : UiStrings.offline;
color: connection-card.available ? WorkbenchStyle.success : WorkbenchStyle.muted;
font-size: 10px; font-weight: 500; vertical-alignment: center;
}
@@ -1286,7 +1650,7 @@ export component MainWindow inherits Window {
}
Text {
x: 12px; y: 24px; height: 16px; width: parent.width - 56px;
text: connection.remote-id; font-size: 11px; color: WorkbenchStyle.muted;
text: connection.lan ? connection.endpoint : connection.remote-id; font-size: 11px; color: WorkbenchStyle.muted;
overflow: elide;
}
}
@@ -1367,7 +1731,7 @@ export component MainWindow inherits Window {
menu-width: root.compact-language ? 116px : root.language-index == 2 ? 156px : 128px;
items: [
{label: GalleryStrings.tr("修改名称", "Rename", "Переименовать"), enabled: true},
{label: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы"), enabled: true},
{label: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы"), enabled: !connection.lan},
{label: GalleryStrings.tr("删除记录", "Delete record", "Удалить запись"), enabled: true},
];
item-icons: [FontAwesomeIcons.pen, FontAwesomeIcons.tags, FontAwesomeIcons.trash-can];
@@ -1465,7 +1829,7 @@ export component MainWindow inherits Window {
root.delete-remote-id = id; root.delete-from-gallery = true; root.delete-open = true;
}
}
// Server status and reconnect actions belong to the connection window.
// Show availability for the selected connection mode.
if !root.gallery-mode: Rectangle {
y: parent.height - 28px;
height: 28px;
@@ -1477,13 +1841,16 @@ export component MainWindow inherits Window {
}
Rectangle {
x: 20px; y: 11px; width: 6px; height: 6px; border-radius: 3px;
background: root.signal-tls-error || root.signal-reconnecting ? #d6932b : root.signal-connected ? WorkbenchStyle.success : #d65252;
background: root.lan-mode ? (root.lan-message != "" || root.lan-listener-error != "" ? #d6932b : root.lan-listening ? WorkbenchStyle.success : WorkbenchStyle.muted) : root.signal-tls-error || root.signal-reconnecting ? #d6932b : root.signal-connected ? WorkbenchStyle.success : #d65252;
}
Text {
x: 34px;
y: 0px;
height: parent.height;
text: root.signal-tls-error ? UiStrings.tls-error
width: parent.width - 160px;
overflow: elide;
text: root.lan-mode ? (root.lan-message != "" ? LanStrings.error-message(root.lan-message) : root.lan-listener-error != "" ? (root.lan-listener-address == "" ? "" : root.lan-listener-address + " · ") + LanStrings.error-message(root.lan-listener-error) : root.lan-listening ? GalleryStrings.tr("局域网模式已启用", "LAN mode enabled", "Режим LAN включён") : root.lan-password-pending ? GalleryStrings.tr("局域网模式准备中…", "Preparing LAN mode…", "Подготовка режима LAN…") : GalleryStrings.tr("局域网模式未就绪", "LAN mode not ready", "Режим LAN не готов"))
: root.signal-tls-error ? UiStrings.tls-error
: root.signal-connected ? UiStrings.signal-connected
: root.signal-reconnecting ? UiStrings.signal-reconnecting
: UiStrings.signal-disconnected;
@@ -1491,12 +1858,12 @@ export component MainWindow inherits Window {
font-size: 11px;
vertical-alignment: center;
}
if root.signal-connected && !root.signal-tls-error: Text {
if root.lan-mode || (root.signal-connected && !root.signal-tls-error): Text {
x: parent.width - self.preferred-width - 20px; height: parent.height;
text: root.current-version;
font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center;
}
if !root.signal-connected && !root.signal-tls-error: WorkbenchButton {
if !root.lan-mode && !root.signal-connected && !root.signal-tls-error: WorkbenchButton {
x: parent.width - self.width - 20px;
y: (parent.height - self.height) / 2;
width: max(64px, reconnect-label-metrics.preferred-width + 22px);
@@ -1521,7 +1888,7 @@ export component MainWindow inherits Window {
// Shared device details sit outside the clipped recent list and wall rail,
// below the modal layer (z >= 39).
if (root.gallery-mode ? gallery.hovered-device.id != "" : root.recent-tooltip-visible)
&& !root.connection-dialog-open: device-tip := Rectangle {
&& !root.connection-dialog-open && !root.lan-open: device-tip := Rectangle {
private property <string> device-name: root.gallery-mode ? gallery.hovered-device.name : root.recent-tooltip-name;
private property <string> host-name: root.gallery-mode ? gallery.hovered-device.host-name : root.recent-tooltip-host;
private property <string> device-id: root.gallery-mode ? gallery.hovered-device.id : root.recent-tooltip-id;
@@ -1824,7 +2191,7 @@ export component MainWindow inherits Window {
icon: self.error ? FontAwesomeIcons.xmark : FontAwesomeIcons.check;
text: self.error
? GalleryStrings.tr("复制失败,请重试", "Couldn’t copy. Try again", "Не удалось скопировать. Повторите попытку")
: UiStrings.copied;
: root.lan-mode ? GalleryStrings.tr("IP 和端口已复制", "IP and port copied", "IP и порт скопированы") : UiStrings.copied;
z: 30;
}
@@ -1854,6 +2221,7 @@ export component MainWindow inherits Window {
alignment: center; spacing: 8px;
PrimaryButton {
text: UiStrings.ok;
enabled: root.lan-mode ? !root.lan-password-pending : !root.password-change-pending;
clicked => {
if (root.reset-password(root.new-password-input)) {
root.new-password-input = "";
@@ -2047,32 +2415,41 @@ export component MainWindow inherits Window {
}
// Connection feedback follows the workbench spacing and action hierarchy.
if root.connection-dialog-open: Rectangle {
if root.connection-dialog-open || root.lan-open: Rectangle {
background: #15223850;
z: 48;
TouchArea { clicked => { focus-sink.focus(); } }
TouchArea { clicked => { if !root.lan-open { focus-sink.focus(); } } }
WorkbenchCard {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 340px;
height: root.connection-password-required && !root.connection-validating ? 236px : 168px;
Text {
x: 24px; y: 24px; width: parent.width - 48px; height: 46px;
text: root.connection-status-text;
height: (root.prompt-password-required && !root.prompt-validating ? 236px : 168px)
+ max(0px, connection-prompt-text.preferred-height - 46px);
connection-prompt-text := Text {
x: 24px; y: 24px; width: parent.width - 48px; height: max(46px, self.preferred-height);
text: root.prompt-status-text;
color: WorkbenchStyle.text; font-size: 14px;
wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center;
}
if root.connection-password-required && !root.connection-validating: PasswordInput {
x: (parent.width - self.width) / 2; y: 86px;
if !root.lan-open && root.connection-password-required && !root.connection-validating: PasswordInput {
x: (parent.width - self.width) / 2; y: connection-prompt-text.y + connection-prompt-text.height + 16px;
width: 164px; height: 36px; text-size: 16px;
border-radius: UiRadius.control; border-color: WorkbenchStyle.control-border;
text <=> root.connection-password;
accepted => { root.connection-submit-password(root.connection-password, root.connection-remember-password); }
accepted => { root.submit-connection-password(); }
}
if root.connection-password-required && !root.connection-validating: Rectangle {
x: (parent.width - self.width) / 2; y: 134px;
if root.lan-open && !root.lan-connecting: PasswordInput {
x: (parent.width - self.width) / 2; y: connection-prompt-text.y + connection-prompt-text.height + 16px;
width: 164px; height: 36px; text-size: 16px; max-length: 6;
border-radius: UiRadius.control; border-color: WorkbenchStyle.control-border;
text <=> root.lan-access-password;
accepted => { root.submit-connection-password(); }
}
if root.prompt-password-required && !root.prompt-validating: Rectangle {
x: (parent.width - self.width) / 2; y: connection-prompt-text.y + connection-prompt-text.height + 64px;
width: remember-label.preferred-width + 29px; height: 21px;
ImGuiCheckBox { x: 0px; y: 0px; checked <=> root.connection-remember-password; }
if root.lan-open: ImGuiCheckBox { x: 0px; y: 0px; checked <=> root.lan-remember-password; }
if !root.lan-open: ImGuiCheckBox { x: 0px; y: 0px; checked <=> root.connection-remember-password; }
remember-label := Text {
x: 29px; height: parent.height;
text: UiStrings.remember-password;
@@ -2080,21 +2457,25 @@ export component MainWindow inherits Window {
}
TouchArea {
x: 29px; width: parent.width - 29px; height: parent.height;
clicked => { root.connection-remember-password = !root.connection-remember-password; }
clicked => {
if root.lan-open { root.lan-remember-password = !root.lan-remember-password; }
else { root.connection-remember-password = !root.connection-remember-password; }
}
}
}
HorizontalLayout {
x: 24px; y: parent.height - 60px; width: parent.width - 48px; height: 36px;
spacing: 12px; alignment: center;
if root.connection-password-required || root.connection-pending: WorkbenchButton {
if root.prompt-password-required || root.prompt-pending: WorkbenchButton {
text: UiStrings.cancel;
clicked => { root.connection-cancel(); }
clicked => { root.cancel-connection-prompt(); }
}
if root.connection-password-required && !root.connection-validating: WorkbenchButton {
if root.prompt-password-required && !root.prompt-validating: WorkbenchButton {
primary: true; text: UiStrings.ok;
clicked => { root.connection-submit-password(root.connection-password, root.connection-remember-password); }
enabled: !root.lan-open || (root.lan-retry-after == 0 && root.lan-access-password.character-count == 6);
clicked => { root.submit-connection-password(); }
}
if !root.connection-password-required && !root.connection-pending: WorkbenchButton {
if !root.prompt-password-required && !root.prompt-pending: WorkbenchButton {
primary: true; text: UiStrings.ok;
clicked => { root.connection-acknowledge(); }
}
@@ -2253,7 +2634,7 @@ export component MainWindow inherits Window {
if root.settings-tab == 0: settings-scroll := ScrollView {
x: settings-dialog.content-inset; y: 108px;
width: parent.width - 2 * settings-dialog.content-inset + settings-dialog.scrollbar-gutter;
height: parent.height - 174px;
height: parent.height - 174px - (root.settings-save-error == "" ? 0px : 28px);
viewport-width: self.visible-width;
viewport-height: settings-content.preferred-height;
viewport-y <=> root.settings-scroll-y;
@@ -2289,16 +2670,40 @@ export component MainWindow inherits Window {
}
SettingsGroup {
title: GalleryStrings.tr("网络连接", "Network", "Сеть"); rows: 3;
SettingsToggleRow { label: UiStrings.turn-relay; checked <=> root.turn-enabled; enabled: !root.settings-session-active; }
SettingsToggleRow { label: UiStrings.force-relay; checked <=> root.force-relay-enabled; enabled: root.turn-enabled && !root.settings-session-active; }
SettingsToggleRow {
label: GalleryStrings.tr("启用 IP 直连", "Enable direct IP", "Прямое IP-подключение");
checked <=> root.ip-direct-enabled-draft;
}
SettingsToggleRow { label: UiStrings.force-relay; checked <=> root.force-relay-enabled; enabled: !root.settings-session-active; }
SettingsToggleRow {
label: UiStrings.self-hosted;
label-reserve: self-host-edit.width + 84px;
checked <=> root.self-hosted-enabled; enabled: !root.settings-session-active; divider: false;
SettingsButton { x: 0px; y: 2px; width: parent.width - 60px; height: 32px; quiet: true; align-left: true; text: UiStrings.self-hosted; enabled: !root.settings-session-active; clicked => { root.self-host-settings-open = true; } }
self-host-edit := SettingsButton {
x: parent.width - self.width - 60px; y: 4px;
width: self-host-edit-metrics.preferred-width + 24px; height: 28px;
text: GalleryStrings.tr("编辑", "Edit", "Изменить");
enabled: !root.settings-session-active;
clicked => { root.self-host-settings-open = true; }
self-host-edit-metrics := Text {
visible: false; accessible-role: none;
text: parent.text; font-size: 12px;
}
}
}
}
Text {
text: GalleryStrings.tr("关闭 IP 直连将隐藏模式按钮,并断开 IP 直连会话。", "Disabling direct IP hides the mode button and disconnects direct IP sessions.", "Отключение скроет кнопку режима и завершит прямые IP-сеансы.");
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
}
SettingsGroup {
title: GalleryStrings.tr("隐私保护", "Privacy", "Приватность"); rows: 1;
SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; divider: false; }
title: GalleryStrings.tr("隐私保护", "Privacy", "Приватность"); rows: 2;
SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; 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; }
}
Text {
text: GalleryStrings.tr("最后一个远程控制端断开后,自动锁定本机。", "Lock this device when the last remote controller disconnects.", "Блокировать это устройство после отключения последнего управляющего клиента.");
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
}
SettingsGroup {
title: GalleryStrings.tr("本地存储", "Local storage", "Локальное хранение"); rows: 2;
@@ -2324,12 +2729,18 @@ export component MainWindow inherits Window {
}
if root.settings-tab == 1: GallerySettings {
x: settings-dialog.content-inset; y: 108px;
width: parent.width - 2 * settings-dialog.content-inset; height: parent.height - 174px;
width: parent.width - 2 * settings-dialog.content-inset;
height: parent.height - 174px - (root.settings-save-error == "" ? 0px : 28px);
resolution-index <=> root.gallery-resolution-draft;
frame-rate-index <=> root.gallery-frame-rate-draft;
bandwidth-index <=> root.gallery-bandwidth-draft;
error: root.gallery-settings-error;
}
if root.settings-save-error != "": Text {
x: settings-dialog.content-inset; y: parent.height - 92px;
width: parent.width - 2 * settings-dialog.content-inset; height: 24px;
text: root.settings-save-error; font-size: 11px; color: #c53e3e; wrap: word-wrap;
}
Rectangle {
x: settings-dialog.content-inset; y: parent.height - 64px;
width: parent.width - 2 * settings-dialog.content-inset; height: 1px; background: #e6ebf2;
@@ -3104,4 +3515,79 @@ export component MainWindow inherits Window {
z: 200;
}
}
// Each explicit validation failure opens one modal, without a focus loop.
if root.lan-access-warning != "": Rectangle {
width: parent.width; height: parent.height;
background: #00000042;
z: 300;
TouchArea { scroll-event(event) => { accept } }
lan-warning-focus := FocusScope {
width: parent.width; height: parent.height;
key-pressed(event) => {
if event.text == Key.Escape || event.text == Key.Return || event.text == " " {
root.dismiss-lan-access-warning();
} else if event.text == Key.Tab {
self.focus();
}
return accept;
}
Timer {
interval: 1ms;
running: true;
triggered => {
self.running = false;
// Defer focus until after Slint's accessibility update.
root.menu-open = false;
lan-ip-picker.close-menu();
lan-warning-focus.focus();
}
}
DialogSurface {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(root.compact-language ? 270px : 320px, parent.width - 40px);
// Let short messages stay compact; longer translations grow
// naturally without stretching the gap above the action.
height: lan-warning-content.preferred-height;
TouchArea { scroll-event(event) => { accept } }
lan-warning-content := VerticalLayout {
padding: 20px; spacing: 20px;
padding-top: 28px; padding-bottom: 28px;
if root.lan-access-warning-endpoint != "": Text {
text: root.lan-access-warning-endpoint;
font-size: 12px; font-weight: 600; color: WorkbenchStyle.text;
horizontal-alignment: center; wrap: word-wrap;
}
lan-warning-message := Rectangle {
private property <[string]> lines: LanStrings.access-error-lines(root.lan-access-warning);
height: warning-lines.preferred-height;
vertical-stretch: 0;
accessible-role: text;
accessible-label: LanStrings.error-message(root.lan-access-warning);
warning-lines := VerticalLayout {
width: parent.width;
height: self.preferred-height;
spacing: 6px;
for line in (lan-warning-message.lines[0] != "" ? lan-warning-message.lines : [LanStrings.error-message(root.lan-access-warning)]): Text {
text: line; font-size: ImGuiFontStyle.base; color: #30343b;
horizontal-alignment: center; wrap: word-wrap;
accessible-role: none;
}
}
}
HorizontalLayout {
height: 30px; alignment: center;
PrimaryButton {
text: UiStrings.ok;
accessible-role: button;
accessible-label: UiStrings.ok;
accessible-action-default => { root.dismiss-lan-access-warning(); }
clicked => { root.dismiss-lan-access-warning(); }
}
}
}
}
}
}
}
+1 -1
View File
@@ -93,7 +93,6 @@ inline int ApplyMainWindowStrings(
strings.set_capture_method_auto(
Text(localization::screen_capture_method_auto[language]));
strings.set_codec(Text(localization::video_encode_format[language]));
strings.set_turn_relay(Text(localization::enable_turn[language]));
strings.set_force_relay(Text(localization::force_relay[language]));
strings.set_self_hosted(
Text(localization::self_hosted_server_config[language]));
@@ -102,6 +101,7 @@ inline int ApplyMainWindowStrings(
strings.set_save_remote_previews(Text(localization::save_remote_previews[language]));
strings.set_unknown_platform(Text(localization::unknown_platform[language]));
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language]));
strings.set_lock_on_disconnect(Text(localization::lock_on_disconnect[language]));
strings.set_file_save_path(
Text(localization::file_transfer_save_path[language]));
strings.set_default_desktop(Text(localization::default_desktop[language]));
@@ -0,0 +1,134 @@
#include "lan_auth_limiter.h"
#include <algorithm>
#include <exception>
#include <stdexcept>
#include <utility>
namespace crossdesk::lan {
DeviceAuthLimiter::DeviceAuthLimiter(Now now, size_t source_failure_limit,
size_t target_failure_limit)
: now_(std::move(now)),
source_failure_limit_(source_failure_limit),
target_failure_limit_(target_failure_limit) {
if (source_failure_limit == 0 || target_failure_limit == 0)
throw std::invalid_argument(
"Authentication failure limits must be positive");
}
void DeviceAuthLimiter::Prune(Buckets& buckets, Clock::time_point now) {
for (auto it = buckets.begin(); it != buckets.end();) {
if (it->second.expires <= now && it->second.pending == 0)
it = buckets.erase(it);
else
++it;
}
}
DeviceAuthLimiter::Result DeviceAuthLimiter::Verify(
const std::string& source, const std::string& target,
const std::function<bool()>& verify) {
// Reserve both budgets atomically, then release the lock for slow hashing.
std::unique_lock<std::mutex> lock(mutex_);
const auto now = now_();
if (now >= next_cleanup_) {
Prune(sources_, now);
Prune(targets_, now);
next_cleanup_ = now + std::chrono::minutes(1);
}
// Missing source metadata must not become a per-connection bypass.
const std::string source_key = source.empty() ? "unknown" : source;
auto retry = [now](Buckets& buckets, const std::string& key, size_t limit) {
const auto it = buckets.find(key);
if (it == buckets.end()) return 0;
if (it->second.expires <= now) {
it->second.count = 0;
it->second.expires = now + kWindow;
}
if (it->second.count < limit)
return it->second.count + it->second.pending >= limit ? 1 : 0;
return static_cast<int>(
std::chrono::ceil<std::chrono::seconds>(it->second.expires - now)
.count());
};
const int source_retry = retry(sources_, source_key, source_failure_limit_);
const int target_retry = retry(targets_, target, target_failure_limit_);
const int retry_after = std::max(source_retry, target_retry);
if (retry_after > 0) return {false, retry_after};
// Do not evict live failures when flooded with new IDs/IPs. Deny new keys
// until expiry frees capacity, bounding memory without resetting lockouts.
if ((!sources_.count(source_key) && sources_.size() >= kMaxEntries) ||
(!targets_.count(target) && targets_.size() >= kMaxEntries)) {
return {false, static_cast<int>(kWindow.count())};
}
for (auto entry : {std::make_pair(&sources_, source_key),
std::make_pair(&targets_, target)}) {
auto& bucket =
entry.first->try_emplace(entry.second, Failures{0, 0, now + kWindow})
.first->second;
++bucket.pending;
}
lock.unlock();
bool authenticated = false;
std::exception_ptr error;
try {
authenticated = verify();
} catch (...) {
error = std::current_exception();
}
lock.lock();
const auto finished = now_();
auto finish = [&](Buckets& buckets, const std::string& key, size_t limit) {
auto& bucket = buckets.at(key);
--bucket.pending;
if (bucket.expires <= finished) {
bucket.count = 0;
bucket.expires = finished + kWindow;
}
// The reservation already owns this bucket. Start a full cooldown at the
// threshold; blocked requests do not extend it.
if (!authenticated && ++bucket.count == limit)
bucket.expires = finished + kWindow;
if (bucket.count == 0 && bucket.pending == 0) buckets.erase(key);
};
finish(sources_, source_key, source_failure_limit_);
finish(targets_, target, target_failure_limit_);
lock.unlock();
if (error) std::rethrow_exception(error);
return {authenticated, 0};
}
int CredentialWorkLimiter::Admit(const std::string& source) {
std::lock_guard<std::mutex> lock(mutex_);
const auto now = now_();
constexpr auto window = std::chrono::minutes(1);
if (now >= next_cleanup_) {
for (auto it = sources_.begin(); it != sources_.end();) {
if (it->second.expires <= now)
it = sources_.erase(it);
else
++it;
}
next_cleanup_ = now + window;
}
const auto key = source.empty() ? "unknown" : source;
if (!sources_.count(key) && sources_.size() >= DeviceAuthLimiter::kMaxEntries)
return 60;
auto& local =
sources_.try_emplace(key, Window{0, now + window}).first->second;
if (local.expires <= now) local = {0, now + window};
auto retry = [now](const Window& value, size_t limit) {
return value.count < limit
? 0
: static_cast<int>(std::chrono::ceil<std::chrono::seconds>(
value.expires - now)
.count());
};
const int delay = retry(local, source_limit_);
if (delay > 0) return delay;
++local.count;
return 0;
}
} // namespace crossdesk::lan
@@ -0,0 +1,86 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_AUTH_LIMITER_H_
#define _LAN_AUTH_LIMITER_H_
#include <chrono>
#include <cstddef>
#include <functional>
#include <mutex>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
namespace crossdesk::lan {
// Keep password verification and retry policy aligned with crossdesk-server.
// Failure budgets keyed by transport peer IP and credential target, never a
// connection handle. Use separate instances for independent auth domains.
class DeviceAuthLimiter {
public:
using Clock = std::chrono::steady_clock;
using Now = std::function<Clock::time_point()>;
static constexpr size_t kSourceFailures = 20;
static constexpr size_t kTargetFailures = 5;
static constexpr size_t kMaxEntries = 16384; // Per dimension.
static constexpr std::chrono::seconds kWindow{900};
struct Result {
bool authenticated = false;
int retry_after = 0; // Positive only when verification was not attempted.
};
explicit DeviceAuthLimiter(Now now = Clock::now,
size_t source_failure_limit = kSourceFailures,
size_t target_failure_limit = kTargetFailures);
Result Verify(const std::string& source, const std::string& target,
const std::function<bool()>& verify);
private:
struct Failures {
size_t count = 0;
size_t pending = 0;
Clock::time_point expires;
};
using Buckets = std::unordered_map<std::string, Failures>;
void Prune(Buckets& buckets, Clock::time_point now);
Now now_;
const size_t source_failure_limit_, target_failure_limit_;
std::mutex mutex_;
Buckets sources_, targets_;
Clock::time_point next_cleanup_{};
};
// Admission before expensive work, including successful authentication,
// registration and password changes. Separate from credential failure budgets.
class CredentialWorkLimiter {
public:
explicit CredentialWorkLimiter(
DeviceAuthLimiter::Now now = DeviceAuthLimiter::Clock::now,
size_t source_limit = 60)
: now_(std::move(now)), source_limit_(source_limit) {
if (!source_limit_)
throw std::invalid_argument("Credential source limit must be positive");
}
int Admit(const std::string& source);
private:
struct Window {
size_t count = 0;
DeviceAuthLimiter::Clock::time_point expires{};
};
DeviceAuthLimiter::Now now_;
std::mutex mutex_;
const size_t source_limit_;
DeviceAuthLimiter::Clock::time_point next_cleanup_{};
std::unordered_map<std::string, Window> sources_;
};
} // namespace crossdesk::lan
#endif
@@ -0,0 +1,656 @@
#include "lan_identity.h"
#include <openssl/crypto.h>
#include <openssl/pem.h>
#include <openssl/rand.h>
#include <openssl/x509v3.h>
#include <algorithm>
#include <array>
#include <charconv>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <string_view>
#include "lan_password.h"
#ifdef _WIN32
// Winsock must precede windows.h; the networking/ACL 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>
#endif
#endif
namespace crossdesk::lan {
namespace {
void Require(bool ok, const char* error) {
if (!ok) throw std::runtime_error(error);
}
std::string Hex(const unsigned char* data, size_t size) {
static constexpr char digits[] = "0123456789abcdef";
std::string result(size * 2, '0');
for (size_t i = 0; i < size; ++i) {
result[2 * i] = digits[data[i] >> 4];
result[2 * i + 1] = digits[data[i] & 15];
}
return result;
}
bool IsHex(const std::string& text, size_t size) {
return text.size() == size &&
std::all_of(text.begin(), text.end(), [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
});
}
void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
const auto temporary = path.parent_path() / (".write-" + RandomHex(16));
#ifdef _WIN32
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");
LPWSTR sid = nullptr;
Require(ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(data.data())->User.Sid, &sid),
"Cannot read user SID");
std::wstring acl = L"D:P(A;;FA;;;" + std::wstring(sid) + L")";
LocalFree(sid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
"Cannot protect LAN file");
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;
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
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;
}
void CheckPrivateWindowsPath(const std::filesystem::path& path) {
const auto attributes = GetFileAttributesW(path.c_str());
Require(attributes != INVALID_FILE_ATTRIBUTES &&
!(attributes & 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(GetNamedSecurityInfoW(
const_cast<wchar_t*>(path.c_str()), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS,
"Cannot read LAN file permissions");
bool ok = owner && EqualSid(owner, sid) && acl && acl->AceCount > 0;
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);
}
LocalFree(descriptor);
Require(ok, "LAN files must be accessible only by the current user");
}
void CreatePrivateDirectory(const std::filesystem::path& path) {
if (!std::filesystem::exists(path)) {
auto user = CurrentUser();
LPWSTR sid = nullptr;
Require(ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
"Cannot read user SID");
const auto acl = L"D:P(A;OICI;FA;;;" + std::wstring(sid) + L")";
LocalFree(sid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
"Cannot protect LAN directory");
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), {}};
}
std::string BioText(BIO* bio) {
char* data = nullptr;
long size = BIO_get_mem_data(bio, &data);
Require(size > 0, "Cannot encode LAN identity");
return {data, static_cast<size_t>(size)};
}
struct Secret {
std::string value;
~Secret() { OPENSSL_cleanse(value.data(), value.size()); }
};
std::string Unhex(const std::string& value, size_t bytes) {
Require(IsHex(value, bytes * 2), "Invalid saved LAN password");
std::string result(bytes, '\0');
auto digit = [](char c) { return c <= '9' ? c - '0' : c - 'a' + 10; };
for (size_t i = 0; i < bytes; ++i)
result[i] =
static_cast<char>((digit(value[2 * i]) << 4) | digit(value[2 * i + 1]));
return result;
}
std::string PasswordKey(const std::filesystem::path& directory, bool create) {
const auto path = directory / "password-key";
if (create && !std::filesystem::exists(path)) {
Secret key{std::string(32, '\0')};
Require(
RAND_bytes(reinterpret_cast<unsigned char*>(key.value.data()), 32) == 1,
"Cannot generate LAN password storage key");
PrivateWrite(path, key.value);
}
auto key = Read(path);
Require(key.size() == 32, "Invalid LAN password storage key");
return key;
}
// The independent local key and credential files use owner-only permissions.
// Encryption avoids plaintext in identity.json; it does not protect against
// an attacker who can read both this user's key and credential files.
nlohmann::json EncryptPassword(const std::string& password,
const std::string& key,
const std::string& context) {
const auto nonce = Unhex(RandomHex(12), 12);
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> cipher(
EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free);
std::array<unsigned char, 32> data{}, tag{};
int size = 0, tail = 0;
Require(
cipher &&
EVP_EncryptInit_ex(
cipher.get(), EVP_aes_256_gcm(), nullptr,
reinterpret_cast<const unsigned char*>(key.data()),
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
EVP_EncryptUpdate(
cipher.get(), nullptr, &size,
reinterpret_cast<const unsigned char*>(context.data()),
static_cast<int>(context.size())) == 1 &&
EVP_EncryptUpdate(
cipher.get(), data.data(), &size,
reinterpret_cast<const unsigned char*>(password.data()),
static_cast<int>(password.size())) == 1 &&
EVP_EncryptFinal_ex(cipher.get(), data.data() + size, &tail) == 1 &&
EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_GET_TAG, 16,
tag.data()) == 1,
"Cannot save LAN password");
return {{"version", 1},
{"algorithm", "aes-256-gcm"},
{"nonce", Hex(reinterpret_cast<const unsigned char*>(nonce.data()),
nonce.size())},
{"data", Hex(data.data(), size + tail)},
{"tag", Hex(tag.data(), 16)}};
}
std::string DecryptPassword(const nlohmann::json& record,
const std::string& key,
const std::string& context) {
Require(record.at("version") == 1 && record.at("algorithm") == "aes-256-gcm",
"Invalid saved LAN password");
const auto nonce = Unhex(record.at("nonce"), 12);
const auto data = Unhex(record.at("data"), 6);
auto tag = Unhex(record.at("tag"), 16);
Secret plain{std::string(32, '\0')};
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> cipher(
EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free);
int size = 0, tail = 0;
Require(cipher &&
EVP_DecryptInit_ex(
cipher.get(), EVP_aes_256_gcm(), nullptr,
reinterpret_cast<const unsigned char*>(key.data()),
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
EVP_DecryptUpdate(
cipher.get(), nullptr, &size,
reinterpret_cast<const unsigned char*>(context.data()),
static_cast<int>(context.size())) == 1 &&
EVP_DecryptUpdate(
cipher.get(),
reinterpret_cast<unsigned char*>(plain.value.data()), &size,
reinterpret_cast<const unsigned char*>(data.data()),
static_cast<int>(data.size())) == 1 &&
EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_SET_TAG, 16,
tag.data()) == 1 &&
EVP_DecryptFinal_ex(
cipher.get(),
reinterpret_cast<unsigned char*>(plain.value.data()) + size,
&tail) == 1 &&
size + tail == 6,
"Cannot read saved LAN password");
auto password = plain.value.substr(0, 6);
Require(ValidPassword(password), "Invalid saved LAN password");
return password;
}
void CreateCertificate(const std::filesystem::path& key_path,
const std::filesystem::path& cert_path) {
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
EVP_PKEY_Q_keygen(nullptr, nullptr, "EC", "prime256v1"), EVP_PKEY_free);
std::unique_ptr<X509, decltype(&X509_free)> cert(X509_new(), X509_free);
Require(key && cert, "Cannot generate LAN identity");
X509_set_version(cert.get(), 2);
ASN1_INTEGER_set(X509_get_serialNumber(cert.get()), 1);
X509_gmtime_adj(X509_getm_notBefore(cert.get()), -300);
X509_gmtime_adj(X509_getm_notAfter(cert.get()), 10L * 365 * 86400);
X509_set_pubkey(cert.get(), key.get());
auto* name = X509_get_subject_name(cert.get());
X509_NAME_add_entry_by_txt(
name, "CN", MBSTRING_ASC,
reinterpret_cast<const unsigned char*>("CrossDesk LAN"), -1, -1, 0);
X509_set_issuer_name(cert.get(), name);
for (const auto& extension :
{std::pair<int, const char*>{NID_basic_constraints, "critical,CA:FALSE"},
{NID_key_usage, "critical,digitalSignature"},
{NID_ext_key_usage, "serverAuth"}}) {
X509_EXTENSION* ext = X509V3_EXT_conf_nid(
nullptr, nullptr, extension.first, const_cast<char*>(extension.second));
Require(ext != nullptr, "Cannot create certificate extension");
const int result = X509_add_ext(cert.get(), ext, -1);
X509_EXTENSION_free(ext);
Require(result == 1, "Cannot install certificate extension");
}
Require(X509_sign(cert.get(), key.get(), EVP_sha256()) > 0,
"Cannot sign LAN certificate");
std::unique_ptr<BIO, decltype(&BIO_free)> bio(BIO_new(BIO_s_mem()), BIO_free);
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()));
(void)BIO_reset(bio.get());
Require(PEM_write_bio_X509(bio.get(), cert.get()) == 1,
"Cannot encode certificate");
PrivateWrite(cert_path, BioText(bio.get()));
}
} // namespace
unsigned ParsePort(const std::string& value) {
unsigned port = 0;
auto [end, error] =
std::from_chars(value.data(), value.data() + value.size(), port);
Require(error == std::errc{} && end == value.data() + value.size() &&
port > 0 && port <= 65535,
"Invalid LAN port");
return port;
}
bool IsIpv4(const std::string& value) {
unsigned octets[4]{};
size_t offset = 0;
for (unsigned i = 0; i < 4; ++i) {
size_t end = value.find('.', offset);
if (i == 3) end = value.size();
if (end == std::string::npos || end <= offset || end - offset > 3 ||
(end - offset > 1 && value[offset] == '0'))
return false;
for (size_t j = offset; j < end; ++j) {
if (value[j] < '0' || value[j] > '9') return false;
octets[i] = octets[i] * 10 + value[j] - '0';
}
if (octets[i] > 255) return false;
offset = end + 1;
}
return offset == value.size() + 1 && octets[0] > 0 && octets[0] != 127 &&
octets[0] < 224;
}
std::pair<std::string, unsigned> ParseEndpoint(const std::string& value,
unsigned default_port) {
std::string address = value;
const auto colon = address.find(':');
unsigned port = default_port;
if (colon != std::string::npos) {
port = ParsePort(address.substr(colon + 1));
address.resize(colon);
}
Require(IsIpv4(address) && port > 0 && port <= 65535,
"Enter a numeric IPv4 address or IPv4:port");
return {address, port};
}
std::vector<Interface> PhysicalInterfaces() {
std::vector<Interface> result;
#ifdef _WIN32
ULONG bytes = 16384;
std::vector<unsigned char> buffer(bytes);
auto* adapters = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buffer.data());
ULONG code =
GetAdaptersAddresses(AF_INET,
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST |
GAA_FLAG_SKIP_DNS_SERVER,
nullptr, adapters, &bytes);
if (code == ERROR_BUFFER_OVERFLOW) {
buffer.resize(bytes);
adapters = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buffer.data());
code = GetAdaptersAddresses(AF_INET, 0, nullptr, adapters, &bytes);
}
if (code != NO_ERROR) return result;
for (auto* a = adapters; a; a = a->Next) {
if (a->OperStatus != IfOperStatusUp ||
(a->IfType != IF_TYPE_ETHERNET_CSMACD &&
a->IfType != IF_TYPE_IEEE80211) ||
a->PhysicalAddressLength == 0)
continue;
// HardwareInterface rejects software adapters that imitate Ethernet.
MIB_IF_ROW2 row{};
row.InterfaceLuid = a->Luid;
if (GetIfEntry2(&row) != NO_ERROR ||
!row.InterfaceAndOperStatusFlags.HardwareInterface)
continue;
for (auto* u = a->FirstUnicastAddress; u; u = u->Next) {
char address[INET_ADDRSTRLEN]{};
inet_ntop(
AF_INET,
&reinterpret_cast<sockaddr_in*>(u->Address.lpSockaddr)->sin_addr,
address, sizeof(address));
if (IsIpv4(address)) result.push_back({address});
}
}
#else
ifaddrs* list = nullptr;
if (getifaddrs(&list) != 0) return result;
for (auto* a = list; a; a = a->ifa_next) {
if (!a->ifa_addr || a->ifa_addr->sa_family != AF_INET ||
!(a->ifa_flags & IFF_UP) ||
(a->ifa_flags & (IFF_LOOPBACK | IFF_POINTOPOINT)))
continue;
bool physical = false;
#ifdef __APPLE__
for (auto* link = list; link; link = link->ifa_next)
if (link->ifa_addr && link->ifa_addr->sa_family == AF_LINK &&
std::string(link->ifa_name) == a->ifa_name) {
const auto* dl = reinterpret_cast<sockaddr_dl*>(link->ifa_addr);
physical = dl->sdl_type == IFT_ETHER && dl->sdl_alen == 6 &&
std::string(a->ifa_name).rfind("en", 0) == 0;
}
#else
physical = std::filesystem::exists(std::filesystem::path("/sys/class/net") /
a->ifa_name / "device");
#endif
if (!physical) continue;
char address[INET_ADDRSTRLEN]{};
inet_ntop(AF_INET, &reinterpret_cast<sockaddr_in*>(a->ifa_addr)->sin_addr,
address, sizeof(address));
if (IsIpv4(address)) result.push_back({address});
}
freeifaddrs(list);
#endif
return result;
}
std::string RandomHex(size_t bytes) {
std::vector<unsigned char> data(bytes);
Require(RAND_bytes(data.data(), static_cast<int>(bytes)) == 1,
"Random generator failed");
return Hex(data.data(), bytes);
}
bool ValidPassword(const std::string& password) {
return password.size() == 6 &&
password.find_first_not_of(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy"
"z") == std::string::npos;
}
bool VerifyLanCertificate(X509* cert) {
if (!cert || !X509_get0_pubkey(cert)) return false;
return X509_cmp_current_time(X509_get0_notBefore(cert)) < 0 &&
X509_cmp_current_time(X509_get0_notAfter(cert)) > 0 &&
X509_verify(cert, X509_get0_pubkey(cert)) == 1 &&
X509_check_purpose(cert, X509_PURPOSE_SSL_SERVER, 0) == 1 &&
(X509_get_key_usage(cert) & KU_DIGITAL_SIGNATURE) != 0;
}
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
const auto store = directory_ / "identity.json";
if (!std::filesystem::exists(store)) {
Require(!std::filesystem::exists(private_key()) &&
!std::filesystem::exists(certificate()),
"Incomplete LAN identity; manual recovery required");
CreateCertificate(private_key(), certificate());
state_ = {
{"version", 1}, {"device_id", RandomHex(16)}, {"verifier", nullptr}};
Save(state_);
} else
state_ = nlohmann::json::parse(Read(store));
Require(state_.at("version") == 1 &&
IsHex(state_.at("device_id").get<std::string>(), 32),
"Invalid LAN identity store");
// Legacy trust records no longer affect connections. Drop them from memory
// and from the next ordinary settings/credential save, without requiring a
// migration write before an outgoing connection can authenticate.
state_.erase("trust");
// Migrate the single endpoint in memory; the next ordinary save persists it.
// Ports belong to exact IP addresses, including addresses currently offline.
auto& settings = state_["settings"];
if (settings.is_null()) settings = nlohmann::json::object();
Require(settings.is_object(), "Invalid LAN settings");
const auto address = settings.value("address", std::string{});
Require(address.empty() || IsIpv4(address), "Invalid LAN listener address");
if (!settings.contains("ports")) {
settings["ports"] = nlohmann::json::object();
if (!address.empty())
settings["ports"][address] = settings.contains("port")
? settings["port"]
: nlohmann::json(52888u);
}
Require(settings["ports"].is_object(), "Invalid LAN port settings");
for (const auto& [ip, port] : settings["ports"].items())
Require(IsIpv4(ip) && port.is_number_integer() && port > 0 && port <= 65535,
"Invalid LAN port settings");
settings["address"] = address;
settings["version"] = 2;
settings.erase("enabled");
settings.erase("interface");
settings.erase("port");
id_ = state_.at("device_id");
auto pem = Read(certificate());
std::unique_ptr<BIO, decltype(&BIO_free)> bio(
BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())), BIO_free);
std::unique_ptr<X509, decltype(&X509_free)> cert(
PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), X509_free);
Require(VerifyLanCertificate(cert.get()),
"LAN certificate invalid or expired");
auto secret = Read(private_key());
std::unique_ptr<BIO, decltype(&BIO_free)> key_bio(
BIO_new_mem_buf(secret.data(), static_cast<int>(secret.size())),
BIO_free);
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
PEM_read_bio_PrivateKey(key_bio.get(), nullptr, nullptr, nullptr),
EVP_PKEY_free);
Require(key && X509_check_private_key(cert.get(), key.get()) == 1,
"LAN private key does not match certificate");
OPENSSL_cleanse(secret.data(), secret.size());
if (!state_["verifier"].is_null()) {
const auto& v = state_["verifier"];
if (v.at("version") == 1) {
Require(v.at("algorithm") == "scrypt-32768-8-1" &&
IsHex(v.at("salt"), 32) && IsHex(v.at("hash"), 64),
"Invalid LAN password verifier");
} else if (v.at("version") == 2) {
// Legacy PAKE records are read only to recover the locally encrypted
// password for migration. They are never used by the network protocol.
Require(v.at("algorithm") == "spake2p-p256-scrypt-32768-8-1" &&
IsHex(v.at("salt"), 32) && IsHex(v.at("w0"), 64) &&
IsHex(v.at("L"), 130),
"Invalid LAN password verifier");
} else {
Require(CurrentVerifier(v), "Invalid LAN password verifier");
}
}
// Lifetime lockouts are obsolete; retain only expiring in-memory cooldowns.
state_.erase("password_attempts");
}
void Identity::Save(const nlohmann::json& state) {
PrivateWrite(directory_ / "identity.json", state.dump(2));
}
std::pair<std::string, nlohmann::json> Identity::GeneratePassword() {
constexpr std::string_view alphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
constexpr auto unbiased_limit = 256 - 256 % alphabet.size();
std::string password;
// Match cloud password generation: unbiased ASCII alphanumeric characters,
// regenerating all-numeric results. Existing numeric passwords remain valid.
do {
password.clear();
while (password.size() < 6) {
unsigned char byte = 0;
Require(RAND_bytes(&byte, 1) == 1, "Cannot generate LAN password");
if (byte < unbiased_limit) password += alphabet[byte % alphabet.size()];
}
} while (password.find_first_not_of("0123456789") == std::string::npos);
return {password, RegisterPassword(password)};
}
void Identity::SetPassword(const std::string& password,
const nlohmann::json& verifier) {
Require(ValidPassword(password),
"LAN password must contain exactly 6 ASCII letters or digits");
Require(CurrentVerifier(verifier), "Invalid LAN password verifier");
Secret key{PasswordKey(directory_, !state_.contains("password"))};
auto next = state_;
next["verifier"] = verifier;
next["password"] = EncryptPassword(
password, key.value,
"crossdesk.lan.password.v1:" + id_ + ":" + verifier.dump());
Save(next);
state_ = std::move(next);
}
std::string Identity::ReadPassword() const {
// Verifier-only legacy records require a local password entry to migrate.
if (!state_.contains("password")) return {};
Secret key{PasswordKey(directory_, false)};
return DecryptPassword(
state_.at("password"), key.value,
"crossdesk.lan.password.v1:" + id_ + ":" + verifier().dump());
}
nlohmann::json Identity::RegisterPassword(const std::string& password) {
Require(ValidPassword(password),
"LAN password must contain exactly 6 ASCII letters or digits");
const auto record = DevicePassword::Hash(password);
Require(record.has_value(), "Cannot derive LAN password verifier");
return {{"version", 3}, {"salt", record->salt}, {"hash", record->hash}};
}
bool Identity::CurrentVerifier(const nlohmann::json& verifier) {
return verifier.is_object() && verifier.value("version", 0) == 3 &&
verifier.contains("salt") && verifier["salt"].is_string() &&
verifier.contains("hash") && verifier["hash"].is_string() &&
DevicePassword::IsCurrent({verifier["salt"], verifier["hash"]});
}
bool Identity::VerifyPassword(const nlohmann::json& verifier,
const std::string& password) {
if (!ValidPassword(password) || !CurrentVerifier(verifier)) return false;
return DevicePassword::Verify(
DevicePasswordRecord{verifier["salt"], verifier["hash"]}, password);
}
void Identity::SaveSettings(const std::string& address, unsigned port) {
Require(IsIpv4(address) && port > 0 && port <= 65535,
"Invalid LAN listener address");
auto next = state_;
const auto interfaces = PhysicalInterfaces();
const auto selected = std::find_if(
interfaces.begin(), interfaces.end(),
[&](const auto& i) { return i.address == address; });
Require(selected != interfaces.end(), "Selected LAN interface is unavailable");
next["settings"]["ports"][address] = port;
Save(next);
state_ = std::move(next);
}
unsigned Identity::PortFor(const std::string& address) const {
return state_.at("settings").at("ports").value(address, 52888u);
}
bool Identity::HasPortFor(const std::string& address) const {
return state_.at("settings").at("ports").contains(address);
}
void Identity::SelectAddress(const std::string& address) {
const auto interfaces = PhysicalInterfaces();
Require(std::any_of(interfaces.begin(), interfaces.end(),
[&](const auto& i) { return i.address == address; }),
"Selected LAN interface is unavailable");
if (state_["settings"].value("address", "") == address) return;
auto next = state_;
next["settings"]["address"] = address;
Save(next);
state_ = std::move(next);
}
} // namespace crossdesk::lan
@@ -0,0 +1,73 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_IDENTITY_H_
#define _LAN_IDENTITY_H_
#include <openssl/x509.h>
#include <filesystem>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
namespace crossdesk::lan {
struct Interface {
std::string address;
};
struct Target {
std::string address;
// Learned from the current connection, never pinned.
std::string device_id;
unsigned port = 52888;
};
unsigned ParsePort(const std::string& value);
std::pair<std::string, unsigned> ParseEndpoint(const std::string& value,
unsigned default_port);
bool IsIpv4(const std::string& value);
std::vector<Interface> PhysicalInterfaces();
std::string RandomHex(size_t bytes);
bool ValidPassword(const std::string& password);
bool VerifyLanCertificate(X509* cert);
// Files are separate from cloud settings and thumbnail credentials. A corrupt
// store is an error; it must never be replaced with a newly generated identity.
class Identity {
public:
explicit Identity(std::filesystem::path directory);
void Load();
static std::pair<std::string, nlohmann::json> GeneratePassword();
void SetPassword(const std::string& password, const nlohmann::json& verifier);
std::string ReadPassword() const;
static nlohmann::json RegisterPassword(const std::string& password);
static bool CurrentVerifier(const nlohmann::json& verifier);
static bool VerifyPassword(const nlohmann::json& verifier,
const std::string& password);
void SaveSettings(const std::string& address, unsigned port);
// An unconfigured address uses the default port; offline addresses keep
// their saved port. Selection only controls the address shown in the UI.
unsigned PortFor(const std::string& address) const;
bool HasPortFor(const std::string& address) const;
void SelectAddress(const std::string& address);
const std::string& id() const { return id_; }
std::filesystem::path certificate() const {
return directory_ / "certificate.pem";
}
std::filesystem::path private_key() const {
return directory_ / "private-key.pem";
}
const nlohmann::json& verifier() const { return state_.at("verifier"); }
const nlohmann::json& settings() const { return state_.at("settings"); }
private:
void Save(const nlohmann::json& state);
std::filesystem::path directory_;
std::string id_;
nlohmann::json state_;
};
} // namespace crossdesk::lan
#endif
@@ -0,0 +1,122 @@
#include "lan_password.h"
#include <openssl/core_names.h>
#include <openssl/crypto.h>
#include <openssl/kdf.h>
#include <openssl/params.h>
#include <openssl/rand.h>
#include <array>
#include <cstdint>
#include <memory>
#include <stdexcept>
namespace crossdesk::lan {
namespace {
// An application format, with a hex salt in password_salt and hex digest here.
// Only known work factors are accepted, bounding CPU/memory for corrupt data.
constexpr char kPrefix[] = "argon2id$v=19$m=19456,t=2,p=1$";
constexpr size_t kSaltBytes = 16, kDigestBytes = 32;
bool ValidInput(const std::string& password) {
return password.size() <= 256 && password.find('\0') == std::string::npos;
}
std::string Hex(const unsigned char* 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 Unhex(const std::string& text, unsigned char* output, size_t size) {
if (text.size() != size * 2) return false;
auto digit = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
for (size_t i = 0; i < size; ++i) {
const int hi = digit(text[i * 2]), lo = digit(text[i * 2 + 1]);
if (hi < 0 || lo < 0) return false;
output[i] = static_cast<unsigned char>((hi << 4) | lo);
}
return true;
}
using Kdf = std::unique_ptr<EVP_KDF, decltype(&EVP_KDF_free)>;
using Context = std::unique_ptr<EVP_KDF_CTX, decltype(&EVP_KDF_CTX_free)>;
std::optional<DevicePasswordRecord> Derive(
const std::string& password,
const std::array<unsigned char, kSaltBytes>& salt) {
Kdf kdf(EVP_KDF_fetch(nullptr, "ARGON2ID", nullptr), EVP_KDF_free);
if (!kdf) return std::nullopt;
Context context(EVP_KDF_CTX_new(kdf.get()), EVP_KDF_CTX_free);
if (!context) return std::nullopt;
uint32_t memory = 19456, iterations = 2, lanes = 1, threads = 1,
version = 0x13;
OSSL_PARAM params[] = {
OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_PASSWORD,
const_cast<char*>(password.data()),
password.size()),
OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT,
const_cast<unsigned char*>(salt.data()),
salt.size()),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_MEMCOST, &memory),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ITER, &iterations),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_LANES, &lanes),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_THREADS, &threads),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_VERSION, &version),
OSSL_PARAM_construct_end()};
std::array<unsigned char, kDigestBytes> digest{};
if (EVP_KDF_derive(context.get(), digest.data(), digest.size(), params) != 1)
return std::nullopt;
DevicePasswordRecord result{Hex(salt.data(), salt.size()),
kPrefix + Hex(digest.data(), digest.size())};
OPENSSL_cleanse(digest.data(), digest.size());
return result;
}
} // namespace
namespace DevicePassword {
void CheckSupport() {
// Exercise the provider and parameters, not just algorithm discovery.
if (!Hash("startup-check"))
throw std::runtime_error(
"Argon2id or the secure random source is unavailable");
}
std::optional<DevicePasswordRecord> Hash(const std::string& password) {
if (!ValidInput(password)) return std::nullopt;
std::array<unsigned char, kSaltBytes> salt{};
if (RAND_bytes(salt.data(), salt.size()) != 1) return std::nullopt;
return Derive(password, salt);
}
bool IsCurrent(const DevicePasswordRecord& record) {
std::array<unsigned char, kSaltBytes> salt{};
std::array<unsigned char, kDigestBytes> digest{};
return record.hash.rfind(kPrefix, 0) == 0 &&
Unhex(record.salt, salt.data(), salt.size()) &&
Unhex(record.hash.substr(sizeof(kPrefix) - 1), digest.data(),
digest.size());
}
bool Verify(const DevicePasswordRecord& record, const std::string& password) {
if (!ValidInput(password) || !IsCurrent(record)) return false;
std::array<unsigned char, kSaltBytes> salt{};
Unhex(record.salt, salt.data(), salt.size());
const auto computed = Derive(password, salt);
return computed && computed->hash.size() == record.hash.size() &&
CRYPTO_memcmp(computed->hash.data(), record.hash.data(),
computed->hash.size()) == 0;
}
} // namespace DevicePassword
} // namespace crossdesk::lan
@@ -0,0 +1,33 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_PASSWORD_H_
#define _LAN_PASSWORD_H_
#include <optional>
#include <string>
namespace crossdesk::lan {
// Keep the password record format aligned with crossdesk-server.
// Stored in identity.json. The hash identifies the algorithm,
// version and work factors; unrecognized formats never fall back to SHA-256.
struct DevicePasswordRecord {
std::string salt;
std::string hash;
};
namespace DevicePassword {
void CheckSupport();
std::optional<DevicePasswordRecord> Hash(const std::string& password);
// LAN accepts only current Argon2id records. Older LAN records are migrated
// locally from the encrypted password before connections are accepted.
bool Verify(const DevicePasswordRecord& record, const std::string& password);
bool IsCurrent(const DevicePasswordRecord& record);
} // namespace DevicePassword
} // namespace crossdesk::lan
#endif
@@ -0,0 +1,806 @@
#include "lan_session_manager.h"
#include <openssl/crypto.h>
#include <algorithm>
#include <thread>
namespace crossdesk::lan {
namespace {
using Clock = std::chrono::steady_clock;
using Json = nlohmann::json;
template <class T>
bool Ready(std::future<T>& future) {
return future.valid() &&
future.wait_for(std::chrono::seconds(0)) == std::future_status::ready;
}
void Check(bool condition) {
if (!condition) throw std::runtime_error("Invalid LAN protocol message");
}
bool Id(const Json& value) {
if (!value.is_string()) return false;
const auto& s = value.get_ref<const std::string&>();
return s.size() == 32 && std::all_of(s.begin(), s.end(), [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
});
}
Json Capabilities() {
return Json::array({"host-only-ipv4", "dtls-srtp-all", "password-auth-v1"});
}
void Capable(const Json& j) {
const auto& caps = j.at("capabilities");
Check(caps.is_array() && caps.size() <= 16);
for (const auto& required : Capabilities())
Check(std::find(caps.begin(), caps.end(), required) != caps.end());
}
void Wipe(std::string& text) {
if (!text.empty()) OPENSSL_cleanse(text.data(), text.size());
text.clear();
}
const char* FailureReason(const Session& s, const char* fallback) {
switch (s.failure_code.load()) {
case MiniRtcDirectInvalidSignal:
return "InvalidSignal";
case MiniRtcDirectSignalSendFailed:
return "SignalSendFailed";
case MiniRtcDirectDtlsHandshakeFailed:
return "DtlsHandshakeFailed";
case MiniRtcDirectRequiredCryptoUnsupported:
return "RequiredCryptoUnsupported";
case MiniRtcDirectSrtpInstallFailed:
return "SrtpInstallFailed";
case MiniRtcDirectNetworkPolicyFailed:
return "NetworkPolicyFailed";
case MiniRtcDirectFingerprintMissing:
return "FingerprintMissing";
case MiniRtcDirectFingerprintMismatch:
return "FingerprintMismatch";
case MiniRtcDirectSrtpKeyExportFailed:
return "SrtpKeyExportFailed";
default:
return fallback;
}
}
} // namespace
int Session::SendSignal(const char* data, size_t size, void* context) {
auto* s = static_cast<Session*>(context);
if (!s || s->closing || !data || !size || size > 256 * 1024) return -1;
std::lock_guard lock(s->signal_mutex);
if (s->closing || s->signal_count >= 256 ||
s->signal_bytes + size > 1024 * 1024)
return -1;
++s->signal_count;
s->signal_bytes += size;
s->signals.emplace_back(data, size);
return 0;
}
void Session::Error(MiniRtcDirectError error, void* context) {
if (auto* s = static_cast<Session*>(context)) {
int empty = -1;
s->failure_code.compare_exchange_strong(empty, error);
s->failed = true;
}
}
void Session::Status(ConnectionStatus status) {
if (closing) return;
if (status == ConnectionStatus::Connected)
ready = true;
else if (status == ConnectionStatus::Closed ||
status == ConnectionStatus::Failed ||
status == ConnectionStatus::Disconnected)
failed = true;
}
SessionManager::SessionManager(std::filesystem::path path, Hooks hooks)
: identity_(std::move(path)), hooks_(std::move(hooks)) {
identity_.Load();
access_password_ = identity_.ReadPassword();
new_password_ = access_password_;
if (!Identity::CurrentVerifier(identity_.verifier())) {
if (!access_password_.empty()) {
// Migrate the saved password, retaining its value, identity and settings.
SetPassword(access_password_);
} else if (identity_.verifier().is_null()) {
// Generate the initial password before automatically accepting connections.
ResetPassword();
} else {
hooks_.error("LAN password is not ready");
}
}
}
SessionManager::~SessionManager() {
Stop();
while (!Quiescent()) {
Tick();
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
transport_.Shutdown();
Wipe(new_password_);
Wipe(access_password_);
}
void SessionManager::Configure(const std::string& address, unsigned port) {
if (stopping_) return;
if (!port || port > 65535) throw std::runtime_error("Invalid LAN port");
if (!Identity::CurrentVerifier(identity_.verifier()))
throw std::runtime_error("LAN password is not ready");
ReplaceListener(address, port, true);
}
void SessionManager::ReplaceListener(const std::string& address, unsigned port,
bool save, bool allow_fallback) {
const auto previous = listeners_.find(address);
if (previous != listeners_.end() && previous->second.port == port) return;
const auto next = [&] {
try {
return transport_.Listen(identity_, address, port);
} catch (const std::exception& e) {
const std::string error = e.what();
if (!allow_fallback ||
(error != "LanPortInUse" && error != "LanPortPermissionDenied" &&
error != "LanPortCheckFailed"))
throw;
// Keep the OS-assigned socket bound through persistence/publication;
// probing then releasing a free port would introduce an allocation race.
return transport_.Listen(identity_, address, 0);
}
}();
try {
if (save) identity_.SaveSettings(address, next.port);
} catch (...) {
transport_.StopAccept(next.id);
throw;
}
const auto old = previous == listeners_.end() ? 0 : previous->second.id;
listeners_[address] = {next.id, next.port};
SetListenerError(address, next.port, "");
if (old) RetireListener(old);
}
void SessionManager::RetireListener(uint64_t id) {
transport_.StopAccept(id);
// Only cancel unfinished authentication on this retired endpoint. Other
// addresses and sessions that already authenticated keep their channels.
for (auto& [_, s] : sessions_)
if (s->incoming && s->channel->listener == id &&
s->phase < Session::AwaitOffer)
Close(s, "ListenerChanged");
}
void SessionManager::ResetPassword() {
if (stopping_ || reset_job_.valid()) return;
reset_job_ =
std::async(std::launch::async, [current = access_password_]() mutable {
try {
for (;;) {
auto result = Identity::GeneratePassword();
if (result.first != current) {
Wipe(current);
return result;
}
Wipe(result.first);
}
} catch (...) {
Wipe(current);
throw;
}
});
}
void SessionManager::SetPassword(std::string password) {
if (!ValidPassword(password)) {
Wipe(password);
throw std::runtime_error(
"LAN password must contain exactly 6 ASCII letters or digits");
}
if (stopping_ || reset_job_.valid() ||
(password == access_password_ &&
Identity::CurrentVerifier(identity_.verifier()))) {
Wipe(password);
return;
}
reset_job_ = std::async(
std::launch::async, [password = std::move(password)]() mutable {
try {
auto record = Identity::RegisterPassword(password);
return std::make_pair(std::move(password), std::move(record));
} catch (...) {
Wipe(password);
throw;
}
});
}
void SessionManager::SetListenerError(const std::string& address, unsigned port,
const std::string& error) {
if (error.empty()) {
listener_errors_.erase(address);
listener_warnings_.erase(address);
return;
}
const auto previous = listener_errors_.find(address);
if (previous != listener_errors_.end() && previous->second.port == port &&
previous->second.error == error)
return;
ListenerIssue issue{address, port, error};
listener_errors_[address] = issue;
listener_warnings_[address] = std::move(issue);
}
std::string SessionManager::ListenerError(const std::string& address) const {
const auto it = listener_errors_.find(address);
return it == listener_errors_.end() ? "" : it->second.error;
}
std::optional<SessionManager::ListenerIssue> SessionManager::TakeListenerWarning() {
if (listener_warnings_.empty()) return std::nullopt;
auto it = listener_warnings_.begin();
auto issue = std::move(it->second);
listener_warnings_.erase(it);
return issue;
}
void SessionManager::RefreshListeners() {
if (stopping_ || !Identity::CurrentVerifier(identity_.verifier())) return;
const auto available = [&](const auto& address) {
return std::any_of(interfaces_.begin(), interfaces_.end(),
[&](const auto& i) { return i.address == address; });
};
for (auto it = listeners_.begin(); it != listeners_.end();) {
if (available(it->first)) {
++it;
continue;
}
RetireListener(it->second.id);
it = listeners_.erase(it);
}
for (auto it = listener_errors_.begin(); it != listener_errors_.end();) {
if (available(it->first)) {
++it;
continue;
}
listener_warnings_.erase(it->first);
it = listener_errors_.erase(it);
}
for (const auto& interface : interfaces_) {
const auto port = identity_.PortFor(interface.address);
const bool initialize = !identity_.HasPortFor(interface.address);
try {
// Persist the first choice even when the default works. Later conflicts
// must not silently change an endpoint users may already have shared.
ReplaceListener(interface.address, port, initialize, initialize);
} catch (const std::exception& e) {
SetListenerError(interface.address, port, e.what());
}
}
}
std::string SessionManager::TakeNewPassword() {
return std::exchange(new_password_, {});
}
SessionManager::SessionPtr SessionManager::Connect(const std::string& address,
unsigned port,
std::string password) {
if (stopping_ || sessions_.size() >= 24)
throw std::runtime_error("LAN connection limit reached");
if (!ValidPassword(password))
throw std::runtime_error(
"LAN password must contain exactly 6 ASCII letters or digits");
if (RetryAfter(address, port) > 0) {
Wipe(password);
throw std::runtime_error("AuthenticationRateLimited");
}
auto s = std::make_shared<Session>();
const auto [ip, endpoint_port] = ParseEndpoint(address, port);
s->target = Target{ip, {}, endpoint_port};
s->channel = transport_.Connect(s->target);
s->password = std::move(password);
s->epoch = epoch_;
s->key = "lan/" + RandomHex(16);
s->deadline = Clock::now() + std::chrono::seconds(8);
sessions_[s->channel->id] = s;
return s;
}
bool SessionManager::Send(const SessionPtr& s, Json j, bool sequenced) {
j["version"] = 2;
if (sequenced) {
j["session_id"] = s->session_id;
j["seq"] = s->send_seq + 1;
}
if (s->closing || !transport_.Send(s->channel, j.dump())) {
Close(s, "SignalSendFailed");
return false;
}
if (sequenced) ++s->send_seq;
return true;
}
int SessionManager::RetryAfter(const std::string& address,
unsigned port) const {
const auto [ip, endpoint_port] = ParseEndpoint(address, port);
const auto it = retry_at_.find(ip + ":" + std::to_string(endpoint_port));
const auto now = Clock::now();
return it == retry_at_.end() || it->second <= now
? 0
: static_cast<int>(
std::chrono::ceil<std::chrono::seconds>(it->second - now)
.count());
}
void SessionManager::Fail(const SessionPtr& s, const std::string& error,
int retry_after) {
Json reply = {{"type", "error"}, {"error", error}};
// Use the same failure codes and retry_after semantics as cloud Join.
if (error == "InvalidCredentials")
reply["error_code"] = "AUTHENTICATION_FAILED";
if (error == "AuthenticationRateLimited")
reply["error_code"] = "AUTHENTICATION_THROTTLED";
if (error == "Busy") reply["error_code"] = "CREDENTIAL_SERVICE_BUSY";
if (retry_after > 0) reply["retry_after"] = retry_after;
if (s->session_id.empty() ||
(s->incoming && s->phase < Session::AwaitOffer)) {
reply["request_id"] = s->auth_request;
Send(s, std::move(reply));
} else {
Send(s, std::move(reply), true);
}
Close(s, error);
}
void SessionManager::Prepare(const SessionPtr& s) {
size_t admitted = 0;
for (auto& [_, other] : sessions_)
if (!other->closing && (other->phase == Session::Preparing ||
other->phase >= Session::AwaitOffer))
++admitted;
if (admitted >= 8 || prepare_jobs_.size() >= 2) {
Fail(s, "Busy");
return;
}
s->phase = Session::Preparing;
if (s->incoming) {
s->session_id = RandomHex(16);
s->local_id = identity_.id();
s->remote_id = RandomHex(16);
}
auto work = hooks_.prepare(s);
prepare_jobs_.push_back(
{s, std::async(std::launch::async, [s, work = std::move(work)] {
if (s->closing) return Prepared{};
Prepared prepared = work();
if (!prepared.peer || s->closing) return prepared;
MiniRtcDirectOptions options{};
options.struct_size = sizeof(options);
options.version = 1;
options.local_id = s->local_id.c_str();
options.remote_id = s->remote_id.c_str();
options.session_id = s->session_id.c_str();
options.local_address = s->channel->local_address.c_str();
options.remote_address = s->channel->remote_address.c_str();
options.offerer = !s->incoming;
options.send_signal = Session::SendSignal;
options.on_error = Session::Error;
options.context = s.get();
if (InitDirectPeer(prepared.peer, &options) != 0 ||
(!s->closing &&
StartDirectSession(prepared.peer, !s->incoming) != 0))
s->failed = true;
return prepared;
})});
}
void SessionManager::Message(const SessionPtr& s, const std::string& text) {
auto j = Json::parse(text);
Check(j.is_object() && j.at("version").is_number_integer() &&
j.at("version") == 2 && j.at("type").is_string() &&
!j.contains("user_id") && !j.contains("remote_user_id"));
const auto type = j.at("type").get<std::string>();
if (type == "error" && (s->session_id.empty() ||
(s->incoming && s->phase < Session::AwaitOffer))) {
Check(!j.contains("session_id") && !j.contains("seq") &&
j.at("request_id") == s->auth_request);
const auto error = j.at("error").get<std::string>();
Check(error.size() <= 80);
if (!s->incoming &&
(error == "AuthenticationRateLimited" || error == "Busy") &&
j.contains("retry_after")) {
Check(j["retry_after"].is_number_integer() && j["retry_after"] > 0 &&
j["retry_after"] <= 900);
const auto now = Clock::now();
for (auto it = retry_at_.begin(); it != retry_at_.end();)
if (it->second <= now)
it = retry_at_.erase(it);
else
++it;
if (retry_at_.size() < 64)
retry_at_[s->target.address + ":" + std::to_string(s->target.port)] =
now + std::chrono::seconds(j["retry_after"].get<int>());
}
Close(s, error);
return;
}
if (s->phase == Session::Preparing && !s->incoming) {
Check(j.at("session_id") == s->session_id &&
j.at("seq").is_number_unsigned() &&
j.at("seq").get<uint64_t>() ==
s->receive_seq + s->pending_receive.size() + 1 &&
!j.contains("request_id"));
// Identity is already bound by auth_result. Process cancellation without
// waiting for Peer preparation, before the following WSS close can erase
// its reason; earlier queued SDP/candidates never grant authorization.
if (type == "bye" || type == "error") {
const auto reason = type == "bye" ? std::string("RemoteClosed")
: j.at("error").get<std::string>();
Check(!reason.empty() && reason.size() <= 80);
Close(s, reason);
return;
}
Check(type == "answer" || type == "candidate");
Check(s->pending_receive.size() < 128);
s->pending_receive.push_back(text);
return;
}
if (s->phase == Session::AwaitAuth) {
Check(type == "auth" && !j.contains("session_id") && !j.contains("seq") &&
Id(j.at("request_id")) && j.at("password").is_string() &&
!j.contains("share") && !j.contains("proof"));
Capable(j);
s->auth_request = j.at("request_id");
auto& submitted = j["password"].get_ref<std::string&>();
if (!ValidPassword(submitted)) {
Wipe(submitted);
Fail(s, "InvalidCredentials");
return;
}
if (auth_jobs_.size() >= 2) {
Wipe(submitted);
Fail(s, "Busy", 1);
return;
}
const auto delay =
credential_work_limiter_.Admit(s->channel->remote_address);
if (delay > 0) {
Wipe(submitted);
Fail(s, "AuthenticationRateLimited", delay);
return;
}
s->phase = Session::Authenticating;
auth_jobs_.push_back(
{s,
std::async(std::launch::async,
[this, password = std::exchange(submitted, {}),
record = identity_.verifier(),
source = s->channel->remote_address,
target = identity_.id()]() mutable {
try {
auto result = auth_limiter_.Verify(source, target, [&] {
return Identity::VerifyPassword(record, password);
});
Wipe(password);
return result;
} catch (...) {
Wipe(password);
throw;
}
})});
return;
}
if (s->phase == Session::AwaitAuthResult) {
Check(type == "auth_result" && j.at("request_id") == s->auth_request &&
!j.contains("seq"));
if (!j.value("success", false)) {
Close(s, "InvalidCredentials");
return;
}
Check(Id(j.at("session_id")) && Id(j.at("host_peer_id")) &&
Id(j.at("controller_peer_id")) &&
j.at("controller_peer_id") != j.at("host_peer_id"));
s->target.device_id = j.at("host_peer_id");
s->session_id = j.at("session_id");
s->local_id = j.at("controller_peer_id");
s->remote_id = j.at("host_peer_id");
Prepare(s);
return;
}
Check(s->peer && j.at("session_id") == s->session_id &&
j.at("seq").is_number_unsigned() &&
j.at("seq").get<uint64_t>() == s->receive_seq + 1 &&
!j.contains("request_id"));
++s->receive_seq;
if (type == "bye" || type == "error") {
const auto reason = type == "bye" ? std::string("RemoteClosed")
: j.at("error").get<std::string>();
Check(!reason.empty() && reason.size() <= 80);
Close(s, reason);
return;
}
if (type == "offer") {
Check(s->incoming && s->phase == Session::AwaitOffer);
s->phase = Session::Negotiating;
s->deadline = Clock::now() + std::chrono::seconds(15);
} else if (type == "answer") {
Check(!s->incoming && s->phase == Session::AwaitAnswer);
s->phase = Session::Negotiating;
} else
Check(type == "candidate" &&
(s->phase == Session::AwaitAnswer ||
s->phase == Session::Negotiating || s->phase == Session::Connected));
if (ReceiveDirectSignal(s->peer, text.data(), text.size()) != 0)
Fail(s, "InvalidSignal");
}
void SessionManager::Dispose(const SessionPtr& s) {
if (!s->peer) return;
auto* peer = std::exchange(s->peer, nullptr);
auto owner = s->callback_owner;
auto drain = s->drain_callbacks;
cleanup_jobs_.push_back(
std::async(std::launch::async, [s, peer, owner, drain]() mutable {
if (drain) drain();
CloseDirectSession(peer);
DestroyPeer(&peer);
}));
}
void SessionManager::Close(const SessionPtr& s, const std::string& error) {
if (!s || s->closing.exchange(true)) return;
s->authorized = false;
if (error.empty() && !s->session_id.empty() &&
s->phase >= Session::AwaitOffer)
transport_.Send(s->channel, Json{{"version", 2},
{"type", "bye"},
{"session_id", s->session_id},
{"seq", ++s->send_seq}}
.dump());
s->phase = Session::Closing;
s->error = error;
Wipe(s->password);
transport_.Close(s->channel);
hooks_.closed(s);
Dispose(s);
if (!error.empty() && !s->incoming && !stopping_) hooks_.error(error);
}
void SessionManager::Stop() {
if (stopping_) return;
stopping_ = true;
for (const auto& [_, listener] : listeners_) transport_.StopAccept(listener.id);
listeners_.clear();
listener_errors_.clear();
listener_warnings_.clear();
for (auto& [_, s] : sessions_) Close(s);
}
bool SessionManager::Quiescent() const {
return (!stopping_ || network_drained_) && sessions_.empty() &&
auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() &&
!reset_job_.valid();
}
std::string SessionManager::PendingWork() const {
return "sessions=" + std::to_string(sessions_.size()) +
", auth=" + std::to_string(auth_jobs_.size()) +
", preparing=" + std::to_string(prepare_jobs_.size()) +
", cleanup=" + std::to_string(cleanup_jobs_.size()) +
", reset=" + std::to_string(reset_job_.valid()) +
", network_drained=" + std::to_string(network_drained_);
}
void SessionManager::Tick() {
if (Ready(reset_job_)) {
try {
auto [password, verifier] = reset_job_.get();
if (!stopping_) {
const bool changed = verifier != identity_.verifier();
try {
identity_.SetPassword(password, verifier);
} catch (...) {
Wipe(password);
throw;
}
if (changed) ++epoch_;
Wipe(new_password_);
Wipe(access_password_);
access_password_ = password;
new_password_ = std::move(password);
for (auto& [_, s] : sessions_)
if (s->incoming && !s->authorized && s->epoch != epoch_)
Fail(s, "CredentialsChanged");
interface_check_ = {}; // Start listening as soon as credentials are ready.
} else
Wipe(password);
} catch (const std::exception& e) {
hooks_.error(e.what());
}
}
for (auto it = auth_jobs_.begin(); it != auth_jobs_.end();) {
if (!Ready(it->result)) {
++it;
continue;
}
auto s = it->session;
DeviceAuthLimiter::Result result;
bool failed = false;
try {
result = it->result.get();
} catch (...) {
failed = true;
}
it = auth_jobs_.erase(it);
if (s->closing || stopping_ || s->epoch != epoch_) continue;
if (failed) {
Fail(s, "Busy", 1);
} else if (result.retry_after > 0) {
Fail(s, "AuthenticationRateLimited", result.retry_after);
} else if (!result.authenticated) {
Fail(s, "InvalidCredentials");
} else {
try {
Prepare(s);
} catch (...) {
Fail(s, "PeerPreparationFailed");
}
}
}
for (auto it = prepare_jobs_.begin(); it != prepare_jobs_.end();) {
if (!Ready(it->result)) {
++it;
continue;
}
auto s = it->session;
try {
auto p = it->result.get();
s->peer = p.peer;
s->callback_owner = std::move(p.callback_owner);
s->drain_callbacks = std::move(p.drain_callbacks);
} catch (...) {
s->failed = true;
}
it = prepare_jobs_.erase(it);
if (s->closing || stopping_ || (s->incoming && s->epoch != epoch_)) {
Dispose(s);
continue;
}
if (!s->peer || s->failed) {
Fail(s, FailureReason(*s, "PeerPreparationFailed"));
continue;
}
hooks_.attached(s);
transport_.Authenticated(s->channel);
if (s->incoming) {
s->phase = Session::AwaitOffer;
s->deadline = Clock::now() + std::chrono::seconds(5);
Send(s, {{"type", "auth_result"},
{"request_id", s->auth_request},
{"success", true},
{"session_id", s->session_id},
{"host_peer_id", s->local_id},
{"controller_peer_id", s->remote_id}});
} else {
s->phase = Session::AwaitAnswer;
s->deadline = Clock::now() + std::chrono::seconds(15);
while (!s->pending_receive.empty() && !s->closing) {
auto text = std::move(s->pending_receive.front());
s->pending_receive.pop_front();
try {
Message(s, text);
} catch (...) {
Fail(s, "InvalidSignal");
}
}
}
}
for (auto& event : transport_.Poll()) {
auto it = sessions_.find(event.channel->id);
if (it == sessions_.end()) {
const auto listener = listeners_.find(event.channel->local_address);
if (event.kind != Transport::Event::Open || !event.channel->incoming ||
stopping_ || listener == listeners_.end() ||
event.channel->listener != listener->second.id) {
transport_.Close(event.channel);
continue;
}
auto s = std::make_shared<Session>();
s->channel = event.channel;
s->incoming = true;
s->key = "lan/" + RandomHex(16);
s->epoch = epoch_;
sessions_[event.channel->id] = s;
it = sessions_.find(event.channel->id);
}
auto s = it->second;
if (s->closing) continue;
if (event.kind == Transport::Event::Open) {
s->deadline = Clock::now() + std::chrono::seconds(10);
s->phase = s->incoming ? Session::AwaitAuth : Session::AwaitAuthResult;
if (!s->incoming) {
s->auth_request = RandomHex(16);
Json auth = {{"type", "auth"},
{"request_id", s->auth_request},
{"password", std::exchange(s->password, {})},
{"capabilities", Capabilities()}};
Send(s, auth);
Wipe(auth["password"].get_ref<std::string&>());
}
} else if (event.kind == Transport::Event::Closed)
Close(s, event.payload.empty() ? "ConnectionClosed" : event.payload);
else
try {
Message(s, event.payload);
} catch (...) {
Fail(s, "InvalidSignal");
}
Wipe(event.payload);
}
const auto now = Clock::now();
for (auto& [_, s] : sessions_) {
if (s->closing) continue;
if (s->failed) {
Fail(s, FailureReason(*s, "SecureTransportFailed"));
continue;
}
if (s->phase != Session::Connected && now >= s->deadline) {
Fail(s, s->phase == Session::Negotiating ? "IceConnectionTimeout"
: "Timeout");
continue;
}
std::deque<std::string> signals;
{
std::lock_guard lock(s->signal_mutex);
signals.swap(s->signals);
}
for (const auto& raw : signals) try {
auto j = Json::parse(raw);
Check(j.at("transmission_id") == s->session_id &&
j.at("user_id") == s->local_id &&
j.at("remote_user_id") == s->remote_id);
const auto type = j.at("type").get<std::string>();
Json out;
if (type == "offer" || type == "answer")
out = {{"type", type}, {"sdp", j.at("sdp")}};
else {
Check(type == "new_candidate");
out = {{"type", "candidate"}, {"ufrag", j.at("ufrag")}};
if (j.at("sdp") == "a=end-of-candidates\r\n")
out["end_of_candidates"] = true;
else
out["candidate"] = j.at("sdp");
}
if (!Send(s, std::move(out), true)) break;
} catch (...) {
Fail(s, "InvalidLocalSignal");
break;
}
if (s->ready.exchange(false) && !s->closing && !s->failed) {
if (s->phase != Session::Negotiating || stopping_ ||
(s->incoming && s->epoch != epoch_)) {
Fail(s, "CredentialsChanged");
continue;
}
s->phase = Session::Connected;
s->authorized = true;
hooks_.connected(s);
}
}
if (!stopping_ && now - interface_check_ >= std::chrono::seconds(2)) {
interface_check_ = now;
interfaces_ = PhysicalInterfaces();
RefreshListeners();
for (auto& [_, s] : sessions_)
if (s->phase != Session::Connecting &&
!s->channel->local_address.empty() &&
std::none_of(interfaces_.begin(), interfaces_.end(),
[&](const auto& i) {
return i.address == s->channel->local_address;
}))
Close(s, "InterfaceRemoved");
}
for (auto it = cleanup_jobs_.begin(); it != cleanup_jobs_.end();)
if (Ready(*it)) {
try {
it->get();
} catch (...) {
}
it = cleanup_jobs_.erase(it);
} else
++it;
for (auto it = sessions_.begin(); it != sessions_.end();) {
const auto& s = it->second;
const bool pending =
std::any_of(auth_jobs_.begin(), auth_jobs_.end(),
[&](const auto& j) { return j.session == s; }) ||
std::any_of(prepare_jobs_.begin(), prepare_jobs_.end(),
[&](const auto& j) { return j.session == s; });
if (s->closing && !pending) {
s->phase = Session::Closed;
it = sessions_.erase(it);
} else
++it;
}
if (stopping_ && !network_drained_ && sessions_.empty() &&
auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() &&
!reset_job_.valid()) {
transport_.Shutdown();
network_drained_ = true;
}
}
} // namespace crossdesk::lan
@@ -0,0 +1,151 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_SESSION_MANAGER_H_
#define _LAN_SESSION_MANAGER_H_
#include <atomic>
#include <chrono>
#include <deque>
#include <functional>
#include <future>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include "lan_auth_limiter.h"
#include "lan_transport.h"
#include "minirtc.h"
namespace crossdesk::lan {
struct Session {
enum Phase {
Connecting,
AwaitAuth,
Authenticating,
Preparing,
AwaitAuthResult,
AwaitOffer,
AwaitAnswer,
Negotiating,
Connected,
Closing,
Closed
} phase = Connecting;
Transport::ChannelPtr channel;
Target target;
std::string key, session_id, local_id, remote_id, auth_request;
std::string password, error;
bool incoming = false;
uint64_t epoch = 0, receive_seq = 0, send_seq = 0;
std::chrono::steady_clock::time_point deadline;
PeerPtr* peer = nullptr; // Only published/cleared by the manager.
std::atomic<bool> closing{false}, authorized{false}, ready{false},
failed{false};
std::atomic<int> failure_code{-1};
std::mutex signal_mutex;
size_t signal_count = 0, signal_bytes = 0;
std::deque<std::string> signals, pending_receive;
std::shared_ptr<void> callback_owner;
std::function<void()> drain_callbacks;
static int SendSignal(const char* data, size_t size, void* context);
static void Error(MiniRtcDirectError error, void* context);
void Status(ConnectionStatus status);
};
// All public methods run on the GUI manager thread. Only Session's bounded
// callback entry points are callable by RTC workers.
class SessionManager {
public:
using SessionPtr = std::shared_ptr<Session>;
struct Prepared {
PeerPtr* peer = nullptr;
std::shared_ptr<void> callback_owner;
std::function<void()> drain_callbacks;
};
struct Hooks {
// Called on the manager to take device snapshots; returned work runs off
// UI.
std::function<std::function<Prepared()>(const SessionPtr&)> prepare;
std::function<void(const SessionPtr&)> attached, connected, closed;
std::function<void(const std::string&)> error;
};
SessionManager(std::filesystem::path directory, Hooks hooks);
~SessionManager();
Identity& identity() { return identity_; }
// Refreshed by Tick; UI and listener maintenance share the same snapshot.
const std::vector<Interface>& interfaces() const { return interfaces_; }
void Configure(const std::string& address, unsigned port);
bool password_pending() const { return reset_job_.valid(); }
void ResetPassword();
void SetPassword(std::string password);
bool listening() const { return !listeners_.empty(); }
struct ListenerIssue {
std::string address;
unsigned port;
std::string error;
};
std::string ListenerError(const std::string& address) const;
std::optional<ListenerIssue> TakeListenerWarning();
std::string TakeNewPassword();
int RetryAfter(const std::string& address, unsigned port) const;
SessionPtr Connect(const std::string& address, unsigned port,
std::string password);
void Close(const SessionPtr& session, const std::string& error = {});
void Tick();
void Stop();
bool Quiescent() const;
std::string PendingWork() const;
private:
void Prepare(const SessionPtr& session);
void Message(const SessionPtr& session, const std::string& text);
bool Send(const SessionPtr& session, nlohmann::json message,
bool sequenced = false);
void Dispose(const SessionPtr& session);
void Fail(const SessionPtr& session, const std::string& error,
int retry_after = 0);
void RefreshListeners();
void ReplaceListener(const std::string& address, unsigned port, bool save,
bool allow_fallback = false);
void RetireListener(uint64_t id);
void SetListenerError(const std::string& address, unsigned port,
const std::string& error);
Identity identity_;
Hooks hooks_;
Transport transport_;
std::vector<Interface> interfaces_;
std::map<uint64_t, SessionPtr> sessions_;
struct AuthJob {
SessionPtr session;
std::future<DeviceAuthLimiter::Result> result;
};
struct PrepareJob {
SessionPtr session;
std::future<Prepared> result;
};
std::vector<AuthJob> auth_jobs_;
std::vector<PrepareJob> prepare_jobs_;
std::vector<std::future<void>> cleanup_jobs_;
DeviceAuthLimiter auth_limiter_;
CredentialWorkLimiter credential_work_limiter_;
std::map<std::string, std::chrono::steady_clock::time_point> retry_at_;
struct Listener {
uint64_t id;
unsigned port;
};
std::map<std::string, Listener> listeners_;
std::map<std::string, ListenerIssue> listener_errors_, listener_warnings_;
uint64_t epoch_ = 1;
bool stopping_ = false, network_drained_ = false;
std::chrono::steady_clock::time_point interface_check_{};
std::future<std::pair<std::string, nlohmann::json>> reset_job_;
std::string new_password_, access_password_;
};
} // namespace crossdesk::lan
#endif
@@ -0,0 +1,550 @@
#include "lan_transport.h"
#include <atomic>
#include <chrono>
#include <deque>
#include <future>
#include <map>
#include <mutex>
#include <set>
#include <thread>
#include <websocketpp/client.hpp>
#include <websocketpp/config/asio.hpp>
#include <websocketpp/config/asio_client.hpp>
#include <websocketpp/server.hpp>
namespace crossdesk::lan {
namespace {
using Clock = std::chrono::steady_clock;
using Server = websocketpp::server<websocketpp::config::asio_tls>;
using Client = websocketpp::client<websocketpp::config::asio_tls_client>;
using Hdl = websocketpp::connection_hdl;
using Error = websocketpp::lib::error_code;
using Context = websocketpp::lib::asio::ssl::context;
constexpr const char* kPath = "/crossdesk/lan/v2";
constexpr const char* kProtocol = "crossdesk.lan.v2";
void ValidateListener(const std::string& address, unsigned port) {
if (port > 65535) throw std::runtime_error("Invalid LAN port");
const auto interfaces = PhysicalInterfaces();
if (!IsIpv4(address) ||
std::none_of(interfaces.begin(), interfaces.end(),
[&](const auto& i) { return i.address == address; }))
throw std::runtime_error("Selected LAN interface is unavailable");
}
Error ConfigureAcceptor(asio::ip::tcp::acceptor& acceptor, bool automatic_port) {
Error error;
#ifdef _WIN32
(void)automatic_port;
acceptor.set_option(asio::socket_base::reuse_address(false), error);
if (error) return error;
const BOOL exclusive = TRUE;
if (::setsockopt(acceptor.native_handle(), SOL_SOCKET, SO_EXCLUSIVEADDRUSE,
reinterpret_cast<const char*>(&exclusive),
sizeof(exclusive)) == SOCKET_ERROR)
return Error(WSAGetLastError(), asio::error::get_system_category());
#else
// Fixed ports may rebind after TIME_WAIT. OS-assigned ports need no reuse;
// exclusive allocation also avoids wildcard listener overlap on BSD.
acceptor.set_option(asio::socket_base::reuse_address(!automatic_port), error);
#endif
return error;
}
void CheckListenError(const Error& error) {
if (!error) return;
if (error == asio::error::address_in_use)
throw std::runtime_error("LanPortInUse");
if (error == asio::error::access_denied)
throw std::runtime_error("LanPortPermissionDenied");
throw std::runtime_error("Cannot listen on LAN port: " + error.message());
}
void CheckExistingListener(const std::string& address, unsigned port) {
#ifdef __APPLE__
// BSD permits a specific-address listener alongside a wildcard listener with
// SO_REUSEADDR. Detect that live listener without treating TIME_WAIT or our
// established sessions as occupied ports. No application data is sent.
asio::io_context io;
asio::ip::tcp::socket socket(io);
asio::steady_timer deadline(io, std::chrono::milliseconds(200));
Error result;
bool timed_out = false;
socket.async_connect(
{asio::ip::make_address_v4(address), static_cast<unsigned short>(port)},
[&](const Error& error) {
result = error;
deadline.cancel();
});
deadline.async_wait([&](const Error& error) {
if (error) return;
timed_out = true;
Error ignored;
socket.close(ignored);
});
io.run();
if (timed_out) throw std::runtime_error("LanPortCheckFailed");
if (!result) throw std::runtime_error("LanPortInUse");
if (result != asio::error::connection_refused) CheckListenError(result);
#else
(void)address;
(void)port;
#endif
}
std::shared_ptr<Context> TlsContext() {
auto context = std::make_shared<Context>(Context::tls);
if (SSL_CTX_set_min_proto_version(context->native_handle(), TLS1_3_VERSION) !=
1 ||
SSL_CTX_set_max_early_data(context->native_handle(), 0) != 1)
throw std::runtime_error("TLS 1.3 is required for LAN connections");
SSL_CTX_set_options(context->native_handle(), SSL_OP_NO_COMPRESSION);
SSL_CTX_set_session_cache_mode(context->native_handle(), SSL_SESS_CACHE_OFF);
return context;
}
} // namespace
struct Transport::Channel::State {
std::atomic<bool> closing{false};
std::mutex budget_mutex;
size_t sent_count = 0, sent_bytes = 0, received_count = 0, received_bytes = 0;
bool authenticated = false, opened = false, terminal = false;
Clock::time_point created = Clock::now(), last_ping = Clock::now(),
ping_sent{}, ping_window = Clock::now();
unsigned inbound_pings = 0;
std::string pending_ping, error;
std::function<void(const std::string&)> send, ping;
std::function<void()> close, graceful_close;
};
struct Transport::Impl {
asio::io_context io;
asio::executor_work_guard<asio::io_context::executor_type> work{
io.get_executor()};
asio::steady_timer timer{io};
std::thread thread;
std::atomic<bool> stopping{false};
std::atomic<uint64_t> next_id{1};
std::map<uint64_t, std::shared_ptr<Server>> servers;
std::map<uint64_t, std::shared_ptr<Client>> clients;
std::map<Hdl, ChannelPtr, std::owner_less<Hdl>> channels;
size_t preauth = 0;
std::set<uint64_t> retired_listeners;
std::mutex event_mutex;
std::vector<Event> events;
Impl() {
Tick();
thread = std::thread([this] { io.run(); });
}
bool AdmissionBlocked() {
std::lock_guard lock(event_mutex);
// Keep closed-but-not-yet-consumed channel events in the admission budget.
return events.size() >= 64;
}
void Emit(Event::Kind kind, const ChannelPtr& c, std::string payload = {}) {
std::lock_guard lock(event_mutex);
events.push_back({kind, c, std::move(payload)});
}
void Finish(const ChannelPtr& c) {
if (c->state->terminal) return;
c->state->closing = true;
c->state->terminal = true;
if (c->incoming && !c->state->authenticated) --preauth;
Emit(Event::Closed, c, c->state->error);
}
void Abort(const ChannelPtr& c) {
if (c->state->terminal) return;
c->state->closing = true;
if (c->state->close) c->state->close();
}
void Tick() {
timer.expires_after(std::chrono::seconds(1));
timer.async_wait([this](const Error& error) {
if (error || stopping) return;
const auto now = Clock::now();
std::vector<ChannelPtr> expired;
for (const auto& [_, c] : channels) {
auto& s = *c->state;
if (s.terminal || s.closing) continue;
if ((!s.opened && now - s.created >= std::chrono::seconds(8)) ||
(!s.pending_ping.empty() &&
now - s.ping_sent >= std::chrono::seconds(15))) {
s.error = s.opened ? "HeartbeatTimeout" : "ConnectionTimeout";
expired.push_back(c);
continue;
}
if (s.opened && s.pending_ping.empty() &&
now - s.last_ping >= std::chrono::seconds(5)) {
s.pending_ping = RandomHex(8);
s.last_ping = s.ping_sent = now;
s.ping(s.pending_ping);
}
}
for (auto& c : expired) Abort(c);
for (auto it = retired_listeners.begin();
it != retired_listeners.end();) {
const auto id = *it;
if (std::none_of(channels.begin(), channels.end(),
[id](const auto& item) {
return item.second->listener == id;
})) {
servers.erase(id);
it = retired_listeners.erase(it);
} else
++it;
}
Tick();
});
}
template <class Endpoint>
void Attach(Endpoint* endpoint, Hdl hdl, const ChannelPtr& c) {
channels[hdl] = c;
c->state->send = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)](
const std::string& text) {
Error e;
endpoint->send(hdl, text, websocketpp::frame::opcode::text, e);
if (e)
if (auto c = cweak.lock()) Abort(c);
};
c->state->ping = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)](
const std::string& nonce) {
Error e;
endpoint->ping(hdl, nonce, e);
if (e)
if (auto c = cweak.lock()) Abort(c);
};
c->state->graceful_close = [this, endpoint, hdl,
cweak = std::weak_ptr<Channel>(c)] {
Error error;
endpoint->close(hdl, websocketpp::close::status::going_away, "", error);
if (error)
if (auto c = cweak.lock()) Abort(c);
};
c->state->close = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)] {
Error e;
auto connection = endpoint->get_con_from_hdl(hdl, e);
if (!e && connection) {
connection->get_raw_socket().cancel(e);
connection->get_raw_socket().shutdown(
asio::ip::tcp::socket::shutdown_both, e);
connection->get_raw_socket().close(e);
}
if (auto c = cweak.lock()) Finish(c);
};
}
template <class Endpoint>
void Configure(Endpoint* endpoint) {
endpoint->clear_access_channels(websocketpp::log::alevel::all);
endpoint->clear_error_channels(websocketpp::log::elevel::all);
endpoint->init_asio(&io);
endpoint->set_max_message_size(256 * 1024);
endpoint->set_max_http_body_size(0);
endpoint->set_open_handshake_timeout(8000);
endpoint->set_close_handshake_timeout(1000);
endpoint->set_open_handler([this, endpoint](Hdl hdl) {
auto it = channels.find(hdl);
if (it == channels.end()) return;
auto c = it->second;
if (c->state->closing) {
Abort(c);
return;
}
Error e;
auto connection = endpoint->get_con_from_hdl(hdl, e);
if (e) {
Abort(c);
return;
}
const auto local = connection->get_raw_socket().local_endpoint(e);
if (e) {
Abort(c);
return;
}
const auto remote = connection->get_raw_socket().remote_endpoint(e);
if (e) {
Abort(c);
return;
}
if (!local.address().is_v4() || !remote.address().is_v4()) {
Abort(c);
return;
}
c->local_address = local.address().to_string();
c->remote_address = remote.address().to_string();
const auto interfaces = PhysicalInterfaces();
if (!IsIpv4(c->remote_address) ||
std::none_of(
interfaces.begin(), interfaces.end(),
[&](const auto& i) { return i.address == c->local_address; })) {
c->state->error = "UnsupportedNetworkPath";
Abort(c);
return;
}
const auto protocol =
c->incoming
? connection->get_subprotocol()
: connection->get_response_header("Sec-WebSocket-Protocol");
if (protocol != kProtocol) {
c->state->error = "IncompatibleProtocol";
Abort(c);
return;
}
c->state->opened = true;
Emit(Event::Open, c);
});
auto closed = [this, endpoint](Hdl hdl) {
auto it = channels.find(hdl);
if (it != channels.end()) {
auto& state = *it->second->state;
if (state.error.empty() && !state.opened && !state.closing) {
Error error;
auto connection = endpoint->get_con_from_hdl(hdl, error);
state.error =
!error && connection
? "ConnectionFailed: " + connection->get_ec().message()
: "ConnectionFailed";
}
const auto id = it->second->id;
const bool incoming = it->second->incoming;
Finish(it->second);
channels.erase(it);
if (!incoming) asio::post(io, [this, id] { clients.erase(id); });
}
};
endpoint->set_close_handler(closed);
endpoint->set_fail_handler(closed);
endpoint->set_message_handler(
[this](Hdl hdl, typename Endpoint::message_ptr message) {
auto it = channels.find(hdl);
if (it == channels.end()) return;
auto c = it->second;
auto& s = *c->state;
if (s.closing) return;
const auto& text = message->get_payload();
if (message->get_opcode() != websocketpp::frame::opcode::text ||
text.empty() || text.size() > 256 * 1024 ||
++s.received_count > 256 ||
(s.received_bytes += text.size()) > 1024 * 1024) {
Abort(c);
return;
}
Emit(Event::Message, c, text);
});
endpoint->set_ping_handler([this](Hdl hdl, std::string nonce) {
auto it = channels.find(hdl);
if (it == channels.end()) return false;
auto c = it->second;
auto& s = *c->state;
auto now = Clock::now();
if (now - s.ping_window >= std::chrono::seconds(5)) {
s.ping_window = now;
s.inbound_pings = 0;
}
if (s.closing || nonce.size() != 16 || ++s.inbound_pings > 2) {
Abort(c);
return false;
}
return true;
});
endpoint->set_pong_handler([this](Hdl hdl, std::string nonce) {
auto it = channels.find(hdl);
if (it == channels.end()) return;
auto& s = *it->second->state;
if (!s.pending_ping.empty() && nonce == s.pending_ping)
s.pending_ping.clear();
});
}
};
Transport::Transport() : impl_(std::make_unique<Impl>()) {}
Transport::~Transport() { Shutdown(); }
Transport::Listener Transport::Listen(const Identity& identity,
const std::string& address,
unsigned port) {
ValidateListener(address, port);
auto context = TlsContext();
context->use_certificate_chain_file(identity.certificate().string());
context->use_private_key_file(identity.private_key().string(), Context::pem);
auto promise = std::make_shared<std::promise<Listener>>();
auto future = promise->get_future();
asio::post(impl_->io, [this, address, port, context, promise] {
try {
if (port) CheckExistingListener(address, port);
auto server = std::make_shared<Server>();
impl_->Configure(server.get());
const uint64_t id = impl_->next_id++;
server->set_tls_init_handler([context](Hdl) { return context; });
server->set_tcp_pre_init_handler(
[this, endpoint = server.get(), id](Hdl hdl) {
Error error;
auto connection = endpoint->get_con_from_hdl(hdl, error);
if (error) return;
if (impl_->stopping || impl_->retired_listeners.count(id) ||
impl_->preauth >= 16 || impl_->AdmissionBlocked() ||
impl_->channels.size() >= 24) {
connection->get_raw_socket().close(error);
return;
}
auto c = std::make_shared<Channel>();
c->id = impl_->next_id++;
c->listener = id;
c->incoming = true;
c->state = std::make_shared<Channel::State>();
++impl_->preauth;
impl_->Attach(endpoint, hdl, c);
});
server->set_validate_handler([endpoint = server.get()](Hdl hdl) {
auto c = endpoint->get_con_from_hdl(hdl);
if (c->get_resource() != kPath || !c->get_request_body().empty() ||
!c->get_request_header("Transfer-Encoding").empty() ||
(!c->get_request_header("Content-Length").empty() &&
c->get_request_header("Content-Length") != "0"))
return false;
const auto& protocols = c->get_requested_subprotocols();
if (protocols.size() != 1 || protocols[0] != kProtocol) return false;
c->select_subprotocol(kProtocol);
return true;
});
auto bound_acceptor = std::make_shared<Server::acceptor_ptr>();
server->set_tcp_pre_bind_handler(
[port, bound_acceptor](Server::acceptor_ptr acceptor) {
*bound_acceptor = acceptor;
return ConfigureAcceptor(*acceptor, port == 0);
});
Error listen_error;
server->listen(
asio::ip::tcp::endpoint(asio::ip::make_address_v4(address),
static_cast<unsigned short>(port)),
listen_error);
CheckListenError(listen_error);
const auto actual_port = server->get_local_endpoint(listen_error).port();
CheckListenError(listen_error);
#ifndef _WIN32
if (!port) {
// Allocation is already exclusive. Enable TIME_WAIT reuse before any
// accepted socket inherits options so the saved port can restart too.
(*bound_acceptor)->set_option(asio::socket_base::reuse_address(true),
listen_error);
CheckListenError(listen_error);
}
#endif
server->set_tcp_pre_bind_handler({});
server->start_accept();
impl_->servers[id] = server;
promise->set_value({id, actual_port});
} catch (...) {
promise->set_exception(std::current_exception());
}
});
return future.get();
}
void Transport::StopAccept(uint64_t listener) {
asio::post(impl_->io, [this, listener] {
auto it = impl_->servers.find(listener);
if (it != impl_->servers.end()) {
Error e;
it->second->stop_listening(e);
impl_->retired_listeners.insert(listener);
for (auto& [_, c] : impl_->channels)
if (c->listener == listener && !c->state->authenticated) {
c->state->error = "ListenerChanged";
impl_->Abort(c);
}
}
});
}
Transport::ChannelPtr Transport::Connect(const Target& target) {
if (!IsIpv4(target.address) || target.port == 0 || target.port > 65535)
throw std::runtime_error("Invalid LAN endpoint");
auto c = std::make_shared<Channel>();
c->id = impl_->next_id++;
c->state = std::make_shared<Channel::State>();
asio::post(impl_->io, [this, c, target] {
try {
if (impl_->stopping || impl_->channels.size() >= 24 ||
impl_->AdmissionBlocked()) {
impl_->Finish(c);
return;
}
auto client = std::make_shared<Client>();
impl_->Configure(client.get());
auto context = TlsContext();
context->set_verify_mode(asio::ssl::verify_peer);
context->set_verify_callback(
[c](bool, asio::ssl::verify_context& verification) {
X509_STORE_CTX* store = verification.native_handle();
auto* certificate = X509_STORE_CTX_get_current_cert(store);
// Self-signed LAN certificates establish the encrypted channel.
// Password verification authorizes access at the receiving host;
// it does not authenticate this certificate's device identity.
const bool valid = X509_STORE_CTX_get_error_depth(store) == 0 &&
VerifyLanCertificate(certificate);
if (!valid) c->state->error = "InvalidCertificate";
return valid;
});
client->set_tls_init_handler([context](Hdl) { return context; });
Error error;
auto connection = client->get_connection(
"wss://" + target.address + ":" + std::to_string(target.port) + kPath,
error);
if (error) {
impl_->Finish(c);
return;
}
connection->add_subprotocol(kProtocol);
impl_->Attach(client.get(), connection->get_handle(), c);
impl_->clients[c->id] = client;
client->connect(connection);
} catch (...) {
impl_->Abort(c);
impl_->Finish(c);
}
});
return c;
}
bool Transport::Send(const ChannelPtr& c, std::string message) {
if (!c || impl_->stopping || c->state->closing || message.empty() ||
message.size() > 256 * 1024)
return false;
{
std::lock_guard lock(c->state->budget_mutex);
if (c->state->sent_count >= 256 ||
c->state->sent_bytes + message.size() > 1024 * 1024)
return false;
++c->state->sent_count;
c->state->sent_bytes += message.size();
}
asio::post(impl_->io, [c, message = std::move(message)] {
if (!c->state->terminal && c->state->send) c->state->send(message);
});
return true;
}
void Transport::Authenticated(const ChannelPtr& c) {
asio::post(impl_->io, [this, c] {
if (c->incoming && !c->state->terminal && !c->state->authenticated) {
c->state->authenticated = true;
--impl_->preauth;
}
});
}
void Transport::Close(const ChannelPtr& c) {
if (!c) return;
c->state->closing = true;
asio::post(impl_->io, [this, c] {
if (c->state->opened && !c->state->terminal && c->state->graceful_close)
c->state->graceful_close();
else
impl_->Abort(c);
});
}
std::vector<Transport::Event> Transport::Poll() {
std::lock_guard lock(impl_->event_mutex);
std::vector<Event> result;
result.swap(impl_->events);
return result;
}
void Transport::Shutdown() {
if (!impl_ || impl_->stopping.exchange(true)) return;
asio::post(impl_->io, [this] {
Error e;
impl_->timer.cancel();
for (auto& [_, server] : impl_->servers) server->stop_listening(e);
for (auto& [_, c] : impl_->channels) impl_->Abort(c);
impl_->work.reset();
});
if (impl_->thread.joinable()) impl_->thread.join();
}
} // namespace crossdesk::lan
@@ -0,0 +1,65 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_TRANSPORT_H_
#define _LAN_TRANSPORT_H_
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "lan_identity.h"
namespace crossdesk::lan {
// One network thread; callbacks enqueue bounded value events for the UI-owned
// session manager. No network callback calls MiniRTC or destroys a Peer.
class Transport {
public:
struct Channel;
using ChannelPtr = std::shared_ptr<Channel>;
struct Event {
enum Kind { Open, Message, Closed } kind;
ChannelPtr channel;
std::string payload;
};
Transport();
~Transport();
Transport(const Transport&) = delete;
Transport& operator=(const Transport&) = delete;
struct Listener {
uint64_t id;
unsigned port;
};
// Port zero lets the OS allocate and hold a free port on this address.
Listener Listen(const Identity& identity, const std::string& address,
unsigned port);
void StopAccept(uint64_t listener);
ChannelPtr Connect(const Target& target);
bool Send(const ChannelPtr& channel, std::string message);
void Authenticated(const ChannelPtr& channel);
void Close(const ChannelPtr& channel);
std::vector<Event> Poll();
void Shutdown();
struct Channel {
uint64_t id = 0, listener = 0;
bool incoming = false;
// Published by Open, immutable thereafter.
std::string local_address, remote_address;
private:
friend class Transport;
struct State;
std::shared_ptr<State> state;
};
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace crossdesk::lan
#endif
@@ -40,6 +40,7 @@ std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config) {
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
{"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"},
{"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"},
{"lock_on_disconnect", "lock_on_disconnect", OnOff(config.IsLockOnDisconnect()), "on / off"},
{"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"},
{"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"},
{"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "on / off"},
@@ -120,6 +121,8 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
save = [&config, enabled] { return config.SetPrivacyScreen(enabled); };
effect = SettingEffect::next_connection;
message = "console_settings_next_connection";
} else if (key == "lock_on_disconnect") {
save = [&config, enabled] { return config.SetLockOnDisconnect(enabled); };
} else if (key == "autostart") {
save = [&config, enabled] { return config.SetAutostart(enabled); };
effect = SettingEffect::next_login;
@@ -0,0 +1,60 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include "screen_lock.h"
#include <dbus/dbus.h>
#include "rd_log.h"
namespace crossdesk {
bool LockScreen() {
dbus_threads_init_default();
DBusError error;
dbus_error_init(&error);
DBusConnection* connection = dbus_bus_get_private(DBUS_BUS_SESSION, &error);
if (!connection) {
LOG_WARN("Cannot connect to screen lock service: {}",
error.message ? error.message : "session bus unavailable");
dbus_error_free(&error);
return false;
}
dbus_connection_set_exit_on_disconnect(connection, false);
// Address the current desktop's session bus, including headless desktops;
// never lock every logged-in user or an unrelated system login session.
struct Service { const char* name; const char* path; };
constexpr Service services[] = {
{"org.freedesktop.ScreenSaver", "/ScreenSaver"},
{"org.gnome.ScreenSaver", "/org/gnome/ScreenSaver"},
{"org.cinnamon.ScreenSaver", "/org/cinnamon/ScreenSaver"},
{"org.mate.ScreenSaver", "/org/mate/ScreenSaver"},
{"org.xfce.ScreenSaver", "/org/xfce/ScreenSaver"},
};
bool accepted = false;
for (const auto& service : services) {
DBusMessage* message = dbus_message_new_method_call(
service.name, service.path, service.name, "Lock");
if (!message) break;
dbus_message_set_auto_start(message, false);
DBusMessage* reply = dbus_connection_send_with_reply_and_block(
connection, message, 1000, &error);
dbus_message_unref(message);
if (reply) {
accepted = dbus_message_get_type(reply) == DBUS_MESSAGE_TYPE_METHOD_RETURN;
dbus_message_unref(reply);
}
dbus_error_free(&error);
if (accepted) break;
}
dbus_connection_close(connection);
dbus_connection_unref(connection);
if (!accepted) LOG_WARN("No desktop screen lock service accepted the request");
return accepted;
}
} // namespace crossdesk
@@ -0,0 +1,35 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include "screen_lock.h"
#include <dlfcn.h>
#include "rd_log.h"
namespace crossdesk {
bool LockScreen() {
// macOS has no public immediate-lock API. Resolve login.framework at
// runtime so an OS without this private entry point fails gracefully.
static void* framework = dlopen(
"/System/Library/PrivateFrameworks/login.framework/login",
RTLD_LAZY | RTLD_LOCAL);
using LockScreenFunction = int (*)();
static const auto lock = framework
? reinterpret_cast<LockScreenFunction>(
dlsym(framework, "SACLockScreenImmediate"))
: nullptr;
if (!lock) {
LOG_WARN("macOS screen lock API is unavailable");
return false;
}
const int result = lock();
if (result != 0) LOG_WARN("macOS screen lock request failed: {}", result);
return result == 0;
}
} // namespace crossdesk
@@ -45,6 +45,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
if (CreateConnectionPeer() != 0) CloseConnectionPeer();
}
HandleConnectionStatusChange();
HandleScreenLock();
HandleWindowsServiceIntegration();
devices_.UpdateInteractions();
ShareLocalCursorState();
@@ -71,6 +72,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
devices_.DestroyDevices();
devices_.DestroyFactories();
privacy_.Shutdown();
HandleScreenLock();
WriteUnattendedStatus(settings_.ActiveIdentity(), false, session);
SDL_Quit();
return 0;
@@ -197,8 +197,9 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
optimistic.available = local_service_available_;
optimistic.interactive_stage = local_interactive_stage_;
const auto message = BuildWindowsServiceStatusAction(optimistic).to_json();
SendReliableDataFrame(peer_, message.data(), message.size(),
control_data_label_.c_str());
for (const auto& id : ConnectedControllers())
SendControllerData(id, message.data(), message.size(),
control_data_label_.c_str());
}
result.reset();
}
@@ -293,10 +294,11 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
RemoteAction remote_action =
BuildWindowsServiceStatusAction(broadcast_status);
std::string msg = remote_action.to_json();
int ret = SendReliableDataFrame(peer_, msg.data(), msg.size(),
control_data_label_.c_str());
if (ret != 0) {
LOG_WARN("Broadcast Windows service status failed, ret={}", ret);
for (const auto& id : ConnectedControllers()) {
const int ret = SendControllerData(id, msg.data(), msg.size(),
control_data_label_.c_str());
if (ret != 0)
LOG_WARN("Windows service status send failed, peer={}, ret={}", id, ret);
}
#endif
}
@@ -0,0 +1,21 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include "screen_lock.h"
#include <windows.h>
#include "rd_log.h"
namespace crossdesk {
bool LockScreen() {
if (LockWorkStation()) return true;
LOG_WARN("LockWorkStation failed: {}", GetLastError());
return false;
}
} // namespace crossdesk
@@ -1,6 +1,7 @@
#include "platform.h"
#include <Winsock2.h>
#include <Windows.h>
#include <iphlpapi.h>
#include <cstdio>
@@ -25,19 +26,38 @@ std::string GetMac() {
}
std::string GetHostName() {
WSADATA data = {};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) {
LOG_ERROR("WSAStartup failed");
// Read Unicode directly; Winsock's narrow hostname is not guaranteed UTF-8.
DWORD size = 0;
if (!GetComputerNameExW(ComputerNameDnsHostname, nullptr, &size) &&
GetLastError() != ERROR_MORE_DATA) {
LOG_ERROR("GetComputerNameExW size query failed: {}", GetLastError());
return {};
}
char hostname[256] = {};
if (gethostname(hostname, sizeof(hostname)) == SOCKET_ERROR) {
LOG_ERROR("gethostname failed: {}", WSAGetLastError());
WSACleanup();
if (size == 0) return {};
std::wstring hostname(size, L'\0');
if (!GetComputerNameExW(ComputerNameDnsHostname, hostname.data(), &size)) {
LOG_ERROR("GetComputerNameExW failed: {}", GetLastError());
return {};
}
WSACleanup();
return hostname;
if (size == 0) return {};
const int length = static_cast<int>(size);
const int utf8_size =
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, hostname.data(),
length, nullptr, 0, nullptr, nullptr);
if (utf8_size == 0) {
LOG_ERROR("Hostname UTF-8 size query failed: {}", GetLastError());
return {};
}
std::string utf8_name(utf8_size, '\0');
if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, hostname.data(),
length, utf8_name.data(), utf8_size, nullptr,
nullptr) == 0) {
LOG_ERROR("Hostname UTF-8 conversion failed: {}", GetLastError());
return {};
}
return utf8_name;
}
bool IsWaylandSession() { return false; }
+168 -102
View File
@@ -11,11 +11,14 @@
#include <algorithm>
#include <chrono>
#include <cmath>
#include <nlohmann/json.hpp>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <map>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
@@ -29,53 +32,41 @@
#include "stb_image_write.h"
namespace crossdesk {
namespace {
void ScaleNv12ToABGR(char* src, int src_w, int src_h, int dst_w, int dst_h,
char* dst_rgba) {
uint8_t* y = reinterpret_cast<uint8_t*>(src);
uint8_t* uv = y + src_w * src_h;
// Keep enough detail for high-DPI cards, independently of the display used
// when the session closes. Preserve the source aspect ratio without bars.
constexpr int kPreviewMaxEdge = 1024;
float src_aspect = float(src_w) / src_h;
float dst_aspect = float(dst_w) / dst_h;
int fit_w = dst_w, fit_h = dst_h;
if (src_aspect > dst_aspect) {
fit_h = int(dst_w / src_aspect);
} else {
fit_w = int(dst_h * src_aspect);
}
bool ScaleNv12ToABGR(const char* src, int src_w, int src_h, int dst_w,
int dst_h, uint8_t* dst_rgba) {
const auto* y = reinterpret_cast<const uint8_t*>(src);
const auto* uv = y + size_t(src_w) * src_h;
const int src_cw = (src_w + 1) / 2, src_ch = (src_h + 1) / 2;
const int dst_cw = (dst_w + 1) / 2, dst_ch = (dst_h + 1) / 2;
std::vector<uint8_t> y_i420(size_t(src_w) * src_h);
std::vector<uint8_t> u_i420(size_t(src_cw) * src_ch);
std::vector<uint8_t> v_i420(u_i420.size());
if (libyuv::NV12ToI420(y, src_w, uv, src_cw * 2, y_i420.data(), src_w,
u_i420.data(), src_cw, v_i420.data(), src_cw,
src_w, src_h) != 0)
return false;
std::vector<uint8_t> y_i420(src_w * src_h);
std::vector<uint8_t> u_i420((src_w / 2) * (src_h / 2));
std::vector<uint8_t> v_i420((src_w / 2) * (src_h / 2));
libyuv::NV12ToI420(y, src_w, uv, src_w, y_i420.data(), src_w, u_i420.data(),
src_w / 2, v_i420.data(), src_w / 2, src_w, src_h);
std::vector<uint8_t> y_fit(fit_w * fit_h);
std::vector<uint8_t> u_fit((fit_w + 1) / 2 * (fit_h + 1) / 2);
std::vector<uint8_t> v_fit((fit_w + 1) / 2 * (fit_h + 1) / 2);
libyuv::I420Scale(y_i420.data(), src_w, u_i420.data(), src_w / 2,
v_i420.data(), src_w / 2, src_w, src_h, y_fit.data(), fit_w,
u_fit.data(), (fit_w + 1) / 2, v_fit.data(),
(fit_w + 1) / 2, fit_w, fit_h, libyuv::kFilterBilinear);
std::vector<uint8_t> abgr(fit_w * fit_h * 4);
libyuv::I420ToABGR(y_fit.data(), fit_w, u_fit.data(), (fit_w + 1) / 2,
v_fit.data(), (fit_w + 1) / 2, abgr.data(), fit_w * 4,
fit_w, fit_h);
std::memset(dst_rgba, 0, dst_w * dst_h * 4);
for (int i = 0; i < dst_w * dst_h; ++i) {
dst_rgba[i * 4 + 3] = static_cast<char>(0xFF);
}
for (int row = 0; row < fit_h; ++row) {
int dst_offset =
((row + (dst_h - fit_h) / 2) * dst_w + (dst_w - fit_w) / 2) * 4;
std::memcpy(dst_rgba + dst_offset, abgr.data() + row * fit_w * 4,
fit_w * 4);
}
std::vector<uint8_t> y_fit(size_t(dst_w) * dst_h);
std::vector<uint8_t> u_fit(size_t(dst_cw) * dst_ch);
std::vector<uint8_t> v_fit(u_fit.size());
if (libyuv::I420Scale(y_i420.data(), src_w, u_i420.data(), src_cw,
v_i420.data(), src_cw, src_w, src_h, y_fit.data(), dst_w,
u_fit.data(), dst_cw, v_fit.data(), dst_cw, dst_w,
dst_h, libyuv::kFilterBox) != 0)
return false;
return libyuv::I420ToABGR(y_fit.data(), dst_w, u_fit.data(), dst_cw,
v_fit.data(), dst_cw, dst_rgba, dst_w * 4,
dst_w, dst_h) == 0;
}
} // namespace
Thumbnail::Thumbnail(std::string save_path) {
if (!save_path.empty()) {
save_path_ = save_path;
@@ -97,19 +88,6 @@ Thumbnail::Thumbnail(std::string save_path, unsigned char* aes128_key,
std::filesystem::create_directories(save_path_);
}
Thumbnail::~Thumbnail() {
if (rgba_buffer_) {
delete[] rgba_buffer_;
rgba_buffer_ = nullptr;
}
}
int Thumbnail::SetThumbnailDpiScale(float dpi_scale) {
thumbnail_width_ = static_cast<int>(thumbnail_width_ * dpi_scale);
thumbnail_height_ = static_cast<int>(thumbnail_height_ * dpi_scale);
return 0;
}
namespace {
bool IsRemoteId(const std::string& id) {
@@ -118,7 +96,14 @@ bool IsRemoteId(const std::string& id) {
[](unsigned char c) { return c >= '0' && c <= '9'; });
}
bool IsLanRecordId(const std::string& id) {
return id.size() == 68 && id.rfind("lan-", 0) == 0 &&
std::all_of(id.begin() + 4, id.end(), [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
});
}
std::string ConnectionKey(const Thumbnail::RecentConnection& connection) {
if (!connection.lan.device_id.empty()) return connection.remote_id;
return connection.remote_id + (connection.remember_password ? "Y" : "N") +
connection.remote_host_name +
(connection.remember_password ? "@" + connection.password : "");
@@ -126,17 +111,37 @@ std::string ConnectionKey(const Thumbnail::RecentConnection& connection) {
} // namespace
std::string Thumbnail::LanRecordId(const std::string& device_id) {
unsigned char digest[32]{};
unsigned int size = 0;
const std::string input = "lan_direct/" + device_id;
if (EVP_Digest(input.data(), input.size(), digest, &size, EVP_sha256(),
nullptr) != 1 ||
size != 32)
return {};
std::ostringstream text;
text << "lan-" << std::hex << std::setfill('0');
for (const auto byte : digest) text << std::setw(2) << unsigned(byte);
return text.str();
}
bool Thumbnail::WriteRecord(const RecentConnection& connection) {
const auto password = connection.remember_password
? AES_encrypt(connection.password, aes128_key_, aes128_iv_) : "";
if (connection.remember_password && password.empty()) return false;
const auto path =
std::filesystem::path(save_path_) / (connection.remote_id + ".json");
const auto temporary = path.string() + ".tmp";
const nlohmann::json record = {
{"version", 2},
{"kind", connection.lan.device_id.empty() ? "cloud_id" : "lan_direct"},
{"lan",
{{"address", connection.lan.address},
{"port", connection.lan.port},
{"device_id", connection.lan.device_id}}},
{"remote_id", connection.remote_id},
{"host_name", connection.remote_host_name},
{"password",
connection.remember_password
? AES_encrypt(connection.password, aes128_key_, aes128_iv_)
: ""},
{"password", password},
{"platform", HostPlatformName(connection.platform)}};
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
output << record.dump();
@@ -167,14 +172,25 @@ bool Thumbnail::ReadRecord(const std::filesystem::path& path,
if (!record.is_object()) return false;
try {
connection->remote_id = record.value("remote_id", "");
if (!IsRemoteId(connection->remote_id) ||
path.stem() != connection->remote_id)
const auto kind = record.value("kind", "cloud_id");
if (kind == "lan_direct") {
const auto& target = record.at("lan");
connection->lan = {target.at("address"), target.at("device_id"),
target.at("port")};
if (!IsLanRecordId(connection->remote_id) ||
connection->lan.device_id.size() != 32 ||
connection->remote_id != LanRecordId(connection->lan.device_id) ||
!connection->lan.port || connection->lan.port > 65535 ||
connection->lan.address.size() > 15)
return false;
} else if (kind != "cloud_id" || !IsRemoteId(connection->remote_id))
return false;
if (path.stem() != connection->remote_id) return false;
connection->remote_host_name = record.value("host_name", "");
const auto password = record.value("password", "");
connection->remember_password = !password.empty();
connection->password =
password.empty() ? "" : AES_decrypt(password, aes128_key_, aes128_iv_);
connection->remember_password = !connection->password.empty();
connection->platform =
ParseHostPlatform(record.value("platform", "unknown"));
const auto image = path.parent_path() / (connection->remote_id + ".png");
@@ -201,30 +217,35 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height,
const std::string& remote_id,
const std::string& host_name,
const std::string& password,
HostPlatform platform) {
HostPlatform platform, const LanTarget* lan) {
std::lock_guard lock(mutex_);
// Successful connections are history even without saved credentials.
if (!IsRemoteId(remote_id)) return -1;
if (lan ? (!IsLanRecordId(remote_id) ||
remote_id != LanRecordId(lan->device_id))
: !IsRemoteId(remote_id))
return -1;
RecentConnection connection;
ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"),
&connection);
connection.remote_id = remote_id;
if (!host_name.empty()) connection.remote_host_name = host_name;
connection.lan = lan ? *lan : LanTarget{};
connection.password = password;
connection.remember_password = !password.empty();
connection.remember_password = !connection.password.empty();
if (platform != HostPlatform::Unknown) connection.platform = platform;
if (!WriteRecord(connection)) return -1;
const auto image = std::filesystem::path(save_path_) / (remote_id + ".png");
if (save_previews_ && nv12 && width > 0 && height > 0) {
if (!rgba_buffer_) {
rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4];
}
ScaleNv12ToABGR(const_cast<char*>(nv12), width, height, thumbnail_width_,
thumbnail_height_, rgba_buffer_);
if (!stbi_write_png(image.string().c_str(), thumbnail_width_,
thumbnail_height_, 4, rgba_buffer_,
thumbnail_width_ * 4))
const double scale = std::min(1.0, double(kPreviewMaxEdge) /
std::max(width, height));
const int image_width = std::max(1, int(std::lround(width * scale)));
const int image_height = std::max(1, int(std::lround(height * scale)));
std::vector<uint8_t> rgba(size_t(image_width) * image_height * 4);
if (!ScaleNv12ToABGR(nv12, width, height, image_width, image_height,
rgba.data()) ||
!stbi_write_png(image.string().c_str(), image_width, image_height, 4,
rgba.data(), image_width * 4))
return -1;
} else if (!save_previews_) {
std::error_code error;
@@ -235,11 +256,8 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height,
}
int Thumbnail::LoadThumbnail(
std::vector<std::pair<std::string, RecentConnection>>& recent_connections,
int* width, int* height) {
std::vector<std::pair<std::string, RecentConnection>>& recent_connections) {
std::lock_guard lock(mutex_);
if (width) *width = thumbnail_width_;
if (height) *height = thumbnail_height_;
return LoadThumbnailLocked(recent_connections);
}
@@ -323,32 +341,80 @@ int Thumbnail::LoadThumbnailLocked(
}
bool Thumbnail::DecodeImage(const std::filesystem::path& image_path,
int target_width, int target_height,
std::vector<unsigned char>* rgba, int* width,
int* height) const {
if (!rgba || !width || !height) {
if (!rgba || !width || !height || target_width <= 0 || target_height <= 0) {
return false;
}
int channels = 0;
unsigned char* pixels =
stbi_load(image_path.string().c_str(), width, height, &channels, 4);
if (!pixels || *width <= 0 || *height <= 0) {
if (pixels) {
stbi_image_free(pixels);
}
rgba->clear();
rgba->clear();
*width = *height = 0;
int source_width = 0, source_height = 0, channels = 0;
std::unique_lock lock(mutex_);
const std::unique_ptr<unsigned char, decltype(&stbi_image_free)> pixels(
stbi_load(image_path.string().c_str(), &source_width, &source_height,
&channels, 4), stbi_image_free);
lock.unlock();
if (!pixels || source_width <= 0 || source_height <= 0)
return false;
}
const size_t byte_count =
static_cast<size_t>(*width) * static_cast<size_t>(*height) * 4;
rgba->assign(pixels, pixels + byte_count);
stbi_image_free(pixels);
// Match image-fit: cover, cropping once from the original cached image.
int crop_width = source_width, crop_height = source_height;
if (int64_t(source_width) * target_height >
int64_t(source_height) * target_width)
crop_width = std::max(1, int(int64_t(source_height) * target_width /
target_height));
else
crop_height = std::max(1, int(int64_t(source_width) * target_height /
target_width));
const double scale = std::min({1.0, double(crop_width) / target_width,
double(crop_height) / target_height});
const int output_width = std::max(1, int(std::lround(target_width * scale)));
const int output_height = std::max(1, int(std::lround(target_height * scale)));
const auto* cropped = pixels.get() +
(size_t((source_height - crop_height) / 2) * source_width +
(source_width - crop_width) / 2) * 4;
// ARGBScale falls back to bilinear for general box downsampling. Scale
// separate color planes to get area filtering without another YUV conversion.
const size_t source_area = size_t(crop_width) * crop_height;
const size_t output_area = size_t(output_width) * output_height;
std::vector<uint8_t> source_planes(source_area * 4);
std::vector<uint8_t> output_planes(output_area * 4);
libyuv::SplitARGBPlane(cropped, source_width * 4,
source_planes.data(), crop_width,
source_planes.data() + source_area, crop_width,
source_planes.data() + source_area * 2, crop_width,
source_planes.data() + source_area * 3, crop_width,
crop_width, crop_height);
for (size_t channel = 0; channel < 4; ++channel) {
if (libyuv::ScalePlane(source_planes.data() + source_area * channel,
crop_width, crop_width, crop_height,
output_planes.data() + output_area * channel,
output_width, output_width, output_height,
libyuv::kFilterBox) != 0)
return false;
}
rgba->resize(output_area * 4);
libyuv::MergeARGBPlane(output_planes.data(), output_width,
output_planes.data() + output_area, output_width,
output_planes.data() + output_area * 2, output_width,
output_planes.data() + output_area * 3, output_width,
rgba->data(), output_width * 4,
output_width, output_height);
*width = output_width;
*height = output_height;
return true;
}
int Thumbnail::DeleteThumbnail(const std::string& filename_keyword) {
std::lock_guard lock(mutex_);
if (IsLanRecordId(filename_keyword)) {
for (const auto* extension : {".json", ".png"})
std::filesystem::remove(std::filesystem::path(save_path_) /
(filename_keyword + extension));
return 0;
}
const auto remote_id = filename_keyword.substr(0, 9);
if (!IsRemoteId(remote_id)) return -1;
for (const auto& entry : std::filesystem::directory_iterator(save_path_)) {
@@ -411,14 +477,14 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
ctx = EVP_CIPHER_CTX_new();
if (!ctx) {
LOG_ERROR("Error in EVP_CIPHER_CTX_new");
return plaintext;
return {};
}
ret = EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv);
if (1 != ret) {
LOG_ERROR("Error in EVP_EncryptInit_ex");
EVP_CIPHER_CTX_free(ctx);
return plaintext;
return {};
}
ret = EVP_EncryptUpdate(
@@ -428,7 +494,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
if (1 != ret) {
LOG_ERROR("Error in EVP_EncryptUpdate");
EVP_CIPHER_CTX_free(ctx);
return plaintext;
return {};
}
ciphertext_len = len;
@@ -436,7 +502,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
if (1 != ret) {
LOG_ERROR("Error in EVP_EncryptFinal_ex");
EVP_CIPHER_CTX_free(ctx);
return plaintext;
return {};
}
ciphertext_len += len;
@@ -448,7 +514,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
if (1 != ret) {
LOG_ERROR("Error in OPENSSL_buf2hexstr_ex");
EVP_CIPHER_CTX_free(ctx);
return plaintext;
return {};
}
EVP_CIPHER_CTX_free(ctx);
@@ -471,13 +537,13 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext,
&ciphertext_buf_len, ciphertext.c_str(), '\0');
if (1 != ret) {
LOG_ERROR("Error in OPENSSL_hexstr2buf_ex");
return ciphertext;
return {};
}
ctx = EVP_CIPHER_CTX_new();
if (!ctx) {
LOG_ERROR("Error in EVP_CIPHER_CTX_new");
return ciphertext;
return {};
}
ret = EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv);
@@ -485,7 +551,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext,
LOG_ERROR("Error in EVP_DecryptInit_ex");
EVP_CIPHER_CTX_free(ctx);
return ciphertext;
return {};
}
ret = EVP_DecryptUpdate(ctx, plaintext, &plaintext_len, ciphertext_buf,
@@ -494,7 +560,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext,
LOG_ERROR("Error in EVP_DecryptUpdate");
EVP_CIPHER_CTX_free(ctx);
return ciphertext;
return {};
}
ret =
@@ -503,7 +569,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext,
LOG_ERROR("Error in EVP_DecryptFinal_ex");
EVP_CIPHER_CTX_free(ctx);
return ciphertext;
return {};
}
plaintext_len += plaintext_final_len;
+15 -14
View File
@@ -1,7 +1,7 @@
/*
* @Author: DI JUNKUN
* @Date: 2024-11-07
* Copyright (c) 2024 by DI JUNKUN, All Rights Reserved.
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _THUMBNAIL_H_
@@ -20,12 +20,18 @@ namespace crossdesk {
class Thumbnail {
public:
struct LanTarget {
std::string address, device_id;
unsigned port = 52888;
};
static std::string LanRecordId(const std::string& device_id);
struct RecentConnection {
std::filesystem::path image_path;
std::string remote_id;
std::string remote_host_name;
std::string password;
bool remember_password = false;
LanTarget lan;
HostPlatform platform = HostPlatform::Unknown;
};
@@ -33,10 +39,6 @@ class Thumbnail {
Thumbnail(std::string save_path);
explicit Thumbnail(std::string save_path, unsigned char* aes128_key,
unsigned char* aes128_iv);
~Thumbnail();
public:
int SetThumbnailDpiScale(float dpi_scale);
// Serialized with writes so disabling also clears any in-flight preview.
int SetSavePreviews(bool enabled);
@@ -45,16 +47,18 @@ class Thumbnail {
// credentials (and clears any remembered password from a previous session).
int SaveToThumbnail(const char* nv12, int width, int height,
const std::string& remote_id,
const std::string& host_name,
const std::string& password,
HostPlatform platform = HostPlatform::Unknown);
const std::string& host_name, const std::string& password,
HostPlatform platform = HostPlatform::Unknown,
const LanTarget* lan = nullptr);
int LoadThumbnail(
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>&
recent_connections,
int* width, int* height);
recent_connections);
// Crop from the saved master and downsample for a physical-pixel viewport.
// Low-resolution legacy images are never enlarged in the cache.
bool DecodeImage(const std::filesystem::path& image_path,
int target_width, int target_height,
std::vector<unsigned char>* rgba, int* width,
int* height) const;
@@ -94,11 +98,8 @@ class Thumbnail {
unsigned char* iv);
private:
std::mutex mutex_;
mutable std::mutex mutex_;
bool save_previews_ = true;
int thumbnail_width_ = 160;
int thumbnail_height_ = 90;
char* rgba_buffer_ = nullptr;
std::string save_path_ = "thumbnails/";
unsigned char aes128_key_[16];
+9 -4
View File
@@ -86,11 +86,14 @@ function setup_targets()
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")
add_files("apps/desktop/src/platform/windows/system_info.cpp",
"apps/desktop/src/platform/windows/screen_lock.cpp")
elseif is_os("macosx") then
add_files("apps/desktop/src/platform/macos/system_info.cpp")
add_files("apps/desktop/src/platform/macos/system_info.cpp",
"apps/desktop/src/platform/macos/screen_lock.cpp")
elseif is_os("linux") then
add_files("apps/desktop/src/platform/linux/system_info.cpp",
"apps/desktop/src/platform/linux/screen_lock.cpp",
"apps/desktop/src/platform/linux/common/*.cpp")
add_includedirs("apps/desktop/src/platform/linux/common")
end
@@ -563,7 +566,9 @@ function setup_targets()
-- version and use fmt's supported runtime-parser fallback here.
add_defines("FMT_CONSTEVAL=")
add_packages("slint", {public = true})
add_packages("libyuv", "tinyfiledialogs", "openssl3", "libsdl3", "nlohmann_json")
add_packages("libyuv", "tinyfiledialogs", "openssl3", "libsdl3", "nlohmann_json", "asio", "websocketpp")
add_defines("ASIO_STANDALONE")
add_files("apps/desktop/src/network/lan/*.cpp")
if not is_config("CROSSDESK_PORTABLE", true) then
add_defines("CROSSDESK_HAS_UPDATER=1", {public = true})
add_deps("updater_core")
@@ -599,7 +604,7 @@ function setup_targets()
add_deps("windows_unattended_config")
add_files("apps/desktop/src/platform/windows/gui/application/unattended_application.cpp")
add_links("opengl32")
add_syslinks("Comctl32", {public = true})
add_syslinks("Comctl32", "Iphlpapi", "Ws2_32", "Advapi32", {public = true})
add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp",
"apps/desktop/src/platform/windows/gui/slint_backend.cpp",
"apps/desktop/src/platform/windows/gui/window_icons.cpp",
+3 -1
View File
@@ -750,8 +750,10 @@ private struct PasswordPromptView: View {
SecureField("访问密码", text: $password)
}
}
.keyboardType(.numberPad)
.keyboardType(.asciiCapable)
.textContentType(.password)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($passwordFocused)
Button {
+1 -1
View File
@@ -297,7 +297,7 @@
"sha256": "d72eb6fa9d5e93abb11d13b6c6f04fda2ba183d0705e703887cda243f6f1eead"
}
],
"recipe_sha256": "bc4b8d10ff76a79fc7cbffe3fe9b8c0272d320fc549cba4044d2b1d3337e0116",
"recipe_sha256": "dbb3cd85fd197f6495c611a08f99b1c70b88c48c91f6e6ea24946a1b0701a3d5",
"recipe_path": "deps/submodules/minirtc/thirdparty/libnice/xmake.lua",
"package": "libnice",
"notice_prefixes": [
+18
View File
@@ -138,6 +138,7 @@ PipeWire 不属于强制运行时依赖:程序在运行时检测宿主系统
--USE_DRM=true/false: 在 Linux 上启用 DRM 捕获,默认不启用
--CROSSDESK_PORTABLE=true/false: 构建便携版本,默认不启用
--CROSSDESK_VERSION=xxx: 指定 CrossDesk 的版本
--CROSSDESK_UPDATE_PUBLIC_KEY=xxx: 可选的 Ed25519 更新验签公钥,64 个小写十六进制字符;默认为空,不启用自动安装更新
--MINIRTC_ENABLE_AOM=true/false: 编译可选 libaom 后端,默认不启用;AV1 仍使用 SVT-AV1 / dav1d
# 示例
@@ -150,6 +151,23 @@ xmake f --CROSSDESK_VERSION=1.4.3 --USE_CUDA=true
xmake r crossdesk
```
<a id="update-public-key"></a>
### 更新公钥(可选)
`CROSSDESK_UPDATE_PUBLIC_KEY` 在编译时嵌入桌面客户端,用于验证升级清单的 Ed25519 签名。本地开发可以省略:程序仍可正常编译、运行和检查新版本,但只提供手动下载入口,不执行应用内自动安装。
需要应用内自动安装更新时,在对应平台的 `xmake f` 配置命令中加入此参数,再编译。例如,已完成平台配置后执行(将占位符替换为实际公钥):
```sh
xmake f --CROSSDESK_UPDATE_PUBLIC_KEY="<64位小写十六进制公钥>"
xmake b crossdesk
```
也可以在构建前设置同名环境变量;Xmake 配置值优先于环境变量。公钥必须是 32 字节 Ed25519 公钥的十六进制表示,共 64 个小写字符;非空但格式不正确时,构建配置会报错。发布清单必须由对应私钥签名,并满足安装位置等升级条件;便携版始终使用手动下载。
GitHub Actions 构建从仓库 Actions variable `CROSSDESK_UPDATE_PUBLIC_KEY` 读取公钥。私钥仅供发布端签名使用,无需提供给普通构建。密钥生成、发布端配置及升级条件见 [桌面端应用内升级](UPDATER.md)。
## 无 CUDA 环境下的开发支持
对于**未安装 CUDA 环境的 Linux 开发者,如果希望编译后的成果物拥有硬件编解码能力**,这里提供了预配置的 [Ubuntu 20.04 兼容构建镜像](https://hub.docker.com/r/crossdesk/ubuntu20.04)。该镜像内置必要的构建依赖,可生成兼容 glibc 2.31 的两种 Linux 安装包。
+18
View File
@@ -144,6 +144,7 @@ enabled at build time) when it is unavailable.
--USE_DRM=true/false: Enable DRM capture on Linux, disabled by default
--CROSSDESK_PORTABLE=true/false: Build the portable variant, disabled by default
--CROSSDESK_VERSION=xxx: Set the CrossDesk version
--CROSSDESK_UPDATE_PUBLIC_KEY=xxx: Optional Ed25519 update verification key, 64 lowercase hex characters; empty by default, disabling in-app update installation
# Example
xmake f --CROSSDESK_VERSION=1.4.3 --USE_CUDA=true
@@ -155,6 +156,23 @@ Run:
xmake r crossdesk
```
<a id="update-public-key"></a>
### Update public key (optional)
`CROSSDESK_UPDATE_PUBLIC_KEY` is embedded in the desktop client at build time to verify Ed25519 signatures on update manifests. Local development builds can omit it: the app still builds, runs, and checks for new versions, but only offers manual downloads without in-app update installation.
For in-app update installation, add this option to your platform's `xmake f` configuration command before building. For example, after configuring the platform, run the following with the placeholder replaced by your actual public key:
```sh
xmake f --CROSSDESK_UPDATE_PUBLIC_KEY="<64 lowercase hex characters>"
xmake b crossdesk
```
You can also set the environment variable with the same name before building; the Xmake configuration value takes precedence. The value must encode a 32-byte Ed25519 public key as 64 lowercase hexadecimal characters. A nonempty value with an invalid format fails build configuration. Update manifests must be signed with the matching private key, and the installation must meet the other updater requirements, including a supported installation location. Portable builds always use manual downloads.
GitHub Actions builds read the key from the repository Actions variable `CROSSDESK_UPDATE_PUBLIC_KEY`. The private key is only needed for release signing, not ordinary builds. See the [desktop updater guide](UPDATER.md) (Chinese) for key generation, release configuration, and updater requirements.
## Development Without CUDA Environment
For **Linux developers who do not have a CUDA environment installed and want to enable hardware codec feature**, a preconfigured [Ubuntu 20.04 compatibility build image](https://hub.docker.com/r/crossdesk/ubuntu20.04) is provided.
+2 -2
View File
@@ -12,7 +12,7 @@
## 对等连接失败
在 **☰ → 设置** 中勾选 **启用中继服务**,点击“确认”后重新连接。自托管用户还需检查 Coturn 是否正常运行,以及中继端口 TCP/UDP、媒体端口范围 UDP 是否放通。
默认连接策略已允许 TURN 中继回退。可在 **☰ → 设置** 中开启 **强制中继连接**,保存后重新连接以排查直连问题。自托管用户还需检查 Coturn 是否正常运行,以及中继端口 TCP/UDP、媒体端口范围 UDP 是否放通。
公共服务的兼容性变更也会影响旧客户端;请核对 [Release 说明](https://github.com/kunkundi/crossdesk/releases),尽量让两端使用兼容的近期版本。
@@ -85,7 +85,7 @@ Windows 安装包和便携包内置了 Amyuni 的 `usbmmidd_v2` 虚拟显示器
| --- | --- |
| Server disconnected / TLS error | Network access, signaling endpoint, certificate dates, hostname, and system trust. See [self-hosting](SELF_HOSTING_EN.md). |
| Remote device offline | Keep the host running, connect both sides to the same signaling server, and copy its current ID again. |
| P2P connection failed | Enable TURN relay in Settings and reconnect. For self-hosting, check Coturn and relay/media firewall ports. Review [release compatibility notes](https://github.com/kunkundi/crossdesk/releases). |
| P2P connection failed | The default connection policy already allows TURN relay fallback. Enable Force Relay in Settings, save, and reconnect to troubleshoot direct connections. For self-hosting, check Coturn and relay/media firewall ports. Review [release compatibility notes](https://github.com/kunkundi/crossdesk/releases). |
| Black screen / no input on macOS | Grant Screen Recording and Accessibility, then reopen the app. A changed app path or signature may require fresh permission grants. |
| No capture on Linux Wayland | Check the Wayland build option, host PipeWire 0.3, and desktop portal permission. Compare with an X11 session. See [Linux build instructions](BUILD_EN.md#linux). |
| Linux host has no monitor or graphical session | Start CrossDesk normally to automatically discover and select your existing desktop, preserving its Dock and apps; no display number is needed. A bare Xvfb shows only CrossDesk on a black background; use an existing session or install Xfce and specify `--headless-session startxfce4`. See [Linux headless operation](HEADLESS_EN.md). |
+3 -3
View File
@@ -43,9 +43,9 @@ sudo docker compose ps
使用自签证书时,先按 [证书信任步骤](#3-配置信任证书) 在两端导入根证书,再检查连接状态。
1. 断开现有会话,点击主窗口右上角 **☰ → 设置**。
2. 在设置列表中向下滚动,点击 **自托管配置**。
2. 在设置列表中向下滚动,点击 **自托管配置** 一行的 **编辑** 按钮。
3. 填写 **服务器地址**、**信令服务端口**,点击“确认”。地址支持主机名或 IP(如 `my-server.example.com`),也支持带路径的信令地址(如 `my-server.example.com/signal-path`)或 `https://` / `wss://` URL。URL 内明确填写的端口优先;否则使用单独填写的信令服务端口。所有连接均使用 WSS,带路径的地址需要反向代理将该路径转发到信令 WebSocket 服务,普通网页地址不能直接用于信令连接。
4. 回到设置窗口,勾选 **自托管配置** 右侧复选框,再点击“确认”保存。
4. 回到设置窗口,打开 **自托管配置** 右侧开关,再点击“保存”。
5. 在控制端和被控端执行相同配置。等待两端都显示“已连接服务器”,再使用被控端当前显示的 ID 发起连接。
地址与端口在最后确认设置时一起保存;格式无效或保存失败时会保留输入并显示错误。
@@ -95,7 +95,7 @@ sudo security add-trusted-cert -d -r trustRoot \
- **未连接服务器 / TLS 错误:** 先检查地址、信令端口、证书有效期、证书主机名及根证书信任。
- **已连服务器但设备离线:** 确认两端连接同一服务、被控端仍在运行,并重新复制当前 ID。
- **P2P 连接失败:** 检查“启用中继服务”、Coturn 状态、TURN 端口和媒体端口范围,并核对客户端与服务端版本兼容性。
- **P2P 连接失败:** 默认已允许中继回退;可开启“强制中继连接”排查直连问题。检查 Coturn 状态、TURN 端口和媒体端口范围,并核对客户端与服务端版本兼容性。
- **配置修改未生效:** 在配置目录重新执行 `sudo docker compose up -d`;单纯重启容器不会应用修改过的环境变量。
日志、备份和迁移操作请参考 [服务端维护说明](https://github.com/kunkundi/crossdesk-server)。
+2 -2
View File
@@ -43,9 +43,9 @@ sudo docker compose ps
For a self-signed deployment, complete the [certificate trust steps](#3-trust-the-tls-certificate) on both clients before checking their connection status.
1. Disconnect existing sessions and open **☰ → Settings**.
2. Scroll down and click **Self-Hosted Config**.
2. Scroll down and click **Edit** in the **Self-Hosted Config** row.
3. Enter **Server Address** and **Signal Service Port**, then confirm. The address accepts a hostname or IP (such as `my-server.example.com`), a signaling path (such as `my-server.example.com/signal-path`), or an `https://` / `wss://` URL. An explicit URL port takes precedence; otherwise the separate signaling port is used. Connections always use WSS. Paths must be routed to the signaling WebSocket service by your reverse proxy; an ordinary web page is not a signaling endpoint.
4. Enable the checkbox beside **Self-Hosted Config**, then click **OK** in the parent settings window.
4. Enable the switch beside **Self-Hosted Config**, then click **Save** in the parent settings window.
5. Configure both the controller and host this way. Once both report a server connection, connect using the host's currently displayed ID.
<p align="center">
+33 -6
View File
@@ -4,7 +4,7 @@
首次使用请先查看 [下载与安装](../README.md#download)。本文介绍当前源码中的客户端操作;安装包的可用功能以对应 Release 为准。
[安装与权限](#installation) · [快速连接](#quick-start) · [桌面墙](#deskhub) · [会话操作](#session) · [设置与更新](#settings) · [iOS](#ios) · [Android](#android) · [鸿蒙](#harmonyos) · [Windows 无人值守](#windows-service) · [自托管](#self-hosting)
[安装与权限](#installation) · [快速连接](#quick-start) · [IP 直连](#lan-direct) · [桌面墙](#deskhub) · [会话操作](#session) · [设置与更新](#settings) · [iOS](#ios) · [Android](#android) · [鸿蒙](#harmonyos) · [Windows 无人值守](#windows-service) · [自托管](#self-hosting)
截图使用演示设备和凭据,来源与生成方式见 [截图说明](images/README.md)。
@@ -39,15 +39,36 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
## 快速连接
### 从另一台电脑连接
### 通过设备 ID 从另一台电脑连接
1. **准备被控电脑。** 安装并运行 CrossDesk,等待底部显示“已连接服务器”。在左侧“本桌面”查看 **本机 ID** 和 **密码**,提供给控制端。
1. **准备被控电脑。** 安装并运行 CrossDesk,切换至 ID 连接模式,等待底部显示“已连接服务器”。在左侧“本桌面”查看 **本机 ID** 和 **密码**,提供给控制端。
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 **9 位 ID**,点击 **连接**。
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连。点击卡片右上角的“…”菜单可编辑别名、修改分组或删除记录,右下角按钮用于连接 / 断开;标题右侧可按分组筛选设备。
本机密码旁的眼睛按钮用于显示 / 隐藏密码,刷新按钮用于生成新的随机密码,铅笔按钮用于手动修改。密码统一为 **6 位数字或英文字母**。修改或刷新时需保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
<a id="lan-direct"></a>
### 通过局域网 IP 直连
当前 IP 直连入口用于 Windows、macOS、Linux 的普通桌面客户端。两台电脑的 IPv4 地址需要可互相访问;有无外网均可,不需要等待连接信令服务器。浏览器、移动端、Windows 登录前服务及 Linux 无头控制台目前继续使用设备 ID 连接流程。
1. **启用并切换。** 两端在 **设置 → 常规 → 网络连接** 中启用 **IP 直连** 并保存(默认开启),再用主窗口左上角按钮切换到 **IP 直连模式**。
2. **获取对方地址。** 被控端在“本机 IP / 端口”查看地址,复制按钮会复制完整的 `IP:端口`。有多个地址时,选择控制端能够访问的地址。
3. **连接并验证。** 控制端在“远端 IP / 端口”输入例如 `192.168.1.20:52888`,点击“连接”,再输入被控端此模式下的六位密码。省略端口时使用 **52888**;当前支持数字 IPv4,不支持主机名或 IPv6。
4. **保存与重连。** 密码弹窗可勾选“记住密码”。近期连接按 ID / IP 模式分别显示;不勾选也会保存设备记录,下次连接需重新输入密码。可从卡片重连、修改别名或删除记录。
<p align="center">
<img src="images/desktop-lan-password-zh.png" width="720" alt="IP 直连密码弹窗与记住密码选项,背景为演示局域网设备" />
</p>
**地址和端口:** 点击“切换 IP”选择展示与分享的地址,点击“修改端口”编辑该 IP 的端口。程序同时监听可用物理网卡上的 IPv4,每个 IP 单独保存端口;切换展示地址不会停用其他地址。首次为某个 IP 分配端口时优先使用 **52888**,被占用时自动分配并保存可用端口。已有配置的端口发生冲突时会提示,需手动修改。端口范围为 **1–65535**,修改失败会保留原设置;连接方需要使用界面显示的实际端口。局域网防火墙需允许 CrossDesk 的 TCP 协商连接和 UDP 远控流量。
**密码:** IP 直连使用独立的 **6 位英文字母或数字密码,区分大小写**。没有设置时自动生成,眼睛、刷新和铅笔按钮分别用于显示 / 隐藏、随机生成和自定义;修改成功后即时生效,无需重启或连接服务器。已保存旧密码的控制端需要重新输入。
**模式和开关:** 模式切换会保存,下次启动沿用。切回 ID 模式仅切换连接入口和近期记录,IP 监听仍然运行。需要停止 IP 直连时,在设置中取消“启用 IP 直连”并保存:程序会断开现有 IP 直连会话、释放监听端口、回到 ID 模式并隐藏模式按钮;原有 ID 会话继续运行,IP 密码、端口及近期记录保留。重新启用后沿用这些设置。
### 从浏览器连接
1. 保持被控电脑上的 CrossDesk 运行,并确认已连接服务器。
@@ -127,14 +148,20 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
| 语言 | 中文、English、Русский |
| 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 |
| 视频编码格式 | H.264 / AV1;H.264 自动优先使用硬件编解码,不可用或初始化失败时回退到软件;桌面端 AV1 使用软件编解码 |
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
| 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 |
| 启用 IP 直连 | 默认开启;保存后即时启停 IP 直连。关闭会断开 IP 会话、停止监听并隐藏主窗口模式按钮,重启后保持 |
| 强制中继连接 | 关闭时自动选择连接方式,允许 P2P 和 TURN 中继回退;开启时仅通过中继连接 |
| 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 |
| 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 |
| 自托管配置 | 填写服务器地址和信令服务端口,并勾选启用 |
| 自托管配置 | 点击“编辑”填写服务器地址和信令服务端口,使用右侧开关启用 |
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
| 文件保存路径 | 选择接收文件的目录 |
网络连接分组中的 IP 直连开关独立于 ID 连接使用的中继设置:
<p align="center">
<img src="images/desktop-settings-network-zh.png" width="720" alt="常规设置的网络连接分组:启用 IP 直连开关及关闭后的行为说明" />
</p>
### 桌面墙画面传输
在 **设置 → 桌面墙** 中统一配置墙内设备,保存后立即应用到当前桌面墙会话,并用于后续连接;普通连接不受影响。
+33 -6
View File
@@ -4,7 +4,7 @@
Start with [download and installation](../README_EN.md#download). This guide describes the current source; check the release notes for the features available in a downloaded build.
[Installation and permissions](#installation) · [Quick start](#quick-start) · [DeskHub](#deskhub) · [Session controls](#session) · [Settings and updates](#settings) · [iOS](#ios) · [Android](#android) · [HarmonyOS](#harmonyos) · [Windows unattended access](#windows-service) · [Self-hosting](#self-hosting)
[Installation and permissions](#installation) · [Quick start](#quick-start) · [Direct IP](#lan-direct) · [DeskHub](#deskhub) · [Session controls](#session) · [Settings and updates](#settings) · [iOS](#ios) · [Android](#android) · [HarmonyOS](#harmonyos) · [Windows unattended access](#windows-service) · [Self-hosting](#self-hosting)
Screenshots use demonstration devices and credentials. See the [screenshot notes](images/README.md) for sources and reproduction instructions.
@@ -39,15 +39,36 @@ Both contain the same complete application, replace each other and preserve user
## Quick start
### Connect from another computer
### Connect from another computer by device ID
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the bottom status bar to show that the server is connected. Share the **Local ID** and **Password** shown under **Local Desktop** with the controller.
1. **Prepare the host.** Install and run CrossDesk on the computer to control and select ID mode. Wait for the bottom status bar to show that the server is connected. Share the **Local ID** and **Password** shown under **Local Desktop** with the controller.
2. **Enter the remote ID.** On the controlling computer, enter the host's **9-digit ID** in **Remote Desktop → Remote ID**, then click **Connect**.
3. **Verify the password.** Enter the host's current connection password when prompted and confirm. Select **Remember password** if you want to save it.
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, open its top-right **…** menu to rename it, edit its groups, or remove its record, and use the bottom-right button to connect / disconnect. Use the group filter beside the heading to narrow the list.
The eye button shows or hides the local password, the refresh button generates a new random password, and the pencil button lets you edit it. Passwords use **6 ASCII letters or digits**. Stay connected to the server when editing or refreshing a password. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
<a id="lan-direct"></a>
### Connect directly over LAN
The direct IP entry is available in the regular Windows, macOS, and Linux desktop clients. The computers must have mutually reachable IPv4 addresses. Internet access is optional, and there is no need to wait for a signaling server connection. Browsers, mobile clients, the Windows pre-sign-in service, and the Linux headless console currently use the device ID workflow.
1. **Enable and switch.** On both computers, select **Enable direct IP** under **Settings → General → Network** and save (enabled by default). Use the top-left button to switch to **Direct IP mode**.
2. **Get the host address.** Read **Local IP / Port** on the host. The copy button copies the full `IP:port`. If several addresses are available, choose one reachable from the controller.
3. **Connect and verify.** Enter an address such as `192.168.1.20:52888` under **Remote IP / Port**, click **Connect**, and enter the host's six-character password for this mode. Omitting the port uses **52888**. Only numeric IPv4 addresses are currently supported, not hostnames or IPv6.
4. **Save and reconnect.** Select **Remember password** in the password dialog if needed. Recent connections are separated by ID / IP mode. Device records are kept even without a saved password; you will be prompted next time. Cards let you reconnect, rename devices, or remove records.
<p align="center">
<img src="images/desktop-lan-password-en.png" width="720" alt="Direct IP password dialog with Remember password and demonstration LAN devices in the background" />
</p>
**Addresses and ports:** use **Switch IP** to choose the displayed address and **Edit port** to change its port. The app listens on available physical-interface IPv4 addresses simultaneously and saves a separate port for each IP. Selecting a displayed address keeps the others listening. The first port assignment for an IP tries **52888**; if busy, it allocates and saves an available port. A conflict on an already saved port is reported and requires a manual change. Valid ports are **1–65535**; failed changes preserve the previous settings. Connect using the actual port displayed on the host. The LAN firewall must allow CrossDesk's TCP negotiation connection and UDP remote-control traffic.
**Passwords:** direct IP has its own **six-character, case-sensitive ASCII letter/digit password**. A missing password is generated automatically. Use the eye, refresh, and pencil buttons to show/hide, randomly generate, or customize it. A successful change applies immediately without restarting or connecting to a server. Controllers that saved the old password must enter the new one.
**Mode and availability:** the selected mode is remembered at the next launch. Switching back to ID mode changes the connection controls and recent list while IP listeners keep running. To stop direct IP, clear **Enable direct IP** in Settings and save. This disconnects IP sessions, releases listening ports, returns to ID mode, and hides the mode button. Existing ID sessions continue; IP passwords, ports, and recent records are retained for re-enabling.
### Connect from a browser
1. Keep CrossDesk running and connected to the server on the host computer.
@@ -127,14 +148,20 @@ Open **☰ → Settings** in the top-right corner, choose **General / DeskHub**,
| Language | 中文, English, Русский |
| Capture Method (Windows) | Auto / DXGI / WGC / GDI; choose Auto for automatic headless adaptation |
| Video Encode Format | H.264 / AV1; H.264 automatically prefers hardware codecs, with software fallback when unavailable or initialization fails; desktop AV1 uses software codecs |
| Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails |
| Force Relay | Require relay connections when TURN is enabled; turning TURN off also disables this option |
| Enable direct IP | Enabled by default; saving starts or stops direct IP immediately. Disabling disconnects IP sessions, stops listeners, and hides the mode button; the choice persists across restarts |
| Force Relay | When off, automatically select a connection path with P2P and TURN relay fallback; when on, use relay connections only |
| Save remote screen previews | On by default. When disabled, the thumbnail area shows the remote device’s platform and existing previews are cleared; connection records and passwords are preserved |
| Privacy screen on connect | Automatically enable privacy when this computer is controlled remotely; the controller can toggle it from the session toolbar |
| Self-Hosted Config | Set the server address and signaling port, then enable the checkbox |
| Self-Hosted Config | Click Edit to set the server address and signaling port, then enable the switch on the right |
| Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
| File Save Path | Choose where received files are saved |
The direct IP switch in the Network group is independent of the relay settings used for ID connections:
<p align="center">
<img src="images/desktop-settings-network-en.png" width="720" alt="Network settings with Enable direct IP and an explanation of what happens when disabled" />
</p>
### DeskHub video transmission
Use **Settings → DeskHub** to configure wall sessions together. Saving applies changes immediately to current DeskHub sessions and future connections; ordinary connections are unaffected.
+276
View File
@@ -0,0 +1,276 @@
# ID 连接与 IP 直连架构
[项目首页](../README.md) · [使用指南](USER_GUIDE.md#quick-start) · [IP 直连操作](USER_GUIDE.md#lan-direct) · [GUI 架构](gui-architecture.md)
本文描述当前已实现的桌面客户端架构,代码依据为 CrossDesk `cc48eb65`、MiniRTC `040114d`,核对日期为 **2026-10-25**。ID 模式的服务端行为按客户端协议说明;服务端内部实现由独立的 CrossDesk Server 项目维护。
## 1. 两种模式的边界
两种模式复用桌面采集、编码、音视频传输、键鼠、文件和剪贴板处理。差别主要在**如何找到设备、在哪里验证密码,以及通过什么通道交换连接参数**。
“ID 连接”不等于“公网中继”:通过 ID 找到设备后,远控数据可以在局域网内直连,也可以经 NAT 穿透直连,必要时使用 TURN 中继。“IP 直连”仍有连接协商,只是协商消息直接在两台电脑之间交换。
| 项目 | ID 连接模式 | IP 直连模式 |
| --- | --- | --- |
| 用户输入 | 9 位设备 ID 和密码 | 数字 IPv4、可选端口和密码 |
| 设备寻址 | 信令服务维护的设备及在线状态 | 直接连接指定 `IP:端口` |
| 密码验证位置 | 控制端提交 Join,由信令服务返回验证结果 | 被控端在本机验证收到的密码 |
| 信令通道 | 双方连接官方或自建服务的 WSS | 控制端直接连接被控端的 WSS |
| ICE 策略 | `ManagedServers`,使用服务端下发的 ICE 配置 | `HostOnly`,限定本次 WSS 连接的双方 IPv4 |
| 数据路径 | P2P 直连,或按配置与网络条件使用 TURN | 双方 UDP 直连,无 TURN 回退 |
| 原生数据加密要求 | 按对端能力协商 SRTP,保留旧端兼容分支,见第 4.1 节 | 必须支持视频、音频及数据 SRTP,否则拒绝建立会话 |
| 外网要求 | 取决于信令及 STUN/TURN 服务部署位置 | 直连会话不依赖外网服务 |
| 本机端口 | 无需用户提供固定远控监听端口 | 每个本机 IP 独立保存 TCP 协商端口,首次优先 `52888` |
| 密码与近期记录 | ID 凭据与 `cloud_id` 记录 | 独立 LAN 凭据与 `lan_direct` 记录 |
以下流程图以原生桌面客户端互连为主。浏览器通过 ID 模式接入时,MiniRTC 使用 `DataChannelConnection` 适配 WebRTC;不能直接连接 LAN 的 WSS 协商入口来代替 Web 客户端协议。
## 2. ID 连接模式
### 2.1 网络拓扑
```mermaid
flowchart LR
C["控制端 CrossDesk"] <-->|"WSS:登录、Join、SDP、候选地址"| S["官方或自建信令服务"]
H["被控端 CrossDesk"] <-->|"WSS:登录、在线状态、连接协商"| S
C -.->|"地址探测"| U["STUN 服务"]
H -.->|"地址探测"| U
C <-->|"P2P 数据路径"| H
C <-->|"中继数据路径"| T["TURN 服务"]
T <-->|"中继数据路径"| H
```
实线中的 P2P 与 TURN 表示可选择的数据路径,不表示每个会话同时使用两者。信令服务负责控制消息,TURN 负责中继数据;二者即使部署在同一台服务器上,也是不同职责。
### 2.2 建立连接
以下时序以双方支持视频、音频及数据 SRTP 的原生桌面客户端为例;旧端兼容行为见第 4.1 节。图中的“控制端”代表整个客户端进程,其常驻 Peer 和控制端会话 Peer 各自建立 WSS 连接。
```mermaid
sequenceDiagram
participant C as 控制端
participant S as 信令服务
participant H as 被控端
H->>S: 常驻 Peer 通过 WSS 登录,保持设备可发现
C->>S: 常驻 Peer 登录,必要时查询目标在线状态
C->>S: 会话 Peer 通过独立 WSS 以 C-本机 ID 登录
C->>S: join_transmission(目标 ID 与密码)
alt 验证失败或服务繁忙
S-->>C: 返回失败原因或 retry_after
else 验证通过
S->>H: user_join_transmission 与连接配置
H->>S: offer
S->>C: 转发 offer 与连接配置
C->>S: answer
S->>H: 转发 answer
C->>S: 本端 ICE 候选地址
S->>H: 转发控制端候选地址
H->>S: 本端 ICE 候选地址
S->>C: 转发被控端候选地址
Note over C,H: ICE 选择可用路径:P2P 或 TURN
Note over C,H: 本图假设双方支持视频、音频及数据 SRTP
C->>H: 加密握手与控制数据(经选定路径)
H-->>C: 加密握手响应与画面、音频(经选定路径)
end
```
1. `GuiRuntime::CreateConnectionPeer()` 创建本机的常驻 MiniRTC Peer,`Init()` 连接信令服务并登录。控制另一个设备时,`ConnectTo()` 创建或复用对应的 `RemoteSession` 和控制端 Peer;该会话 Peer 以 `C-<本机ID>` 作为登录身份,使用独立的信令连接。
2. 普通交互连接在需要时进行在线探测;桌面墙后台连接可以直接由 Join 结果判定可用性。没有密码时先在本地弹窗收集,不先发送一次空密码请求。
3. 当前桌面调用为 `JoinConnection(peer, "目标ID@密码")`,MiniRTC 将其放入 `join_transmission` 的 `transmission_id`,经 WSS 提交给信令服务。登录尚未完成时,Join 会等待登录结果。
4. 服务端的加入通知驱动被控端发起 offer,控制端回复 answer;双方通过服务端转发 ICE 候选。支持新版协议的服务端提供每次连接的 ICE 配置及有效期,客户端仍保留旧服务端兼容路径。
5. MiniRTC 根据中继设置、对端能力和网络条件选择直连或中继。数据不通过信令 WebSocket 搬运。当前桌面端始终请求 SRTP;原生 ID 会话仍允许第 4.1 节所述的旧端兼容行为,因此请求 SRTP 不代表所有数据最终都经过加密。
官方服务与自托管服务采用同一路径,区别是服务地址、证书信任和部署配置。两端必须接入同一服务。ID 登录状态和远控数据连接是分别管理的,信令存在重连与会话恢复处理,不能把每次信令重连都视为新建远控会话。
对应代码:[桌面连接流程](../apps/desktop/src/gui/runtime/connection_runtime.cpp)、[本机 Peer 初始化](../apps/desktop/src/gui/runtime/gui_runtime.cpp)、[MiniRTC 登录与 Join](../deps/submodules/minirtc/src/pc/peer_connection.cpp)、[信令 TLS 与重连](../deps/submodules/minirtc/src/ws/ws_client.cpp)。
## 3. IP 直连模式
### 3.1 网络拓扑与端口
```mermaid
flowchart LR
subgraph Controller["控制端"]
CM["LAN SessionManager"]
CP["MiniRTC Direct Peer"]
end
subgraph Host["被控端"]
HM["LAN SessionManager"]
HP["MiniRTC Direct Peer"]
end
CM <-->|"WSS / TCP:密码、SDP、候选、心跳"| HM
CM -->|"初始化与协商回调"| CP
HM -->|"初始化与协商回调"| HP
CP <-->|"动态 UDP 端口:DTLS / SRTP 远控数据"| HP
```
用户填写的 `52888` 或自定义端口是**被控端 TCP 认证与协商入口**。视频、音频和业务数据使用 ICE 协商出来的 UDP 端口,不固定占用同一个 `52888`。仅放行协商 TCP 端口不足以保证远控数据能够通过防火墙。
LAN 协商通道使用 TLS 1.3、路径 `/crossdesk/lan/v2` 和 WebSocket 子协议 `crossdesk.lan.v2`。不需要部署中心信令服务,但每台启用该功能的电脑都会提供本机监听服务。应用原有 ID 模式的后台登录可能仍在运行;“IP 会话不依赖外网”不等于整个进程绝不访问外网。
### 3.2 初始化与多地址监听
`GuiRuntime::InitializeLan()` 在功能启用且运行宿主支持时创建 `lan::SessionManager`。管理器加载本地 LAN 身份、证书、密码和端口配置;尚无密码时先异步生成六位英文字母或数字密码,准备完成后开始接受连接。
- `PhysicalInterfaces()` 枚举符合平台筛选条件的物理网卡 IPv4,排除回环、点对点及不支持的虚拟接口。当前只接收数字 IPv4 地址,不解析主机名或 IPv6。
- 管理器约每 2 秒刷新接口快照;界面和监听维护共用该快照。可用地址分别监听,不绑定到一个通配地址来代替所有 IP。
- 每个 IP 首次配置优先尝试 `52888`;无法绑定时请求操作系统分配端口,并在保持 socket 占用期间保存实际端口,避免“探测后释放、使用时被抢占”的竞争。
- 已保存的端口冲突时报告错误并重试原端口,不自动换号。手动改端口按“绑定新端口 → 保存配置 → 替换监听”的顺序执行,失败时保留原设置。
- “切换 IP”只改变显示和分享的地址。端口修改会停止旧端口接受新连接,但保留已经完成认证的会话及其他 IP 的监听。
- 网卡下线或 DHCP 换址导致本地 IP 从接口快照中消失时,管理器停止该地址的监听,并以 `InterfaceRemoved` 关闭使用该本地 IP 的已有会话。其他 IP 的监听继续运行;当前直连协议只允许一轮 ICE 协商,不支持会话内 ICE 重启或无缝迁移到另一张网卡,需要重新建立连接。
- 端口配置以 **IP 地址**为键,不跟随网卡标识。DHCP 分配未记录的新 IP 时,该地址重新执行“尝试 `52888` → 必要时分配可用端口”的首次配置流程;旧 IP 再次出现时沿用其保存的端口。
这里的 LAN 策略限定接口与候选地址,不通过“是否有外网”判断工作模式,也不只是检查地址是否属于某个私网网段。实际连接仍要求本次 WSS 连接使用的双方 IPv4 的 TCP 和 UDP 路径可达。
### 3.3 认证与协商时序
```mermaid
sequenceDiagram
participant C as 控制端 SessionManager
participant H as 被控端 SessionManager
participant CP as 控制端 MiniRTC
participant HP as 被控端 MiniRTC
C->>H: 连接 IP:端口,建立 TLS 1.3 / WSS
C->>H: auth(request_id、六位密码、capabilities)
H->>H: 限流,异步执行 Argon2id 密码验证
alt 密码错误或限流
H-->>C: error(InvalidCredentials / AuthenticationRateLimited)
else 验证通过
H->>HP: InitDirectPeer,StartDirectSession(false)
H-->>C: auth_result(会话 ID、双方内部 ID)
C->>CP: InitDirectPeer,StartDirectSession(true)
CP-->>C: offer / candidate 回调
C->>H: 经原 WSS 发送 offer / candidate
H->>HP: ReceiveDirectSignal
HP-->>H: answer / candidate 回调
H->>C: 经原 WSS 发送 answer / candidate
C->>CP: ReceiveDirectSignal
Note over CP,HP: HostOnly ICE → DTLS → SRTP 就绪
CP-->>C: 加密通道就绪
HP-->>H: 加密通道就绪
Note over C,H: 标记 authorized,开放业务数据处理
CP->>HP: UDP 加密控制数据
HP-->>CP: UDP 加密画面、音频
end
```
IP 直连由**控制端**发起 offer。双方内部 ID 和随机 `session_id` 用于本次会话路由,不需要用户填写设备 ID。协商消息带会话 ID 和严格递增序号;会话管理器检查状态顺序,再调用 `ReceiveDirectSignal()`。MiniRTC 进一步检查 SDP、候选数量、地址和 ICE 标识一致性,并去重候选。
SessionManager 将当前 WSS socket 的本地与对端 IPv4 传入 `InitDirectPeer()`,由 MiniRTC 构造 `HostOnly` 策略,启动独立 ICE worker,不执行云端登录。MiniRTC 通过 `SignalSender` 回调把协商消息交给 SessionManager,再由 WSS 发送。
MiniRTC 关闭 STUN/TURN 服务器访问、UPnP、ICE TCP 和公网打洞扩展,仅收集指定本机 IPv4 的 UDP host 候选。libnice 的 `direct-peer-address` 补丁同时检查远端候选和入站来源:在解析 STUN 连通性检查、学习新候选或回复数据之前,拒绝不匹配的 IP。ICE 自身的 STUN 检查报文仍会在双方之间使用,这与访问 STUN 服务器是两回事。
WSS 在连接成功后继续承担心跳和会话关闭消息。当前心跳约每 5 秒发送一次,等待响应超过 15 秒时关闭通道;WSS 关闭会驱动对应的 MiniRTC 会话清理。
对应代码:[LAN 传输层](../apps/desktop/src/network/lan/lan_transport.cpp)、[LAN 会话管理](../apps/desktop/src/network/lan/lan_session_manager.cpp)、[MiniRTC Direct Peer](../deps/submodules/minirtc/src/pc/direct_peer.cpp)、[候选策略](../deps/submodules/minirtc/src/ice/direct_ice_policy.h)、[libnice 补丁](../deps/submodules/minirtc/thirdparty/libnice/patches/direct_peer_0.1.24.patch)。
## 4. 密码、加密与身份的职责
### 4.1 ID 模式
客户端对信令服务建立 WSS,使用系统信任及平台 TLS 校验。登录身份按 Peer 的用途区分:
| 场景 | 登录身份及职责 |
| --- | --- |
| 本机常驻 Peer | 使用本机缓存身份;已有 ID 与密码时,普通登录提交 `本机ID@本机密码`,用于本机上线与接受远控 |
| 控制端会话 Peer | 以 `C-<本机ID>` 登录;目标设备的密码在后续 Join 中提交,不附加在该登录身份中 |
| 自托管首次身份分配 | 无缓存身份时,常驻 Peer 以空 `user_id` 请求服务端分配身份,不能假定首次登录时已经具备 ID 和密码 |
服务端支持重连令牌且客户端持有有效令牌时,后续登录可使用不带密码的 ID 与令牌;令牌被拒绝后可回退到原登录身份。连接目标设备时,控制端会话 Peer 将 `目标ID@目标密码` 放入 Join,由服务端返回成功、凭据错误、限流或繁忙结果。客户端解析结果并驱动密码弹窗与重试;服务端保存密码的具体方式及配额以配套服务端实现为准。
信令 TLS 与远控数据加密属于两条路径。原生远控的 DTLS 握手和 SRTP 处理位于 MiniRTC,不能把“信令使用 TLS”等同于“画面经 TLS WebSocket 传输”。
在 SDP 格式与能力字段校验通过的前提下,原生 ID 会话的 `NegotiateSrtp()` 保留以下兼容分支:
| 对端 SDP 能力 | 当前原生 ID 会话的处理 |
| --- | --- |
| 有 DTLS 指纹,且声明音频/数据 SRTP 能力 | 协商视频、音频及数据 SRTP,完成 DTLS 密钥交换后使用加密传输 |
| 有 DTLS 指纹,但未声明音频/数据 SRTP 能力 | 保留旧端兼容:视频使用 SRTP,音频和数据为明文 |
| 没有 DTLS 指纹 | 关闭该传输的 SRTP,允许进入兼容路径,不因缺少指纹直接按 IP 模式的规则拒绝连接 |
该表适用于 `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)。
### 4.2 IP 模式
IP 直连采用 **TLS 内提交密码,由被控端本地验证**:
- 密码为 6 位 ASCII 字母或数字,区分大小写。验证记录使用随机盐和 Argon2id,当前参数为 `m=19456 KiB, t=2, p=1`;不使用 SPAKE2+ 或额外的密码证明交换。
- 为支持主窗口显示和重启恢复密码,本地还保存 AES-256-GCM 加密的密码副本;密钥及凭据文件受本机用户权限保护。能读取该用户密钥和凭据文件的进程仍能恢复密码。
- LAN WSS 接受通过有效期、自签名及证书用途等基本检查的自签证书;不执行历史设备信任、TOFU、证书固定或设备身份确认。密码验证授权控制方访问被控端,本身不证明对方就是上次连接的设备。
- DTLS 仍核对本次 SDP 中协商的证书指纹。直连要求视频、音频及数据的 SRTP 能力成功协商,密钥导出和加密会话安装成功后才发送 `secure_ready`;桌面运行时随后设置 `authorized`,开放业务回调。
这意味着被动抓包不能直接读取 TLS 内的密码,但当前模型没有通过持久证书信任识别被冒充的被控端。当前会话的 DTLS 指纹校验也不提供这种历史身份保证。
认证限流分别按传输层来源 IP 和本机凭据目标计数,当前来源上限为 20 次失败、目标上限为 5 次失败。每个计数桶使用 **900 秒有效窗口**;未达到阈值时,窗口到期会清除旧的失败计数。某个桶达到阈值时,从该次验证失败完成的时刻重新计时,进入完整的 **900 秒冷却**,期间被拦截的请求不会延长冷却。任一维度处于冷却都会阻止密码验证。
并发验证先预留额度,避免同时请求绕过上限;另有每来源每分钟 60 次昂贵认证工作的准入限制。成功认证、重连或重复保存同一密码不会主动清空仍在有效窗口内的失败记录,跨窗口的旧失败次数不会永久累计。
**限流记录属于 SessionManager 的内存状态**,没有持久化到磁盘。退出程序或关闭功能并销毁管理器后,再创建管理器会获得新的限流状态;当前实现不提供跨重启的永久累计失败次数限制。
对应代码:[密码验证](../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)。
## 5. 运行时复用、线程与关闭
```mermaid
flowchart TD
UI["MainWindow / GuiApplication"] --> ID["GuiRuntime::ConnectTo"]
UI --> IP["GuiRuntime::ConnectLan"]
ID --> Managed["MiniRTC Init / JoinConnection"]
IP --> LAN["LAN SessionManager / Transport"]
LAN --> Direct["MiniRTC InitDirectPeer / StartDirectSession"]
Managed --> Common["MiniRtcConnection / IceTransport / IceAgent"]
Direct --> Common
Common --> Events["PeerEventHandler"]
Events --> Features["视频与音频、输入、文件、剪贴板、隐私屏"]
Features --> Session["RemoteSession / 串流窗口"]
```
图中的共享传输层针对原生桌面会话。ID 模式中的 WebRTC 客户端走独立适配实现,共用上层业务接口。
- **会话隔离:** ID 模式保留本机常驻 Peer 和控制端会话 Peer。每个 LAN 会话拥有独立 Direct Peer,内部使用 `lan/` 前缀的随机键,避免与设备 ID 冲突;`lan_controllers_` 管理被控方向的 LAN 会话。
- **业务复用:** `ControllerEvents()`、`WithController()` 等根据会话类型路由到正确 Peer。视频、音频、键鼠、文件、剪贴板仍交给原有回调和功能模块;采集设备按整体连接需求管理。
- **线程分工:** SessionManager 的公开操作和 `Tick()` 在 GUI 管理线程运行;Transport 使用一个共享 Asio 网络线程,回调仅提交有界事件。密码计算、Peer 准备与销毁在后台任务执行,MiniRTC 自身处理 ICE 和媒体工作。
- **数据放行:** LAN 业务回调检查会话授权和关闭状态。加密就绪通知与数据到达可能交错,少量早期控制消息会在有界队列中暂存,授权后按序处理;其他业务数据不因排队而提前获得授权。
- **关闭顺序:** 标记关闭并停止新回调,取消会话相关传输,关闭 WSS;后台排空已有回调,再执行 `CloseDirectSession()` 和 `DestroyPeer()`。网络回调中不直接销毁 Peer。
- **资源边界:** 当前管理器最多保留 24 个通道,未认证接入最多 16 个,异步密码验证与 Peer 准备各最多 2 个任务,同时准入的准备中/已认证会话最多 8 个。协商还设有消息数量、字节和阶段超时限制,防止无限队列或等待。
关闭 IP 功能时,运行时等待会话、密码任务、准备任务及清理任务全部结束,再释放 LAN 管理器和网络线程。快速关闭后重新开启会等待旧实例排空;应用退出时不会重新创建监听。
对应代码:[LAN 与桌面运行时的衔接](../apps/desktop/src/gui/runtime/lan_runtime.cpp)、[回调生命周期](../apps/desktop/src/gui/runtime/session_lifecycle.cpp)、[控制消息处理](../apps/desktop/src/gui/runtime/peer_data_callbacks.cpp)。
## 6. 配置、模式切换与近期记录
| 状态 | 含义与行为 |
| --- | --- |
| `connection_mode = id / ip` | 主窗口的连接入口、凭据展示和近期列表模式;切换后保存,下次启动恢复 |
| `enable_ip_direct = true / false` | IP 功能总开关,默认开启;控制 LAN 管理器及监听是否存在 |
| LAN 身份目录 | 位于应用配置目录的 `lan` 子目录,保存内部设备 ID、证书、密码及每 IP 的端口 |
| `cloud_id` 近期记录 | 保存 ID 设备及可选记住的密码,接入原有在线状态与分组流程 |
| `lan_direct` 近期记录 | 保存当前连接返回的内部设备 ID、最近 IP/端口及可选密码;与 ID 记录分别展示 |
主窗口切回 ID 模式不会停止 LAN 监听,也不自动断开已有 LAN 会话。取消“启用 IP 直连”并保存,才会断开 LAN 会话、释放监听端口、回到 ID 模式并隐藏模式按钮;已有 ID 会话继续运行。重新启用时恢复原有密码、端口及近期记录。
LAN 近期记录的设备 ID 用于归档,不用于确认对端身份。重连先使用记录中的 IP/端口;界面上的 `LAN` 标记表示连接类型,不代表服务器在线查询的结果。两种模式均可在不保存密码的情况下记录设备。
同一台被控电脑的不同 LAN IP 共用一份 LAN 密码,各地址的端口单独保存。密码热更新会终止使用旧凭据的未完成认证,已经授权的会话继续运行;后续连接需使用新密码。
对应代码:[全局配置](../apps/desktop/src/config_center/config_center.cpp)、[模式与界面绑定](../apps/desktop/src/gui/application/gui_application.cpp)、[近期记录存储](../apps/desktop/src/thumbnail/thumbnail.cpp)。
## 7. 支持范围与排查入口
IP 直连目前面向 Windows、macOS、Linux 的普通桌面客户端。浏览器、原生移动端、Windows 登录前服务和 Linux 无头控制台没有这条直连入口。物理网卡筛选、数字 IPv4 和 UDP host 候选是当前实现约束,不能把它当作主机名解析、IPv6、任意 VPN 或公网 NAT 穿透的通用入口。
| 现象 | 首先检查的层 |
| --- | --- |
| ID 模式未连接服务器 | WSS 服务地址、TLS 信任、登录与重连状态 |
| ID 可查询但远控失败 | Join 返回的凭据/限流错误,以及 ICE 配置、直连或 TURN 路径 |
| IP 模式按钮未显示 | `enable_ip_direct` 设置是否开启并保存 |
| IP / 端口无法连接 | 对方实际显示的端口、物理接口、TCP 监听及防火墙 |
| 密码通过但没有远控画面 | 双方 UDP 可达性、HostOnly 候选检查、DTLS/SRTP 就绪状态及采集权限 |
| 改端口后旧连接仍能使用 | 已认证会话保留原通道,这是端口热更新的预期行为 |
| 网卡下线或换 IP 后直连中断 | 原本地 IP 是否消失;关联会话会关闭,需使用当前可达的 IP/端口重新连接 |
| 切回 ID 模式仍有 LAN 监听 | 模式选择只改变入口;停止监听需要关闭 IP 功能总开关 |
使用步骤见 [IP 直连指南](USER_GUIDE.md#lan-direct),自建信令服务见 [自托管说明](SELF_HOSTING.md)。本文记录当前架构,不替代 Windows/Linux、跨机器或长期会话的实际验证结果。
+18 -1
View File
@@ -2,6 +2,8 @@
本文档说明 `apps/desktop/src/gui` 的目录结构、核心类型、依赖方向和代码归属规则,用于避免 `Render`、`GuiApplication` 或 `GuiRuntime` 再次演变成职责混杂的大类。
ID 连接与 IP 直连的网络拓扑、认证和传输流程见 [连接模式架构](connection-architecture.md)。
## 目录结构
```text
@@ -160,7 +162,7 @@ Render::Run
→ Tick 协调连接、处理后台事件并同步窗口状态
```
### 建立远端连接
### 建立 ID 远端连接
```text
MainWindow::connect_requested
@@ -173,6 +175,21 @@ MainWindow::connect_requested
→ SyncStreamWindow 创建串流窗口并开始呈现视频
```
### 建立 IP 直连
```text
MainWindow 地址校验与密码弹窗
→ GuiRuntime::ConnectLan
→ lan::SessionManager::Connect
→ WSS 连接被控端 IP:端口,由被控端验证密码
→ MiniRTC InitDirectPeer / StartDirectSession
→ WSS 交换 SDP 与候选,HostOnly ICE 建立 UDP 通道
→ DTLS/SRTP 就绪,SessionManager 标记授权
→ PeerEventHandler 与原有串流窗口、输入、文件及剪贴板处理
```
LAN 初始化和生命周期集中在 `runtime/lan_runtime.cpp`,监听、密码及协商协议位于 `src/network/lan`。主窗口模式切换与 IP 功能启停独立;后者负责停止监听并排空 LAN 会话。
### 接收远端数据
```text
+99 -64
View File
@@ -4,96 +4,127 @@
## 来源 / Sources
- 两张常规设置截图(`desktop-settings-zh.png` / `desktop-settings-en.png`)于 **2026-10-22** 从 `f8f4aa3c` 加当前工作区修改重新渲染,移除硬件编解码开关;使用现有 `slint_ui_smoke_test`、macOS arm64 和 2× 软件渲染,PPM 中间文件存放在工程外临时目录。
- 16 张中英文桌面截图更新于 **2026-10-19**,UI 源代码为 `1ef372d2`,使用 Slint 1.17.1、macOS arm64 和软件渲染器。图片直接渲染仓库中的 Slint 组件。
- 捕获程序来自现有 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp),在**工程外的临时目录**中复制源文件,仅为桌面墙调整演示设备数量、单元格宽度和网格窗口高度。Slint 组件没有改动,工程内没有新增或修改测试文件。
- 主窗口展示桌面墙入口、连接区、近期设备卡片与分组筛选;设置展示“常规 / 桌面墙”选项卡,关于窗口展示三个赞助入口。桌面墙网格展示六台演示设备;放大图展示只读预览、群控和接管入口,没有建立真实连接。
- 本机 ID `123 456 789`、密码 `123456`、设备名称、在线 / 连接状态及记录均为演示数据。主窗口展示三台设备,桌面墙展示六台;它们不是真实设备凭据或真实远程会话。示例配置不代表所有运行时默认值。
- **2026-10-25** 删除“启用中继服务”开关,并将自托管配置拆分为普通标签、“编辑”按钮和开关后,从当前工作区组件重新生成常规设置与网络设置共 4 张中英文截图。
- **2026-10-25** 从 `ae6f81e8` 的实际 Slint 组件重新生成 16 张中英文截图:更新主窗口、常规设置、桌面墙设置、自托管及关于共 10 张,新增 IP 直连主界面、记住密码弹窗和网络设置共 6 张。使用 Slint 1.17.1、macOS arm64 和 2× 软件渲染。
- 捕获程序在**工程外临时目录**中复用现有 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp) 的截图辅助函数,并注入演示界面状态;没有启动应用运行时、打开监听端口或建立远控会话。没有修改产品组件或工程内测试,临时源码、可执行程序与 PPM 均位于工程外。
- 主窗口展示 ID / IP 模式切换、桌面墙入口和各自的近期设备;IP 模式展示地址编辑、六位密码和默认端口 52888,网络设置展示“启用 IP 直连”及关闭说明。IP 近期设备的 `LAN` 标记表示记录类型,不代表在线探测结果。
- 本机 ID `123 456 789`、密码 `Ab12Cd` / `Cd34Ef`、地址 `192.168.1.10` 与 `192.168.1.20–22`、设备名称和连接状态均为演示数据,不是真实设备凭据。IP 列表还包含 `192.168.2.10` 作为切换示例。截图中勾选的“记住密码”是演示选择,配置状态不代表全部运行时默认值。
- 桌面墙网格、放大视图和会话画面设置共 6 张继续沿用 **2026-10-19** 从 `1ef372d2` 渲染的截图。桌面墙展示六台演示设备,主窗口展示三台;本轮没有重新采集这些页面。
- “关于”中的 `v1.5.2` 和“已是最新版本”是捕获工具的示例值,不代表当前发布版本或实际在线检查结果。会话图使用公开页面作为远端画面,展示真实工具栏与画面设置菜单;隐私屏按钮处于关闭状态。
- 缩略图沿用既有公开页面截图:`fixtures/source.png` 来自 [CrossDesk Xmake 源码页](https://github.com/kunkundi/crossdesk/blob/ios-support/xmake.lua),`fixtures/website.png` 来自 [CrossDesk 官网](https://www.crossdesk.cn/),两者采集于 2026-09-07。它们只用于展示预览效果,不代表这些页面正在对应设备上运行,页面中的版本信息也不代表最新发布。
- `web-client.png` 保留 **2026-09-25** 从 [Web 客户端](https://web.crossdesk.cn/) 采集的 1280 × 720 连接首页,页面版本为 `2026.09.24.2`,ID 与密码为空。本次未重新采集 Web 页面;桌面设备预览继续复用这张图片。
The two General settings images were refreshed again on **2026-10-22** from `f8f4aa3c` plus the current working-tree changes to remove the hardware codec switch, using the existing `slint_ui_smoke_test` on macOS arm64 at 2× software rendering scale. Intermediate PPM files were kept outside the repository.
All 16 desktop images were previously refreshed on **2026-10-19** from UI source `1ef372d2`, using Slint 1.17.1 on macOS arm64. They render the actual components with demonstration data. The capture program is an external temporary copy of the existing source, adjusted only for DeskHub's six sample devices, 300 logical-pixel cells, and shorter grid window; no UI components or repository tests were changed in that refresh. The About view includes all three sponsorship links. IDs, credentials, connection states, versions, and update results are illustrative. No remote sessions were opened. Public-page fixtures remain from 2026-09-07, and the Web screenshot remains from 2026-09-25. This refresh does not include native mobile screenshots.
On **2026-10-25**, 16 images were rendered from the actual Slint components at `ae6f81e8`: ten refreshed images (main window, General settings, DeskHub settings, self-hosting, and About) and six additions (Direct IP mode, its password dialog, and Network settings), in Chinese and English. Later that day, the four General and Network settings images were refreshed from the working tree after removing Enable TURN Service and separating Self-Hosted Config into a label, Edit button, and switch. The capture-only program reused the existing helper functions in an external temporary directory, using Slint 1.17.1 on macOS arm64 at 2× scale. Capture did not modify product components or repository tests, and no listeners or remote sessions were opened. IDs, IPs, passwords, device records, connection states, and update results are illustrative; `LAN` labels indicate record type, not an online check. The checked Remember password box is a demonstration choice. The other six desktop screenshots remain from 2026-10-19, public-page fixtures from 2026-09-07, and the Web image from 2026-09-25. Native mobile screenshots are not included.
## 文件 / Files
| 文件(`zh` / `en`) | 页面 | PNG 尺寸 |
| --- | --- | --- |
| `desktop-main-*.png` | 主窗口 / Main window | 1280 × 944 |
| `desktop-main-*.png` | ID 连接模式 / ID mode | 1280 × 944 |
| `desktop-lan-*.png`(不含 `password`) | IP 直连模式 / Direct IP mode | 1280 × 944 |
| `desktop-lan-password-*.png` | IP 直连密码与记住密码 / Direct IP password dialog | 1280 × 944 |
| `desktop-settings-network-*.png` | IP 直连开关与网络设置 / Direct IP switch and Network settings | 1280 × 944 |
| `desktop-deskhub-*.png`(不含 `focus`) | 六设备桌面墙 / Six-device DeskHub grid | 1920 × 1000 |
| `desktop-deskhub-focus-*.png` | 桌面墙放大视图 / Enlarged DeskHub view | 1920 × 1396 |
| `desktop-settings-*.png`(不含 `deskhub`) | 常规设置 / General settings | 1280 × 944 |
| `desktop-settings-zh.png` / `desktop-settings-en.png` | 常规设置 / General settings | 1280 × 944 |
| `desktop-settings-deskhub-*.png` | 桌面墙传输设置 / DeskHub video settings | 1280 × 944 |
| `desktop-self-hosted-*.png` | 自托管设置 / Self-hosting | 1280 × 944 |
| `desktop-stream-video-settings-*.png` | 会话工具栏与画面设置 / Session video settings | 1920 × 1200 |
| `desktop-about-*.png` | 关于与检查更新 / About and update check | 1280 × 944 |
| `web-client.png`(中文) | Web 连接首页 / Web connection page | 1280 × 720 |
桌面图片使用 2× 原生渲染缩放,捕获窗口内容区,不包含 macOS 原生标题栏。未放大旧 UI 截图,也未重绘控件;演示远端画面由实际组件按比例显示。Markdown 只设置展示宽度。常规设置可滚动,截图仅展示首屏;Windows 专属采集方式与便携版服务入口不会出现在这些 macOS 图片中。
桌面图片使用 2× 原生渲染缩放,捕获窗口内容区,不包含 macOS 原生标题栏。未放大旧 UI 截图,也未重绘控件;演示远端画面由实际组件按比例显示。Markdown 只设置展示宽度。常规设置可滚动,`desktop-settings` 展示首屏,`desktop-settings-network` 展示网络连接分组;Windows 专属采集方式与便携版服务入口不会出现在这些 macOS 图片中。
## 重新生成 / Regenerate
以下命令在仓库根目录、已配置依赖的 macOS 开发环境执行。所有临时源码、PPM 和额外捕获程序均写入系统临时目录,最终 PNG 才写入 `docs/images`。`sips` 仅做 PPM 到 PNG 的无损格式转换,其他平台可使用支持 PPM 的转换工具。
以下命令在已配置依赖的 macOS 工程根目录执行。生成程序只渲染演示状态;所有临时源码、可执行文件和 PPM 均存放在工程外,确认图片后才将 PNG 复制进 `docs/images`。`sips` 仅做无损格式转换,图片没有重绘或后期修改。
先检查 `/tmp/crossdesk-ui-capture-page` 和 `/tmp/crossdesk-ui-capture-language`:旧覆盖文件存在且非空时会优先于环境变量,应确认并移开后再捕获。`CROSSDESK_UI_CAPTURE_DEMO_ASSETS` 必须包含上述公开页面图片,缺失图片会导致捕获失败。工具等待窗口尺寸与缩放初始化后再截图。
先检查 `/tmp/crossdesk-ui-capture-page` 和 `/tmp/crossdesk-ui-capture-language`:旧覆盖文件存在且非空时会优先于环境变量,需确认并移开后再捕获。`CROSSDESK_UI_CAPTURE_DEMO_ASSETS` 指向本目录,供近期设备缩略图使用。
### 主窗口、设置与会话
### 生成捕获程序
复用现有源文件的捕获辅助函数,截去测试入口,在临时目录加入演示状态及独立入口;保持工程内测试文件不变。源码结构变化时需复核替换位置。
```bash
set -e
xmake b -y slint_ui_smoke_test
xmake b -y crossdesk_ui
capture_dir=$(mktemp -d "${TMPDIR:-/tmp}/crossdesk-readme-capture.XXXXXX")
export capture_dir
for locale in zh en; do
language=0
if [ "$locale" = en ]; then language=1; fi
for page in main settings settings-deskhub self-hosted about stream-video-settings; do
snapshot="$capture_dir/desktop-$page-$locale.ppm"
SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \
CROSSDESK_UI_CAPTURE=1 \
CROSSDESK_UI_CAPTURE_DEMO_ASSETS="$PWD/docs/images" \
CROSSDESK_UI_CAPTURE_PAGE="$page" \
CROSSDESK_UI_CAPTURE_LANGUAGE="$language" \
CROSSDESK_UI_CAPTURE_SNAPSHOT="$snapshot" \
xmake r slint_ui_smoke_test
sips -s format png "$snapshot" \
--out "docs/images/desktop-$page-$locale.png"
done
done
```
### 六设备桌面墙
现有捕获模式的 `gallery` / `gallery-focus` 默认使用三台设备。要复现本页的六设备图,在上一步同一 shell 中创建临时副本,只修改演示场景:
```bash
python3 - <<'PY'
import os
from pathlib import Path
source = Path('apps/desktop/tests/slint_ui_smoke_test.cpp').read_text()
changes = [
('options.page != "main-recent-scrolled") {',
'options.page != "main-recent-scrolled" &&\n'
' !options.page.starts_with("gallery")) {'),
('window->set_gallery_mode(true);\n std::vector',
'window->set_gallery_mode(true);\n'
' window->set_gallery_tile_width(300);\n std::vector'),
('698.0f;\n#else\n 720.0f;',
'options.page == "gallery" ? 500.0f : 698.0f;\n#else\n'
' options.page == "gallery" ? 500.0f : 720.0f;'),
]
for old, new in changes:
assert source.count(old) == 1, 'Capture source changed; review the fixture adjustment.'
source = source.replace(old, new, 1)
Path(os.environ['capture_dir'], 'readme_capture.cpp').write_text(source)
import os
root = Path.cwd()
out = Path(os.environ['capture_dir'])
source = (root / 'apps/desktop/tests/slint_ui_smoke_test.cpp').read_text()
source = source[:source.index('// Sample the native window while')]
old = ' // Preview DeskHub with the same demo images, without opening any sessions.'
new = ''' // Documentation-only state; no runtime, listeners, or remote sessions.
window->set_local_password("Ab12Cd");
window->set_ip_direct_enabled(true);
window->set_ip_direct_enabled_draft(true);
if (options.page == "settings-network") window->set_settings_open(true);
if (options.page == "lan" || options.page == "lan-password") {
window->set_lan_mode(true);
window->set_lan_listener_ip("192.168.1.10");
window->set_lan_listener_port("52888");
window->set_lan_local_port("52888");
window->set_lan_interfaces(std::make_shared<slint::VectorModel<slint::SharedString>>(
std::vector<slint::SharedString>{"192.168.1.10", "192.168.2.10"}));
window->set_lan_new_password("Cd34Ef");
window->set_lan_listening(true);
window->set_remote_id_input("192.168.1.20:52888");
const auto recent = window->get_recent_connections();
for (size_t i = 0; i < recent->row_count(); ++i) {
auto device = recent->row_data(i).value();
device.lan = true;
device.online = false;
device.remote_id = slint::SharedString(("lan-demo-" + std::to_string(i)).c_str());
device.endpoint = slint::SharedString(("192.168.1." + std::to_string(20 + i) + ":52888").c_str());
recent->set_row_data(i, device);
}
if (options.page == "lan-password") {
window->set_lan_remote_address("192.168.1.20");
window->set_lan_remote_port("52888");
window->set_lan_open(true);
window->set_lan_remember_password(true);
}
}
''' + old
assert source.count(old) == 1
source = source.replace(old,new)
old = ' // Let settings scroll animations settle as well as hover/focus feedback.'
new = ''' if (options.page == "settings-deskhub") window->set_settings_tab(1);
if (options.page == "settings-network") {
window->window().dispatch_pointer_scroll_event(
slint::LogicalPosition({400.0f, 250.0f}), 0.0f, -148.0f);
window->window().dispatch_pointer_move_event(slint::LogicalPosition({2.0f, 2.0f}));
}
''' + old
assert source.count(old) == 1
source = source.replace(old,new)
source += '''} // namespace
int main() {
const auto options = LoadCaptureOptions();
if (!options.enabled || options.snapshot_path.empty()) return 1;
auto window = crossdesk::ui::MainWindow::create();
auto stream = crossdesk::ui::StreamWindow::create();
auto server = crossdesk::ui::ServerWindow::create();
if (!RegisterFontAwesome(window->window()) || !RegisterFontAwesome(stream->window()) ||
!RegisterFontAwesome(server->window())) return 2;
window->set_local_id("123 456 789");
window->set_local_password("Ab12Cd");
window->window().set_size(slint::LogicalSize({640.0f, 472.0f}));
return RunCaptureMode(options, window, stream, server);
}
'''
(out / 'readme_capture.cpp').write_text(source)
print(out / 'readme_capture.cpp')
PY
```
以下编译路径对应本次的 `macosx / arm64 / debug` 配置。其他配置先用 `xmake show -t slint_ui_smoke_test` 查看当前生成头文件和库目录,再替换路径;不要在工程内添加临时构建入口。
### 编译与捕获
以下路径对应 `macosx / arm64 / debug` 配置;其他配置先用 `xmake show -t slint_ui_smoke_test` 查看生成头文件与库路径,再调整。捕获按顺序执行,避免窗口互相抢占焦点。
```bash
slint_prefix=$(xmake lua -c 'import("core.project.config"); config.load(); import("core.project.project"); print(project.target("slint_ui_smoke_test"):pkg("slint"):installdir())')
@@ -111,10 +142,8 @@ xcrun clang++ -std=c++20 \
for locale in zh en; do
language=0
if [ "$locale" = en ]; then language=1; fi
for page in gallery gallery-focus; do
name=deskhub
if [ "$page" = gallery-focus ]; then name=deskhub-focus; fi
snapshot="$capture_dir/desktop-$name-$locale.ppm"
for page in main lan lan-password settings settings-network settings-deskhub self-hosted about; do
snapshot="$capture_dir/desktop-$page-$locale.ppm"
SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \
CROSSDESK_UI_CAPTURE=1 \
CROSSDESK_UI_CAPTURE_DEMO_ASSETS="$PWD/docs/images" \
@@ -123,9 +152,15 @@ for locale in zh en; do
CROSSDESK_UI_CAPTURE_SNAPSHOT="$snapshot" \
"$capture_dir/readme_capture"
sips -s format png "$snapshot" \
--out "docs/images/desktop-$name-$locale.png"
--out "$capture_dir/desktop-$page-$locale.png"
done
done
```
更新后逐张检查文字、布局、演示数据和文档引用,并同步日期、源码版本与图片尺寸。若更新 Web 图片,应打开 Web 客户端,等待表单与底部版本号加载完成,保持 ID 与密码为空,再截取页面视口;替换后也需重新生成桌面图以同步缩略图。
逐张检查语言、页面、文字裁切、尺寸和演示数据后,将这些 PNG 复制到 `docs/images`,并更新日期与源码版本。桌面墙设置的标签在窗口初始化后设置,网络页滚动到对应分组,避免捕获尚未稳定的页面。
### 其他页面
会话图可用相同程序的 `stream-video-settings` 页面捕获。六设备桌面墙需要先调整**临时** `readme_capture.cpp` 的演示场景:在 `ConfigureDocumentationDemo` 的三设备限制中排除 `gallery` 开头的页面;在开启 gallery 模式后设置 `gallery_tile_width` 为 300;将 `gallery` 网格窗口的内容高度设为 500,`gallery-focus` 保持 698(macOS)。重新编译后,分别捕获 `gallery` / `gallery-focus` 并命名为 `desktop-deskhub-*` / `desktop-deskhub-focus-*`。这些调整只影响示例数据和窗口尺寸,不改变实际组件。
若更新 Web 图片,应打开 Web 客户端,等待表单与底部版本号加载完成,保持 ID 与密码为空,再截取页面视口;替换后也需重新生成桌面图以同步缩略图。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 148 KiB

+4 -4
View File
@@ -236,10 +236,10 @@ struct HostDisplay {
};
// Builds the host_infomation message advertised on every new connection.
// |host_name| is truncated to the wire field size. The display arrays are
// heap-allocated with the same layout the decoder produces; release the
// result with FreeRemoteAction. An allocation failure yields an empty display
// list rather than a partially filled one.
// |host_name| is UTF-8 and truncated at a character boundary to fit the wire
// field size. The display arrays are heap-allocated with the same layout the
// decoder produces; release the result with FreeRemoteAction. An allocation
// failure yields an empty display list rather than a partially filled one.
// Missing or oversized app versions are advertised as unknown (empty).
RemoteAction MakeHostInformation(const std::string& host_name,
const std::vector<HostDisplay>& displays,
+15 -9
View File
@@ -34,6 +34,19 @@ void ResetHostInfo(HostInfo& info) {
info.supports_deskhub_bandwidth = false;
}
void CopyHostName(HostInfo& info, const std::string& host_name) {
std::size_t size = std::min(
{host_name.size(), host_name.find('\0'), sizeof(info.host_name) - 1});
// Keep the fixed-size UTF-8 field from ending inside a multibyte character.
while (size > 0 && size < host_name.size() &&
(static_cast<unsigned char>(host_name[size]) & 0xC0) == 0x80) {
--size;
}
std::memcpy(info.host_name, host_name.data(), size);
info.host_name[size] = '\0';
info.host_name_size = size;
}
bool AllocateHostDisplays(HostInfo& info, std::size_t count) {
if (count == 0) return true;
@@ -104,11 +117,7 @@ RemoteAction MakeHostInformation(const std::string& host_name,
action.i.supports_privacy_screen = supports_privacy_screen;
action.i.platform = platform;
const std::size_t name_size =
std::min(host_name.size(), sizeof(action.i.host_name) - 1);
std::memcpy(action.i.host_name, host_name.data(), name_size);
action.i.host_name[name_size] = '\0';
action.i.host_name_size = name_size;
CopyHostName(action.i, host_name);
// Never advertise a truncated version as a different, valid release.
if (app_version.size() < sizeof(action.i.app_version) &&
app_version.find('\0') == std::string::npos) {
@@ -462,10 +471,7 @@ bool RemoteAction::FromJson(const std::string& json_string,
host_info_object.value("supports_deskhub_bandwidth", false);
const std::string host_name =
host_info_object.at("host_name").get<std::string>();
std::strncpy(output.i.host_name, host_name.c_str(),
sizeof(output.i.host_name) - 1);
output.i.host_name[sizeof(output.i.host_name) - 1] = '\0';
output.i.host_name_size = std::strlen(output.i.host_name);
CopyHostName(output.i, host_name);
const auto& displays = host_info_object.at("displays");
if (!displays.is_array()) return false;