Compare commits

..
24 Commits
Author SHA1 Message Date
dijunkun c3ea4eb8fc [ui] flatten desktop surfaces and controls 2026-10-26 14:53:09 +08:00
dijunkun 38d0ce7728 [build] remove license validation from mobile builds 2026-10-26 13:12:11 +08:00
dijunkun 778c3575cd [ui] refine desktop controls and align localized copy 2026-10-26 13:00:03 +08:00
dijunkun dad8384666 [docs] add README banner and clarify license metadata 2026-10-26 11:36:36 +08:00
dijunkun 9496bc8f1c [docs] update README screenshots for refreshed desktop UI 2026-10-26 11:23:02 +08:00
dijunkun e551e04e8f [ui] refine desktop shadows and control contrast 2026-10-26 11:18:41 +08:00
dijunkun e3b3f78b06 [fix] invalidate stale Slint build artifacts after UI changes 2026-10-26 10:29:12 +08:00
dijunkun 5db213d126 [ui] replace connection mode button with a sliding toggle 2026-10-26 10:16:17 +08:00
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
118 changed files with 6332 additions and 1596 deletions
+12 -104
View File
@@ -7,124 +7,32 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | 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 - type: textarea
id: problem id: problem
attributes: attributes:
label: 问题与预期 / Problem and expected behavior label: 问题描述 / Problem description
description: 描述实际发生的情况、错误提示,以及期望的行为。Describe what happened, any error messages, and what you expected. description: 遇到了什么问题?如方便,也请说明触发操作或错误提示。What went wrong? If possible, include what you were doing or any error message.
placeholder: |
实际结果 / Actual behavior:
预期结果 / Expected behavior:
validations: validations:
required: true required: true
- type: textarea - type: input
id: reproduction id: environment
attributes: attributes:
label: 复现步骤 / Steps to reproduce label: 使用环境(选填) / Environment (optional)
description: 说明操作步骤和出现频率;偶发问题可描述发生前的操作。Include the steps and frequency. For intermittent issues, describe what you were doing. description: 操作系统和 CrossDesk 版本;连接问题可填写两端信息。OS and CrossDesk version; include both devices for connection issues.
placeholder: | placeholder: Windows 11 → macOS 15; CrossDesk x.y.z
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.
validations: validations:
required: false required: false
- type: textarea - type: textarea
id: attachments id: attachments
attributes: attributes:
label: 日志与截图 / Logs and screenshots label: 截图或日志(选填) / Screenshots or logs (optional)
description: | 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: validations:
required: false required: false
+6 -51
View File
@@ -7,66 +7,21 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | 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. Only a description of your idea is required. Chinese or English is welcome.
- 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
- type: textarea - type: textarea
id: proposal id: proposal
attributes: attributes:
label: 功能建议与预期效果 / Proposal and expected outcome label: 你的建议 / Your idea
description: 描述希望新增或改进的行为,以及理想的使用方式。Describe the behavior you would like to add or improve and how you expect to use it. description: 希望新增或改进什么?如方便,也请说明使用场景。What would you like to add or improve? If possible, describe when you would use it.
validations: validations:
required: true 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 - type: textarea
id: references id: references
attributes: attributes:
label: 参考资料与补充说明 / References and additional context label: 补充说明(选填) / Additional context (optional)
description: 可提供参考链接、截图、示意图或其他要求。Include links, screenshots, mockups, or any additional requirements. description: 可附截图、参考链接或其他说明。Add screenshots, links, or any other details.
validations: validations:
required: false required: false
-4
View File
@@ -36,7 +36,6 @@ on:
- "apps/android/**" - "apps/android/**"
- "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json" - "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"
- "apps/ios/licenses/**" - "apps/ios/licenses/**"
- "apps/ios/scripts/generate_third_party_licenses.py"
- "deps/submodules/minirtc" - "deps/submodules/minirtc"
- "libs/wire/**" - "libs/wire/**"
@@ -155,9 +154,6 @@ jobs:
cache-provider: basic cache-provider: basic
cache-read-only: ${{ github.event_name == 'pull_request' }} cache-read-only: ${{ github.event_name == 'pull_request' }}
- name: Verify shared license catalog
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Resolve Android version - name: Resolve Android version
shell: python shell: python
env: env:
+1 -4
View File
@@ -169,9 +169,6 @@ jobs:
with: with:
python-version: "3.13" python-version: "3.13"
- name: Verify iOS license catalog
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Install xmake - name: Install xmake
uses: xmake-io/github-action-setup-xmake@v1 uses: xmake-io/github-action-setup-xmake@v1
with: with:
@@ -181,7 +178,7 @@ jobs:
shell: bash shell: bash
run: | run: |
xmake --version | tee "${RUNNER_TEMP}/xmake-version.txt" xmake --version | tee "${RUNNER_TEMP}/xmake-version.txt"
python3 -B apps/ios/scripts/generate_third_party_licenses.py \ python3 -B apps/ios/scripts/verify_native_build.py \
--xmake-version "${RUNNER_TEMP}/xmake-version.txt" --xmake-version "${RUNNER_TEMP}/xmake-version.txt"
- name: Pin xmake package repository - name: Pin xmake package repository
+3
View File
@@ -1,3 +1,6 @@
CrossDesk
SPDX-License-Identifier: GPL-3.0-only
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 29 June 2007
+30 -7
View File
@@ -1,6 +1,5 @@
<div align="center"> <div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" /> <img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk 标志:图标在左,双色文字在右" />
<h1>CrossDesk</h1>
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p> <p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p> <p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
</div> </div>
@@ -22,13 +21,14 @@
CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。 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> <a id="features"></a>
## 主要功能 ## 主要功能
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。 - **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。 - **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。 - **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。 - **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
@@ -61,22 +61,42 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
## 快速连接 ## 快速连接
1. **准备被控电脑。** 安装并运行 CrossDesk,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。 ### 通过设备 ID 连接
1. **准备被控电脑。** 安装并运行 CrossDesk,切换至 ID 连接模式,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。 2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。 3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。 使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](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> <a id="preview"></a>
## 界面预览 ## 界面预览
### 桌面客户端 ### 桌面客户端
本机凭据、远程连接和近期设备集中在主窗口,左上角进入桌面墙。 本机凭据、远程连接和近期设备集中在主窗口,左上角提供连接模式切换和桌面墙入口。
<p align="center"> <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> </p>
<a id="deskhub"></a> <a id="deskhub"></a>
@@ -96,7 +116,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
## 会话操作与设置 ## 会话操作与设置
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置编码、中继、自托管与桌面墙画面预算。 远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。 - [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。 - [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
@@ -151,9 +171,12 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。 - [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。 - [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。 - [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。 - [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
桌面端的 `CROSSDESK_UPDATE_PUBLIC_KEY` 是可选编译参数,用于验证应用内升级清单的 Ed25519 签名。不配置仍可正常编译、运行和检查版本,但更新只能手动下载;需要应用内自动安装更新时,须配置与发布私钥匹配的公钥。配置方式见 [更新公钥](docs/BUILD.md#update-public-key),密钥生成与发布流程见 [桌面端应用内升级](docs/UPDATER.md)。
<a id="sponsor"></a> <a id="sponsor"></a>
## 支持 CrossDesk ## 支持 CrossDesk
+30 -7
View File
@@ -1,6 +1,5 @@
<div align="center"> <div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" /> <img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk logo with the icon on the left and a two-tone wordmark on the right" />
<h1>CrossDesk</h1>
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p> <p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p> <p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
</div> </div>
@@ -22,13 +21,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. 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> <a id="features"></a>
## Features ## 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. - **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. - **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. - **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. - **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
@@ -61,22 +61,42 @@ Requirements follow the current source and build configuration. Check the releas
## Quick start ## 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. 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**. 3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems. For self-hosting, both ends must use the same service. 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> <a id="preview"></a>
## Preview ## Preview
### Desktop client ### 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"> <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> </p>
<a id="deskhub"></a> <a id="deskhub"></a>
@@ -96,7 +116,7 @@ Group control sends input to all connected devices; closing DeskHub disconnects
## Session controls and settings ## 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. - [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. - [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
@@ -151,9 +171,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. - [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. - [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. - [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. - [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> <a id="sponsor"></a>
## Support CrossDesk ## Support CrossDesk
+1 -1
View File
@@ -246,4 +246,4 @@ AV1 硬件路径尚无对应实机验证;Retroid 上使用 dav1d 软件解码
设置 → 关于使用与 iOS 相同的分组及入口:版本、软件许可、开源组件、源码与构建说明。软件许可中可分别阅读 GNU GPL 第 3 版及开源软件权利说明;开源组件页单独展示 OpenFEC 告知,其余组件列出名称和许可证,详情包含版本、源码、适用的构建配方和每一份许可文档。内容按 Android 构建生成,不显示未使用的 iOS 依赖,Android 签名说明替换 Apple 签名说明。 设置 → 关于使用与 iOS 相同的分组及入口:版本、软件许可、开源组件、源码与构建说明。软件许可中可分别阅读 GNU GPL 第 3 版及开源软件权利说明;开源组件页单独展示 OpenFEC 告知,其余组件列出名称和许可证,详情包含版本、源码、适用的构建配方和每一份许可文档。内容按 Android 构建生成,不显示未使用的 iOS 依赖,Android 签名说明替换 Apple 签名说明。
APK 保留完整 `THIRD_PARTY_NOTICES.txt`,并生成轻量的 `ThirdPartyLicenses.json` 索引、72 份独立许可文档及 `SourceMetadata.json`。打开文档时才读取正文,长文按有限长度分块显示,支持文本选择、复制与网页链接;打包验证核对每份正文的 SHA-256。源码页面显示标签或提交版本及未发布修改提示,源码链接指向基础提交。当前 Android 构建说明、构建脚本、MiniRTC、OpenSSL 及 SVT-AV1 配方随 APK 离线提供,避免开发版链接指向基础提交中尚不存在的 Android 文件。 APK 保留完整 `THIRD_PARTY_NOTICES.txt`,并生成轻量的 `ThirdPartyLicenses.json` 索引、72 份独立许可文档及 `SourceMetadata.json`。打开文档时才读取正文,长文按有限长度分块显示,支持文本选择、复制与网页链接。构建继续生成并打包许可资料,CI 和 APK 验证不再检查许可证内容、哈希或组件清单。源码页面显示标签或提交版本及未发布修改提示,源码链接指向基础提交。当前 Android 构建说明、构建脚本、MiniRTC、OpenSSL 及 SVT-AV1 配方随 APK 离线提供,避免开发版链接指向基础提交中尚不存在的 Android 文件。
+1 -3
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Package existing reviewed notices for the Android runtime dependency set.""" """Package existing reviewed notices for the Android runtime dependency set."""
import json import json
import hashlib
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
@@ -70,8 +69,7 @@ def write_pages(output, catalog, additional, revision, minirtc):
target = output / asset target = output / asset
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(document['text'], encoding='utf-8') target.write_text(document['text'], encoding='utf-8')
documents.append({'title': document['title'], 'asset': asset, documents.append({'title': document['title'], 'asset': asset})
'sha256': hashlib.sha256(document['text'].encode('utf-8')).hexdigest()})
component['documents'] = documents component['documents'] = documents
(output / 'ThirdPartyLicenses.json').write_text(json.dumps( (output / 'ThirdPartyLicenses.json').write_text(json.dumps(
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') {'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
+1 -20
View File
@@ -2,7 +2,6 @@
"""Check the shipped native ABI, ELF load alignment and APK zip alignment.""" """Check the shipped native ABI, ELF load alignment and APK zip alignment."""
from pathlib import Path from pathlib import Path
import argparse import argparse
import hashlib
import json import json
import re import re
import struct import struct
@@ -40,33 +39,15 @@ def verify(path):
name, extra = struct.unpack('<HH', raw.read(4)) name, extra = struct.unpack('<HH', raw.read(4))
if (library.header_offset + 30 + name + extra) % 16384: if (library.header_offset + 30 + name + extra) % 16384:
raise ValueError('APK native library entry is not 16 KB aligned') raise ValueError('APK native library entry is not 16 KB aligned')
if 'assets/THIRD_PARTY_NOTICES.txt' not in archive.namelist():
raise ValueError('Missing third-party notices')
if archive.read('assets/PRIVACY.md') != (Path(__file__).resolve().parents[3] / 'PRIVACY.md').read_bytes(): if archive.read('assets/PRIVACY.md') != (Path(__file__).resolve().parents[3] / 'PRIVACY.md').read_bytes():
raise ValueError('Missing or outdated bundled privacy policy') raise ValueError('Missing or outdated bundled privacy policy')
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.json'))
source = json.loads(archive.read('assets/SourceMetadata.json')) source = json.loads(archive.read('assets/SourceMetadata.json'))
components = catalog['components']
ids = [item['id'] for item in components]
if catalog['schemaVersion'] != 1 or len(ids) != len(set(ids)) or not {
'crossdesk', 'minirtc', 'openfec', 'openh264', 'libiconv', 'android-ndk', 'svt-av1'}.issubset(ids):
raise ValueError('Incomplete About catalog')
for component in components:
if not component['documents']:
raise ValueError('Missing component documents')
for document in component['documents']:
text = archive.read('assets/'+document['asset'])
if not text or hashlib.sha256(text).hexdigest() != document['sha256']:
raise ValueError('Incomplete license document: '+document['asset'])
for document in component.get('buildDocuments', []):
if not archive.read('assets/'+document['asset']):
raise ValueError('Missing build document')
if source['schemaVersion'] != 1 or len(source['revision']) != 40: if source['schemaVersion'] != 1 or len(source['revision']) != 40:
raise ValueError('Missing source revision') raise ValueError('Missing source revision')
for document in source['buildDocuments']: for document in source['buildDocuments']:
if not archive.read('assets/'+document['asset']): if not archive.read('assets/'+document['asset']):
raise ValueError('Missing source build instructions') raise ValueError('Missing source build instructions')
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, privacy policy, notices, About catalog and documents') print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, privacy policy and source metadata')
if __name__ == '__main__': if __name__ == '__main__':
+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); 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( const long screen_capture_method_value = ini_->GetLongValue(
section_, "screen_capture_method", section_, "screen_capture_method",
static_cast<long>(ScreenCaptureMethod::Auto)); static_cast<long>(ScreenCaptureMethod::Auto));
@@ -130,6 +137,8 @@ int ConfigCenter::Load() {
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_); enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
enable_privacy_screen_.store( enable_privacy_screen_.store(
ini_->GetBoolValue(section_, "enable_privacy_screen", true)); ini_->GetBoolValue(section_, "enable_privacy_screen", true));
lock_on_disconnect_.store(
ini_->GetBoolValue(section_, "lock_on_disconnect", true));
save_remote_previews_.store( save_remote_previews_.store(
ini_->GetBoolValue(section_, "save_remote_previews", true)); ini_->GetBoolValue(section_, "save_remote_previews", true));
portable_service_prompt_suppressed_ = portable_service_prompt_suppressed_ =
@@ -201,6 +210,9 @@ int ConfigCenter::StoreValues(
int ConfigCenter::Save() { int ConfigCenter::Save() {
ini_->SetLongValue(section_, "language", static_cast<long>(language_)); 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", ini_->SetLongValue(section_, "screen_capture_method",
static_cast<long>(screen_capture_method_)); static_cast<long>(screen_capture_method_));
ini_->SetLongValue(section_, "video_encode_format", ini_->SetLongValue(section_, "video_encode_format",
@@ -222,6 +234,7 @@ int ConfigCenter::Save() {
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_); ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
ini_->SetBoolValue(section_, "enable_privacy_screen", ini_->SetBoolValue(section_, "enable_privacy_screen",
enable_privacy_screen_.load()); enable_privacy_screen_.load());
ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
ini_->SetBoolValue(section_, "save_remote_previews", ini_->SetBoolValue(section_, "save_remote_previews",
save_remote_previews_.load()); save_remote_previews_.load());
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
@@ -249,6 +262,34 @@ int ConfigCenter::SetLanguage(LANGUAGE language) {
return 0; 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) { int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) { if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
return -1; return -1;
@@ -278,10 +319,7 @@ int ConfigCenter::SetTurnMode(TURN_MODE mode) {
return 0; return 0;
} }
int ConfigCenter::SetTurn(bool enable_turn, bool force_relay) { int ConfigCenter::SetForceRelay(bool force_relay) {
if (!enable_turn) {
return SetTurnMode(TURN_MODE::DISABLED);
}
if (!force_relay) { if (!force_relay) {
return SetTurnMode(TURN_MODE::AUTO_UDP_TCP); return SetTurnMode(TURN_MODE::AUTO_UDP_TCP);
} }
@@ -371,6 +409,17 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
return 0; 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) { int ConfigCenter::SetSaveRemotePreviews(bool enabled) {
if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) { if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) {
return -1; return -1;
@@ -409,6 +458,12 @@ int ConfigCenter::SetSponsorshipPromptDismissedVersion(
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; } 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 { ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
return screen_capture_method_; return screen_capture_method_;
} }
@@ -421,10 +476,6 @@ ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
return turn_mode_; return turn_mode_;
} }
bool ConfigCenter::IsEnableTurn() const {
return turn_mode_ != TURN_MODE::DISABLED;
}
bool ConfigCenter::IsForceRelay() const { bool ConfigCenter::IsForceRelay() const {
return turn_mode_ == TURN_MODE::FORCE_UDP || turn_mode_ == TURN_MODE::FORCE_TCP; 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 { class ConfigCenter {
public: public:
enum class LANGUAGE { CHINESE = 0, ENGLISH = 1, RUSSIAN = 2 }; 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 VIDEO_ENCODE_FORMAT { H264 = 0, AV1 = 1 };
enum class TURN_MODE { enum class TURN_MODE {
DISABLED = 0, DISABLED = 0,
@@ -35,10 +36,12 @@ class ConfigCenter {
// write config // write config
int SetLanguage(LANGUAGE language); int SetLanguage(LANGUAGE language);
int SetConnectionMode(CONNECTION_MODE mode);
int SetIpDirectEnabled(bool enabled);
int SetScreenCaptureMethod(ScreenCaptureMethod method); int SetScreenCaptureMethod(ScreenCaptureMethod method);
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format); int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
int SetTurnMode(TURN_MODE turn_mode); 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 SetServerHost(const std::string& signal_server_host);
int SetServerPort(int signal_server_port); int SetServerPort(int signal_server_port);
int SetServerEndpoint(const std::string& address, int port); int SetServerEndpoint(const std::string& address, int port);
@@ -46,6 +49,7 @@ class ConfigCenter {
int SetAutostart(bool enable_autostart); int SetAutostart(bool enable_autostart);
int SetDaemon(bool enable_daemon); int SetDaemon(bool enable_daemon);
int SetPrivacyScreen(bool enable_privacy_screen); int SetPrivacyScreen(bool enable_privacy_screen);
int SetLockOnDisconnect(bool enabled);
int SetSaveRemotePreviews(bool enabled); int SetSaveRemotePreviews(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed); int SetPortableServicePromptSuppressed(bool suppressed);
int SetSponsorshipPromptDismissedVersion(const std::string& version); int SetSponsorshipPromptDismissedVersion(const std::string& version);
@@ -54,10 +58,11 @@ class ConfigCenter {
// read config // read config
LANGUAGE GetLanguage() const; LANGUAGE GetLanguage() const;
CONNECTION_MODE GetConnectionMode() const;
bool IsIpDirectEnabled() const;
ScreenCaptureMethod GetScreenCaptureMethod() const; ScreenCaptureMethod GetScreenCaptureMethod() const;
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const; VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
TURN_MODE GetTurnMode() const; TURN_MODE GetTurnMode() const;
bool IsEnableTurn() const;
bool IsForceRelay() const; bool IsForceRelay() const;
bool IsEnableSrtp() const; bool IsEnableSrtp() const;
std::string GetSignalServerHost() const; std::string GetSignalServerHost() const;
@@ -68,6 +73,7 @@ class ConfigCenter {
bool IsEnableAutostart() const; bool IsEnableAutostart() const;
bool IsEnableDaemon() const; bool IsEnableDaemon() const;
bool IsEnablePrivacyScreen() const; bool IsEnablePrivacyScreen() const;
bool IsLockOnDisconnect() const;
bool IsSaveRemotePreviews() const; bool IsSaveRemotePreviews() const;
bool IsPortableServicePromptSuppressed() const; bool IsPortableServicePromptSuppressed() const;
std::string GetSponsorshipPromptDismissedVersion() const; std::string GetSponsorshipPromptDismissedVersion() const;
@@ -86,6 +92,8 @@ class ConfigCenter {
const char* section_ = "Settings"; const char* section_ = "Settings";
LANGUAGE language_ = LANGUAGE::CHINESE; LANGUAGE language_ = LANGUAGE::CHINESE;
CONNECTION_MODE connection_mode_ = CONNECTION_MODE::DEVICE_ID;
bool enable_ip_direct_ = true;
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto; ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264; VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP; TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
@@ -97,6 +105,7 @@ class ConfigCenter {
bool enable_autostart_ = false; bool enable_autostart_ = false;
bool enable_daemon_ = false; bool enable_daemon_ = false;
std::atomic<bool> enable_privacy_screen_{true}; std::atomic<bool> enable_privacy_screen_{true};
std::atomic<bool> lock_on_disconnect_{true};
std::atomic<bool> save_remote_previews_{true}; std::atomic<bool> save_remote_previews_{true};
bool portable_service_prompt_suppressed_ = false; bool portable_service_prompt_suppressed_ = false;
std::string sponsorship_prompt_dismissed_version_; std::string sponsorship_prompt_dismissed_version_;
@@ -42,6 +42,7 @@
#include "features/gallery/gallery_window_state.h" #include "features/gallery/gallery_window_state.h"
#include "features/gallery/gallery_workspace.h" #include "features/gallery/gallery_workspace.h"
#include "features/gallery/preview_bandwidth.h" #include "features/gallery/preview_bandwidth.h"
#include "features/gallery/preview_resolution.h"
#include "filesystem_utf8.h" #include "filesystem_utf8.h"
#include "localization.h" #include "localization.h"
#include "platform.h" #include "platform.h"
@@ -1034,6 +1035,9 @@ struct GuiApplication::SlintUi {
bool portable_service_dialog_initialized = false; bool portable_service_dialog_initialized = false;
bool sponsorship_prompt_pending = false; bool sponsorship_prompt_pending = false;
std::string recent_model_signature; 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::optional<std::string> recent_group_filter;
std::vector<std::string> recent_filter_groups; std::vector<std::string> recent_filter_groups;
slint::Timer timer; slint::Timer timer;
@@ -1162,6 +1166,7 @@ int GuiApplication::Run() {
updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath()); updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath());
#endif #endif
InitializeUi(); InitializeUi();
InitializeLan();
#ifndef __OHOS__ #ifndef __OHOS__
ui_->activation_receiver = std::move(activation_receiver); ui_->activation_receiver = std::move(activation_receiver);
#endif #endif
@@ -1296,6 +1301,10 @@ void GuiApplication::InitializeUi() {
slint::set_xdg_app_id("crossdesk"); slint::set_xdg_app_id("crossdesk");
#endif #endif
ui_ = std::make_unique<SlintUi>(); 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 #if _WIN32
ui_->shutdown_guard = ui_->shutdown_guard =
std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_); std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_);
@@ -1436,7 +1445,6 @@ void GuiApplication::InitializeSystemTray() {
}; };
auto exit_app = [this] { auto exit_app = [this] {
exit_ = true; exit_ = true;
slint::quit_event_loop();
}; };
#if _WIN32 #if _WIN32
ui_->tray = std::make_unique<WinTray>( ui_->tray = std::make_unique<WinTray>(
@@ -1484,7 +1492,8 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
auto& main = ui_->Window(gallery_window); auto& main = ui_->Window(gallery_window);
main->set_language_index(language_button_value_); main->set_language_index(language_button_value_);
main->set_codec_index(video_encode_format_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_force_relay_enabled(config_center_->IsForceRelay());
main->set_self_hosted_enabled(enable_self_hosted_); main->set_self_hosted_enabled(enable_self_hosted_);
main->set_autostart_enabled(enable_autostart_); 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_save_remote_previews(config_center_->IsSaveRemotePreviews());
main->set_privacy_on_connect_enabled( main->set_privacy_on_connect_enabled(
config_center_->IsEnablePrivacyScreen()); config_center_->IsEnablePrivacyScreen());
main->set_lock_on_disconnect_enabled(config_center_->IsLockOnDisconnect());
#ifdef _WIN32 #ifdef _WIN32
main->set_capture_method_visible(true); main->set_capture_method_visible(true);
main->set_capture_method_index( main->set_capture_method_index(
@@ -1502,8 +1512,10 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
#endif #endif
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__)) #if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
main->set_privacy_setting_available(true); main->set_privacy_setting_available(true);
main->set_lock_on_disconnect_available(true);
#else #else
main->set_privacy_setting_available(false); main->set_privacy_setting_available(false);
main->set_lock_on_disconnect_available(false);
#endif #endif
#if defined(__OHOS__) #if defined(__OHOS__)
// The sandbox default is not the system desktop. Show the actual receive // 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); SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta);
}); });
main->on_open_gallery([this] { ShowGalleryWindow(); }); 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( main->on_toggle_gallery_fullscreen(
[this] { ToggleGalleryFullscreen(); }); [this] { ToggleGalleryFullscreen(); });
main->on_gallery_action( main->on_gallery_action(
@@ -1678,8 +1703,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
show_password_ = !show_password_; show_password_ = !show_password_;
main->set_password_visible(show_password_); main->set_password_visible(show_password_);
}); });
main->on_reset_password([this](slint::SharedString value) { main->on_reset_password([this, main](slint::SharedString value) {
return RequestPasswordChange(std::string(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] { main->on_refresh_password([this] {
const auto password = GenerateRandomPassword(password_saved_); const auto password = GenerateRandomPassword(password_saved_);
@@ -1691,6 +1727,106 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
} }
RequestPasswordChange(*password); 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( main->on_connect_requested(
[this](slint::SharedString id) { ConnectFromUi(std::string(id)); }); [this](slint::SharedString id) { ConnectFromUi(std::string(id)); });
main->on_format_remote_id([](slint::SharedString value) { main->on_format_remote_id([](slint::SharedString value) {
@@ -1703,7 +1839,7 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
if (auto props = FindRemoteSession(remote_id); if (auto props = FindRemoteSession(remote_id);
props && props->connection_status_ == ConnectionStatus::Connected && props && props->connection_status_ == ConnectionStatus::Connected &&
!props->closing_) { !props->closing_) {
CloseStreamTab(remote_id); CloseStreamTab(props->remote_id_);
SyncMainWindow(); SyncMainWindow();
} }
}); });
@@ -2266,9 +2402,101 @@ void GuiApplication::BindServerCallbacks() {
void GuiApplication::Tick() { void GuiApplication::Tick() {
if (exit_) { if (exit_) {
slint::quit_event_loop(); BeginShutdown();
if (ShutdownDrained()) slint::quit_event_loop();
return; 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 _WIN32
if (ui_->shutdown_guard) { if (ui_->shutdown_guard) {
ui_->shutdown_guard->Update(ui_->shutdown_reason); ui_->shutdown_guard->Update(ui_->shutdown_reason);
@@ -2356,11 +2584,13 @@ void GuiApplication::Tick() {
HandleRecentConnections(); HandleRecentConnections();
if (was_reloading_recent_connections && !reload_recent_connections_) { if (was_reloading_recent_connections && !reload_recent_connections_) {
ui_->recent_model_signature.clear(); ui_->recent_model_signature.clear();
ui_->recent_preview_images.clear();
} }
HandleConnectionStatusChange(); HandleConnectionStatusChange();
HandlePendingPresenceProbe(); HandlePendingPresenceProbe();
HandlePresenceProbeTimeout(); HandlePresenceProbeTimeout();
HandlePrivacy(); HandlePrivacy();
HandleScreenLock();
HandleWindowsServiceIntegration(); HandleWindowsServiceIntegration();
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
SyncXWaylandWindowActivation(); 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(); cursor_delivery_states_.clear();
return; return;
} }
@@ -2587,9 +2817,8 @@ void GuiApplication::ShareLocalCursorState() {
action.type = ControlType::cursor_state; action.type = ControlType::cursor_state;
action.cs = outgoing; action.cs = outgoing;
const std::string message = action.to_json(); const std::string message = action.to_json();
const int result = SendReliableDataFrameToPeer( const int result = SendControllerData(recipient.remote_id, message.c_str(),
peer_, message.c_str(), message.size(), mouse_label_.c_str(), message.size(), mouse_label_.c_str());
recipient.remote_id.c_str(), recipient.remote_id.size());
if (result != 0) { if (result != 0) {
LOG_WARN("Send cursor state to [{}] failed, ret={}", LOG_WARN("Send cursor state to [{}] failed, ret={}",
recipient.remote_id, result); recipient.remote_id, result);
@@ -3008,8 +3237,9 @@ void GuiApplication::SyncMainWindow() {
std::vector<slint::SharedString> recent_groups{ std::vector<slint::SharedString> recent_groups{
tr_group("全部设备", "All devices", "Все устройства"), tr_group("全部设备", "All devices", "Все устройства"),
tr_group("未分组", "Ungrouped", "Без группы")}; tr_group("未分组", "Ungrouped", "Без группы")};
const bool lan_mode = ui_->main->get_lan_mode();
int recent_group_index = 0; 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; if (ui_->recent_group_filter->empty()) recent_group_index = 1;
else { else {
const auto found = std::find(ui_->gallery.groups.begin(), const auto found = std::find(ui_->gallery.groups.begin(),
@@ -3046,12 +3276,26 @@ void GuiApplication::SyncMainWindow() {
return props && props->connection_status_ == ConnectionStatus::Connected && return props && props->connection_status_ == ConnectionStatus::Connected &&
!props->closing_; !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; 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' << bool(ui_->recent_group_filter) << '\0'
<< ui_->recent_group_filter.value_or("") << '\n'; << ui_->recent_group_filter.value_or("") << '\n';
for (const auto& [key, connection] : recent_connections_) { for (const auto& [key, connection] : recent_connections_) {
signature_builder << key << '\0' << connection.remote_id << '\0' signature_builder << key << '\0' << connection.remote_id << '\0'
<< connection.lan.address << ':' << connection.lan.port << '\0'
<< connection.remote_host_name << '\0' << connection.remote_host_name << '\0'
<< static_cast<int>(connection.platform) << '\0' << static_cast<int>(connection.platform) << '\0'
<< settings_.RecentConnectionDisplayName(connection) << settings_.RecentConnectionDisplayName(connection)
@@ -3072,7 +3316,9 @@ void GuiApplication::SyncMainWindow() {
std::vector<ui::RecentConnection> model; std::vector<ui::RecentConnection> model;
model.reserve(recent_connections_.size()); model.reserve(recent_connections_.size());
for (const auto& [_, connection] : recent_connections_) { 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; continue;
// Slint-generated scalar fields have no defaults. With no image, // Slint-generated scalar fields have no defaults. With no image,
// has_thumbnail must remain false so the remote platform is rendered. // 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.group_names = UiText(group_labels[connection.remote_id]);
item.display_name = item.display_name =
UiText(settings_.RecentConnectionDisplayName(connection)); 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.connected = is_connected(connection.remote_id);
item.platform = UiText(HostPlatformName(connection.platform)); item.platform = UiText(HostPlatformName(connection.platform));
std::vector<unsigned char> rgba; if (save_previews && !connection.image_path.empty()) {
int image_width = 0; const auto path = connection.image_path.string();
int image_height = 0; auto cached = ui_->recent_preview_images.find(path);
if (config_center_->IsSaveRemotePreviews() && if (cached == ui_->recent_preview_images.end()) {
!connection.image_path.empty() && std::vector<unsigned char> rgba;
thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width, int image_width = 0, image_height = 0;
&image_height)) { if (thumbnail_->DecodeImage(connection.image_path, preview_size.width,
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width, preview_size.height, &rgba, &image_width,
image_height); &image_height)) {
std::memcpy(pixels.begin(), rgba.data(), rgba.size()); slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
item.thumbnail = slint::Image(std::move(pixels)); image_height);
item.has_thumbnail = true; 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)); model.push_back(std::move(item));
} }
@@ -3163,7 +3422,9 @@ void GuiApplication::SyncConnectionDialog() {
} else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) { } else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) {
text = localization::connection_not_authenticated[localization_language_index_]; text = localization::connection_not_authenticated[localization_language_index_];
} else { } 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) { if (auth.retry_after > 0) {
char retry_text[256]; char retry_text[256];
@@ -4260,6 +4521,7 @@ bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) {
bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
const auto previous_server = GetSignalServerSettings(*config_center_); const auto previous_server = GetSignalServerSettings(*config_center_);
auto& main = ui_->Window(gallery_window); auto& main = ui_->Window(gallery_window);
main->set_settings_save_error("");
const bool has_endpoint = main->get_self_hosted_enabled() || const bool has_endpoint = main->get_self_hosted_enabled() ||
!std::string(main->get_server_host()).empty() || !std::string(main->get_server_host()).empty() ||
!std::string(main->get_server_port()).empty(); !std::string(main->get_server_port()).empty();
@@ -4282,7 +4544,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
if (!wall_settings || (wall_changed && !updated_wall.Save(ui_->gallery_path))) { if (!wall_settings || (wall_changed && !updated_wall.Save(ui_->gallery_path))) {
main->set_gallery_settings_error(UiText( main->set_gallery_settings_error(UiText(
localization_language_index_ == 0 ? "保存失败,请重试" localization_language_index_ == 0 ? "保存失败,请重试"
: localization_language_index_ == 2 ? "Не удалось сохранить" : localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
: "Could not save; try again")); : "Could not save; try again"));
main->set_settings_tab(1); main->set_settings_tab(1);
return false; return false;
@@ -4298,10 +4560,29 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
main->set_self_host_settings_open(true); main->set_self_host_settings_open(true);
return false; 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 IP connection 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-on-disconnect settings. Try again."));
return false;
}
#endif
language_button_value_ = language_button_value_ =
localization::detail::ClampLanguageIndex(main->get_language_index()); localization::detail::ClampLanguageIndex(main->get_language_index());
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1); 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(); enable_self_hosted_ = main->get_self_hosted_enabled();
#if !defined(__OHOS__) #if !defined(__OHOS__)
enable_autostart_ = main->get_autostart_enabled(); enable_autostart_ = main->get_autostart_enabled();
@@ -4317,7 +4598,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
config_center_->SetVideoEncodeFormat( config_center_->SetVideoEncodeFormat(
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>( static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
video_encode_format_button_value_)); 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()); main->set_force_relay_enabled(config_center_->IsForceRelay());
#if !defined(__OHOS__) #if !defined(__OHOS__)
config_center_->SetAutostart(enable_autostart_); 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_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
localization_language_ = config_center_->GetLanguage(); localization_language_ = config_center_->GetLanguage();
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat()); video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
enable_turn_ = config_center_->IsEnableTurn();
enable_self_hosted_ = config_center_->IsSelfHosted(); enable_self_hosted_ = config_center_->IsSelfHosted();
enable_autostart_ = config_center_->IsEnableAutostart(); enable_autostart_ = config_center_->IsEnableAutostart();
enable_daemon_ = config_center_->IsEnableDaemon(); 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()); std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
language_button_value_last_ = language_button_value_; language_button_value_last_ = language_button_value_;
video_encode_format_button_value_last_ = video_encode_format_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_self_hosted_last_ = enable_self_hosted_;
enable_autostart_last_ = enable_autostart_; enable_autostart_last_ = enable_autostart_;
enable_daemon_last_ = enable_daemon_; enable_daemon_last_ = enable_daemon_;
@@ -4407,9 +4686,12 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
#endif #endif
if (ui_) { if (ui_) {
ui_->localized_language = -1; 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_->main->get_settings_open()) ResetSettingsUi();
if (!ui_->wall->get_settings_open()) ResetSettingsUi(true); if (!ui_->wall->get_settings_open()) ResetSettingsUi(true);
} }
ApplyLanSettings();
if (reconnect && !HasActiveSession()) { if (reconnect && !HasActiveSession()) {
// Tick recreates the peer after asynchronous cleanup, using the committed // Tick recreates the peer after asynchronous cleanup, using the committed
// configuration. Keep the application and its console running throughout. // 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) { void GuiApplication::ConnectFromUi(const std::string& input) {
ui_->gallery_auth_id.clear(); 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); const std::string remote_id = CompactPeerId(input);
if (!gallery::Workspace::ValidId(remote_id)) { if (!gallery::Workspace::ValidId(remote_id)) {
ui_->main->set_connection_input_error( ui_->main->set_connection_input_error(
@@ -5009,8 +5311,8 @@ bool GuiApplication::AddGalleryDevice(const std::string& input,
password.find_first_not_of( password.find_first_not_of(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") != "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") !=
std::string::npos) std::string::npos)
return error("请输入不超过 6 位的字母或数字密码", return error("请输入 1–6 位英文字母或数字密码",
"Use a password of 1–6 letters or digits", "Use 1–6 letters (A–Z, a–z) or digits (0–9)",
"Пароль: 1–6 латинских букв или цифр"); "Пароль: 1–6 латинских букв или цифр");
if (!signal_connected_) if (!signal_connected_)
return error("尚未连接服务器,请稍后重试", return error("尚未连接服务器,请稍后重试",
@@ -5028,7 +5330,7 @@ bool GuiApplication::AddGalleryDevice(const std::string& input,
updated.Add(id, Trim(group)); updated.Add(id, Trim(group));
if (!updated.Save(ui_->gallery_path)) if (!updated.Save(ui_->gallery_path))
return error("保存失败,请重试", "Could not save; try again", return error("保存失败,请重试", "Could not save; try again",
"Не удалось сохранить"); "Не удалось сохранить. Повторите попытку");
ui_->gallery = std::move(updated); ui_->gallery = std::move(updated);
ui_->gallery_hidden_active_ids.erase(id); ui_->gallery_hidden_active_ids.erase(id);
if (auto props = FindRemoteSession(id)) { if (auto props = FindRemoteSession(id)) {
@@ -5071,7 +5373,7 @@ bool GuiApplication::ImportGalleryRecent(const std::string& input_group) {
if (!updated.Save(ui_->gallery_path)) { if (!updated.Save(ui_->gallery_path)) {
ui_->wall->set_gallery_notice(UiText( ui_->wall->set_gallery_notice(UiText(
localization_language_index_ == 0 ? "保存失败,请重试" localization_language_index_ == 0 ? "保存失败,请重试"
: localization_language_index_ == 2 ? "Не удалось сохранить" : localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
: "Could not save; try again")); : "Could not save; try again"));
return false; return false;
} }
@@ -5153,7 +5455,7 @@ bool GuiApplication::SaveDeviceGroups(const std::string& new_group,
if (!updated.SetGroups(id, names) || !updated.Save(ui_->gallery_path)) { if (!updated.SetGroups(id, names) || !updated.Save(ui_->gallery_path)) {
main->set_device_group_error(UiText( main->set_device_group_error(UiText(
localization_language_index_ == 0 ? "保存失败,请重试" localization_language_index_ == 0 ? "保存失败,请重试"
: localization_language_index_ == 2 ? "Не удалось сохранить" : localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
: "Could not save; try again")); : "Could not save; try again"));
return false; return false;
} }
@@ -5209,6 +5511,7 @@ void GuiApplication::GalleryAction(const std::string& action,
std::set<std::string> seen; std::set<std::string> seen;
int available = 0; int available = 0;
for (const auto& [_, recent] : recent_connections_) { for (const auto& [_, recent] : recent_connections_) {
if (!recent.lan.device_id.empty()) continue;
if (!gallery::Workspace::ValidId(recent.remote_id) || if (!gallery::Workspace::ValidId(recent.remote_id) ||
!seen.insert(recent.remote_id).second) !seen.insert(recent.remote_id).second)
continue; continue;
@@ -5412,7 +5715,7 @@ void GuiApplication::GalleryAction(const std::string& action,
SyncGallery(); SyncGallery();
}; };
if (action == "file") { if (action == "file") {
OpenFileDialog(std::string(tr("选择分发文件", "Select a file", "Выберите файл")), OpenFileDialog(std::string(tr("选择分发文件", "Select a file to send", "Выберите файл для отправки")),
[enqueue](std::string file) { [enqueue](std::string file) {
if (!file.empty()) enqueue(file); if (!file.empty()) enqueue(file);
}); });
@@ -5697,8 +6000,9 @@ void GuiApplication::SyncGallery() {
}; };
const auto name = [this](const std::string& id) { const auto name = [this](const std::string& id) {
for (const auto& [_, recent] : recent_connections_) for (const auto& [_, recent] : recent_connections_)
if (recent.remote_id == id) if (recent.lan.device_id.empty())
return settings_.RecentConnectionDisplayName(recent); if (recent.remote_id == id)
return settings_.RecentConnectionDisplayName(recent);
Thumbnail::RecentConnection device{}; Thumbnail::RecentConnection device{};
device.remote_id = id; device.remote_id = id;
if (auto props = FindRemoteSession(id)) { if (auto props = FindRemoteSession(id)) {
@@ -5716,7 +6020,7 @@ void GuiApplication::SyncGallery() {
{ {
std::shared_lock lock(remote_sessions_mutex_); std::shared_lock lock(remote_sessions_mutex_);
for (const auto& [id, props] : remote_sessions_) for (const auto& [id, props] : remote_sessions_)
if (props && !props->closing_ && if (props && !props->lan_session_ && !props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected) props->connection_status_ == ConnectionStatus::Connected)
connected_ids.push_back(id); connected_ids.push_back(id);
} }
@@ -5732,9 +6036,9 @@ void GuiApplication::SyncGallery() {
device.id = UiText(FormatPeerId(id.c_str())); device.id = UiText(FormatPeerId(id.c_str()));
const auto mode = props->traversal_mode_.load(); const auto mode = props->traversal_mode_.load();
device.mode = UiText(mode == TraversalMode::P2P device.mode = UiText(mode == TraversalMode::P2P
? tr("直连", "Direct", "Напрямую") ? tr("直连", "Direct", "Прямой")
: mode == TraversalMode::UnknownMode : mode == TraversalMode::UnknownMode
? tr("等待统计", "Awaiting stats", "Ждём данные") ? tr("等待统计", "Awaiting stats", "Ожидание статистики")
: tr("中继", "Relay", "Ретрансляция")); : tr("中继", "Relay", "Ретрансляция"));
device.inbound = device.outbound = device.loss = "—"; device.inbound = device.outbound = device.loss = "—";
device.video_latency = "—"; device.video_latency = "—";
@@ -5816,11 +6120,12 @@ void GuiApplication::SyncGallery() {
bool remember = operation.remember_password; bool remember = operation.remember_password;
if (!operation.password) if (!operation.password)
for (const auto& [_, recent] : recent_connections_) for (const auto& [_, recent] : recent_connections_)
if (recent.remote_id == operation.id) { if (recent.lan.device_id.empty())
password = recent.password; if (recent.remote_id == operation.id) {
remember = recent.remember_password; password = recent.password;
break; remember = recent.remember_password;
} break;
}
// Join itself determines availability. Background probes must never // Join itself determines availability. Background probes must never
// overwrite the interactive page's single pending presence probe. // overwrite the interactive page's single pending presence probe.
ConnectTo(operation.id, password.c_str(), remember, true, true); ConnectTo(operation.id, password.c_str(), remember, true, true);
@@ -5970,6 +6275,7 @@ void GuiApplication::SyncGallery() {
tile.selected = wall.selected.count(id); tile.selected = wall.selected.count(id);
auto props = FindRemoteSession(id); auto props = FindRemoteSession(id);
for (const auto& [_, recent] : recent_connections_) { for (const auto& [_, recent] : recent_connections_) {
if (!recent.lan.device_id.empty()) continue;
if (recent.remote_id != id) continue; if (recent.remote_id != id) continue;
tile.host_name = UiText(recent.remote_host_name); tile.host_name = UiText(recent.remote_host_name);
break; break;
@@ -5995,7 +6301,7 @@ void GuiApplication::SyncGallery() {
}); });
// A queued retry takes precedence over errors from the previous session. // A queued retry takes precedence over errors from the previous session.
if (queued) if (queued)
tile.status = UiText(tr("排队中", "Queued", "В очереди")); tile.status = UiText(tr("队列中", "Queued", "В очереди"));
else if (props && props->connection_auth_.Get().password_required) else if (props && props->connection_auth_.Get().password_required)
tile.status = UiText(tr("需要密码", "Password required", "Нужен пароль")); tile.status = UiText(tr("需要密码", "Password required", "Нужен пароль"));
else if (props && !tile.connected) else if (props && !tile.connected)
@@ -6205,6 +6511,9 @@ void GuiApplication::SyncGallery() {
} }
void GuiApplication::Cleanup() { void GuiApplication::Cleanup() {
BeginShutdown();
while (!ShutdownDrained())
std::this_thread::sleep_for(std::chrono::milliseconds(2));
remote_input_.Stop(); remote_input_.Stop();
#if _WIN32 #if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed); windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
@@ -6245,6 +6554,7 @@ void GuiApplication::Cleanup() {
keyboard_.ReleaseAllRemotePressedKeys("application_exit"); keyboard_.ReleaseAllRemotePressedKeys("application_exit");
devices_.DestroyDevices(); devices_.DestroyDevices();
privacy_.Shutdown(); privacy_.Shutdown();
HandleScreenLock();
devices_.DestroyFactories(); devices_.DestroyFactories();
CloseAllRemoteSessions(); CloseAllRemoteSessions();
WaitForSessionCleanup(); WaitForSessionCleanup();
@@ -21,23 +21,30 @@ struct TranslationRow {
// Single source of truth for all UI strings. // Single source of truth for all UI strings.
#define CROSSDESK_LOCALIZATION_ALL(X) \ #define CROSSDESK_LOCALIZATION_ALL(X) \
X(window_minimize, u8"最小化", "Minimize", u8"Свернуть") \
X(window_maximize, u8"最大化", "Maximize", u8"Развернуть") \
X(window_restore, u8"还原窗口", "Restore window", u8"Восстановить окно") \
X(window_close, u8"关闭窗口", "Close window", u8"Закрыть окно") \
X(save, u8"保存", "Save", u8"Сохранить") \
X(copyright_notice, u8"© 2026 JUNKUN DI。保留所有权利。", "© 2026 by JUNKUN DI. All rights reserved.", u8"© 2026 JUNKUN DI. Все права защищены.") \
X(license_notice, u8"依据 GNU GPL v3 许可发布。", "Licensed under GNU GPL v3.", u8"Распространяется по лицензии GNU GPL v3.") \
X(messages, u8"消息", "Messages", u8"Сообщения") \ X(messages, u8"消息", "Messages", u8"Сообщения") \
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \ X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
X(message_read, u8"已读", "Read", u8"Прочитано") \ X(message_read, u8"已读", "Read", u8"Прочитано") \
X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \ X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \ X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \ X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление для просмотра") \
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \ X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения. Повторите попытку.") \ X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения локально. Повторите попытку.") \
X(message_close, u8"关闭", "Close", u8"Закрыть") \ X(message_close, u8"关闭", "Close", u8"Закрыть") \
X(message_delete, u8"删除", "Delete", u8"Удалить") \ X(message_delete, u8"删除", "Delete", u8"Удалить") \
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \ X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
X(message_delete_confirmation, u8"删除这条公告?", "Delete this announcement?", u8"Удалить это объявление?") \ X(message_delete_confirmation, u8"删除这条公告?", "Delete this announcement?", u8"Удалить это объявление?") \
X(message_delete_error, u8"无法删除这条公告,请重试。", "Unable to delete this announcement. Please try again.", u8"Не удалось удалить объявление. Повторите попытку.") \ X(message_delete_error, u8"无法删除这条公告,请重试。", "Unable to delete this announcement. Please try again.", u8"Не удалось удалить объявление. Повторите попытку.") \
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_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_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk без графического интерфейса") \
X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \ 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 — show ID/password/status; settings — configure; password — change password; random — random password; help — help; quit — exit", u8"Команды: status — ID/пароль/состояние; settings — настройки; password — сменить пароль; random — случайный пароль; help — справка; quit — выход") \
X(console_status, u8"状态", "Status", u8"Состояние") \ X(console_status, u8"状态", "Status", u8"Состояние") \
X(console_online, u8"在线", "Online", u8"В сети") \ X(console_online, u8"在线", "Online", u8"В сети") \
X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \ X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
@@ -77,7 +84,7 @@ struct TranslationRow {
X(console_settings_next_connection, u8"已保存,将用于后续连接。", "Saved; applies to subsequent connections.", u8"Сохранено. Будет применено к следующим подключениям.") \ X(console_settings_next_connection, u8"已保存,将用于后续连接。", "Saved; applies to subsequent connections.", u8"Сохранено. Будет применено к следующим подключениям.") \
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \ X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \
X(console_settings_autostart, u8"已保存,在下次图形桌面登录时生效。", "Saved; applies at the next graphical desktop login.", u8"Сохранено. Будет применено при следующем входе в графический сеанс.") \ X(console_settings_autostart, u8"已保存,在下次图形桌面登录时生效。", "Saved; applies at the next graphical desktop login.", u8"Сохранено. Будет применено при следующем входе в графический сеанс.") \
X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль остаётся на переднем плане.") \ X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль без графического интерфейса остаётся на переднем плане.") \
X(console_settings_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", u8"Укажите адрес и порт сервера перед включением собственного сервера.") \ X(console_settings_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", u8"Укажите адрес и порт сервера перед включением собственного сервера.") \
X(console_settings_path_invalid, u8"请选择已存在且可写的绝对目录路径;default 使用默认目录。", "Use an existing writable absolute directory, or default for the default location.", u8"Укажите существующий доступный для записи абсолютный путь к каталогу или default.") \ X(console_settings_path_invalid, u8"请选择已存在且可写的绝对目录路径;default 使用默认目录。", "Use an existing writable absolute directory, or default for the default location.", u8"Укажите существующий доступный для записи абсолютный путь к каталогу или default.") \
X(console_settings_readonly, u8"该选项由系统或控制端管理,不可在此修改。", "This option is managed by the system or controller and is read-only here.", u8"Этот параметр управляется системой или клиентом и здесь недоступен для изменения.") \ X(console_settings_readonly, u8"该选项由系统或控制端管理,不可在此修改。", "This option is managed by the system or controller and is read-only here.", u8"Этот параметр управляется системой или клиентом и здесь недоступен для изменения.") \
@@ -91,7 +98,7 @@ struct TranslationRow {
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \ X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \ X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \ X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \
X(save_remote_previews, u8"保存远程画面预览:", "Save remote screen previews:", u8"Сохранять превью экрана:") \ X(save_remote_previews, u8"保存远程画面预览:", "Save remote screen previews:", u8"Сохранять превью удалённого экрана:") \
X(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \ X(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \ X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \ X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
@@ -100,17 +107,17 @@ struct TranslationRow {
X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \ X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \ X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \
X(local_desktop, u8"本桌面", "Local Desktop", 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", \ X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
u8"Скопировано в буфер обмена") \ u8"Скопировано в буфер обмена") \
X(password, u8"密码", "Password", u8"Пароль") \ X(password, u8"密码", "Password", u8"Пароль") \
X(refresh_password, u8"刷新随机密码", "Refresh random password", \ X(refresh_password, u8"刷新随机密码", "Refresh random password", \
u8"Обновить случайный пароль") \ u8"Обновить случайный пароль") \
X(max_password_len, u8"最大6个字符", "Max 6 chars", u8"Макс. 6 символов") \ X(password_format_invalid, u8"密码必须为 6 位英文字母或数字。", "Use exactly 6 letters (A–Z, a–z) or digits (0–9).", u8"Пароль должен содержать ровно 6 латинских букв или цифр.") \
X(remote_desktop, u8"远程桌面", "Remote Desktop", \ X(remote_desktop, u8"远程桌面", "Remote Desktop", \
u8"Удаленный рабочий стол") \ u8"Удаленный рабочий стол") \
X(device_name, u8"设备名称", "Device Name", 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(connect, u8"连接", "Connect", u8"Подключиться") \
X(recent_connections, u8"近期连接", "Recent Connections", \ X(recent_connections, u8"近期连接", "Recent Connections", \
u8"Недавние подключения") \ u8"Недавние подключения") \
@@ -126,11 +133,9 @@ struct TranslationRow {
u8"Развернуть панель управления") \ u8"Развернуть панель управления") \
X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \ X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \
u8"Свернуть панель управления") \ u8"Свернуть панель управления") \
X(fullscreen, u8"全屏", " Fullscreen", u8"Полный экран") \ X(fullscreen, u8"全屏", "Fullscreen", u8"На весь экран") \
X(show_net_traffic_stats, u8"显示网络状态", "Show Net Traffic Stats", \ X(show_net_traffic_stats, u8"显示网络状态", "Show network status", u8"Показать состояние сети") \
u8"Показать статистику трафика") \ X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide network status", u8"Скрыть состояние сети") \
X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide Net Traffic Stats", \
u8"Скрыть статистику трафика") \
X(video, u8"视频", "Video", u8"Видео") \ X(video, u8"视频", "Video", u8"Видео") \
X(audio, u8"音频", "Audio", u8"Аудио") \ X(audio, u8"音频", "Audio", u8"Аудио") \
X(data, u8"数据", "Data", u8"Данные") \ X(data, u8"数据", "Data", u8"Данные") \
@@ -142,19 +147,15 @@ struct TranslationRow {
u8"Выйти из полноэкранного режима") \ u8"Выйти из полноэкранного режима") \
X(control_mouse, u8"控制鼠标", "Control Mouse", u8"Управление мышью") \ X(control_mouse, u8"控制鼠标", "Control Mouse", u8"Управление мышью") \
X(release_mouse, u8"释放鼠标", "Release Mouse", u8"Освободить мышь") \ X(release_mouse, u8"释放鼠标", "Release Mouse", u8"Освободить мышь") \
X(audio_capture, u8"播放声音", "Audio Capture", u8"Воспроизведение звука") \ X(audio_capture, u8"播放声音", "Play audio", u8"Воспроизведение звука") \
X(mute, u8" 静音", " Mute", u8"Без звука") \ X(mute, u8"静音", "Mute", u8"Без звука") \
X(send_shortcut, u8"发送组合键", "Send Shortcut", 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(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать удалённое устройство") \
X(remote_password_box_visible, u8"远端密码框已出现", \ X(remote_password_box_visible, u8"远端密码框已出现", "Remote password box visible", u8"На удалённом устройстве появилось поле пароля") \
"Remote password box visible", u8"Окно ввода пароля видно") \ X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送 SAS", "Remote lock screen visible, send SAS", u8"Удалённый экран заблокирован, можно отправить SAS") \
X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送SAS", \ X(remote_secure_desktop_active, u8"远端已进入安全桌面", "Remote secure desktop active", u8"На удалённом устройстве активен защищённый рабочий стол") \
"Remote lock screen visible, send SAS", \ X(remote_service_unavailable, u8"远端 Windows 服务不可用", \
u8"Видна блокировка, отправьте SAS") \
X(remote_secure_desktop_active, u8"远端已进入安全桌面", \
"Remote secure desktop active", u8"Активен защищенный рабочий стол") \
X(remote_service_unavailable, u8"远端Windows服务不可用", \
"Remote Windows service unavailable", \ "Remote Windows service unavailable", \
u8"Служба Windows на удаленной стороне недоступна") \ u8"Служба Windows на удаленной стороне недоступна") \
X(windows_service_setup_title, u8"安装 CrossDesk Service", \ X(windows_service_setup_title, u8"安装 CrossDesk Service", \
@@ -173,8 +174,7 @@ struct TranslationRow {
u8"нормально; будет недоступно только управление этим компьютером на " \ u8"нормально; будет недоступно только управление этим компьютером на " \
u8"экране блокировки.") \ u8"экране блокировки.") \
X(install_windows_service, u8"安装", "Install", u8"Установить") \ X(install_windows_service, u8"安装", "Install", u8"Установить") \
X(windows_service_settings_label, u8"锁屏控制服务:", \ X(windows_service_settings_label, u8"锁屏控制服务:", "Lock-screen control service:", u8"Служба управления при блокировке:") \
"Lock Screen Service:", u8"Служба блокировки экрана:") \
X(windows_service_installed, u8"已安装", "Installed", u8"Установлена") \ X(windows_service_installed, u8"已安装", "Installed", u8"Установлена") \
X(do_not_remind_again, u8"不再提醒", "Do not remind again", \ X(do_not_remind_again, u8"不再提醒", "Do not remind again", \
u8"Больше не напоминать") \ u8"Больше не напоминать") \
@@ -210,13 +210,8 @@ struct TranslationRow {
u8"Способ захвата:") \ u8"Способ захвата:") \
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \ X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \ X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
X(remote_update_title, u8"建议升级被控端", "Remote update recommended", \ X(remote_update_title, u8"建议升级被控端", "Remote update recommended", u8"Рекомендуем обновить удалённое устройство") \
u8"Рекомендуется обновление") \ X(remote_update, u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", "Update the remote device to the latest version for improved features and performance.", u8"В новой версии улучшены функции и производительность. Рекомендуем обновить CrossDesk на удалённом устройстве до последней версии.") \
X(remote_update, \
u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", \
"Update the remote device to the latest version for improved features and performance.", \
u8"В новой версии улучшены функции и производительность. " \
u8"Рекомендуем обновить CrossDesk на удалённом устройстве.") \
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \ X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \ X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \ X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \
@@ -233,46 +228,34 @@ struct TranslationRow {
X(video_quality_high, u8"高", "High", u8"Высокое") \ X(video_quality_high, u8"高", "High", u8"Высокое") \
X(video_quality_medium, u8"中", "Medium", u8"Среднее") \ X(video_quality_medium, u8"中", "Medium", u8"Среднее") \
X(video_quality_low, u8"低", "Low", u8"Низкое") \ X(video_quality_low, u8"低", "Low", u8"Низкое") \
X(video_encode_format, u8"视频编码格式:", \ X(video_encode_format, u8"视频编码格式:", "Video codec:", u8"Видеокодек:") \
"Video Encode Format:", u8"Формат кодека видео:") \
X(av1, u8"AV1", "AV1", "AV1") \ X(av1, u8"AV1", "AV1", "AV1") \
X(h264, u8"H.264", "H.264", "H.264") \ X(h264, u8"H.264", "H.264", "H.264") \
X(enable_turn, u8"启用中继服务:", \ X(force_relay, u8"强制中继连接:", "Force relay connection:", u8"Только через ретранслятор:") \
"Enable TURN Service:", u8"Включить TURN-сервис:") \ X(self_hosted_server_config, u8"自托管配置", "Self-hosted server", u8"Собственный сервер") \
X(force_relay, u8"强制中继连接:", "Force Relay:", u8"Только через реле:") \ X(self_hosted_server_settings, u8"自托管设置", "Self-hosted server settings", u8"Настройки своего сервера") \
X(self_hosted_server_config, u8"自托管配置", "Self-Hosted Config", \
u8"Конфигурация self-hosted") \
X(self_hosted_server_settings, u8"自托管设置", "Self-Hosted Settings", \
u8"Настройки self-hosted") \
X(self_hosted_server_address, u8"服务器地址:", \ X(self_hosted_server_address, u8"服务器地址:", \
"Server Address:", u8"Адрес сервера:") \ "Server Address:", u8"Адрес сервера:") \
X(self_hosted_server_port, u8"信令服务端口:", \ X(self_hosted_server_port, u8"信令服务端口:", "Signaling port:", u8"Порт сигнализации:") \
"Signal Service Port:", u8"Порт сигнального сервиса:") \
X(self_hosted_address_invalid, u8"请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。", "Enter a valid host, IP, or HTTPS/WSS signaling URL. Paths are supported.", u8"Введите имя хоста, IP или HTTPS/WSS-адрес сигнального сервиса. Путь допустим.") \ X(self_hosted_address_invalid, u8"请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。", "Enter a valid host, IP, or HTTPS/WSS signaling URL. Paths are supported.", u8"Введите имя хоста, IP или HTTPS/WSS-адрес сигнального сервиса. Путь допустим.") \
X(self_hosted_port_invalid, u8"请输入 1–65535 范围内的信令端口。", "Enter a signaling port between 1 and 65535.", u8"Введите порт сигнального сервиса от 1 до 65535.") \ X(self_hosted_port_invalid, u8"请输入 1–65535 范围内的信令端口。", "Enter a signaling port between 1 and 65535.", u8"Введите порт сигнального сервиса от 1 до 65535.") \
X(self_hosted_save_failed, u8"配置保存失败,请检查配置目录是否可写后重试。", "Could not save settings. Check that the configuration directory is writable and retry.", u8"Не удалось сохранить настройки. Проверьте доступ на запись в каталог конфигурации.") \ X(self_hosted_save_failed, u8"配置保存失败,请检查配置目录是否可写后重试。", "Could not save settings. Check that the configuration directory is writable and retry.", u8"Не удалось сохранить настройки. Проверьте доступ на запись в каталог конфигурации и повторите попытку.") \
X(ok, u8"确认", "OK", u8"ОК") \ X(ok, u8"确认", "OK", u8"ОК") \
X(cancel, u8"取消", "Cancel", u8"Отмена") \ X(cancel, u8"取消", "Cancel", u8"Отмена") \
X(new_password, u8"请输入六位密码:", \ X(new_password, u8"请输入六位密码:", "Enter a six-character password:", u8"Введите пароль из 6 символов:") \
"Please input a six-char password:", u8"Введите шестизначный пароль:") \
X(input_password, u8"请输入密码:", \ X(input_password, u8"请输入密码:", \
"Please input password:", u8"Введите пароль:") \ "Please input password:", u8"Введите пароль:") \
X(validate_password, u8"验证密码中...", "Validate password ...", \ X(validate_password, u8"验证密码中...", "Validating password...", u8"Проверка пароля...") \
u8"Проверка пароля...") \
X(reinput_password, u8"请重新输入密码", "Please input password again", \ X(reinput_password, u8"请重新输入密码", "Please input password again", \
u8"Повторно введите пароль") \ u8"Повторно введите пароль") \
X(remember_password, u8"记住密码", "Remember password", \ X(remember_password, u8"记住密码", "Remember password", \
u8"Запомнить пароль") \ u8"Запомнить пароль") \
X(signal_connected, u8"已连接服务器", "Connected", u8"Подключено к серверу") \ X(signal_connected, u8"已连接服务器", "Connected to server", u8"Подключено к серверу") \
X(signal_disconnected, u8"未连接服务器", "Disconnected", \ X(signal_disconnected, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
u8"Нет подключения к серверу") \
X(signal_reconnect, u8"重新连接", "Reconnect", u8"Переподключиться") \ X(signal_reconnect, u8"重新连接", "Reconnect", u8"Переподключиться") \
X(signal_reconnecting, u8"正在重新连接...", "Reconnecting...", \ X(signal_reconnecting, u8"正在重新连接...", "Reconnecting...", \
u8"Переподключение...") \ u8"Переподключение...") \
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", \ X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", "Certificate verification failed. Reinstall the self-hosted root certificate.", u8"Ошибка проверки сертификата. Переустановите корневой сертификат собственного сервера.") \
"Certificate verification failed. Reinstall the self-hosted root " \
"certificate.", \
u8"Ошибка проверки сертификата. Переустановите корневой сертификат.") \
X(p2p_connected, u8"对等连接已建立", "P2P Connected", u8"P2P подключено") \ X(p2p_connected, u8"对等连接已建立", "P2P Connected", u8"P2P подключено") \
X(p2p_disconnected, u8"对等连接已断开", "P2P Disconnected", \ X(p2p_disconnected, u8"对等连接已断开", "P2P Disconnected", \
u8"P2P отключено") \ u8"P2P отключено") \
@@ -283,12 +266,12 @@ struct TranslationRow {
X(receiving_screen, u8"画面接收中...", "Receiving screen...", \ X(receiving_screen, u8"画面接收中...", "Receiving screen...", \
u8"Получение изображения...") \ u8"Получение изображения...") \
X(p2p_failed, u8"对等连接失败", "P2P Failed", u8"Сбой P2P") \ X(p2p_failed, u8"对等连接失败", "P2P Failed", u8"Сбой P2P") \
X(connection_auth_throttled, u8"验证尝试过于频繁,请稍后重试。", "Too many authentication attempts. Try again later.", u8"Слишком много попыток. Повторите позже.") \ X(connection_auth_throttled, u8"验证尝试过于频繁,请稍后重试。", "Too many authentication attempts. Try again later.", u8"Слишком много попыток аутентификации. Повторите позже.") \
X(connection_service_busy, u8"认证服务繁忙,请稍后重试。", "Authentication service busy. Try again later.", u8"Сервис проверки занят. Повторите позже.") \ X(connection_service_busy, u8"认证服务繁忙,请稍后重试。", "Authentication service busy. Try again later.", u8"Сервис проверки занят. Повторите позже.") \
X(connection_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", u8"Сеанс входа истёк. Подключитесь заново.") \ X(connection_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", u8"Сеанс входа истёк. Подключитесь заново.") \
X(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \ X(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \ 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(about, u8"关于", "About", u8"О программе") \
X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \ X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \
X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \ X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
@@ -319,26 +302,24 @@ struct TranslationRow {
"Access Website: ", u8"Официальный сайт: ") \ "Access Website: ", u8"Официальный сайт: ") \
X(update, u8"更新", "Update", u8"Обновить") \ X(update, u8"更新", "Update", u8"Обновить") \
X(install_update, u8"安装更新", "Install update", u8"Установить обновление") \ X(install_update, u8"安装更新", "Install update", u8"Установить обновление") \
X(manual_download, u8"手动下载", "Download", u8"Скачать") \ X(manual_download, u8"手动下载", "Download manually", u8"Скачать вручную") \
X(install_update_confirm, u8"下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?", \ X(install_update_confirm, u8"下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?", \
"CrossDesk will close after downloading the update. Connections and file transfers will be interrupted. Installation requires system authorization, then CrossDesk will restart. Continue?", \ "CrossDesk will close after downloading the update. Connections and file transfers will be interrupted. Installation requires system authorization, then CrossDesk will restart. Continue?", \
u8"После загрузки CrossDesk закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \ u8"После загрузки CrossDesk закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \
X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \ X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \
X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \ X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \
X(connection_alias, u8"修改名称", "Edit Alias", \ X(connection_alias, u8"修改名称", "Rename", u8"Переименовать") \
u8"Изменить имя подключения") \
X(delete_connection, u8"删除连接", "Delete Connection", \ X(delete_connection, u8"删除连接", "Delete Connection", \
u8"Удалить подключение") \ u8"Удалить подключение") \
X(connect_to_this_connection, u8"发起连接", "Connect to this connection", \ X(connect_to_this_connection, u8"发起连接", "Connect", u8"Подключиться") \
u8"Подключиться") \
X(input_connection_alias, u8"请输入连接名称:", \ X(input_connection_alias, u8"请输入连接名称:", \
"Please input connection name:", u8"Введите имя подключения:") \ "Please input connection name:", u8"Введите имя подключения:") \
X(confirm_delete_connection, u8"确认删除此连接", \ X(confirm_delete_connection, u8"确认删除此连接", "Delete this connection?", u8"Удалить это подключение?") \
"Confirm to delete this connection", u8"Удалить это подключение?") \
X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \ X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \
X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \ X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on connect:", \ X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on incoming connection:", u8"Приватный экран при подключении:") \
u8"Приватность при подключении:") \ X(lock_on_disconnect, u8"连接断开后锁屏:", "Lock screen on disconnect:", \
u8"Блокировка при отключении:") \
X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \ X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \
u8"Вступит в силу после перезапуска") \ u8"Вступит в силу после перезапуска") \
X(select_file, u8"选择文件发送", "Select File to Send", \ X(select_file, u8"选择文件发送", "Select File to Send", \
@@ -371,19 +352,19 @@ struct TranslationRow {
"File Transfer Save Path:", u8"Путь сохранения файлов:") \ "File Transfer Save Path:", u8"Путь сохранения файлов:") \
X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \ X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \
X(resolution, u8"分辨率", "Res", u8"Разрешение") \ X(resolution, u8"分辨率", "Res", u8"Разрешение") \
X(video_latency, u8"视频延时", "Video delay", u8"Задержка") \ X(video_latency, u8"视频延时", "Video latency", u8"Задержка видео") \
X(connection_latency, u8"连接延时", "Connection RTT", u8"Задержка соединения") \ X(connection_latency, u8"连接延时", "Connection latency", u8"Задержка соединения") \
X(connection_mode, u8"连接模式", "Mode", u8"Режим") \ X(connection_mode, u8"连接模式", "Connection mode", u8"Режим подключения") \
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \ X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \ X(connection_mode_relay, u8"中继", "Relay", u8"Ретрансляция") \
X(transport_encryption, u8"加密传输", "Encryption", u8"Шифрование") \ X(transport_encryption, u8"加密传输", "Encryption", u8"Шифрование") \
X(remote_video_encoder, u8"被控端编码器", "Remote encoder", u8"Кодер удалённой стороны") \ X(remote_video_encoder, u8"被控端编码器", "Remote encoder", u8"Кодер удалённой стороны") \
X(local_video_decoder, u8"控制端解码器", "Local decoder", u8"Локальный декодер") \ X(local_video_decoder, u8"控制端解码器", "Local decoder", u8"Локальный декодер") \
X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \ X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \ X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
X(online, u8"在线", "Online", u8"Онлайн") \ X(online, u8"在线", "Online", u8"В сети") \
X(offline, u8"离线", "Offline", u8"Офлайн") \ X(offline, u8"离线", "Offline", u8"Не в сети") \
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство офлайн") \ X(device_offline, u8"设备离线", "Device Offline", u8"Устройство не в сети") \
X(request_permissions, u8"权限请求", "Request Permissions", \ X(request_permissions, u8"权限请求", "Request Permissions", \
u8"Запрос разрешений") \ u8"Запрос разрешений") \
X(screen_recording_permission, u8"屏幕录制权限", \ X(screen_recording_permission, u8"屏幕录制权限", \
@@ -226,15 +226,11 @@ int ClipboardController::SendToPeers(const std::string &text) {
} }
} }
if (owner_.peer_) { lock.unlock();
const int ret = for (const auto& id : owner_.ConnectedControllers()) {
SendReliableDataFrame(owner_.peer_, text.data(), text.size(), const int ret = owner_.SendControllerData(id, text.data(), text.size(),
owner_.clipboard_label_.c_str()); owner_.clipboard_label_.c_str());
if (ret != 0) { if (ret != 0) return ret;
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
owner_.remote_id_display_, ret);
return ret;
}
} }
return 0; return 0;
@@ -43,18 +43,17 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
SpeakerCapturerFactory().Create()); SpeakerCapturerFactory().Create());
}, },
[this](unsigned char *data, size_t size, const char *) { [this](unsigned char *data, size_t size, const char *) {
if (!owner_.peer_) return;
MiniRtcAudioFrame frame{}; MiniRtcAudioFrame frame{};
frame.data = reinterpret_cast<const char *>(data); frame.data = reinterpret_cast<const char *>(data);
frame.size = size; frame.size = size;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_); for (const auto& id : owner_.ConnectedControllers())
std::shared_lock lock(owner_.connection_status_mutex_); owner_.WithController(
for (const auto& [id, status] : owner_.connection_status_) { id, [&](PeerPtr* peer, const std::string& wire) {
if (status == ConnectionStatus::Connected) frame.captured_timestamp = GetSystemTimeMicros(peer);
SendAudioFrameToPeer(owner_.peer_, &frame, return SendAudioFrameToPeer(peer, &frame,
owner_.audio_label_.c_str(), id.data(), owner_.audio_label_.c_str(),
id.size()); wire.data(), wire.size());
} });
}) {} }) {}
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); } SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
@@ -132,6 +131,10 @@ void SessionDeviceManager::Initialize() {
DeviceControllerFactory::Device::Keyboard)); DeviceControllerFactory::Device::Keyboard));
} }
int SessionDeviceManager::EnsureCaptureReady() {
return screen_capturer_ ? 0 : InitializeScreenCapturer();
}
int SessionDeviceManager::InitializeScreenCapturer() { int SessionDeviceManager::InitializeScreenCapturer() {
#ifdef __APPLE__ #ifdef __APPLE__
if (!owner_.EnsureMacScreenRecordingPermission()) { if (!owner_.EnsureMacScreenRecordingPermission()) {
@@ -193,16 +196,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
.count() .count()
: 0; : 0;
std::vector<std::string> connected_remote_ids; const auto connected_remote_ids = owner_.ConnectedControllers();
{
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);
}
}
}
// Capture can still deliver frames while ICE is gathering or after // Capture can still deliver frames while ICE is gathering or after
// the final controller disconnects. Do not broadcast those frames to // the final controller disconnects. Do not broadcast those frames to
@@ -232,28 +226,27 @@ int SessionDeviceManager::InitializeScreenCapturer() {
has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs; has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs;
const bool stream_changed = !last_video_frame_stream_id_.empty() && const bool stream_changed = !last_video_frame_stream_id_.empty() &&
last_video_frame_stream_id_ != stream_id; 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{}; MiniRtcVideoFrame frame{};
frame.data = reinterpret_cast<const char *>(data); frame.data = reinterpret_cast<const char *>(data);
frame.size = size; frame.size = size;
frame.width = width; frame.width = width;
frame.height = height; frame.height = height;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
frame.native_frame = native_frame; frame.native_frame = native_frame;
const auto send_started = std::chrono::steady_clock::now(); const auto send_started = std::chrono::steady_clock::now();
uint64_t send_errors = 0; uint64_t send_errors = 0;
for (const std::string &remote_id : connected_remote_ids) { for (const std::string &remote_id : connected_remote_ids) {
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time, if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
stream_id)) continue; stream_id)) continue;
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(), if (owner_.WithController(
remote_id.data(), remote_id.size()) != 0) 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; ++send_errors;
} }
const auto send_us = const auto send_us =
@@ -54,6 +54,7 @@ public:
void DestroyFactories(); void DestroyFactories();
int InitializeScreenCapturer(); int InitializeScreenCapturer();
int EnsureCaptureReady();
int StartScreenCapturer(); int StartScreenCapturer();
int StopScreenCapturer(); int StopScreenCapturer();
bool ReleaseRemoteMouseButtons(); bool ReleaseRemoteMouseButtons();
@@ -94,13 +94,19 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
peer = props ? props->peer_ : owner_.peer_; peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_; 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>( auto session = std::make_shared<file_transfer::Session>(
[peer, events, file, props](const char*, const char* data, size_t size) { [peer, events, file, props](const char*, const char* data, size_t size) {
if (!peer || !events || (props && props->closing_)) return -1; if (!peer || !events || (props && props->closing_)) return -1;
auto callback = events->EnterCallback(); auto callback = events->EnterCallback();
if (!callback) return -1; if (!callback || !events->DataAllowed()) return -1;
return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(), const auto wire_id = events->WireId(file.remote_id);
file.remote_id.c_str(), file.remote_id.size()); return SendReliableDataFrameToPeer(
peer, data, size, file.file_label.c_str(),
wire_id.data(), wire_id.size());
}, file_transfer::Session::Progress{}); }, file_transfer::Session::Progress{});
state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0; state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0;
state.current_file_id_ = id; state.current_file_id_ = id;
@@ -313,6 +319,10 @@ void FileTransferManager::Receive(const char* data, size_t size,
peer = props ? props->peer_ : owner_.peer_; peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_; 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; if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
std::shared_ptr<FileReceiver> receiver; std::shared_ptr<FileReceiver> receiver;
std::filesystem::path default_directory; 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) { entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) {
auto callback = events->EnterCallback(); auto callback = events->EnterCallback();
if (!callback) return -1; if (!callback || !events->DataAllowed()) return -1;
const auto bytes = EncodeFileTransferAck(ack); const auto bytes = EncodeFileTransferAck(ack);
return SendReliableDataFrameToPeer(peer, bytes.data(), bytes.size(), kFileFeedbackStream, const auto wire_id = events->WireId(remote);
remote.c_str(), remote.size()); return SendReliableDataFrameToPeer(
peer, bytes.data(), bytes.size(), kFileFeedbackStream,
wire_id.data(), wire_id.size());
}); });
} }
receiver = entry.session; receiver = entry.session;
@@ -465,7 +465,6 @@ int SettingsManager::Load() {
static_cast<int>(owner_.config_center_->GetLanguage())); static_cast<int>(owner_.config_center_->GetLanguage()));
owner_.video_encode_format_button_value_ = owner_.video_encode_format_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat()); static_cast<int>(owner_.config_center_->GetVideoEncodeFormat());
owner_.enable_turn_ = owner_.config_center_->IsEnableTurn();
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted(); owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart(); owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon(); owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
@@ -484,7 +483,6 @@ int SettingsManager::Load() {
owner_.language_button_value_last_ = owner_.language_button_value_; owner_.language_button_value_last_ = owner_.language_button_value_;
owner_.video_encode_format_button_value_last_ = owner_.video_encode_format_button_value_last_ =
owner_.video_encode_format_button_value_; owner_.video_encode_format_button_value_;
owner_.enable_turn_last_ = owner_.enable_turn_;
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_; owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
owner_.enable_autostart_last_ = owner_.enable_autostart_; owner_.enable_autostart_last_ = owner_.enable_autostart_;
@@ -744,6 +742,8 @@ std::string SettingsManager::RecentConnectionDisplayName(
connection.remote_host_name != "unknown") { connection.remote_host_name != "unknown") {
return connection.remote_host_name; 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; return connection.remote_id;
} }
@@ -14,6 +14,7 @@
#include "platform.h" #include "platform.h"
#include "platform/video_renderer.h" #include "platform/video_renderer.h"
#include "rd_log.h" #include "rd_log.h"
#include "screen_lock.h"
#include "runtime/device_presence_request.h" #include "runtime/device_presence_request.h"
#include "runtime/gui_runtime.h" #include "runtime/gui_runtime.h"
@@ -26,6 +27,7 @@ constexpr auto kPresenceRetryInterval = std::chrono::seconds(1);
} // namespace } // namespace
bool GuiRuntime::HasActiveSession() { bool GuiRuntime::HasActiveSession() {
if (lan_ && !lan_->Quiescent()) return true;
if (stream_window_inited_) return true; if (stream_window_inited_) return true;
std::shared_lock lock(connection_status_mutex_); std::shared_lock lock(connection_status_mutex_);
return std::any_of(connection_status_.begin(), connection_status_.end(), return std::any_of(connection_status_.begin(), connection_status_.end(),
@@ -188,7 +190,8 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
void GuiRuntime::HandleServerControllerDisconnected( void GuiRuntime::HandleServerControllerDisconnected(
const std::string& remote_id, const char* reason) { 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); preview_subscriptions_.Remove(remote_id);
bool has_connected_controller = false; bool has_connected_controller = false;
@@ -196,6 +199,8 @@ void GuiRuntime::HandleServerControllerDisconnected(
{ {
std::unique_lock lock(connection_status_mutex_); std::unique_lock lock(connection_status_mutex_);
connection_status_.erase(remote_id); connection_status_.erase(remote_id);
disconnect_lock_.Disconnected(remote_id,
config_center_->IsLockOnDisconnect());
remote_input_.Disconnect(remote_id); remote_input_.Disconnect(remote_id);
devices_.OnControllerConnectionsChanged(); devices_.OnControllerConnectionsChanged();
connection_host_names_.erase(remote_id); connection_host_names_.erase(remote_id);
@@ -243,11 +248,30 @@ void GuiRuntime::HandleServerControllerDisconnected(
#endif #endif
devices_.StopSpeakerCapturer(); devices_.StopSpeakerCapturer();
start_mouse_controller_ = false; 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(); remote_client_id_.clear();
audio_capture_ = false; 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, int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
const char* password, const char* password,
bool remember_password) { bool remember_password) {
@@ -294,10 +318,15 @@ int GuiRuntime::ForceSignalReconnect() {
} }
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) { 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); MuteRemoteAudio(props);
if (props->peer_events_) { if (props->peer_events_) {
props->peer_events_->Deactivate(); props->peer_events_->StopAccepting();
transfers_.Cancel(props->peer_events_.get()); transfers_.Cancel(props->peer_events_.get());
} }
props->connection_established_ = false; 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( session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask(
[props, peer, frame_snapshot, video_width, video_height, platform, [props, peer, frame_snapshot, video_width, video_height, platform,
thumbnail = thumbnail_, queued_at]() mutable { 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_, LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
props->remote_id_); props->remote_id_);
LeaveConnection(peer, props->remote_id_.c_str()); LeaveConnection(peer, props->remote_id_.c_str());
@@ -359,11 +389,20 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (thumbnail && props->was_connected_) { if (thumbnail && props->was_connected_) {
const char* nv12 = frame_snapshot && !frame_snapshot->empty() const char* nv12 = frame_snapshot && !frame_snapshot->empty()
? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr; ? 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( if (thumbnail->SaveToThumbnail(
nv12, video_width, video_height, props->remote_id_, nv12, video_width, video_height, record_id,
props->remote_host_name_, props->remote_host_name_,
props->remember_password_ ? props->remote_password_ : "", props->remember_password_ ? props->remote_password_ : "",
platform) != 0) { platform, direct ? &target : nullptr) != 0) {
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_); 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) { 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)) if (!peer_ || remote_id.empty() || controller_cleanup_tasks_.count(remote_id))
return; return;
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask( controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
@@ -392,20 +442,17 @@ void GuiRuntime::CloseConnectionPeer() {
announcements_.Reset(); announcements_.Reset();
return; return;
} }
peer_events_->Deactivate(); peer_events_->StopAccepting();
transfers_.Cancel(peer_events_.get()); transfers_.Cancel(peer_events_.get());
announcements_.Reset(); announcements_.Reset();
std::vector<std::string> controllers; std::vector<std::string> controllers;
{ {
std::shared_lock lock(connection_status_mutex_); 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) for (const auto& id : controllers)
HandleServerControllerDisconnected(id, "peer_closed"); HandleServerControllerDisconnected(id, "peer_closed");
devices_.StopMouseController();
devices_.StopScreenCapturer();
devices_.StopSpeakerCapturer();
devices_.StopKeyboardCapturer();
signal_connected_ = false; signal_connected_ = false;
device_presence_cache_.SetSignalConnected(false); device_presence_cache_.SetSignalConnected(false);
signal_status_ = SignalStatus::SignalClosed; signal_status_ = SignalStatus::SignalClosed;
@@ -418,6 +465,7 @@ void GuiRuntime::CloseConnectionPeer() {
// these finish before the shared peer is destroyed. // these finish before the shared peer is destroyed.
connection_peer_cleanup_ = session_cleanup_queue_.PostTask( connection_peer_cleanup_ = session_cleanup_queue_.PostTask(
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable { [peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
handler->Deactivate();
LOG_INFO("[{}] Background connection peer cleanup", id); LOG_INFO("[{}] Background connection peer cleanup", id);
LeaveConnection(peer, id.c_str()); LeaveConnection(peer, id.c_str());
DestroyPeer(&peer); DestroyPeer(&peer);
@@ -551,6 +599,12 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
std::shared_lock lock(remote_sessions_mutex_); std::shared_lock lock(remote_sessions_mutex_);
auto it = remote_sessions_.find(remote_id); auto it = remote_sessions_.find(remote_id);
if (it == remote_sessions_.end()) { 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 nullptr;
} }
return it->second; return it->second;
@@ -22,6 +22,7 @@ inline nlohmann::json BuildDevicePresenceRequest(
const auto append = [&](const std::string& id) { const auto append = [&](const std::string& id) {
// Recent connection keys contain Y/N plus display settings after the 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")); 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() && if (!pure_id.empty() &&
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) { std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
devices.push_back(pure_id); 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); windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
if (windows_service_worker_) windows_service_worker_->Reset(); if (windows_service_worker_) windows_service_worker_->Reset();
#endif #endif
lan_.reset();
transfers_.Shutdown(); transfers_.Shutdown();
WaitForSessionCleanup(); WaitForSessionCleanup();
} }
@@ -292,7 +293,7 @@ int GuiRuntime::CreateConnectionPeer() {
LOG_INFO("Create peer [{}] instance failed", client_id_); LOG_INFO("Create peer [{}] instance failed", client_id_);
} }
if (0 == devices_.InitializeScreenCapturer()) { if (0 == devices_.EnsureCaptureReady()) {
const auto &displays = devices_.display_info_list(); const auto &displays = devices_.display_info_list();
for (size_t index = 0; index < displays.size(); ++index) { for (size_t index = 0; index < displays.size(); ++index) {
const std::string stream_id = MakeDisplayStreamId(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. // Record a successful connection before a preview or host info arrives.
// The close path still updates its final thumbnail. Remember password // The close path still updates its final thumbnail. Remember password
// controls only credential storage, not whether this device is listed. // 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( if (thumbnail_->SaveToThumbnail(
nullptr, 0, 0, props->remote_id_, host_name, nullptr, 0, 0, record_id, host_name,
props->remember_password_ ? props->remote_password_ : "", props->remember_password_ ? props->remote_password_ : "",
platform) == 0) platform, direct ? &target : nullptr) == 0)
reload_recent_connections_ = true; reload_recent_connections_ = true;
else else
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_); 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_) { if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) {
uint32_t now_time = SDL_GetTicks(); uint32_t now_time = SDL_GetTicks();
if (now_time - recent_connection_image_save_time_ >= 50) { if (now_time - recent_connection_image_save_time_ >= 50) {
int ret = thumbnail_->LoadThumbnail(recent_connections_, int ret = thumbnail_->LoadThumbnail(recent_connections_);
&recent_connection_image_width_,
&recent_connection_image_height_);
if (!ret) { if (!ret) {
LOG_INFO("Load recent connection thumbnails"); LOG_INFO("Load recent connection thumbnails");
} }
@@ -356,7 +363,8 @@ void GuiRuntime::HandleRecentConnections() {
recent_connection_ids_.clear(); recent_connection_ids_.clear();
for (const auto &conn : recent_connections_) { 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; need_to_send_recent_connections_ = true;
} }
@@ -19,8 +19,10 @@
#include "features/input/keyboard_controller.h" #include "features/input/keyboard_controller.h"
#include "features/input/remote_input_dispatcher.h" #include "features/input/remote_input_dispatcher.h"
#include "features/settings/settings_manager.h" #include "features/settings/settings_manager.h"
#include "network/lan/lan_session_manager.h"
#include "privacy_controller.h" #include "privacy_controller.h"
#include "runtime/gui_state.h" #include "runtime/gui_state.h"
#include "runtime/disconnect_lock_policy.h"
#include "runtime/announcement_inbox.h" #include "runtime/announcement_inbox.h"
#include "runtime/peer_event_handler.h" #include "runtime/peer_event_handler.h"
#include "runtime/privacy_session_policy.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 SdlCaptureAudioIn(void* userdata, uint8_t* stream, int len);
static void SdlCaptureAudioOut(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 CreateConnectionPeer();
int ConnectTo(const std::string& remote_id, const char* password, int ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check = false, bool remember_password, bool bypass_presence_check = false,
@@ -72,6 +103,7 @@ class GuiRuntime : protected gui_detail::GuiState {
const char* reason); const char* reason);
void HandleWindowsServiceIntegration(); void HandleWindowsServiceIntegration();
void HandlePrivacy(); void HandlePrivacy();
void HandleScreenLock();
bool IsAuthorizedController(const std::string& remote_id); bool IsAuthorizedController(const std::string& remote_id);
void SetControllerPrivacySupport(const std::string& remote_id, bool supported); void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
// Caller holds connection_status_mutex_ exclusively. Turns privacy off when // Caller holds connection_status_mutex_ exclusively. Turns privacy off when
@@ -127,6 +159,7 @@ class GuiRuntime : protected gui_detail::GuiState {
bool unattended_host_ = false; bool unattended_host_ = false;
AnnouncementInbox announcements_; AnnouncementInbox announcements_;
PrivacySessionPolicy privacy_sessions_; PrivacySessionPolicy privacy_sessions_;
DisconnectLockPolicy disconnect_lock_;
std::mutex privacy_commands_mutex_; std::mutex privacy_commands_mutex_;
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_; std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
uint32_t last_privacy_revision_ = 0; 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 } // 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( void PeerEventHandler::OnReceiveDataBuffer(
const char *data, size_t size, const char *user_id, size_t user_id_size, 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) { const char *src_id, size_t src_id_size, void *user_data) {
auto *handler = static_cast<PeerEventHandler *>(user_data); auto *handler = static_cast<PeerEventHandler *>(user_data);
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback || handler->QueueEarlyData(data, size, src_id, src_id_size) ||
GuiRuntime *runtime = handler ? &handler->owner_ : nullptr; !handler->DataAllowed())
if (!runtime) {
return; return;
} GuiRuntime* runtime = &handler->owner_;
std::string source_id = std::string(src_id, src_id_size); std::string source_id = std::string(src_id, src_id_size);
// The service-owned host runs as SYSTEM. Do not expose filesystem or // The service-owned host runs as SYSTEM. Do not expose filesystem or
@@ -105,11 +140,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
source_id == runtime->clipboard_label_)) source_id == runtime->clipboard_label_))
return; return;
if (source_id == runtime->file_label_) { 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; return;
} else if (source_id == runtime->clipboard_label_) { } else if (source_id == runtime->clipboard_label_) {
if (size > 0) { 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 = auto props =
runtime->FindRemoteSession(remote_user_id); runtime->FindRemoteSession(remote_user_id);
if (props && !props->clipboard_allowed_.load()) { if (props && !props->clipboard_allowed_.load()) {
@@ -121,11 +158,15 @@ void PeerEventHandler::OnReceiveDataBuffer(
} }
return; return;
} else if (source_id == runtime->file_feedback_label_) { } 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; 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); std::string json_str(data, size);
const auto preview_json = const auto preview_json =
json_str.find("\"deskhub_preview") != std::string::npos json_str.find("\"deskhub_preview") != std::string::npos
@@ -145,29 +186,31 @@ void PeerEventHandler::OnReceiveDataBuffer(
if (!previous || subscription->IsNewerThan(*previous)) { if (!previous || subscription->IsNewerThan(*previous)) {
if (subscription->fps > 0) if (subscription->fps > 0)
accepted = UpdateVideoSettings( accepted = UpdateVideoSettings(
runtime->peer_, remote_id.data(), remote_id.size(), bound_peer, wire_id.data(), wire_id.size(),
static_cast<VideoQuality>(subscription->quality), static_cast<VideoQuality>(subscription->quality),
subscription->frame_rate, subscription->frame_rate,
static_cast<VideoDegradationPreference>( static_cast<VideoDegradationPreference>(
subscription->EncoderPreference())) == 0; subscription->EncoderPreference())) == 0;
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) != if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
subscription->max_bitrate_bps.value_or(0))) subscription->max_bitrate_bps.value_or(0)))
accepted = UpdateVideoBitrateLimit( 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; subscription->max_bitrate_bps.value_or(0)) == 0;
// Rebalancing bandwidth alone must not force every device to send a // Rebalancing bandwidth alone must not force every device to send a
// keyframe. The pause API requests its own keyframes on resume. // keyframe. The pause API requests its own keyframes on resume.
if (accepted && (!previous || previous->width != subscription->width || if (accepted && (!previous || previous->width != subscription->width ||
previous->height != subscription->height)) previous->height != subscription->height))
accepted = UpdateVideoResolution( accepted = UpdateVideoResolution(bound_peer, wire_id.data(),
runtime->peer_, remote_id.data(), remote_id.size(), wire_id.size(), subscription->width,
subscription->width, subscription->height) == 0; subscription->height) == 0;
if (accepted) if (accepted)
accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(), accepted =
remote_id.size(), subscription->fps == 0) == 0; SetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(),
subscription->fps == 0) == 0;
if (accepted) if (accepted)
accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(), accepted =
remote_id.size(), !subscription->audio) == 0; SetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(),
!subscription->audio) == 0;
if (accepted && subscription->audio) if (accepted && subscription->audio)
runtime->devices_.StartSpeakerCapturer(); runtime->devices_.StartSpeakerCapturer();
} }
@@ -178,9 +221,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
{"request_id", subscription->request_id}, {"request_id", subscription->request_id},
{"accepted", {"accepted",
accepted}}.dump(); accepted}}.dump();
SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(), SendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(),
runtime->control_data_label_.c_str(), runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size()); wire_id.data(), wire_id.size());
return; return;
} }
if (preview_json.is_object() && if (preview_json.is_object() &&
@@ -253,7 +296,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
const auto& settings = remote_action.vs; const auto& settings = remote_action.vs;
remote_action.vs.accepted = remote_action.vs.accepted =
UpdateVideoSettings( 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<VideoQuality>(settings.quality), settings.frame_rate,
static_cast<VideoDegradationPreference>(settings.preference)) == 0; static_cast<VideoDegradationPreference>(settings.preference)) == 0;
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} " 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); settings.frame_rate, settings.preference, remote_action.vs.accepted);
remote_action.type = ControlType::video_settings_status; remote_action.type = ControlType::video_settings_status;
const auto message = remote_action.to_json(); 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(), runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size()); wire_id.data(), wire_id.size());
return; return;
} }
if (remote_action.type == ControlType::privacy_status) { if (remote_action.type == ControlType::privacy_status) {
@@ -407,8 +450,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
props->video_settings_request_tick_ = SDL_GetTicks(); props->video_settings_request_tick_ = SDL_GetTicks();
settings.vs = props->video_settings_; settings.vs = props->video_settings_;
const auto message = settings.to_json(); const auto message = settings.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(), SendReliableDataFrame(
props->control_data_label_.c_str()); handler->IsDirect() ? handler->DirectPeer() : props->peer_,
message.data(), message.size(),
props->control_data_label_.c_str());
} }
} }
// client mode // client mode
@@ -456,8 +501,8 @@ void PeerEventHandler::OnReceiveDataBuffer(
// command arrives after it (e.g. when unmuting an inactive window). // command arrives after it (e.g. when unmuting an inactive window).
const auto subscription = runtime->preview_subscriptions_.Get(remote_id); const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
const bool paused = subscription ? !subscription->audio : !remote_action.a; const bool paused = subscription ? !subscription->audio : !remote_action.a;
const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(), const int ret = SetAudioSendPaused(bound_peer, wire_id.data(),
remote_id.size(), paused); wire_id.size(), paused);
if (remote_action.a) if (remote_action.a)
runtime->devices_.StartSpeakerCapturer(); runtime->devices_.StartSpeakerCapturer();
else if (ret != 0) else if (ret != 0)
@@ -49,6 +49,22 @@ constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
} // namespace } // 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, void PeerEventHandler::SendClientInfo(PeerPtr* peer,
const std::string& client_id) { const std::string& client_id) {
if (!peer) { if (!peer) {
@@ -74,8 +90,8 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime || !message || size == 0) { if (!message || size == 0) {
return; return;
} }
std::string s(message, size); std::string s(message, size);
@@ -179,10 +195,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime) {
return;
}
std::string client_id(user_id, user_id_size); std::string client_id(user_id, user_id_size);
if (client_id == runtime->client_id_) { if (client_id == runtime->client_id_) {
@@ -251,17 +264,30 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
} }
void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
const char* user_id, const char* user_id, size_t size,
const size_t user_id_size, void* context) {
void* user_data) { 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); auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime) return;
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); if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed || else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id); 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_); std::unique_lock lock(runtime->connection_status_mutex_);
if (status == ConnectionStatus::Connected) { if (status == ConnectionStatus::Connected) {
runtime->disconnect_lock_.Connected(remote_id);
// Local privacy requires explicit desktop capability. Host info may // Local privacy requires explicit desktop capability. Host info may
// arrive before Connected; consume that saved capability here. // arrive before Connected; consume that saved capability here.
const bool automatic_enable = runtime->privacy_sessions_.Connected( const bool automatic_enable = runtime->privacy_sessions_.Connected(
@@ -474,8 +501,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
} }
std::string msg = remote_action.to_json(); std::string msg = remote_action.to_json();
const int ret = const int ret =
SendReliableDataFrame(runtime->peer_, msg.data(), msg.size(), runtime->SendControllerData(remote_id, msg.data(), msg.size(),
runtime->control_data_label_.c_str()); runtime->control_data_label_.c_str());
if (ret != 0) { if (ret != 0) {
LOG_WARN("[{}] host information send failed, ret={}", remote_id, LOG_WARN("[{}] host information send failed, ret={}", remote_id,
ret); ret);
@@ -516,21 +543,12 @@ void PeerEventHandler::OnNetStatusReport(
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime) {
return;
}
if (strchr(client_id, '@') != nullptr && strchr(user_id, '-') == nullptr) { if (const char* at_pos = handler->IsDirect() ? nullptr : strchr(client_id, '@');
std::string id, password; at_pos && strchr(user_id, '-') == nullptr) {
const char* at_pos = strchr(client_id, '@'); const std::string id(client_id, at_pos - client_id);
if (at_pos == nullptr) { const std::string password(at_pos + 1);
id = client_id;
password.clear();
} else {
id.assign(client_id, at_pos - client_id);
password = at_pos + 1;
}
bool is_self_hosted = runtime->config_center_->IsSelfHosted(); bool is_self_hosted = runtime->config_center_->IsSelfHosted();
@@ -578,7 +596,7 @@ void PeerEventHandler::OnNetStatusReport(
#endif #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); auto props = runtime->FindRemoteSession(remote_id);
if (!props) { if (!props) {
bool report_codecs = false; bool report_codecs = false;
@@ -597,9 +615,8 @@ void PeerEventHandler::OnNetStatusReport(
net_traffic_stats->video_encoder); net_traffic_stats->video_encoder);
const auto message = action.to_json(); const auto message = action.to_json();
// Report only to this controller, after releasing the status lock. // Report only to this controller, after releasing the status lock.
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(), runtime->SendControllerData(remote_id, message.data(), message.size(),
runtime->control_data_label_.c_str(), runtime->control_data_label_.c_str());
remote_id.data(), remote_id.size());
} }
return; return;
} }
@@ -8,7 +8,10 @@
#define _PEER_EVENT_HANDLER_H_ #define _PEER_EVENT_HANDLER_H_
#include <cstddef> #include <cstddef>
#include <memory>
#include <mutex>
#include <string> #include <string>
#include <vector>
#include "minirtc.h" #include "minirtc.h"
#include "runtime/session_lifecycle.h" #include "runtime/session_lifecycle.h"
@@ -16,12 +19,30 @@
namespace crossdesk { namespace crossdesk {
class GuiRuntime; class GuiRuntime;
namespace lan {
struct Session;
}
// Adapts MiniRTC C callbacks to the GUI runtime. // Adapts MiniRTC C callbacks to the GUI runtime.
class PeerEventHandler { class PeerEventHandler {
public: public:
explicit PeerEventHandler(GuiRuntime &owner); 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(); } bool IsActive() const { return callbacks_.IsActive(); }
auto EnterCallback() { return callbacks_.Enter(); } auto EnterCallback() { return callbacks_.Enter(); }
void Deactivate() { callbacks_.Close(); } void Deactivate() { callbacks_.Close(); }
@@ -51,12 +72,22 @@ public:
void *user_data); void *user_data);
private: 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_; gui_detail::SessionCallbackGate callbacks_;
}; };
} // namespace crossdesk } // namespace crossdesk
#endif #endif
@@ -38,20 +38,21 @@ void PeerEventHandler::OnReceiveVideoBuffer(
auto* handler = static_cast<PeerEventHandler*>(user_data); auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback || !handler->DataAllowed()) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime || !video_frame) { if (!video_frame) {
return; 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); auto props = runtime->FindRemoteSession(remote_id);
if (!props) return; if (!props) return;
if (props->connection_established_) { if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now(); const auto timing_now = VideoLatencyFrame::Clock::now();
// Pair the clocks before preview conversion so its cost is included. // 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); const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record( props->received_video_stats_.Record(
received_native ? received_native->width : video_frame->width, received_native ? received_native->width : video_frame->width,
@@ -274,15 +275,15 @@ void PeerEventHandler::OnReceiveAudioBuffer(
auto* handler = static_cast<PeerEventHandler*>(user_data); auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback || !handler->DataAllowed()) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime || runtime->unattended_host_) { if (runtime->unattended_host_) {
return; return;
} }
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_); std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
const auto session = 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; if (!session || session->closing_ || !session->audio_allowed_.load()) return;
runtime->audio_buffer_fresh_ = true; runtime->audio_buffer_fresh_ = true;
@@ -15,6 +15,10 @@
namespace crossdesk { namespace crossdesk {
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) { 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 // Connections appear here only after the existing password/signalling/RTC
// admission path. Outgoing/viewer sessions live in remote_sessions_ instead. // admission path. Outgoing/viewer sessions live in remote_sessions_ instead.
std::shared_lock lock(connection_status_mutex_); std::shared_lock lock(connection_status_mutex_);
@@ -109,9 +113,9 @@ void GuiRuntime::HandlePrivacy() {
"A connected controller does not support privacy screen"); "A connected controller does not support privacy screen");
} }
} }
if (!peer_ || (last_privacy_status_tick_ != 0 && if (last_privacy_status_tick_ != 0 &&
status.revision == last_privacy_revision_ && status.revision == last_privacy_revision_ &&
now - last_privacy_status_tick_ < 1000)) now - last_privacy_status_tick_ < 1000)
return; return;
RemoteAction action{}; RemoteAction action{};
action.type = ControlType::privacy_status; action.type = ControlType::privacy_status;
@@ -119,9 +123,8 @@ void GuiRuntime::HandlePrivacy() {
const auto message = action.to_json(); const auto message = action.to_json();
bool sent = true; bool sent = true;
for (const auto& id : controllers) { for (const auto& id : controllers) {
const int result = SendReliableDataFrameToPeer( const int result = SendControllerData(id, message.data(), message.size(),
peer_, message.data(), message.size(), control_data_label_.c_str(), control_data_label_.c_str());
id.data(), id.size());
if (result != 0) { if (result != 0) {
sent = false; sent = false;
LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result); LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result);
@@ -31,7 +31,12 @@
#include "runtime/received_video_stats.h" #include "runtime/received_video_stats.h"
#include "runtime/connection_auth_state.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 { namespace crossdesk::gui_detail {
@@ -105,6 +110,7 @@ struct RemoteSession {
// Decoder-thread scratch memory, never shared with the UI. // Decoder-thread scratch memory, never shared with the UI.
std::mutex gallery_conversion_mutex_; std::mutex gallery_conversion_mutex_;
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_; std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
std::shared_ptr<lan::Session> lan_session_;
Params params_; Params params_;
PeerPtr* peer_ = nullptr; PeerPtr* peer_ = nullptr;
std::shared_ptr<PeerEventHandler> peer_events_; std::shared_ptr<PeerEventHandler> peer_events_;
@@ -59,8 +59,6 @@ struct RecentConnectionsState {
recent_connections_; recent_connections_;
std::vector<std::string> recent_connection_ids_; std::vector<std::string> recent_connection_ids_;
std::vector<std::string> gallery_presence_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; uint32_t recent_connection_image_save_time_ = 0;
DevicePresenceCache device_presence_cache_; DevicePresenceCache device_presence_cache_;
std::atomic<bool> need_to_send_recent_connections_{true}; std::atomic<bool> need_to_send_recent_connections_{true};
@@ -150,13 +148,11 @@ struct UserSettingsState {
char connection_login_identity_[17] = ""; char connection_login_identity_[17] = "";
int language_button_value_ = 0; int language_button_value_ = 0;
int video_encode_format_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_ip_[256] = "api.crossdesk.cn";
char signal_server_port_[6] = "9099"; char signal_server_port_[6] = "9099";
bool enable_self_hosted_ = false; bool enable_self_hosted_ = false;
int language_button_value_last_ = 0; int language_button_value_last_ = 0;
int video_encode_format_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_self_hosted_last_ = false;
bool enable_autostart_ = false; bool enable_autostart_ = false;
bool enable_autostart_last_ = 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() { void SessionCallbackGate::Close() {
std::unique_lock lock(mutex_); std::unique_lock lock(mutex_);
accepting_callbacks_ = false; accepting_callbacks_ = false;
@@ -39,6 +39,7 @@ class SessionCallbackGate {
CallbackGuard Enter(); CallbackGuard Enter();
// Called by the session owner, never from within this session's callbacks. // Called by the session owner, never from within this session's callbacks.
void StopAccepting();
void Close(); void Close();
private: private:
+11 -10
View File
@@ -1,9 +1,10 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { ScrollView, TextEdit } from "std-widgets.slint"; import { ScrollView, TextEdit } from "std-widgets.slint";
import { SettingsDialog, SettingsButton } from "settings_controls.slint"; import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint"; import { WorkbenchStyle } from "workbench_controls.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint"; import { GalleryStrings } from "device_strings.slint";
import { FontAwesomeIcons } from "common.slint"; import { FontAwesomeIcons, ControlStyle } from "common.slint";
import "../assets/fonts/message_spacing.ttf"; import "../assets/fonts/message_spacing.ttf";
export enum AboutPage { overview, components, component, document, source } export enum AboutPage { overview, components, component, document, source }
@@ -26,11 +27,11 @@ export global AboutStrings {
out property <string> back: GalleryStrings.tr("返回", "Back", "Назад"); out property <string> back: GalleryStrings.tr("返回", "Back", "Назад");
} }
export component AboutNavigationGroup inherits Rectangle { export component AboutNavigationGroup inherits FlatSurface {
height: rows.preferred-height + 2px; height: rows.preferred-height + 2px;
background: WorkbenchStyle.surface; background: WorkbenchStyle.surface;
border-radius: UiRadius.control; border-radius: UiRadius.control;
border-width: 1px; border-color: WorkbenchStyle.separator; outline-color: UiSurface.edge;
clip: true; clip: true;
rows := VerticalLayout { rows := VerticalLayout {
x: 1px; y: 1px; width: parent.width - 2px; x: 1px; y: 1px; width: parent.width - 2px;
@@ -41,13 +42,13 @@ export component AboutNavigationGroup inherits Rectangle {
export component AboutRowSeparator inherits Rectangle { export component AboutRowSeparator inherits Rectangle {
height: 1px; height: 1px;
Rectangle { SurfaceSeparator {
x: 14px; width: parent.width - 30px; height: 1px; x: 14px; width: parent.width - 30px; height: 1px;
background: WorkbenchStyle.separator; outline-color: UiSurface.separator;
} }
} }
export component AboutNavigationRow inherits Rectangle { export component AboutNavigationRow inherits FlatSurface {
in property <string> title; in property <string> title;
in property <string> subtitle; in property <string> subtitle;
in property <string> icon; in property <string> icon;
@@ -60,7 +61,7 @@ export component AboutNavigationRow inherits Rectangle {
background: touch.pressed ? WorkbenchStyle.pressed-surface background: touch.pressed ? WorkbenchStyle.pressed-surface
: touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface; : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: root.grouped ? 0px : UiRadius.control; border-radius: root.grouped ? 0px : UiRadius.control;
border-width: root.grouped ? 0px : 1px; border-color: WorkbenchStyle.separator; outline-enabled: !root.grouped; outline-color: UiSurface.edge;
accessible-role: button; accessible-role: button;
accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "") accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "")
+ (root.detail != "" ? ", " + root.detail : ""); + (root.detail != "" ? ", " + root.detail : "");
@@ -69,10 +70,10 @@ export component AboutNavigationRow inherits Rectangle {
x: 12px; y: (parent.height - self.height) / 2; x: 12px; y: (parent.height - self.height) / 2;
width: 28px; height: 28px; width: 28px; height: 28px;
border-radius: UiRadius.control; border-radius: UiRadius.control;
background: WorkbenchStyle.field-surface; background: ControlStyle.surface;
Text { Text {
text: root.icon; font-family: "Font Awesome 6 Free"; font-weight: 900; text: root.icon; font-family: "Font Awesome 6 Free"; font-weight: 900;
font-size: 13px; color: WorkbenchStyle.muted; font-size: 13px; color: ControlStyle.icon;
horizontal-alignment: center; vertical-alignment: center; horizontal-alignment: center; vertical-alignment: center;
accessible-role: none; accessible-role: none;
} }
@@ -226,7 +227,7 @@ export component AboutPanel inherits SettingsDialog {
footer := VerticalLayout { footer := VerticalLayout {
x: 24px; y: parent.height - self.height - 24px; x: 24px; y: parent.height - self.height - 24px;
width: parent.width - 48px; height: self.preferred-height; spacing: 12px; width: parent.width - 48px; height: self.preferred-height; spacing: 12px;
Rectangle { height: 1px; background: WorkbenchStyle.separator; } SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
HorizontalLayout { HorizontalLayout {
alignment: end; alignment: end;
SettingsButton { text: AboutStrings.back; clicked => { root.back(); } } SettingsButton { text: AboutStrings.back; clicked => { root.back(); } }
+141 -89
View File
@@ -1,17 +1,42 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line
// colors in one place so the Slint renderer produces the same visual weight.
export global ImGuiLineStyle {
out property <color> border: #0000004d;
out property <color> separator: #6363639e;
out property <color> divider: #0000007a;
out property <color> control-separator: #b2b2b2;
}
export global FloatingBarStyle { export global FloatingBarStyle {
out property <length> corner-radius: UiRadius.menu; out property <length> corner-radius: UiRadius.menu;
} }
// Fields and selection controls share a muted fill.
export global ControlStyle {
out property <color> surface: #dce6f3;
out property <color> hover: #cedef1;
out property <color> pressed: #bfd2ec;
out property <color> selected: #bcd4f7;
out property <color> selected-hover: #adcaf1;
out property <color> selected-pressed: #99bce8;
out property <color> selected-text: #1d4e9e;
out property <color> disabled: #f0f2f5;
out property <color> icon: #52657f;
out property <color> input-surface: #dee7f2;
out property <color> input-focused: #e5ecf5;
out property <color> input-placeholder: #566a84;
// These tints over white match the field fills for standard LineEdit.
out property <color> input-tint: #235eaa26;
out property <color> input-focus-tint: #235eaa1e;
}
// Action buttons use a light surface and a crisp edge, distinct from fields.
export global ButtonStyle {
out property <color> surface: #ffffff;
out property <color> hover: #f0f5ff;
out property <color> pressed: #dfe9fa;
out property <color> border: #b8c7db;
out property <color> hover-border: #7e9fd1;
out property <color> pressed-border: #6488bf;
out property <color> disabled-border: #dce2eb;
out property <color> selected: #3564e3;
out property <color> selected-hover: #4574f0;
out property <color> selected-pressed: #254cc0;
}
// ImGui loads a 32px font atlas and applies per-window font scales. These // ImGui loads a 32px font atlas and applies per-window font scales. These
// logical sizes reproduce those visual tiers on Slint's HiDPI renderer. // logical sizes reproduce those visual tiers on Slint's HiDPI renderer.
export global ImGuiFontStyle { export global ImGuiFontStyle {
@@ -69,10 +94,10 @@ export component IconButton inherits Rectangle {
in property <bool> enabled: true; in property <bool> enabled: true;
in property <bool> danger: false; in property <bool> danger: false;
in property <length> icon-size: 16px; in property <length> icon-size: 16px;
in property <color> icon-color: #24272d; in property <color> icon-color: ControlStyle.icon;
in property <color> normal-background: transparent; in property <color> normal-background: transparent;
in property <color> hover-background: #edf0f5; in property <color> hover-background: ControlStyle.hover;
in property <color> pressed-background: #d9dde5; in property <color> pressed-background: ControlStyle.pressed;
in property <bool> slashed: false; in property <bool> slashed: false;
callback clicked; callback clicked;
@@ -138,8 +163,8 @@ export component ImageButton inherits Rectangle {
width: 30px; width: 30px;
height: 30px; height: 30px;
background: touch.pressed ? (danger ? #d92d20 : #d9dde5) background: touch.pressed ? (danger ? #d92d20 : ControlStyle.pressed)
: touch.has-hover ? (danger ? #f04438 : #edf0f5) : touch.has-hover ? (danger ? #f04438 : ControlStyle.hover)
: transparent; : transparent;
border-radius: UiRadius.control; border-radius: UiRadius.control;
@@ -174,14 +199,14 @@ export component ImageButton inherits Rectangle {
} }
} }
export component CompactComboBox inherits Rectangle { export component CompactComboBox inherits FlatSurface {
in property <[string]> model; in property <[string]> model;
in-out property <int> current-index: 0; in-out property <int> current-index: 0;
in property <bool> enabled: true; in property <bool> enabled: true;
in property <bool> subtle: false; in property <bool> subtle: false;
in property <bool> show-arrow: true; in property <bool> show-arrow: true;
in property <color> normal-border-color: root.subtle ? #dfe6ef : #bfc2c7; in property <color> normal-outline-color: ButtonStyle.border;
in property <color> hover-border-color: root.normal-border-color; in property <color> hover-outline-color: ButtonStyle.hover-border;
property <bool> popup-animation-enabled: false; property <bool> popup-animation-enabled: false;
property <float> popup-progress: 0.0; property <float> popup-progress: 0.0;
callback selected(int); callback selected(int);
@@ -205,43 +230,45 @@ export component CompactComboBox inherits Rectangle {
width: 73px; width: 73px;
height: 24px; height: 24px;
background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1; background: !root.enabled ? ControlStyle.disabled
border-width: 1px; : combo-touch.pressed ? ButtonStyle.pressed
border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color; : combo-popup.is-open || combo-touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
outline-focused: root.enabled && combo-popup.is-open;
outline-color: !root.enabled ? ButtonStyle.disabled-border
: combo-popup.is-open ? UiSurface.focus
: combo-touch.pressed ? ButtonStyle.pressed-border
: combo-touch.has-hover ? root.hover-outline-color : root.normal-outline-color;
border-radius: UiRadius.control; border-radius: UiRadius.control;
animate background, outline-color { duration: 120ms; }
accessible-role: combobox;
accessible-enabled: root.enabled;
accessible-expandable: true;
accessible-expanded: combo-popup.is-open;
accessible-value: root.model[root.current-index];
Text { Text {
x: root.subtle ? 10px : 5px; x: root.subtle ? 10px : 8px;
width: parent.width - (root.show-arrow ? (root.subtle ? 36px : 24px) : (root.subtle ? 20px : 10px)); width: parent.width - self.x - (root.show-arrow ? 28px : self.x);
height: parent.height;
text: root.model[root.current-index]; text: root.model[root.current-index];
color: root.enabled ? #202124 : #8b8f95; color: root.enabled ? #202124 : #8b8f95;
font-size: root.subtle ? 12px : ImGuiFontStyle.body; font-size: root.subtle ? 12px : ImGuiFontStyle.body;
overflow: elide; overflow: elide;
vertical-alignment: center; vertical-alignment: center;
accessible-role: none;
} }
if root.show-arrow: Rectangle { if root.show-arrow: Text {
x: parent.width - (root.subtle ? 27px : 21px); x: parent.width - 26px;
width: 20px; width: 18px; height: parent.height;
height: parent.height - 2px; // Switch glyphs so the software renderer also shows the open state.
y: 1px; text: combo-popup.is-open ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down;
background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc; color: !root.enabled ? #98a2b3 : combo-popup.is-open ? UiSurface.focus : ControlStyle.icon;
border-top-right-radius: UiRadius.control - 1px; font-family: "Font Awesome 6 Free";
border-bottom-right-radius: UiRadius.control - 1px; font-weight: 900;
Text { font-size: 10px;
text: FontAwesomeIcons.angle-down; horizontal-alignment: center;
color: root.subtle ? #79879a : #202124; vertical-alignment: center;
font-family: "Font Awesome 6 Free"; accessible-role: none;
font-weight: 900;
font-size: 10px;
horizontal-alignment: center;
vertical-alignment: center;
transform-rotation: combo-popup.is-open ? 180deg : 0deg;
animate transform-rotation {
duration: 140ms;
easing: ease-out;
enabled: combo-popup.is-open;
}
}
} }
combo-touch := TouchArea { combo-touch := TouchArea {
enabled: root.enabled; mouse-cursor: pointer; enabled: root.enabled; mouse-cursor: pointer;
@@ -269,12 +296,12 @@ export component CompactComboBox inherits Rectangle {
combo-popup := PopupWindow { combo-popup := PopupWindow {
x: 0px; x: 0px;
y: root.height; y: root.height + 4px;
width: root.width; width: root.width;
height: root.model.length * 23px; height: root.model.length * 23px + 8px;
close-policy: close-on-click-outside; close-policy: close-on-click-outside;
Rectangle { FlatSurface {
// Keep the reveal anchored to the popup's top edge while the // Keep the reveal anchored to the popup's top edge while the
// content settles downward into its final position. // content settles downward into its final position.
y: (1.0 - root.popup-progress) * -8px; y: (1.0 - root.popup-progress) * -8px;
@@ -282,18 +309,33 @@ export component CompactComboBox inherits Rectangle {
height: parent.height * root.popup-progress; height: parent.height * root.popup-progress;
opacity: root.popup-progress; opacity: root.popup-progress;
background: white; background: white;
border-width: 1px; outline-color: root.normal-outline-color;
border-color: root.normal-border-color;
border-radius: UiRadius.menu; border-radius: UiRadius.menu;
clip: true; clip: true;
for choice[index] in root.model: Rectangle { for choice[index] in root.model: Rectangle {
y: index * 23px; x: 4px; y: 4px + index * 23px;
width: parent.width; width: parent.width - 8px;
height: 23px; height: 23px;
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white; border-radius: UiRadius.control;
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } background: index == root.current-index
? (option-touch.pressed ? ButtonStyle.pressed : ButtonStyle.hover)
: option-touch.pressed ? ButtonStyle.pressed : option-touch.has-hover ? ButtonStyle.hover : white;
Text {
x: 6px; width: parent.width - 28px; height: parent.height; text: choice;
color: index == root.current-index ? ControlStyle.selected-text : #202124;
font-weight: index == root.current-index ? 600 : 400;
font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center;
}
if index == root.current-index: Text {
x: parent.width - 20px; width: 14px; height: parent.height;
text: FontAwesomeIcons.check; color: UiSurface.focus;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
option-touch := TouchArea { option-touch := TouchArea {
enabled: root.enabled; enabled: root.enabled;
mouse-cursor: pointer;
clicked => { clicked => {
popup-open-timer.running = false; popup-open-timer.running = false;
root.popup-animation-enabled = false; root.popup-animation-enabled = false;
@@ -306,12 +348,12 @@ export component CompactComboBox inherits Rectangle {
} }
} }
} }
Rectangle { FlatSurface {
width: parent.width; width: parent.width;
height: parent.height; height: parent.height;
background: transparent; background: transparent;
border-width: 1px; clip: true;
border-color: root.normal-border-color; outline-color: root.normal-outline-color;
border-radius: UiRadius.menu; border-radius: UiRadius.menu;
z: 2; z: 2;
} }
@@ -319,19 +361,20 @@ export component CompactComboBox inherits Rectangle {
} }
} }
export component ImGuiCheckBox inherits Rectangle { export component ImGuiCheckBox inherits FlatSurface {
in-out property <bool> checked; in-out property <bool> checked;
in property <bool> enabled: true; in property <bool> enabled: true;
width: 21px; width: 21px;
height: 21px; height: 21px;
background: root.enabled ? white : #f1f1f1; background: !root.enabled ? ControlStyle.disabled
border-width: 1px; : root.checked ? (check-touch.pressed ? #254cc0 : check-touch.has-hover ? #4574f0 : #3564e3)
border-color: check-touch.has-hover && root.enabled ? #8cbef2 : #b9bdc2; : check-touch.pressed ? ControlStyle.pressed : check-touch.has-hover ? ControlStyle.hover : ControlStyle.surface;
outline-color: check-touch.has-hover && root.enabled ? UiSurface.focus : UiSurface.edge;
border-radius: 4px; border-radius: 4px;
if root.checked: Text { if root.checked: Text {
text: FontAwesomeIcons.check; text: FontAwesomeIcons.check;
color: root.enabled ? #2e86de : #8b8f95; color: root.enabled ? white : #8b8f95;
font-family: "Font Awesome 6 Free"; font-family: "Font Awesome 6 Free";
font-weight: 900; font-weight: 900;
font-size: 16px; font-size: 16px;
@@ -368,7 +411,7 @@ export component MiniToggleSwitch inherits Rectangle {
} }
} }
export component CompactButton inherits Rectangle { export component CompactButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <length> text-size: ImGuiFontStyle.body; in property <length> text-size: ImGuiFontStyle.body;
in property <bool> primary: false; in property <bool> primary: false;
@@ -378,13 +421,17 @@ export component CompactButton inherits Rectangle {
width: 34px; width: 34px;
height: 24px; height: 24px;
background: !root.enabled ? (root.quiet ? transparent : #eceef0) background: !root.enabled ? (root.quiet ? transparent : ControlStyle.disabled)
: button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd) : button-touch.pressed ? (root.primary ? #82b8f2 : ButtonStyle.pressed)
: button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2) : button-touch.has-hover ? (root.primary ? #b8d9fb : ButtonStyle.hover)
: root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7; : root.primary ? #a8d0fa : root.quiet ? transparent : ButtonStyle.surface;
border-width: 1px; outline-enabled: !root.quiet;
border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0; outline-color: !root.enabled ? ButtonStyle.disabled-border
: root.primary ? UiSurface.focus
: button-touch.pressed ? ButtonStyle.pressed-border
: button-touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
border-radius: UiRadius.control; border-radius: UiRadius.control;
animate background, outline-color { duration: 100ms; }
Text { Text {
text: root.text; text: root.text;
color: root.enabled ? #24272d : #8e9298; color: root.enabled ? #24272d : #8e9298;
@@ -393,27 +440,30 @@ export component CompactButton inherits Rectangle {
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
} }
button-touch := TouchArea { enabled: root.enabled; clicked => { root.clicked(); } } button-touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } }
} }
export component PrimaryButton inherits Rectangle { export component PrimaryButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <length> text-size: ImGuiFontStyle.body; in property <length> text-size: 12px;
in property <bool> enabled: true; in property <bool> enabled: true;
callback clicked; callback clicked;
min-width: 72px; min-width: 72px;
height: 30px; height: 32px;
background: !root.enabled ? #b8c1d1 background: !root.enabled ? #b8c1d1
: touch.pressed ? #1849a9 : touch.pressed ? #254cc0
: touch.has-hover ? #2970d6 : touch.has-hover ? #4574f0
: #2463c7; : #3564e3;
outline-enabled: false;
border-radius: UiRadius.control; border-radius: UiRadius.control;
animate background { duration: 100ms; }
Text { Text {
text: root.text; text: root.text;
color: white; color: white;
font-size: root.text-size; font-size: root.text-size;
font-weight: 600;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
} }
@@ -424,32 +474,35 @@ export component PrimaryButton inherits Rectangle {
} }
} }
export component SecondaryButton inherits Rectangle { export component SecondaryButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <length> text-size: ImGuiFontStyle.body; in property <length> text-size: 12px;
in property <bool> enabled: true; in property <bool> enabled: true;
callback clicked; callback clicked;
min-width: 72px; min-width: 72px;
height: 30px; height: 32px;
background: !root.enabled ? #f1f2f4 background: !root.enabled ? ControlStyle.disabled
: touch.pressed ? #dfe3e9 : touch.pressed ? ButtonStyle.pressed
: touch.has-hover ? #f3f4f6 : white; : touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
border-width: 1px; outline-color: !root.enabled ? ButtonStyle.disabled-border
border-color: #c9ced7; : touch.pressed ? ButtonStyle.pressed-border
: touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
border-radius: UiRadius.control; border-radius: UiRadius.control;
clip: true; animate background, outline-color { duration: 100ms; }
Text { Text {
text: root.text; text: root.text;
color: root.enabled ? #24272d : #949aa5; color: root.enabled ? #303f55 : #949aa5;
font-size: root.text-size; font-size: root.text-size;
font-weight: 500;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
} }
touch := TouchArea { touch := TouchArea {
enabled: root.enabled; enabled: root.enabled;
mouse-cursor: pointer;
clicked => { root.clicked(); } clicked => { root.clicked(); }
} }
} }
@@ -495,9 +548,8 @@ export component SectionTitle inherits Text {
vertical-alignment: center; vertical-alignment: center;
} }
export component DialogSurface inherits Rectangle { export component DialogSurface inherits FlatSurface {
background: white; background: white;
border-width: 1px; outline-color: UiSurface.edge;
border-color: ImGuiLineStyle.border;
border-radius: UiRadius.surface; border-radius: UiRadius.surface;
} }
@@ -1,7 +1,8 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint"; import { RoundedLineEdit } from "rounded_line_edit.slint";
import { CompactButton, ImGuiCheckBox } from "common.slint"; import { CompactButton, ImGuiCheckBox, ControlStyle } from "common.slint";
import { GalleryStrings } from "device_strings.slint"; import { GalleryStrings } from "device_strings.slint";
export struct DeviceGroupOption { export struct DeviceGroupOption {
@@ -20,13 +21,13 @@ export component DeviceGroupDialog inherits Rectangle {
callback cancel(); callback cancel();
background: #00000040; background: #00000040;
TouchArea {} TouchArea {}
dialog := Rectangle { dialog := FlatSurface {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
width: min(360px, parent.width - 40px); width: min(360px, parent.width - 40px);
height: 208px + group-list.height + (root.error == "" ? 0px : 32px); height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
background: white; border-radius: UiRadius.surface; background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #b2b2b2; outline-color: UiSurface.edge;
TouchArea {} TouchArea {}
Text { Text {
x: 18px; y: 16px; height: 24px; x: 18px; y: 16px; height: 24px;
@@ -41,10 +42,10 @@ export component DeviceGroupDialog inherits Rectangle {
x: 18px; y: 67px; width: parent.width - 36px; height: 16px; x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
text: "ID " + root.device-id; color: #858c97; font-size: 11px; text: "ID " + root.device-id; color: #858c97; font-size: 11px;
} }
group-list := Rectangle { group-list := FlatSurface {
x: 18px; y: 95px; width: parent.width - 36px; x: 18px; y: 95px; width: parent.width - 36px;
height: min(5, max(1, root.options.length)) * 32px + 2px; height: min(5, max(1, root.options.length)) * 32px + 2px;
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: UiRadius.surface; background: #fbfcfd; outline-color: UiSurface.edge; border-radius: UiRadius.surface;
clip: true; clip: true;
ScrollView { ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
@@ -52,16 +53,21 @@ export component DeviceGroupDialog inherits Rectangle {
VerticalLayout { VerticalLayout {
spacing: 0px; spacing: 0px;
for option[index] in root.options: Rectangle { for option[index] in root.options: Rectangle {
private property <bool> selected: option.selected && (option.name != "" || root.new-group == "");
height: 32px; height: 32px;
background: row-touch.has-hover ? #eef4fc : transparent; background: self.selected
? (row-touch.pressed ? ControlStyle.selected-pressed : row-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
: row-touch.pressed ? ControlStyle.pressed : row-touch.has-hover ? ControlStyle.hover : transparent;
ImGuiCheckBox { ImGuiCheckBox {
x: 9px; y: 6px; width: 20px; height: 20px; x: 9px; y: 6px; width: 20px; height: 20px;
checked: option.selected && (option.name != "" || root.new-group == ""); checked: parent.selected;
} }
Text { Text {
x: 39px; width: parent.width - 48px; height: parent.height; x: 39px; width: parent.width - 48px; height: parent.height;
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name; text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name;
color: #30343b; font-size: 12px; overflow: elide; vertical-alignment: center; color: parent.selected ? ControlStyle.selected-text : #30343b;
font-weight: parent.selected ? 600 : 400;
font-size: 12px; overflow: elide; vertical-alignment: center;
} }
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } } row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
} }
@@ -75,10 +81,10 @@ export component DeviceGroupDialog inherits Rectangle {
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface; border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white; border-color: white;
} }
Rectangle { FlatSurface {
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface; border-radius: UiRadius.surface;
border-color: #e1e5eb; outline-color: UiSurface.edge;
} }
} }
RoundedLineEdit { RoundedLineEdit {
@@ -3,4 +3,16 @@ export global GalleryStrings {
public pure function tr(zh: string, en: string, ru: string) -> string { public pure function tr(zh: string, en: string, ru: string) -> string {
return self.language == 0 ? zh : self.language == 2 ? ru : en; return self.language == 0 ? zh : self.language == 2 ? ru : en;
} }
public pure function device-count(count: int) -> string {
if self.language == 0 { return count + " 台设备"; }
if self.language == 2 {
let last-two = mod(count, 100);
let last = mod(count, 10);
if last-two >= 11 && last-two <= 14 { return count + " устройств"; }
if last == 1 { return count + " устройство"; }
if last >= 2 && last <= 4 { return count + " устройства"; }
return count + " устройств";
}
return count + (count == 1 ? " device" : " devices");
}
} }
@@ -1,3 +1,4 @@
import { SurfaceSeparator, UiSurface } from "ui_surface.slint";
import { FileTransferEntry } from "stream_window.slint"; import { FileTransferEntry } from "stream_window.slint";
import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint"; import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint";
import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint"; import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint";
@@ -26,7 +27,7 @@ export component FileTransferNotification inherits Window {
DialogSurface { DialogSurface {
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
background: WorkbenchStyle.surface; background: WorkbenchStyle.surface;
border-color: WorkbenchStyle.separator; outline-color: UiSurface.edge;
Text { Text {
x: 16px; y: 8px; x: 16px; y: 8px;
width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px); width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px);
@@ -50,9 +51,9 @@ export component FileTransferNotification inherits Window {
pressed-background: WorkbenchStyle.pressed-surface; pressed-background: WorkbenchStyle.pressed-surface;
clicked => { root.dismissed(); } clicked => { root.dismissed(); }
} }
Rectangle { SurfaceSeparator {
x: 16px; y: 40px; width: parent.width - 32px; height: 1px; x: 16px; y: 40px; width: parent.width - 32px; height: 1px;
background: WorkbenchStyle.separator; outline-color: UiSurface.separator;
} }
Text { Text {
x: 16px; y: 47px; width: parent.width - 32px; height: 18px; x: 16px; y: 47px; width: parent.width - 32px; height: 18px;
@@ -96,7 +97,7 @@ export component FileTransferNotification inherits Window {
text: root.cancel-text; text: root.cancel-text;
text-size: ImGuiFontStyle.base; text-size: ImGuiFontStyle.base;
text-color: WorkbenchStyle.muted; text-color: WorkbenchStyle.muted;
normal-border-color: WorkbenchStyle.border; normal-outline-color: UiSurface.edge;
clicked => { root.cancel-requested(); } clicked => { root.cancel-requested(); }
} }
} }
@@ -1,5 +1,6 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { CompactButton, FontAwesomeIcons } from "common.slint"; import { CompactButton, FontAwesomeIcons, ControlStyle } from "common.slint";
export struct GalleryMenuItem { label: string, enabled: bool } export struct GalleryMenuItem { label: string, enabled: bool }
@@ -88,12 +89,11 @@ export component GalleryMenuButton inherits Rectangle {
color: root.enabled ? #62666d : #8e9298; color: root.enabled ? #62666d : #8e9298;
horizontal-alignment: center; vertical-alignment: center; horizontal-alignment: center; vertical-alignment: center;
} }
if root.compact-trigger: Rectangle { if root.compact-trigger: FlatSurface {
background: more-touch.pressed || popup.is-open ? #e5edfc : more-touch.has-hover ? white : #fffffff0; background: more-touch.pressed || popup.is-open ? ControlStyle.selected : more-touch.has-hover ? ControlStyle.hover : white;
border-radius: UiRadius.menu; border-radius: UiRadius.menu;
border-width: 1px; outline-color: trigger-focus.has-focus ? UiSurface.focus : UiSurface.edge;
border-color: trigger-focus.has-focus ? #3564e3 : #ffffff80; animate background, outline-color { duration: 120ms; }
animate background, border-color { duration: 120ms; }
for dot in 3: Rectangle { for dot in 3: Rectangle {
x: parent.width / 2 - 6px + dot * 5px; x: parent.width / 2 - 6px + dot * 5px;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
@@ -117,11 +117,12 @@ export component GalleryMenuButton inherits Rectangle {
forward-focus: popup-focus; forward-focus: popup-focus;
popup-focus := FocusScope { popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); } key-pressed(event) => { return root.handle-key(event); }
Rectangle { FlatSurface {
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu; background: white; outline-color: UiSurface.edge; border-radius: UiRadius.menu;
if root.destructive-index > 0: Rectangle { clip: true;
if root.destructive-index > 0: SurfaceSeparator {
x: 10px; y: root.menu-padding + root.destructive-index * root.row-height + (root.divider-spacing - 1px) / 2; x: 10px; y: root.menu-padding + root.destructive-index * root.row-height + (root.divider-spacing - 1px) / 2;
width: parent.width - 20px; height: 1px; background: #e8edf4; width: parent.width - 20px; height: 1px; outline-color: UiSurface.separator;
} }
for item[index] in root.items: Rectangle { for item[index] in root.items: Rectangle {
private property <bool> destructive: index == root.destructive-index; private property <bool> destructive: index == root.destructive-index;
@@ -132,7 +133,9 @@ export component GalleryMenuButton inherits Rectangle {
+ (root.destructive-index > 0 && index >= root.destructive-index ? root.divider-spacing : 0px); + (root.destructive-index > 0 && index >= root.destructive-index ? root.divider-spacing : 0px);
width: parent.width - root.menu-padding * 2; height: root.row-height; width: parent.width - root.menu-padding * 2; height: root.row-height;
border-radius: UiRadius.control; border-radius: UiRadius.control;
background: item.enabled && index == root.current-index ? (self.destructive ? #fff0f0 : #edf4fc) : transparent; background: item.enabled && index == root.current-index
? (self.destructive ? (item-touch.pressed ? #f6babb : #fbe0e0)
: item-touch.pressed ? ControlStyle.pressed : ControlStyle.hover) : transparent;
accessible-role: button; accessible-label: item.label; accessible-enabled: item.enabled; accessible-role: button; accessible-label: item.label; accessible-enabled: item.enabled;
accessible-action-default => { root.choose(index); } accessible-action-default => { root.choose(index); }
if self.has-icon: Text { if self.has-icon: Text {
@@ -148,7 +151,7 @@ export component GalleryMenuButton inherits Rectangle {
text: item.label; color: parent.foreground; text: item.label; color: parent.foreground;
font-size: root.compact-menu ? 11px : 12px; vertical-alignment: center; font-size: root.compact-menu ? 11px : 12px; vertical-alignment: center;
} }
TouchArea { item-touch := TouchArea {
enabled: item.enabled; enabled: item.enabled;
mouse-cursor: pointer; mouse-cursor: pointer;
changed has-hover => { if self.has-hover { root.current-index = index; } } changed has-hover => { if self.has-hover { root.current-index = index; } }
@@ -1,3 +1,4 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint"; import { GalleryStrings } from "device_strings.slint";
import { CompactButton } from "common.slint"; import { CompactButton } from "common.slint";
@@ -66,7 +67,7 @@ component NetworkRow inherits Rectangle {
text: !root.device.encryption-known text: !root.device.encryption-known
? GalleryStrings.tr("待确认", "Unknown", "Неизвестно") ? GalleryStrings.tr("待确认", "Unknown", "Неизвестно")
: root.device.encryption-enabled : root.device.encryption-enabled
? GalleryStrings.tr("已加密", "Encrypted", "Защищено") ? GalleryStrings.tr("已加密", "Encrypted", "Зашифровано")
: GalleryStrings.tr("未加密", "Unencrypted", "Без шифр."); : GalleryStrings.tr("未加密", "Unencrypted", "Без шифр.");
color: !root.device.encryption-known ? #7a8490 color: !root.device.encryption-known ? #7a8490
: root.device.encryption-enabled ? #18794e : #8a6428; : root.device.encryption-enabled ? #18794e : #8a6428;
@@ -190,13 +191,13 @@ export component GalleryNetworkDialog inherits Rectangle {
callback close(); callback close();
background: #00000042; background: #00000042;
TouchArea { clicked => { root.close(); } } TouchArea { clicked => { root.close(); } }
Rectangle { FlatSurface {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
width: min(760px, parent.width - 32px); width: min(760px, parent.width - 32px);
height: min(120px + root.list-height, parent.height - 32px); height: min(120px + root.list-height, parent.height - 32px);
background: white; border-radius: UiRadius.surface; background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #c6cdd5; outline-color: UiSurface.edge;
TouchArea {} TouchArea {}
FocusScope { FocusScope {
init => { self.focus(); } init => { self.focus(); }
@@ -207,7 +208,7 @@ export component GalleryNetworkDialog inherits Rectangle {
} }
Text { Text {
x: 20px; y: 17px; width: parent.width - 120px; height: 24px; x: 20px; y: 17px; width: parent.width - 120px; height: 24px;
text: GalleryStrings.tr("设备网络状态", "Device network status", "Сеть устройств"); text: GalleryStrings.tr("设备网络状态", "Device network status", "Состояние сети устройств");
font-size: 17px; font-weight: 600; color: #293746; font-size: 17px; font-weight: 600; color: #293746;
} }
CompactButton { CompactButton {
@@ -237,7 +238,7 @@ export component GalleryNetworkDialog inherits Rectangle {
for device[index] in root.devices: NetworkRow { for device[index] in root.devices: NetworkRow {
y: index * root.row-height; width: list.viewport-width; height: root.row-height; y: index * root.row-height; width: list.viewport-width; height: root.row-height;
device: device; background: mod(index, 2) == 0 ? white : #f8fafc; device: device; background: mod(index, 2) == 0 ? white : #f8fafc;
Rectangle { y: parent.height - 1px; height: 1px; background: #edf0f4; } SurfaceSeparator { y: parent.height - 1px; height: 1px; outline-color: UiSurface.separator; }
} }
} }
if root.devices.length == 0: NetworkEmptyState { if root.devices.length == 0: NetworkEmptyState {
@@ -11,7 +11,7 @@ export component GallerySettings inherits Rectangle {
y: 0px; width: parent.width; y: 0px; width: parent.width;
title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3; title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3;
SettingsRow { SettingsRow {
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение"); label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Макс. разрешение");
label-reserve: self.width - 112px; label-reserve: self.width - 112px;
SettingsSelect { SettingsSelect {
border-radius: UiRadius.control; border-radius: UiRadius.control;
@@ -33,7 +33,7 @@ export component GallerySettings inherits Rectangle {
} }
} }
SettingsRow { SettingsRow {
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео"); label: GalleryStrings.tr("视频总带宽", "Total bandwidth", "Общий битрейт");
label-reserve: self.width - 112px; divider: false; label-reserve: self.width - 112px; divider: false;
SettingsSelect { SettingsSelect {
border-radius: UiRadius.control; border-radius: UiRadius.control;
+71 -66
View File
@@ -1,3 +1,4 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint"; import { GalleryStrings } from "device_strings.slint";
import { RemoteCursor } from "remote_cursor.slint"; import { RemoteCursor } from "remote_cursor.slint";
@@ -6,7 +7,7 @@ import { GalleryMenuButton } from "gallery_menu_button.slint";
import { GroupFilter } from "group_filter.slint"; import { GroupFilter } from "group_filter.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint"; import { RoundedLineEdit } from "rounded_line_edit.slint";
import { CompactButton, SecondaryButton, IconButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint"; import { CompactButton, SecondaryButton, IconButton, FontAwesomeIcons, ImGuiCheckBox, ControlStyle } from "common.slint";
component GalleryButton inherits CompactButton { component GalleryButton inherits CompactButton {
border-radius: UiRadius.control; border-radius: UiRadius.control;
@@ -289,7 +290,7 @@ export component GalleryView inherits Rectangle {
selected(index) => { root.action("group", index); } selected(index) => { root.action("group", index); }
} }
Text { Text {
text: root.count + GalleryStrings.tr(" 台设备", " devices", " устройств"); text: GalleryStrings.device-count(root.count);
min-width: 0px; overflow: elide; font-size: 11px; color: #858c96; vertical-alignment: center; min-width: 0px; overflow: elide; font-size: 11px; color: #858c96; vertical-alignment: center;
} }
Rectangle { horizontal-stretch: 1; min-width: 0px; } Rectangle { horizontal-stretch: 1; min-width: 0px; }
@@ -335,8 +336,8 @@ export component GalleryView inherits Rectangle {
: GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор"); : GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор");
clicked => { root.action("all-select", ""); } clicked => { root.action("all-select", ""); }
} }
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; primary: true; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } } GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; primary: true; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action("connect", ""); } }
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } } GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Disconnect", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
batch-menu := GalleryMenuButton { batch-menu := GalleryMenuButton {
horizontal-padding: root.toolbar-button-padding; horizontal-padding: root.toolbar-button-padding;
width: self.preferred-button-width; enabled: root.selected-count > 0; width: self.preferred-button-width; enabled: root.selected-count > 0;
@@ -473,7 +474,7 @@ export component GalleryView inherits Rectangle {
height: root.card-height; height: root.card-height;
opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1; opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1;
background: !tile.online ? DeviceCardStyle.offline-background background: !tile.online ? DeviceCardStyle.offline-background
: tile.selected ? #eaf3ff : root.batch && tile-touch.has-hover ? #f3f8fe : white; : tile.selected ? ControlStyle.selected : root.batch && tile-touch.has-hover ? ControlStyle.hover : white;
Rectangle { Rectangle {
x: root.card-inset; y: self.x; x: root.card-inset; y: self.x;
width: parent.width - self.x * 2; width: parent.width - self.x * 2;
@@ -491,12 +492,13 @@ export component GalleryView inherits Rectangle {
width: parent.width - self.x - (tile-touch.has-hover && !root.batch ? DeviceCardStyle.toolbar-width + 12px : root.batch ? 35px : 9px); width: parent.width - self.x - (tile-touch.has-hover && !root.batch ? DeviceCardStyle.toolbar-width + 12px : root.batch ? 35px : 9px);
height: 24px; text: tile.name; color: tile.online ? #24272d : DeviceCardStyle.offline-text; font-size: 12px; overflow: elide; vertical-alignment: center; height: 24px; text: tile.name; color: tile.online ? #24272d : DeviceCardStyle.offline-text; font-size: 12px; overflow: elide; vertical-alignment: center;
} }
if root.batch && !root.simplified: Rectangle { if root.batch && !root.simplified: FlatSurface {
x: parent.width - 27px; y: parent.height - 27px; x: parent.width - 27px; y: parent.height - 27px;
width: 18px; height: 18px; border-radius: 4px; border-width: 1px; width: 18px; height: 18px; border-radius: 4px;
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white; background: tile.selected ? (tile-touch.pressed ? #254cc0 : tile-touch.has-hover ? #4574f0 : #3564e3)
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8; : tile-touch.pressed ? ControlStyle.pressed : tile-touch.has-hover ? ControlStyle.hover : ControlStyle.surface;
animate background, border-color { duration: 120ms; } outline-color: tile.selected ? UiSurface.focus : tile-touch.has-hover ? UiSurface.hover : UiSurface.edge;
animate background, outline-color { duration: 120ms; }
if tile.selected: Text { if tile.selected: Text {
text: FontAwesomeIcons.check; color: white; text: FontAwesomeIcons.check; color: white;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px;
@@ -531,27 +533,27 @@ export component GalleryView inherits Rectangle {
} }
} }
// Draw each table line once, continuously across occupied and empty cells. // Draw each table line once, continuously across occupied and empty cells.
for column in root.columns + 1: Rectangle { for column in root.columns + 1: SurfaceSeparator {
visible: root.count > 0; visible: root.count > 0;
x: min(column * root.card-width, grid.table-width - 1px); x: min(column * root.card-width, grid.table-width - 1px);
y: 0px; y: 0px;
width: 1px; height: grid.table-height; width: 1px; height: grid.table-height;
background: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column) vertical: true; outline-color: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column)
? #76aeea : #c9cdd2; ? UiSurface.focus : UiSurface.separator;
} }
for row in root.rows + 1: Rectangle { for row in root.rows + 1: SurfaceSeparator {
visible: root.count > 0; visible: root.count > 0;
x: 0px; x: 0px;
y: min(row * root.card-height, grid.table-height - 1px); y: min(row * root.card-height, grid.table-height - 1px);
width: grid.table-width; height: 1px; width: grid.table-width; height: 1px;
background: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row) outline-color: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row)
? #76aeea : #c9cdd2; ? UiSurface.focus : UiSurface.separator;
} }
if root.drag-target >= 0: Rectangle { if root.drag-target >= 0: FlatSurface {
x: mod(root.drag-target, root.columns) * root.card-width + 1px; x: mod(root.drag-target, root.columns) * root.card-width + 1px;
y: floor(root.drag-target / root.columns) * root.card-height + 1px; y: floor(root.drag-target / root.columns) * root.card-height + 1px;
width: root.card-width - 2px; height: root.card-height - 2px; width: root.card-width - 2px; height: root.card-height - 2px;
background: #76aeea26; border-width: 2px; border-color: #4a94de; background: #76aeea26; outline-color: UiSurface.focus;
} }
if root.count == 0: Text { if root.count == 0: Text {
text: root.simplified text: root.simplified
@@ -577,7 +579,7 @@ export component GalleryView inherits Rectangle {
background: root.controlling ? #e7f1fd : #f0f2f5; background: root.controlling ? #e7f1fd : #f0f2f5;
focus-state := Text { focus-state := Text {
x: 8px; width: parent.width - 16px; height: parent.height; x: 8px; width: parent.width - 16px; height: parent.height;
text: root.group-controlling ? GalleryStrings.tr("群控中", "Group control", "Групповое") text: root.group-controlling ? GalleryStrings.tr("群控中", "Group control", "Групп. управление")
: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление") : root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
: root.focused.connected && root.focused.has-frame && !root.focused.stale : root.focused.connected && root.focused.has-frame && !root.focused.stale
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status; ? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
@@ -588,8 +590,8 @@ export component GalleryView inherits Rectangle {
horizontal-padding: root.toolbar-button-padding; horizontal-padding: root.toolbar-button-padding;
primary: root.group-controlling; primary: root.group-controlling;
enabled: root.group-controlling || (root.focused.connected && root.focused.has-frame && !root.focused.stale); enabled: root.group-controlling || (root.focused.connected && root.focused.has-frame && !root.focused.stale);
text: root.group-controlling ? GalleryStrings.tr("关闭群控", "Stop group", "Откл. группу") : GalleryStrings.tr("开启群控", "Group control", "Групп. ввод"); text: root.group-controlling ? GalleryStrings.tr("关闭群控", "Stop group control", "Откл. групп. ввод") : GalleryStrings.tr("开启群控", "Group control", "Групп. ввод");
alternate-text: root.group-controlling ? GalleryStrings.tr("开启群控", "Group control", "Групп. ввод") : GalleryStrings.tr("关闭群控", "Stop group", "Откл. группу"); alternate-text: root.group-controlling ? GalleryStrings.tr("开启群控", "Group control", "Групп. ввод") : GalleryStrings.tr("关闭群控", "Stop group control", "Откл. групп. ввод");
accessible-role: button; accessible-role: button;
accessible-label: self.text + GalleryStrings.tr(":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах"); accessible-label: self.text + GalleryStrings.tr(":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах");
accessible-action-default => { self.clicked(); } accessible-action-default => { self.clicked(); }
@@ -597,11 +599,11 @@ export component GalleryView inherits Rectangle {
} }
GalleryButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } } GalleryButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
} }
Rectangle { FlatSurface {
x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px; x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px;
width: parent.width; height: parent.height - self.y; width: parent.width; height: parent.height - self.y;
background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: UiRadius.surface; background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: UiRadius.surface;
border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : root.focused.online ? #d5dbe3 : DeviceCardStyle.offline-border; outline-color: root.controlling ? UiSurface.focus : UiSurface.edge;
video := Rectangle { video := Rectangle {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
background: root.focused.online ? #24272d : DeviceCardStyle.offline-preview; background: root.focused.online ? #24272d : DeviceCardStyle.offline-preview;
@@ -668,12 +670,12 @@ export component GalleryView inherits Rectangle {
// Collapse the focused tile and its gap without changing the page model. // Collapse the focused tile and its gap without changing the page model.
width: tile.id == root.focused.id ? 0px : root.rail-slot-width; width: tile.id == root.focused.id ? 0px : root.rail-slot-width;
height: root.rail-card-height; visible: tile.id != root.focused.id; height: root.rail-card-height; visible: tile.id != root.focused.id;
rail-card := Rectangle { rail-card := FlatSurface {
private property <string> presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") private property <string> presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети")
: tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
private property <string> details: tile.name + "\n" + tile.host-name + "\nID: " + tile.id + "\n" + tile.group-names + "\n" + self.presence; private property <string> details: tile.name + "\n" + tile.host-name + "\nID: " + tile.id + "\n" + tile.group-names + "\n" + self.presence;
width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: UiRadius.surface; width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: UiRadius.surface;
background: tile.online ? #f5f6f8 : DeviceCardStyle.offline-background; border-width: 1px; border-color: tile.online ? #d5dbe3 : DeviceCardStyle.offline-border; background: tile.online ? #f5f6f8 : DeviceCardStyle.offline-background; outline-color: UiSurface.edge;
accessible-role: button; accessible-role: button;
accessible-label: self.details; accessible-label: self.details;
accessible-action-default => { root.action("focus", tile.id); } accessible-action-default => { root.action("focus", tile.id); }
@@ -714,9 +716,9 @@ export component GalleryView inherits Rectangle {
visible: root.focus-rail-visible; visible: root.focus-rail-visible;
x: 0px; y: rail.y + rail.height; width: parent.width; height: 12px; x: 0px; y: rail.y + rail.height; width: parent.width; height: 12px;
private property <bool> highlighted: divider-touch.has-hover || root.focus-resizing || divider-focus.has-focus; private property <bool> highlighted: divider-touch.has-hover || root.focus-resizing || divider-focus.has-focus;
Rectangle { SurfaceSeparator {
x: 0px; y: 5px; width: parent.width; height: 1px; x: 0px; y: 5px; width: parent.width; height: 1px;
background: focus-divider.highlighted ? #a6c9ed : #e6e9ed; outline-color: focus-divider.highlighted ? UiSurface.focus : UiSurface.separator;
} }
Rectangle { Rectangle {
x: (parent.width - self.width) / 2; y: 4px; width: 36px; height: 3px; border-radius: 1.5px; x: (parent.width - self.width) / 2; y: 4px; width: 36px; height: 3px; border-radius: 1.5px;
@@ -775,7 +777,7 @@ export component GalleryView inherits Rectangle {
} }
Rectangle { Rectangle {
y: parent.height - 44px; height: 44px; background: #fafbfc; y: parent.height - 44px; height: 44px; background: #fafbfc;
Rectangle { y: 0px; width: parent.width; height: 1px; background: #e1e4e8; } SurfaceSeparator { y: 0px; width: parent.width; height: 1px; outline-color: UiSurface.separator; }
} }
// Connection status and pagination remain available in every selection state. // Connection status and pagination remain available in every selection state.
footer := Rectangle { footer := Rectangle {
@@ -792,8 +794,8 @@ export component GalleryView inherits Rectangle {
} }
network-button := SecondaryButton { network-button := SecondaryButton {
x: connection-count.x + connection-count.width + 12px; x: connection-count.x + connection-count.width + 12px;
min-width: 64px; width: 64px; height: parent.height; width: 64px; height: parent.height;
border-color: #c8cbd0; border-radius: UiRadius.control; outline-color: UiSurface.edge; border-radius: UiRadius.control;
text: GalleryStrings.tr("网络状态", "Network", "Сеть"); text: GalleryStrings.tr("网络状态", "Network", "Сеть");
accessible-role: button; accessible-role: button;
accessible-label: self.text; accessible-label: self.text;
@@ -823,6 +825,7 @@ export component GalleryView inherits Rectangle {
IconButton { IconButton {
width: 24px; height: 24px; width: 24px; height: 24px;
border-radius: UiRadius.control; border-radius: UiRadius.control;
normal-background: ControlStyle.surface;
icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand; icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран"); tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
clicked => { root.toggle-fullscreen(); } clicked => { root.toggle-fullscreen(); }
@@ -840,11 +843,11 @@ export component GalleryView inherits Rectangle {
horizontal-alignment: center; vertical-alignment: center; horizontal-alignment: center; vertical-alignment: center;
} }
} }
if root.dragging: Rectangle { if root.dragging: FlatSurface {
x: clamp(grid.x + root.drag-x + 14px, 8px, parent.width - self.width - 8px); x: clamp(grid.x + root.drag-x + 14px, 8px, parent.width - self.width - 8px);
y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px); y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px);
width: 224px; height: 54px; z: 10; width: 224px; height: max(54px, drag-hint.y + drag-hint.height + 9px); z: 10;
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: UiRadius.surface; background: white; outline-color: UiSurface.focus; border-radius: UiRadius.surface;
Rectangle { Rectangle {
x: 8px; y: 9px; width: 64px; height: 36px; x: 8px; y: 9px; width: 64px; height: 36px;
background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview; background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview;
@@ -854,28 +857,28 @@ export component GalleryView inherits Rectangle {
} }
} }
Text { x: 80px; y: 8px; width: parent.width - 88px; height: 18px; text: root.dragged-tile.name; font-size: 12px; color: #303943; overflow: elide; } Text { x: 80px; y: 8px; width: parent.width - 88px; height: 18px; text: root.dragged-tile.name; font-size: 12px; color: #303943; overflow: elide; }
Text { drag-hint := Text {
x: 80px; y: 29px; width: parent.width - 88px; height: 16px; x: 80px; y: 29px; width: parent.width - 88px; height: max(16px, self.preferred-height);
text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page", "Удерживайте для перелистывания") text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page; release to cancel", "Удерживайте для перелистывания; отпустите для отмены")
: root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена") : root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена")
: GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "На другое устройство"); : GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "Перетащите на другое устройство");
font-size: 10px; color: #527ca8; overflow: elide; font-size: 10px; color: #527ca8; wrap: word-wrap;
} }
} }
if root.swap-save-failed: Rectangle { if root.swap-save-failed: FlatSurface {
x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px; x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px;
width: min(420px, parent.width - 32px); height: 36px; z: 10; width: min(420px, parent.width - 32px); height: 36px; z: 10;
background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: UiRadius.surface; background: #fff2f0; outline-color: UiSurface.edge; border-radius: UiRadius.surface;
Text { x: 12px; y: 0px; width: parent.width - 24px; height: parent.height; font-size: 12px; color: #9d4339; vertical-alignment: center; Text { x: 12px; y: 0px; width: parent.width - 24px; height: parent.height; font-size: 12px; color: #9d4339; vertical-alignment: center;
text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide; text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide;
} }
} }
// Device details are rendered by MainWindow; resize hints stay local. // Device details are rendered by MainWindow; resize hints stay local.
if root.device-tooltip-visible && (root.device-tooltip-id == "resize-grid" || root.device-tooltip-id == "resize-focus"): Rectangle { if root.device-tooltip-visible && (root.device-tooltip-id == "resize-grid" || root.device-tooltip-id == "resize-focus"): FlatSurface {
x: clamp(root.device-tooltip-x, 8px, parent.width - self.width - 8px); x: clamp(root.device-tooltip-x, 8px, parent.width - self.width - 8px);
y: clamp(root.device-tooltip-y + self.height > footer.y ? root.device-tooltip-y - self.height - 8px : root.device-tooltip-y, 4px, parent.height - self.height - 4px); y: clamp(root.device-tooltip-y + self.height > footer.y ? root.device-tooltip-y - self.height - 8px : root.device-tooltip-y, 4px, parent.height - self.height - 4px);
width: min(320px, parent.width - 16px); height: device-tooltip-label.preferred-height + 16px; width: min(320px, parent.width - 16px); height: device-tooltip-label.preferred-height + 16px;
background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: UiRadius.control; background: #22252bf2; outline-color: UiSurface.edge; border-radius: UiRadius.control;
device-tooltip-label := Text { device-tooltip-label := Text {
x: 8px; y: 8px; width: parent.width - 16px; x: 8px; y: 8px; width: parent.width - 16px;
text: root.device-tooltip-text; color: white; font-size: 11px; wrap: word-wrap; text: root.device-tooltip-text; color: white; font-size: 11px; wrap: word-wrap;
@@ -884,9 +887,9 @@ export component GalleryView inherits Rectangle {
if root.adding: Rectangle { if root.adding: Rectangle {
background: #00000042; background: #00000042;
TouchArea {} TouchArea {}
add-dialog := Rectangle { add-dialog := FlatSurface {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px;
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2; background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
function submit() { function submit() {
if root.add-device(device-id.text, password.text, group.text, remember.checked) { if root.add-device(device-id.text, password.text, group.text, remember.checked) {
password.text = ""; password.text = "";
@@ -919,10 +922,10 @@ export component GalleryView inherits Rectangle {
TouchArea { clicked => { remember.checked = !remember.checked; } } TouchArea { clicked => { remember.checked = !remember.checked; } }
} }
Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; } Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; }
GalleryButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } } GalleryButton { x: 136px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
GalleryButton { GalleryButton {
x: 254px; y: 260px; width: 128px; height: 28px; primary: true; x: 218px; y: 260px; width: 164px; height: 28px; primary: true;
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить"); text: GalleryStrings.tr("添加并连接", "Add and connect", "Добавить и подключить");
enabled: device-id.text != "" && password.text != ""; enabled: device-id.text != "" && password.text != "";
clicked => { add-dialog.submit(); } clicked => { add-dialog.submit(); }
} }
@@ -931,25 +934,27 @@ export component GalleryView inherits Rectangle {
if root.importing: Rectangle { if root.importing: Rectangle {
background: #00000042; background: #00000042;
TouchArea {} TouchArea {}
Rectangle { FlatSurface {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px;
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2; background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; } Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从近期连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; } Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add to DeskHub, then select devices to connect.", "Добавьте в DeskHub, затем выберите устройства для подключения."); color: #62666d; font-size: 11px; }
ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-available > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-available; } ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-available > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-available; }
Text { x: 45px; y: 74px; height: 21px; text: GalleryStrings.tr("全选", "Select all", "Выбрать все"); color: root.recent-available > 0 ? #24272d : #92969e; font-size: 12px; vertical-alignment: center; } Text { x: 45px; y: 74px; height: 21px; text: GalleryStrings.tr("全选", "Select all", "Выбрать все"); color: root.recent-available > 0 ? #24272d : #92969e; font-size: 12px; vertical-alignment: center; }
TouchArea { x: 18px; y: 74px; width: 144px; height: 21px; enabled: root.recent-available > 0; clicked => { root.action("recent-all", ""); } } TouchArea { x: 18px; y: 74px; width: 144px; height: 21px; enabled: root.recent-available > 0; clicked => { root.action("recent-all", ""); } }
Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("待添加 ", "To add ", "К добавлению ") + root.recent-selected + " / " + root.recent-available; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; } Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("待添加 ", "To add ", "К добавлению ") + root.recent-selected + " / " + root.recent-available; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; }
Rectangle { FlatSurface {
x: 18px; y: 102px; width: 404px; height: 160px; x: 18px; y: 102px; width: 404px; height: 160px;
border-width: 1px; border-color: #d8dadd; border-radius: UiRadius.surface; clip: true; outline-color: UiSurface.edge; border-radius: UiRadius.surface; clip: true;
recent-list := ScrollView { recent-list := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px; viewport-width: self.width - 12px;
viewport-height: max(self.height, root.recent-devices.length * 44px); viewport-height: max(self.height, root.recent-devices.length * 44px);
for device[index] in root.recent-devices: Rectangle { for device[index] in root.recent-devices: Rectangle {
y: index * 44px; width: recent-list.viewport-width; height: 44px; y: index * 44px; width: recent-list.viewport-width; height: 44px;
background: device.added ? #f1f2f4 : device.selected ? #edf5fe : row-touch.has-hover ? #f4f5f6 : white; background: device.added ? ControlStyle.disabled
: device.selected ? (row-touch.pressed ? ControlStyle.selected-pressed : row-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
: row-touch.pressed ? ControlStyle.pressed : row-touch.has-hover ? ControlStyle.hover : white;
ImGuiCheckBox { x: 8px; y: 11px; enabled: !device.added; checked: device.added || device.selected; } ImGuiCheckBox { x: 8px; y: 11px; enabled: !device.added; checked: device.added || device.selected; }
Text { x: 36px; y: 3px; width: parent.width - 126px; height: 20px; text: device.name; color: device.added ? #92969e : #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; } Text { x: 36px; y: 3px; width: parent.width - 126px; height: 20px; text: device.name; color: device.added ? #92969e : #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; }
Text { x: 36px; y: 23px; width: parent.width - 126px; height: 16px; text: device.id; font-size: 11px; color: device.added ? #92969e : #808080; } Text { x: 36px; y: 23px; width: parent.width - 126px; height: 16px; text: device.id; font-size: 11px; color: device.added ? #92969e : #808080; }
@@ -962,7 +967,7 @@ export component GalleryView inherits Rectangle {
row-touch := TouchArea { enabled: !device.added; clicked => { root.action("recent-select", device.id); } } row-touch := TouchArea { enabled: !device.added; clicked => { root.action("recent-select", device.id); } }
} }
} }
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
Rectangle { Rectangle {
x: -UiRadius.surface; y: -UiRadius.surface; x: -UiRadius.surface; y: -UiRadius.surface;
width: parent.width + 2 * UiRadius.surface; width: parent.width + 2 * UiRadius.surface;
@@ -970,25 +975,25 @@ export component GalleryView inherits Rectangle {
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface; border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white; border-color: white;
} }
Rectangle { FlatSurface {
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface; border-radius: UiRadius.surface;
border-color: #d8dadd; outline-color: UiSurface.edge;
} }
} }
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; } Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; }
import-group := RoundedLineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); } import-group := RoundedLineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; } Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; }
GalleryButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } } GalleryButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
GalleryButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } } GalleryButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add to DeskHub", "В DeskHub"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
} }
} }
if root.confirming: Rectangle { if root.confirming: Rectangle {
background: #00000042; TouchArea {} background: #00000042; TouchArea {}
Rectangle { FlatSurface {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
width: 360px; height: confirmation-list.height + 118px; width: 360px; height: confirmation-list.height + 118px;
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2; background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
Text { Text {
x: 20px; y: 18px; width: parent.width - 132px; height: 24px; x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name; text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
@@ -997,12 +1002,12 @@ export component GalleryView inherits Rectangle {
Rectangle { Rectangle {
x: parent.width - 108px; y: 20px; width: 88px; height: 22px; x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
background: #eff3f8; border-radius: UiRadius.surface; background: #eff3f8; border-radius: UiRadius.surface;
Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; } Text { text: GalleryStrings.device-count(root.selected-count); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
} }
confirmation-list := Rectangle { confirmation-list := FlatSurface {
x: 20px; y: 58px; width: parent.width - 40px; x: 20px; y: 58px; width: parent.width - 40px;
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px); height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px);
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: UiRadius.surface; clip: true; background: #fcfdfe; outline-color: UiSurface.edge; border-radius: UiRadius.surface; clip: true;
confirmation-scroll := ScrollView { confirmation-scroll := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px; viewport-width: self.width - 12px;
@@ -1016,9 +1021,9 @@ export component GalleryView inherits Rectangle {
width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px; width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px;
background: device.connected ? #eaf6ee : #f0f2f5; background: device.connected ? #eaf6ee : #f0f2f5;
Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; } Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; }
Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет связи"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; } Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет соединения"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; }
} }
if index + 1 < root.selected-devices.length: Rectangle { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #edf0f3; } if index + 1 < root.selected-devices.length: SurfaceSeparator { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; outline-color: UiSurface.separator; }
} }
} }
} }
+53 -26
View File
@@ -1,16 +1,27 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons } from "common.slint"; import { FontAwesomeIcons, ControlStyle } from "common.slint";
// Shared by history and DeskHub; system choices precede the named groups. // Shared by history and DeskHub; system choices precede the named groups.
export component GroupFilter inherits Rectangle { export component GroupFilter inherits FlatSurface {
in property <[string]> model; in property <[string]> model;
in property <int> system-count: 2; in property <int> system-count: 2;
in property <bool> enabled: true; in property <bool> enabled: true;
in property <color> normal-border-color: #c9cdd2; // An embedded picker shares its enclosing field's outline and focus state.
in property <color> hover-border-color: root.normal-border-color; in property <bool> embedded: false;
in property <color> focus-border-color: #8db5df; 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-background: ControlStyle.surface;
in property <color> normal-outline-color: UiSurface.edge;
in property <color> hover-outline-color: UiSurface.hover;
in property <color> focus-outline-color: UiSurface.focus;
in-out property <int> current-index: 0; 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); callback selected(int);
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count; 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); private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
@@ -25,12 +36,16 @@ export component GroupFilter inherits Rectangle {
} }
width: 155px; height: 28px; width: 155px; height: 28px;
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white; background: root.embedded ? (root.enabled && trigger-touch.has-hover ? ControlStyle.hover : transparent)
border-width: 1px; : !root.enabled ? ControlStyle.disabled
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color : trigger-touch.pressed || popup.is-open ? ControlStyle.pressed
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color; : trigger-touch.has-hover ? ControlStyle.hover : root.normal-background;
outline-enabled: !root.embedded;
outline-focused: popup.is-open || trigger-focus.has-focus;
outline-color: popup.is-open || trigger-focus.has-focus ? root.focus-outline-color
: trigger-touch.has-hover ? root.hover-outline-color : root.normal-outline-color;
border-radius: UiRadius.control; border-radius: UiRadius.control;
animate background, border-color { duration: 100ms; easing: ease-out; } animate background, outline-color { duration: 100ms; easing: ease-out; }
forward-focus: trigger-focus; forward-focus: trigger-focus;
accessible-role: combobox; accessible-role: combobox;
accessible-enabled: root.enabled; accessible-enabled: root.enabled;
@@ -52,16 +67,18 @@ export component GroupFilter inherits Rectangle {
root.reveal-selection(); root.reveal-selection();
root.selected(root.current-index); root.selected(root.current-index);
} }
function open-menu() { public function open-menu() {
if root.enabled && root.model.length > 0 { if root.enabled && root.model.length > 0 {
popup-open-timer.running = false; popup-open-timer.running = false;
root.popup-animation-enabled = false; root.popup-animation-enabled = false;
root.popup-progress = 0; root.popup-progress = 0;
root.reveal-selection(); root.reveal-selection();
trigger-focus.focus();
popup.show(); popup.show();
popup-open-timer.running = true; popup-open-timer.running = true;
} }
} }
public function close-menu() { popup.close(); }
// Defer until the popup has its initial frame; animate its contents only, // Defer until the popup has its initial frame; animate its contents only,
// keeping the native popup bounds and scrolling geometry stable. // keeping the native popup bounds and scrolling geometry stable.
popup-open-timer := Timer { popup-open-timer := Timer {
@@ -87,14 +104,19 @@ export component GroupFilter inherits Rectangle {
return reject; return reject;
} }
Text { 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]; text: root.model[root.current-index];
color: root.enabled ? #30343b : #92979f; color: root.enabled ? root.text-color : #92979f;
font-size: 12px; overflow: elide; vertical-alignment: center; font-size: root.text-size; font-weight: root.text-weight;
overflow: elide; vertical-alignment: center;
accessible-role: none; accessible-role: none;
} }
Text { if root.show-indicator: Text {
x: parent.width - 25px; width: 16px; height: parent.height; 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"; text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #858c96; font-weight: 900; font-size: 10px; color: #858c96;
horizontal-alignment: center; vertical-alignment: center; horizontal-alignment: center; vertical-alignment: center;
@@ -107,22 +129,24 @@ export component GroupFilter inherits Rectangle {
key-pressed(event) => { return root.handle-key(event); } key-pressed(event) => { return root.handle-key(event); }
trigger-touch := TouchArea { trigger-touch := TouchArea {
enabled: root.enabled; enabled: root.enabled;
clicked => { trigger-focus.focus(); root.open-menu(); } mouse-cursor: pointer;
clicked => { root.open-menu(); }
} }
} }
popup := PopupWindow { popup := PopupWindow {
x: 0px; y: root.height + 4px; 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; close-policy: close-on-click-outside;
forward-focus: popup-focus; forward-focus: popup-focus;
popup-focus := FocusScope { popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); } key-pressed(event) => { return root.handle-key(event); }
Rectangle { FlatSurface {
y: (1 - root.popup-progress) * -4px; y: (1 - root.popup-progress) * -4px;
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
opacity: root.popup-progress; opacity: root.popup-progress;
background: white; border-width: 1px; background: white;
border-color: root.normal-border-color; border-radius: UiRadius.menu; clip: true;
outline-color: root.normal-outline-color; border-radius: UiRadius.menu;
ScrollView { ScrollView {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
viewport-width: self.width; viewport-width: self.width;
@@ -130,27 +154,30 @@ export component GroupFilter inherits Rectangle {
viewport-y <=> root.scroll-y; viewport-y <=> root.scroll-y;
Rectangle { Rectangle {
width: parent.width; height: root.list-height; width: parent.width; height: root.list-height;
if root.has-divider: Rectangle { if root.has-divider: SurfaceSeparator {
x: 6px; y: root.system-count * 26px + 3px; x: 6px; y: root.system-count * 26px + 3px;
width: parent.width - 12px; height: 1px; background: #e9ebee; width: parent.width - 12px; height: 1px; outline-color: UiSurface.separator;
} }
for choice[index] in root.model: Rectangle { for choice[index] in root.model: Rectangle {
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px); y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
width: parent.width; height: 26px; border-radius: UiRadius.control; width: parent.width; height: 26px; border-radius: UiRadius.control;
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent; background: index == root.current-index
? (option-touch.pressed ? ControlStyle.selected-pressed : option-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
: option-touch.pressed ? ControlStyle.pressed : option-touch.has-hover ? ControlStyle.hover : transparent;
animate background { duration: 100ms; easing: ease-out; } animate background { duration: 100ms; easing: ease-out; }
accessible-role: list-item; accessible-role: list-item;
accessible-label: choice; accessible-label: choice;
Text { Text {
x: 8px; width: parent.width - 34px; height: parent.height; x: 8px; width: parent.width - 34px; height: parent.height;
text: choice; color: #30343b; font-size: 12px; text: choice; color: index == root.current-index ? ControlStyle.selected-text : #30343b; font-size: 12px;
font-weight: index == root.current-index ? 600 : 400;
overflow: elide; vertical-alignment: center; overflow: elide; vertical-alignment: center;
accessible-role: none; accessible-role: none;
} }
if index == root.current-index: Text { if index == root.current-index: Text {
x: parent.width - 24px; width: 16px; height: parent.height; x: parent.width - 24px; width: 16px; height: parent.height;
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free"; text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #4e87c0; font-weight: 900; font-size: 10px; color: ControlStyle.selected-text;
horizontal-alignment: center; vertical-alignment: center; horizontal-alignment: center; vertical-alignment: center;
accessible-role: none; accessible-role: none;
} }
@@ -1,9 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs> <circle cx="32" cy="32" r="31" fill="white"/>
<radialGradient id="edge">
<stop offset="95%" stop-color="white"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="32" cy="32" r="32" fill="url(#edge)"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 139 B

+58
View File
@@ -0,0 +1,58 @@
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("未找到可用的局域网连接。", "No local network connection was found.", "Нет доступного подключения к LAN."),
GalleryStrings.tr("请连接 Wi-Fi 或网线,", "Connect to Wi-Fi or Ethernet,", "Подключитесь к Wi-Fi или Ethernet"),
GalleryStrings.tr("或在主界面选择本机 IP。", "or select a local IP on the main screen.", "или выберите локальный IP в главном окне.")
]; }
// 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 == "" { return ""; }
if code == "InvalidCredentials" { return GalleryStrings.tr("密码不正确,或对方密码已更新。请查看对方的局域网访问密码后重试。", "The password is incorrect or has changed. 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 == "IceConnectionTimeout" || code == "Timeout" || code == "ConnectionClosed" || code == "HeartbeatTimeout" || code == "SignalSendFailed" { return GalleryStrings.tr("无法连接,请检查对方 IP、程序是否运行,以及防火墙是否放行。", "Could not connect. 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" || code == "Invalid LAN endpoint" { return GalleryStrings.tr("请输入有效的 IPv4,例如 192.168.1.20;指定端口可用 IPv4:端口,端口范围 1–65535。", "Enter a valid IPv4 address such as 192.168.1.20, or IPv4:port (1–65535).", "Введите IPv4, например 192.168.1.20, или IPv4:порт (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.", "Задайте пароль кнопкой редактирования рядом с паролем в главном окне."); }
if code == "LAN connection limit reached" { return GalleryStrings.tr("IP 连接数量已达上限,请先断开部分连接后重试。", "The IP connection limit has been reached. Disconnect some sessions and try again.", "Достигнут лимит IP-подключений. Завершите часть сеансов и повторите попытку."); }
if code == "UnsupportedNetworkPath" || code == "NetworkPolicyFailed" { return GalleryStrings.tr("当前网络路径不支持 IP 连接,请检查双方的局域网连接。", "This network path does not support IP connections. Check both devices' LAN connections.", "Этот сетевой маршрут не поддерживает IP-подключения. Проверьте подключения обоих устройств к LAN."); }
if code == "IncompatibleProtocol" || code == "RequiredCryptoUnsupported" || code == "TLS 1.3 is required for LAN connections" { return GalleryStrings.tr("双方的协议或加密能力不兼容,请将双方 CrossDesk 升级至最新版本后重试。", "The protocol or encryption capabilities are incompatible. Update CrossDesk on both devices and try again.", "Протоколы или возможности шифрования несовместимы. Обновите CrossDesk на обоих устройствах и повторите попытку."); }
if code == "InvalidCertificate" || code == "FingerprintMissing" || code == "FingerprintMismatch" { return GalleryStrings.tr("无法验证对方设备的身份,请确认连接地址和对方设备后重试。", "Could not verify the remote device's identity. Check the address and the remote device before retrying.", "Не удалось проверить подлинность удалённого устройства. Проверьте адрес и устройство перед повторной попыткой."); }
if code == "DtlsHandshakeFailed" || code == "SrtpInstallFailed" || code == "SrtpKeyExportFailed" || code == "SecureTransportFailed" { return GalleryStrings.tr("无法建立加密连接,请检查双方网络并重试。", "Could not establish an encrypted connection. Check both devices' networks and try again.", "Не удалось установить зашифрованное соединение. Проверьте сеть на обоих устройствах и повторите попытку."); }
if code == "InvalidSignal" || code == "InvalidLocalSignal" || code == "Invalid LAN protocol message" { return GalleryStrings.tr("连接协商数据无效,请重试并确认双方 CrossDesk 版本兼容。", "Invalid connection negotiation data. Try again and check that both CrossDesk versions are compatible.", "Неверные данные согласования соединения. Повторите попытку и проверьте совместимость версий CrossDesk."); }
if code == "ListenerChanged" { return GalleryStrings.tr("连接地址或监听端口已更改,请确认最新地址后重试。", "The connection address or listening port has changed. Check the current address and try again.", "Адрес подключения или порт прослушивания изменился. Проверьте текущий адрес и повторите попытку."); }
if code == "PeerPreparationFailed" { return GalleryStrings.tr("无法准备远程会话,请稍后重试。", "Could not prepare the remote session. Try again shortly.", "Не удалось подготовить удалённый сеанс. Повторите позже."); }
if code == "Cannot copy text" { return GalleryStrings.tr("复制失败,请重试。", "Could not copy. Try again.", "Не удалось скопировать. Повторите попытку."); }
// Keep diagnostic details for unexpected system errors, with a localized explanation.
return GalleryStrings.tr("IP 连接操作失败,详情:", "IP connection operation failed. Details: ", "Ошибка операции IP-подключения. Подробности: ") + code;
}
}
File diff suppressed because it is too large Load Diff
+13 -11
View File
@@ -1,6 +1,7 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
import { IconButton, FontAwesomeIcons } from "common.slint"; import { IconButton, FontAwesomeIcons, ControlStyle } from "common.slint";
import { SettingsDialog, SettingsButton } from "settings_controls.slint"; import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint"; import { WorkbenchStyle } from "workbench_controls.slint";
import "../assets/fonts/message_spacing.ttf"; import "../assets/fonts/message_spacing.ttf";
@@ -90,11 +91,11 @@ export component MessagePanel inherits SettingsDialog {
text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e; text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e;
} }
} }
message-content := Rectangle { message-content := FlatSurface {
x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px); x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
width: parent.width - 48px; height: parent.height - self.y - 24px; width: parent.width - 48px; height: parent.height - self.y - 24px;
background: WorkbenchStyle.surface; background: WorkbenchStyle.surface;
border-radius: UiRadius.surface; border-width: 1px; border-color: WorkbenchStyle.border; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
if root.items.length > 0 || root.selected-id != "": Rectangle { if root.items.length > 0 || root.selected-id != "": Rectangle {
x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px; x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
background: WorkbenchStyle.field-surface; background: WorkbenchStyle.field-surface;
@@ -112,19 +113,19 @@ export component MessagePanel inherits SettingsDialog {
announcement-entries := VerticalLayout { announcement-entries := VerticalLayout {
width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px; width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
alignment: start; alignment: start;
for item in root.items: Rectangle { for item in root.items: FlatSurface {
height: 64px; height: 64px;
background: root.selected-id == item.id ? #e8efff background: root.selected-id == item.id
? (entry-touch.pressed ? ControlStyle.selected-pressed : entry-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
: entry-touch.pressed ? WorkbenchStyle.pressed-surface : entry-touch.pressed ? WorkbenchStyle.pressed-surface
: entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface; : entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: UiRadius.control; border-radius: UiRadius.control;
border-width: 1px; outline-color: root.selected-id == item.id ? UiSurface.focus : UiSurface.edge;
border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border;
Text { Text {
x: 10px; y: 9px; width: parent.width - 30px; height: 20px; x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
text: item.title; overflow: elide; font-size: 13px; text: item.title; overflow: elide; font-size: 13px;
font-weight: item.read ? 400 : 600; font-weight: item.read ? 400 : 600;
color: root.selected-id == item.id ? WorkbenchStyle.accent : WorkbenchStyle.text; color: root.selected-id == item.id ? ControlStyle.selected-text : WorkbenchStyle.text;
vertical-alignment: center; vertical-alignment: center;
} }
if !item.read: Rectangle { if !item.read: Rectangle {
@@ -154,10 +155,10 @@ export component MessagePanel inherits SettingsDialog {
color: WorkbenchStyle.muted; font-size: 13px; color: WorkbenchStyle.muted; font-size: 13px;
horizontal-alignment: center; vertical-alignment: center; horizontal-alignment: center; vertical-alignment: center;
} }
content-divider := Rectangle { content-divider := SurfaceSeparator {
x: announcement-list.x + announcement-list.width + 8px; x: announcement-list.x + announcement-list.width + 8px;
y: 1px; width: 1px; height: parent.height - 2px; y: 1px; width: 1px; height: parent.height - 2px;
background: WorkbenchStyle.separator; vertical: true; outline-color: UiSurface.separator;
visible: root.items.length > 0 || root.selected-id != ""; visible: root.items.length > 0 || root.selected-id != "";
accessible-role: none; accessible-role: none;
} }
@@ -199,13 +200,14 @@ export component MessagePanel inherits SettingsDialog {
x: parent.width - self.width; y: 0px; width: 28px; height: 28px; x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
icon: FontAwesomeIcons.trash-can; icon-size: 12px; icon: FontAwesomeIcons.trash-can; icon-size: 12px;
icon-color: WorkbenchStyle.muted; danger: true; icon-color: WorkbenchStyle.muted; danger: true;
normal-background: ControlStyle.surface;
accessible-role: button; accessible-role: button;
accessible-label: MessageStrings.delete-message + " " + root.selected-title; accessible-label: MessageStrings.delete-message + " " + root.selected-title;
accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); } accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); }
clicked => { root.request-delete(root.selected-id, root.selected-revision); } clicked => { root.request-delete(root.selected-id, root.selected-revision); }
} }
} }
Rectangle { height: 1px; background: WorkbenchStyle.separator; } SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
StyledText { StyledText {
vertical-stretch: 0; vertical-stretch: 0;
text: root.selected-body; text: root.selected-body;
@@ -1,15 +1,17 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { LineEdit } from "std-widgets.slint"; import { LineEdit } from "std-widgets.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { ControlStyle } from "common.slint";
// Retain the standard editor's selection, clipboard, clear button and input // Retain the standard editor's selection, clipboard, clear button and input
// method support. Only its outer frame is replaced by the shared shape. // method support. Its frame receives the shared field tint and rounded shape.
export component RoundedLineEdit inherits Rectangle { export component RoundedLineEdit inherits Rectangle {
in-out property <string> text; in-out property <string> text;
in property <string> placeholder-text; in property <string> placeholder-text;
in property <InputType> input-type: InputType.text; in property <InputType> input-type: InputType.text;
in property <bool> enabled: true; in property <bool> enabled: true;
in property <length> font-size: 12px; in property <length> font-size: 12px;
in property <color> normal-border-color: #dfe6ef; in property <color> normal-outline-color: UiSurface.edge;
in property <brush> surrounding-background: white; in property <brush> surrounding-background: white;
callback accepted(); callback accepted();
callback edited(); callback edited();
@@ -20,8 +22,8 @@ export component RoundedLineEdit inherits Rectangle {
forward-focus: editor; forward-focus: editor;
editor := LineEdit { editor := LineEdit {
x: -1px; y: -1px; x: -2px; y: -2px;
width: parent.width + 2px; height: parent.height + 2px; width: parent.width + 4px; height: parent.height + 4px;
text <=> root.text; text <=> root.text;
placeholder-text: root.placeholder-text; placeholder-text: root.placeholder-text;
input-type: root.input-type; input-type: root.input-type;
@@ -30,8 +32,15 @@ export component RoundedLineEdit inherits Rectangle {
accepted => { root.accepted(); } accepted => { root.accepted(); }
edited => { root.edited(); } edited => { root.edited(); }
} }
// Fluent's LineEdit paints its own background and exposes no fill override.
// A visual-only tint preserves the standard editor and its built-in actions.
Rectangle {
width: parent.width; height: parent.height;
background: !root.enabled ? transparent
: editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint;
}
// The software renderer clips children to rectangular bounds. Mask the // The software renderer clips children to rectangular bounds. Mask the
// standard editor's corners before drawing our rounded border on top. // standard editor's corners before drawing the focus outline on top.
Rectangle { Rectangle {
x: -root.border-radius; y: -root.border-radius; x: -root.border-radius; y: -root.border-radius;
width: parent.width + 2 * root.border-radius; width: parent.width + 2 * root.border-radius;
@@ -40,11 +49,12 @@ export component RoundedLineEdit inherits Rectangle {
border-radius: 2 * root.border-radius; border-radius: 2 * root.border-radius;
border-color: root.surrounding-background; border-color: root.surrounding-background;
} }
Rectangle { FlatSurface {
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
border-radius: root.border-radius; border-radius: root.border-radius;
border-width: editor.has-focus ? 2px : 1px; clip: true;
border-color: editor.has-focus ? #3564e3 : root.normal-border-color; outline-color: editor.has-focus ? UiSurface.focus : root.normal-outline-color;
outline-focused: editor.has-focus;
background: transparent; background: transparent;
} }
} }
+17 -13
View File
@@ -1,6 +1,7 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint"; import { FontAwesomeIcons, FloatingBarStyle, ButtonStyle } from "common.slint";
export struct ControllerEntry { export struct ControllerEntry {
remote-id: string, remote-id: string,
@@ -19,7 +20,7 @@ export global ServerStrings {
in property <string> cancel: "取消"; in property <string> cancel: "取消";
} }
component SessionButton inherits Rectangle { component SessionButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <bool> danger: false; in property <bool> danger: false;
in property <bool> enabled: true; in property <bool> enabled: true;
@@ -29,10 +30,13 @@ component SessionButton inherits Rectangle {
width: label.preferred-width + 20px; width: label.preferred-width + 20px;
height: 28px; height: 28px;
border-radius: UiRadius.control; border-radius: UiRadius.control;
border-width: 1px; outline-enabled: !root.danger;
border-color: root.danger ? #d92d20 : #c9ced7; outline-color: root.pointer-feedback && touch.pressed ? ButtonStyle.pressed-border
: root.pointer-feedback && touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20) background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20)
: (root.pointer-feedback && touch.pressed ? #dfe3e9 : root.pointer-feedback && touch.has-hover ? #f3f4f6 : white); : (root.pointer-feedback && touch.pressed ? ButtonStyle.pressed
: root.pointer-feedback && touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface);
animate background, outline-color { duration: 120ms; }
opacity: root.enabled ? 1 : 0.5; opacity: root.enabled ? 1 : 0.5;
accessible-role: button; accessible-role: button;
accessible-label: root.text; accessible-label: root.text;
@@ -72,9 +76,9 @@ component SessionButton inherits Rectangle {
// Restore hover feedback only after a fresh pointer move or press. // Restore hover feedback only after a fresh pointer move or press.
clicked => { root.pointer-feedback = false; root.keyboard-focus = false; keyboard.focus(); root.clicked(); } clicked => { root.pointer-feedback = false; root.keyboard-focus = false; keyboard.focus(); root.clicked(); }
} }
if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle { if root.enabled && keyboard.has-focus && root.keyboard-focus: FlatSurface {
x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px; x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px;
border-radius: UiRadius.control - 2px; border-width: 1px; border-color: root.danger ? white : #2463c7; border-radius: UiRadius.control - 2px; outline-color: root.danger ? white : UiSurface.focus;
} }
} }
@@ -107,10 +111,10 @@ export component ServerStatusBar inherits Window {
return reject; return reject;
} }
} }
Rectangle { FlatSurface {
x: 0px; y: 0px; width: parent.width; height: parent.height; x: 0px; y: 0px; width: parent.width; height: parent.height;
background: touch.pressed ? #dfe3e9 : touch.has-hover ? #f3f4f6 : white; background: touch.pressed ? #dfe3e9 : touch.has-hover ? #f3f4f6 : white;
border-width: 1px; border-color: ImGuiLineStyle.border; outline-color: UiSurface.edge;
border-radius: FloatingBarStyle.corner-radius; border-radius: FloatingBarStyle.corner-radius;
border-top-right-radius: 0px; border-top-right-radius: 0px;
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
@@ -170,9 +174,9 @@ export component ServerWindow inherits Window {
if event.text == Key.Escape { root.dismiss-panel(); return accept; } if event.text == Key.Escape { root.dismiss-panel(); return accept; }
return reject; return reject;
} }
Rectangle { FlatSurface {
width: root.width; height: root.height; width: root.width; height: root.height;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; background: white; outline-color: UiSurface.edge;
border-radius: UiRadius.surface; border-radius: UiRadius.surface;
Text { Text {
x: 10px; y: 10px; width: parent.width - 20px; height: 24px; x: 10px; y: 10px; width: parent.width - 20px; height: 24px;
@@ -192,9 +196,9 @@ export component ServerWindow inherits Window {
width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px); width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px);
height: parent.height; border-radius: UiRadius.control; height: parent.height; border-radius: UiRadius.control;
background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent; background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent;
if root.controllers.length > 1: Rectangle { if root.controllers.length > 1: FlatSurface {
x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px; x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px;
border-radius: 7px; border-width: 1px; border-color: index == root.selected-controller ? #2463c7 : #8c939e; border-radius: 7px; outline-color: index == root.selected-controller ? UiSurface.focus : UiSurface.edge;
if index == root.selected-controller: Rectangle { x: 3px; y: 3px; width: 7px; height: 7px; border-radius: 4px; background: #2463c7; } if index == root.selected-controller: Rectangle { x: 3px; y: 3px; width: 7px; height: 7px; border-radius: 4px; background: #2463c7; }
} }
name := Text { name := Text {
+21 -18
View File
@@ -1,9 +1,10 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint"; import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons, ControlStyle, ButtonStyle } from "common.slint";
import { WorkbenchStyle } from "workbench_controls.slint"; import { WorkbenchStyle } from "workbench_controls.slint";
// Shared visual treatment for the global settings pages. // Shared visual treatment for the global settings pages.
export component SettingsButton inherits Rectangle { export component SettingsButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <image> icon; in property <image> icon;
in property <bool> primary: false; in property <bool> primary: false;
@@ -14,13 +15,17 @@ export component SettingsButton inherits Rectangle {
private property <length> content-padding: root.has-icon ? 8px : 12px; private property <length> content-padding: root.has-icon ? 8px : 12px;
callback clicked(); callback clicked();
width: 88px; height: 32px; border-radius: UiRadius.control; width: 88px; height: 32px; border-radius: UiRadius.control;
background: !root.enabled ? #f0f3f7 background: !root.enabled ? ControlStyle.disabled
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc) : root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white; : touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover
border-width: root.quiet || root.primary ? 0px : 1px; : root.quiet ? transparent : ButtonStyle.surface;
border-color: !root.enabled ? WorkbenchStyle.border outline-enabled: !root.quiet && !root.primary;
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border; outline-color: !root.enabled ? ButtonStyle.disabled-border
: touch.pressed ? ButtonStyle.pressed-border
: touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
animate background, outline-color { duration: 120ms; }
accessible-role: button; accessible-label: root.text; accessible-role: button; accessible-label: root.text;
accessible-enabled: root.enabled;
accessible-action-default => { if root.enabled { root.clicked(); } } accessible-action-default => { if root.enabled { root.clicked(); } }
Rectangle { Rectangle {
x: root.align-left ? root.content-padding : (parent.width - self.width) / 2; x: root.align-left ? root.content-padding : (parent.width - self.width) / 2;
@@ -59,10 +64,7 @@ export component SettingsDialog inherits DialogSurface {
callback action(); callback action();
callback close(); callback close();
background: #f8fafc; background: #f8fafc;
border-color: WorkbenchStyle.separator; outline-color: UiSurface.edge;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
TouchArea { } TouchArea { }
HorizontalLayout { HorizontalLayout {
x: 24px; y: 18px; width: parent.width - 48px; height: 28px; x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
@@ -97,6 +99,7 @@ export component SettingsDialog inherits DialogSurface {
width: 28px; height: 28px; width: 28px; height: 28px;
icon: FontAwesomeIcons.xmark; icon-size: 14px; icon: FontAwesomeIcons.xmark; icon-size: 14px;
icon-color: WorkbenchStyle.muted; icon-color: WorkbenchStyle.muted;
normal-background: ControlStyle.surface;
hover-background: WorkbenchStyle.hover-surface; hover-background: WorkbenchStyle.hover-surface;
pressed-background: WorkbenchStyle.pressed-surface; pressed-background: WorkbenchStyle.pressed-surface;
accessible-role: button; accessible-label: root.close-label; accessible-role: button; accessible-label: root.close-label;
@@ -109,9 +112,9 @@ export component SettingsDialog inherits DialogSurface {
export component SettingsSelect inherits CompactComboBox { export component SettingsSelect inherits CompactComboBox {
subtle: true; subtle: true;
show-arrow: false; show-arrow: true;
normal-border-color: WorkbenchStyle.control-border; normal-outline-color: UiSurface.edge;
hover-border-color: WorkbenchStyle.hover-border; hover-outline-color: UiSurface.hover;
height: 28px; height: 28px;
} }
@@ -123,9 +126,9 @@ export component SettingsGroup inherits Rectangle {
x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title; x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title;
font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center; font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center;
} }
Rectangle { FlatSurface {
y: 24px; width: parent.width; height: parent.height - self.y; y: 24px; width: parent.width; height: parent.height - self.y;
background: white; border-radius: UiRadius.surface; border-color: WorkbenchStyle.border; border-width: 1px; background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
VerticalLayout { padding: 0px; spacing: 0px; @children } VerticalLayout { padding: 0px; spacing: 0px; @children }
} }
} }
@@ -141,8 +144,8 @@ export component SettingsRow inherits Rectangle {
text: root.label; color: root.enabled ? #34445a : #9ba5b3; text: root.label; color: root.enabled ? #34445a : #9ba5b3;
font-size: 12px; overflow: elide; vertical-alignment: center; font-size: 12px; overflow: elide; vertical-alignment: center;
} }
if root.divider: Rectangle { if root.divider: SurfaceSeparator {
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec; x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; outline-color: UiSurface.separator;
} }
@children @children
} }
+63 -56
View File
@@ -1,7 +1,8 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { ComboBox, ScrollView } from "std-widgets.slint"; import { ComboBox, ScrollView } from "std-widgets.slint";
import { RemoteCursor } from "remote_cursor.slint"; import { RemoteCursor } from "remote_cursor.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint"; import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiFontStyle, ControlStyle, ButtonStyle } from "common.slint";
export struct StreamTab { export struct StreamTab {
remote-id: string, remote-id: string,
@@ -25,6 +26,10 @@ export struct NetworkStatsRow {
} }
export global StreamStrings { export global StreamStrings {
in-out property <string> window-minimize: "Minimize";
in-out property <string> window-maximize: "Maximize";
in-out property <string> window-restore: "Restore window";
in-out property <string> window-close: "Close window";
in-out property <string> remote-update-title: "Remote update recommended"; in-out property <string> remote-update-title: "Remote update recommended";
in-out property <string> remote-update: "Update the remote device to the latest version for improved features and performance."; in-out property <string> remote-update: "Update the remote device to the latest version for improved features and performance.";
in-out property <string> remote-update-dismiss: "Got it"; in-out property <string> remote-update-dismiss: "Got it";
@@ -46,11 +51,11 @@ export global StreamStrings {
in-out property <string> privacy-disable: "Turn privacy off / recover"; in-out property <string> privacy-disable: "Turn privacy off / recover";
in-out property <string> control-mouse: "Control Mouse"; in-out property <string> control-mouse: "Control Mouse";
in-out property <string> release-mouse: "Release Mouse"; in-out property <string> release-mouse: "Release Mouse";
in-out property <string> audio: "Audio Capture"; in-out property <string> audio: "Play audio";
in-out property <string> mute: "Mute"; in-out property <string> mute: "Mute";
in-out property <string> select-file: "Select File to Send"; in-out property <string> select-file: "Select File to Send";
in-out property <string> show-stats: "Show Net Traffic Stats"; in-out property <string> show-stats: "Show network status";
in-out property <string> hide-stats: "Hide Net Traffic Stats"; in-out property <string> hide-stats: "Hide network status";
in-out property <string> fullscreen: "Fullscreen"; in-out property <string> fullscreen: "Fullscreen";
in-out property <string> exit-fullscreen: "Exit fullscreen"; in-out property <string> exit-fullscreen: "Exit fullscreen";
in-out property <string> disconnect: "Disconnect"; in-out property <string> disconnect: "Disconnect";
@@ -64,7 +69,7 @@ export global StreamStrings {
in-out property <string> stats-out: "Out"; in-out property <string> stats-out: "Out";
in-out property <string> stats-loss-rate: "Loss Rate"; in-out property <string> stats-loss-rate: "Loss Rate";
in-out property <string> stats-resolution: "Res"; in-out property <string> stats-resolution: "Res";
in-out property <string> stats-video-latency: "Video delay"; in-out property <string> stats-video-latency: "Video latency";
in-out property <string> stats-connection-latency: "Connection RTT"; in-out property <string> stats-connection-latency: "Connection RTT";
in-out property <string> stats-connection-mode: "Mode"; in-out property <string> stats-connection-mode: "Mode";
in-out property <string> stats-encoder: "Remote encoder"; in-out property <string> stats-encoder: "Remote encoder";
@@ -80,14 +85,10 @@ struct ControlBarTooltip {
} }
// Shared surface treatment for the capsule and its detached detail cards. // Shared surface treatment for the capsule and its detached detail cards.
component StreamControlSurface inherits Rectangle { component StreamControlSurface inherits FlatSurface {
background: white; background: white;
border-width: 1px; outline-color: UiSurface.edge;
border-color: #dbe5f1;
border-radius: 16px; border-radius: 16px;
drop-shadow-color: #1d35571a;
drop-shadow-blur: 10px;
drop-shadow-offset-y: 2px;
} }
// Keep the compact 16px icons inside roomier circular targets. This remains // Keep the compact 16px icons inside roomier circular targets. This remains
@@ -121,14 +122,14 @@ component ControlBarButton inherits Rectangle {
!root.show-logo && root.action-enabled && !root.muted && touch.pressed; !root.show-logo && root.action-enabled && !root.muted && touch.pressed;
// Share the fill with the slash cutout so every icon stays legible while // Share the fill with the slash cutout so every icon stays legible while
// transitioning between idle, hover and pressed states. // transitioning between idle, hover and pressed states.
private property <color> button-fill: root.muted ? #eef0f3 private property <color> button-fill: root.muted ? ControlStyle.disabled
: root.button-pressed ? (root.danger ? #7a271a : #65a5f2) : root.button-pressed ? (root.danger ? #7a271a : root.selected ? ControlStyle.selected-pressed : ControlStyle.pressed)
: root.button-hovered ? (root.danger ? (root.selected ? #912018 : #b42318) : root.button-hovered ? (root.danger ? (root.selected ? #912018 : #b42318)
: root.selected ? #86baff : #a7ceff) : root.selected ? ControlStyle.selected-hover : ControlStyle.hover)
: root.danger ? (root.selected ? #b42318 : #d92d20) : root.danger ? (root.selected ? #b42318 : #d92d20)
: root.selected ? #b3d5fd : #deecff; : root.selected ? ControlStyle.selected : ControlStyle.surface;
private property <color> button-foreground: root.muted ? #8a8f96 private property <color> button-foreground: root.muted ? #8a8f96
: root.danger ? white : #24272d; : root.danger ? white : root.selected ? ControlStyle.selected-text : #303f55;
animate button-fill, button-foreground { animate button-fill, button-foreground {
duration: 90ms; duration: 90ms;
easing: ease-out; easing: ease-out;
@@ -242,14 +243,21 @@ component VideoSettingRow inherits VerticalLayout {
Text { text: root.label; color: #5c626c; font-size: 11px; height: 18px; } Text { text: root.label; color: #5c626c; font-size: 11px; height: 18px; }
HorizontalLayout { HorizontalLayout {
spacing: 4px; spacing: 4px;
for option[index] in root.options: Rectangle { for option[index] in root.options: FlatSurface {
height: 27px; height: 27px;
background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff background: !root.enabled ? ControlStyle.disabled
: option-touch.has-hover ? #eaf3ff : #f5f6f8; : root.selected == index ? (option-touch.pressed ? ButtonStyle.selected-pressed : option-touch.has-hover ? ButtonStyle.selected-hover : ButtonStyle.selected)
: option-touch.pressed ? ButtonStyle.pressed
: option-touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
border-radius: UiRadius.control; border-radius: UiRadius.control;
border-width: 1px; outline-color: !root.enabled ? ButtonStyle.disabled-border
border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6; : root.selected == index ? ButtonStyle.selected-pressed
Text { text: option; color: root.enabled ? #30343b : #8b8f95; : option-touch.pressed ? ButtonStyle.pressed-border
: option-touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
animate background, outline-color { duration: 120ms; }
Text { text: option; color: !root.enabled ? #8b8f95
: root.selected == index ? white : #30343b;
font-weight: root.selected == index ? 600 : 400;
font-size: 11px; horizontal-alignment: center; vertical-alignment: center; } font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
option-touch := TouchArea { option-touch := TouchArea {
mouse-cursor: root.enabled ? pointer : default; mouse-cursor: root.enabled ? pointer : default;
@@ -806,7 +814,7 @@ export component StreamWindow inherits Window {
: root.window-active ? #e6e6e6 : root.window-active ? #e6e6e6
: transparent; : transparent;
accessible-role: button; accessible-role: button;
accessible-label: "Minimize"; accessible-label: StreamStrings.window-minimize;
accessible-action-default => { stream-minimize-touch.clicked(); } accessible-action-default => { stream-minimize-touch.clicked(); }
Rectangle { Rectangle {
x: 8px; x: 8px;
@@ -832,7 +840,7 @@ export component StreamWindow inherits Window {
: root.window-active ? #e6e6e6 : root.window-active ? #e6e6e6
: transparent; : transparent;
accessible-role: button; accessible-role: button;
accessible-label: root.window-maximized ? "Restore" : "Maximize"; accessible-label: root.window-maximized ? StreamStrings.window-restore : StreamStrings.window-maximize;
accessible-action-default => { stream-maximize-touch.clicked(); } accessible-action-default => { stream-maximize-touch.clicked(); }
if !root.window-maximized: Rectangle { if !root.window-maximized: Rectangle {
x: 8px; x: 8px;
@@ -882,7 +890,7 @@ export component StreamWindow inherits Window {
: root.window-active ? #e6e6e6 : root.window-active ? #e6e6e6
: transparent; : transparent;
accessible-role: button; accessible-role: button;
accessible-label: "Close"; accessible-label: StreamStrings.window-close;
accessible-action-default => { stream-close-touch.clicked(); } accessible-action-default => { stream-close-touch.clicked(); }
Path { Path {
width: parent.width; width: parent.width;
@@ -899,10 +907,10 @@ export component StreamWindow inherits Window {
clicked => { root.close-stream-window(); } clicked => { root.close-stream-window(); }
} }
} }
Rectangle { SurfaceSeparator {
y: parent.height - 1px; y: parent.height - 1px;
height: 1px; height: 1px;
background: #d5d3d0; outline-color: UiSurface.separator;
} }
} }
@@ -922,11 +930,11 @@ export component StreamWindow inherits Window {
z: 10; z: 10;
HorizontalLayout { HorizontalLayout {
spacing: 1px; spacing: 1px;
for tab[index] in root.tabs: tab-item := Rectangle { for tab[index] in root.tabs: tab-item := FlatSurface {
width: min(190px, max(110px, (parent.width - 12px) / root.tabs.length)); width: min(190px, max(110px, (parent.width - 12px) / root.tabs.length));
background: index == root.selected-tab ? white : tab-touch.has-hover || root.dragging-tab == index ? #e5e7eb : #d8dbe0; background: index == root.selected-tab ? white : tab-touch.has-hover || root.dragging-tab == index ? #e5e7eb : #d8dbe0;
border-width: root.dragging-tab == index ? 1px : 0px; outline-enabled: root.dragging-tab == index;
border-color: #2463c7; outline-color: UiSurface.focus;
Rectangle { Rectangle {
width: parent.width - 27px; width: parent.width - 27px;
height: parent.height; height: parent.height;
@@ -1199,11 +1207,11 @@ export component StreamWindow inherits Window {
tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen; tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen;
clicked => { root.close-control-menus(); root.toggle-fullscreen(); } clicked => { root.close-control-menus(); root.toggle-fullscreen(); }
} }
if root.control-content-visible: Rectangle { if root.control-content-visible: SurfaceSeparator {
opacity: root.control-content-progress; opacity: root.control-content-progress;
x: close-button.x - 6px; y: 12px; x: close-button.x - 6px; y: 12px;
width: 1px; height: 14px; width: 1px; height: 14px;
background: #dbe5f1; vertical: true; outline-color: UiSurface.separator;
} }
close-button := ControlBarButton { close-button := ControlBarButton {
reveal-progress: root.control-content-progress; reveal-progress: root.control-content-progress;
@@ -1408,12 +1416,12 @@ export component StreamWindow inherits Window {
vertical-alignment: center; vertical-alignment: center;
overflow: elide; overflow: elide;
} }
if index < root.stats-rows.length - 2: Rectangle { if index < root.stats-rows.length - 2: SurfaceSeparator {
x: 4px; x: 4px;
y: parent.height - 1px; y: parent.height - 1px;
width: parent.width - 8px; width: parent.width - 8px;
height: 1px; height: 1px;
background: #edf0f4; outline-color: UiSurface.separator;
} }
} }
@@ -1422,16 +1430,16 @@ export component StreamWindow inherits Window {
text: StreamStrings.stats-encoder + "\n" + StreamStrings.stats-decoder; text: StreamStrings.stats-encoder + "\n" + StreamStrings.stats-decoder;
font-size: ImGuiFontStyle.small; font-size: ImGuiFontStyle.small;
} }
Rectangle { FlatSurface {
x: 0px; x: 0px;
y: root.stats-header-height + root.stats-rows.length * stats-table.row-height y: root.stats-header-height + root.stats-rows.length * stats-table.row-height
+ 70px; + 70px;
width: parent.width; height: 44px; width: parent.width; height: 44px;
background: #f7f9fc; border-radius: UiRadius.control; background: #f7f9fc; border-radius: UiRadius.control;
border-width: 1px; border-color: #e8edf4; outline-color: UiSurface.edge;
Rectangle { SurfaceSeparator {
x: 8px; y: 22px; width: parent.width - 16px; height: 1px; x: 8px; y: 22px; width: parent.width - 16px; height: 1px;
background: #e8edf4; outline-color: UiSurface.separator;
} }
for codec[index] in [ for codec[index] in [
{ label: StreamStrings.stats-encoder, value: root.stats-encoder }, { label: StreamStrings.stats-encoder, value: root.stats-encoder },
@@ -1639,16 +1647,16 @@ export component StreamWindow inherits Window {
padding: 8px; spacing: 1px; padding: 8px; spacing: 1px;
for display[index] in root.displays: Rectangle { for display[index] in root.displays: Rectangle {
height: 29px; height: 29px;
background: index == display-menu.hovered-index && display-touch.pressed ? #bfd4f2 background: index == root.selected-display
: index == display-menu.hovered-index ? #d8e6f8 ? (index == display-menu.hovered-index ? (display-touch.pressed ? ControlStyle.selected-pressed : ControlStyle.selected-hover) : ControlStyle.selected)
: index == root.selected-display ? #e8eef9 : index == display-menu.hovered-index ? (display-touch.pressed ? ControlStyle.pressed : ControlStyle.hover) : transparent;
: transparent;
border-radius: UiRadius.control; border-radius: UiRadius.control;
Text { Text {
x: 8px; x: 8px;
width: parent.width - 34px; width: parent.width - 34px;
text: display; text: display;
color: #30343b; color: index == root.selected-display ? ControlStyle.selected-text : #30343b;
font-weight: index == root.selected-display ? 600 : 400;
font-size: ImGuiFontStyle.body; font-size: ImGuiFontStyle.body;
overflow: elide; overflow: elide;
vertical-alignment: center; vertical-alignment: center;
@@ -1657,7 +1665,7 @@ export component StreamWindow inherits Window {
x: parent.width - 24px; x: parent.width - 24px;
width: 16px; width: 16px;
text: FontAwesomeIcons.check; text: FontAwesomeIcons.check;
color: #2463c7; color: ControlStyle.selected-text;
font-family: "Font Awesome 6 Free"; font-family: "Font Awesome 6 Free";
font-weight: 900; font-weight: 900;
font-size: 10px; font-size: 10px;
@@ -1695,7 +1703,9 @@ export component StreamWindow inherits Window {
VerticalLayout { VerticalLayout {
padding: 8px; spacing: 1px; padding: 8px; spacing: 1px;
for shortcut in root.shortcut-items: Rectangle { for shortcut in root.shortcut-items: Rectangle {
height: 29px; background: shortcut-touch.has-hover ? #e8eef9 : transparent; height: 29px;
border-radius: UiRadius.control;
background: shortcut-touch.pressed ? ControlStyle.pressed : shortcut-touch.has-hover ? ControlStyle.hover : transparent;
Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; } Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
shortcut-touch := TouchArea { shortcut-touch := TouchArea {
mouse-cursor: MouseCursor.pointer; mouse-cursor: MouseCursor.pointer;
@@ -1745,11 +1755,8 @@ export component StreamWindow inherits Window {
y: min(max(64px, parent.height * 0.18), max(16px, parent.height - self.height - 16px)); y: min(max(64px, parent.height * 0.18), max(16px, parent.height - self.height - 16px));
width: min(400px, parent.width - 48px); width: min(400px, parent.width - 48px);
height: remote-update-message.y + remote-update-message.height + 84px; height: remote-update-message.y + remote-update-message.height + 84px;
border-color: #e5e9f0; outline-color: UiSurface.edge;
border-radius: UiRadius.surface; border-radius: UiRadius.surface;
drop-shadow-color: #07142b30;
drop-shadow-blur: 32px;
drop-shadow-offset-y: 12px;
z: 14; z: 14;
TouchArea { TouchArea {
mouse-cursor: default; mouse-cursor: default;
@@ -1774,8 +1781,8 @@ export component StreamWindow inherits Window {
IconButton { IconButton {
x: parent.width - 44px; y: 12px; width: 32px; height: 32px; x: parent.width - 44px; y: 12px; width: 32px; height: 32px;
icon: FontAwesomeIcons.xmark; icon-size: 14px; icon: FontAwesomeIcons.xmark; icon-size: 14px;
icon-color: #8a93a3; icon-color: ControlStyle.icon;
hover-background: #f2f4f7; normal-background: ControlStyle.surface;
clicked => { root.dismiss-remote-update(); } clicked => { root.dismiss-remote-update(); }
} }
PrimaryButton { PrimaryButton {
@@ -1789,10 +1796,10 @@ export component StreamWindow inherits Window {
} }
// Transfer progress overlay at bottom-left. // Transfer progress overlay at bottom-left.
if root.file-transfer-visible: Rectangle { if root.file-transfer-visible: FlatSurface {
x: root.file-panel-x; y: root.file-panel-y; x: root.file-panel-x; y: root.file-panel-y;
width: root.file-panel-width; height: root.file-panel-height; width: root.file-panel-width; height: root.file-panel-height;
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.surface; z: 7; background: white; outline-color: UiSurface.edge; border-radius: UiRadius.surface; z: 7;
// Header/padding and drags leaving the panel belong to local UI. // Header/padding and drags leaving the panel belong to local UI.
TouchArea { TouchArea {
mouse-cursor: default; mouse-cursor: default;
@@ -1801,6 +1808,7 @@ export component StreamWindow inherits Window {
Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; } Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; }
IconButton { IconButton {
x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px; x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px;
normal-background: ControlStyle.surface;
clicked => { root.close-file-transfer(); } clicked => { root.close-file-transfer(); }
} }
ScrollView { ScrollView {
@@ -1840,12 +1848,11 @@ export component StreamWindow inherits Window {
} }
} }
if root.custom-titlebar && !root.fullscreen-enabled: Rectangle { if root.custom-titlebar && !root.fullscreen-enabled: FlatSurface {
width: parent.width; width: parent.width;
height: parent.height; height: parent.height;
background: transparent; background: transparent;
border-width: 1px; outline-color: UiSurface.edge;
border-color: #aaa8a6;
border-radius: border-radius:
root.window-maximized ? 0px : root.window-corner-radius; root.window-maximized ? 0px : root.window-corner-radius;
z: 200; z: 200;
+11 -2
View File
@@ -42,6 +42,11 @@ inline int ApplyMainWindowStrings(
localization::detail::ClampLanguageIndex(language_index); localization::detail::ClampLanguageIndex(language_index);
window->global<ui::GalleryStrings>().set_language(language); window->global<ui::GalleryStrings>().set_language(language);
auto& strings = window->global<ui::UiStrings>(); auto& strings = window->global<ui::UiStrings>();
strings.set_window_minimize(Text(localization::window_minimize[language]));
strings.set_window_close(Text(localization::window_close[language]));
strings.set_save(Text(localization::save[language]));
strings.set_copyright(Text(localization::copyright_notice[language]));
strings.set_license(Text(localization::license_notice[language]));
auto& messages = window->global<ui::MessageStrings>(); auto& messages = window->global<ui::MessageStrings>();
messages.set_messages(Text(localization::messages[language])); messages.set_messages(Text(localization::messages[language]));
messages.set_unread(Text(localization::message_unread[language])); messages.set_unread(Text(localization::message_unread[language]));
@@ -81,7 +86,7 @@ inline int ApplyMainWindowStrings(
strings.set_ok(Text(localization::ok[language])); strings.set_ok(Text(localization::ok[language]));
strings.set_cancel(Text(localization::cancel[language])); strings.set_cancel(Text(localization::cancel[language]));
strings.set_new_password(Text(localization::new_password[language])); strings.set_new_password(Text(localization::new_password[language]));
strings.set_invalid_password(Text(localization::max_password_len[language])); strings.set_invalid_password(Text(localization::password_format_invalid[language]));
strings.set_edit_alias( strings.set_edit_alias(
Text(localization::input_connection_alias[language])); Text(localization::input_connection_alias[language]));
strings.set_delete_connection( strings.set_delete_connection(
@@ -93,7 +98,6 @@ inline int ApplyMainWindowStrings(
strings.set_capture_method_auto( strings.set_capture_method_auto(
Text(localization::screen_capture_method_auto[language])); Text(localization::screen_capture_method_auto[language]));
strings.set_codec(Text(localization::video_encode_format[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_force_relay(Text(localization::force_relay[language]));
strings.set_self_hosted( strings.set_self_hosted(
Text(localization::self_hosted_server_config[language])); Text(localization::self_hosted_server_config[language]));
@@ -102,6 +106,7 @@ inline int ApplyMainWindowStrings(
strings.set_save_remote_previews(Text(localization::save_remote_previews[language])); strings.set_save_remote_previews(Text(localization::save_remote_previews[language]));
strings.set_unknown_platform(Text(localization::unknown_platform[language])); strings.set_unknown_platform(Text(localization::unknown_platform[language]));
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[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( strings.set_file_save_path(
Text(localization::file_transfer_save_path[language])); Text(localization::file_transfer_save_path[language]));
strings.set_default_desktop(Text(localization::default_desktop[language])); strings.set_default_desktop(Text(localization::default_desktop[language]));
@@ -192,6 +197,10 @@ inline int ApplyStreamWindowStrings(
const int language = const int language =
localization::detail::ClampLanguageIndex(language_index); localization::detail::ClampLanguageIndex(language_index);
auto& strings = window->template global<ui::StreamStrings>(); auto& strings = window->template global<ui::StreamStrings>();
strings.set_window_minimize(Text(localization::window_minimize[language]));
strings.set_window_maximize(Text(localization::window_maximize[language]));
strings.set_window_restore(Text(localization::window_restore[language]));
strings.set_window_close(Text(localization::window_close[language]));
strings.set_remote_update_title(Text(localization::remote_update_title[language])); strings.set_remote_update_title(Text(localization::remote_update_title[language]));
strings.set_remote_update(Text(localization::remote_update[language])); strings.set_remote_update(Text(localization::remote_update[language]));
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language])); strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
+35
View File
@@ -0,0 +1,35 @@
// Flat surfaces share solid outlines in software and GPU renderers.
// Interaction states use their actual colors, including focus and validation.
export global UiSurface {
out property <color> edge: #d7dfe9;
out property <color> hover: #8eabd2;
out property <color> focus: #3564e3;
out property <color> error: #d65252;
out property <color> separator: #e1e7ef;
}
export component FlatSurface inherits Rectangle {
in property <color> outline-color: UiSurface.edge;
in property <bool> outline-enabled: true;
in property <bool> outline-focused: false;
border-width: !root.outline-enabled ? 0px : root.outline-focused ? 2px : 1px;
border-color: root.outline-color;
border-top-left-radius: root.border-radius;
border-top-right-radius: root.border-radius;
border-bottom-left-radius: root.border-radius;
border-bottom-right-radius: root.border-radius;
@children
}
export component SurfaceSeparator inherits Rectangle {
in property <bool> vertical: false;
in property <color> outline-color: UiSurface.separator;
height: 1px;
Rectangle {
width: root.vertical ? 1px : root.width;
height: root.vertical ? root.height : 1px;
background: root.outline-color;
}
}
@@ -1,14 +1,13 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { ControlStyle, ButtonStyle } from "common.slint";
// Colors and spacing for the connection workbench; shapes are shared app-wide. // Colors and spacing for the connection workbench; shapes are shared app-wide.
export global WorkbenchStyle { export global WorkbenchStyle {
out property <color> canvas: #f2f5f9; out property <color> canvas: #edf2f8;
out property <color> surface: #ffffff; out property <color> surface: #ffffff;
out property <color> border: #c6cfdd;
out property <color> control-border: #b2bfd1;
out property <color> hover-border: #7f97b8;
out property <color> field-surface: #f5f7fb; out property <color> field-surface: #f5f7fb;
out property <color> hover-surface: #eef3fb; out property <color> hover-surface: ControlStyle.hover;
out property <color> pressed-surface: #e4ebf6; out property <color> pressed-surface: ControlStyle.pressed;
out property <color> separator: #e1e7ef; out property <color> separator: #e1e7ef;
out property <color> text: #1c2940; out property <color> text: #1c2940;
out property <color> muted: #69788e; out property <color> muted: #69788e;
@@ -16,18 +15,19 @@ export global WorkbenchStyle {
out property <color> success: #18835a; out property <color> success: #18835a;
} }
export component WorkbenchButton inherits Rectangle { export component WorkbenchButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <length> text-size: 13px; in property <length> text-size: 13px;
in property <color> text-color: root.destructive || root.primary ? white : WorkbenchStyle.text; in property <color> text-color: root.destructive || root.primary ? white : WorkbenchStyle.text;
in property <color> normal-border-color: WorkbenchStyle.control-border; in property <color> normal-outline-color: ButtonStyle.border;
in property <color> normal-background: ButtonStyle.surface;
in property <string> icon; in property <string> icon;
in property <string> tooltip; in property <string> tooltip;
in property <bool> tooltip-above: false; in property <bool> tooltip-above: false;
in property <bool> tooltip-suppressed: false; in property <bool> tooltip-suppressed: false;
in property <color> icon-color: root.destructive ? white : root.primary in property <color> icon-color: root.destructive ? white : root.primary
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white) ? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
: WorkbenchStyle.muted; : ControlStyle.icon;
in property <bool> primary: false; in property <bool> primary: false;
in property <bool> destructive: false; in property <bool> destructive: false;
in property <bool> soft: false; in property <bool> soft: false;
@@ -45,24 +45,23 @@ export component WorkbenchButton inherits Rectangle {
width: 112px; width: 112px;
height: 36px; height: 36px;
border-radius: UiRadius.control; border-radius: UiRadius.control;
border-width: root.show-focus ? 2px : root.soft ? 0px : 1px; outline-enabled: root.show-focus || (!root.quiet && !root.soft && !root.primary && !root.destructive);
border-color: root.destructive outline-focused: root.show-focus;
? (root.show-focus ? #7a271a : touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20) outline-color: !root.enabled ? ButtonStyle.disabled-border
: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent) : root.show-focus ? WorkbenchStyle.accent
: root.primary ? WorkbenchStyle.accent : touch.pressed ? ButtonStyle.pressed-border
: touch.has-hover ? WorkbenchStyle.hover-border : touch.has-hover ? ButtonStyle.hover-border : root.normal-outline-color;
: root.quiet ? transparent : root.normal-border-color;
background: root.destructive background: root.destructive
? (touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20) ? (touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20)
: root.soft : root.soft
? (root.primary ? (root.primary
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #edf2ff) ? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #dce7ff)
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.field-surface)) : (touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface))
: root.primary : root.primary
? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent) ? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent)
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : root.quiet ? transparent : white); : (touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover : root.quiet ? transparent : root.normal-background);
opacity: root.enabled ? 1 : 0.45; opacity: root.enabled ? 1 : 0.45;
animate background, border-color { duration: 120ms; } animate background, outline-color { duration: 120ms; }
forward-focus: button-focus; forward-focus: button-focus;
accessible-role: button; accessible-role: button;
accessible-label: root.tooltip != "" ? root.tooltip : root.text; accessible-label: root.tooltip != "" ? root.tooltip : root.text;
@@ -92,6 +91,11 @@ export component WorkbenchButton inherits Rectangle {
accessible-role: none; accessible-role: none;
} }
} }
if root.show-focus && (root.primary || root.destructive): Rectangle {
x: 3px; y: 3px; width: parent.width - 6px; height: parent.height - 6px;
border-radius: max(0px, root.border-radius - 3px);
border-width: 1px; border-color: white;
}
button-focus := FocusScope { button-focus := FocusScope {
enabled: root.enabled; enabled: root.enabled;
focus-gained(reason) => { focus-gained(reason) => {
@@ -150,7 +154,7 @@ export component WorkbenchButton inherits Rectangle {
} }
// A raised surface keeps transient action feedback distinct from the workbench. // A raised surface keeps transient action feedback distinct from the workbench.
export component WorkbenchNotice inherits Rectangle { export component WorkbenchNotice inherits FlatSurface {
in property <string> text; in property <string> text;
in property <string> icon; in property <string> icon;
in property <bool> error: false; in property <bool> error: false;
@@ -159,12 +163,8 @@ export component WorkbenchNotice inherits Rectangle {
width: min(root.width-limit, max(280px, message.preferred-width + 64px)); width: min(root.width-limit, max(280px, message.preferred-width + 64px));
height: 64px; height: 64px;
background: root.error ? #fff1f0 : #e9f6ef; background: root.error ? #fff1f0 : #e9f6ef;
border-width: 1px; outline-color: root.error ? UiSurface.error : UiSurface.edge;
border-color: root.error ? #f0c4bf : #b9dfc9;
border-radius: UiRadius.surface; border-radius: UiRadius.surface;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 6px;
accessible-role: AccessibleRole.text; accessible-role: AccessibleRole.text;
accessible-label: root.text; accessible-label: root.text;
Text { Text {
@@ -192,9 +192,8 @@ export component WorkbenchNotice inherits Rectangle {
} }
} }
export component WorkbenchCard inherits Rectangle { export component WorkbenchCard inherits FlatSurface {
background: WorkbenchStyle.surface; background: WorkbenchStyle.surface;
border-width: 1px; outline-color: UiSurface.edge;
border-color: WorkbenchStyle.border;
border-radius: UiRadius.surface; border-radius: UiRadius.surface;
} }
@@ -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_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
{"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"}, {"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"},
{"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"}, {"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"}, {"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"},
{"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"}, {"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"},
{"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "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); }; save = [&config, enabled] { return config.SetPrivacyScreen(enabled); };
effect = SettingEffect::next_connection; effect = SettingEffect::next_connection;
message = "console_settings_next_connection"; message = "console_settings_next_connection";
} else if (key == "lock_on_disconnect") {
save = [&config, enabled] { return config.SetLockOnDisconnect(enabled); };
} else if (key == "autostart") { } else if (key == "autostart") {
save = [&config, enabled] { return config.SetAutostart(enabled); }; save = [&config, enabled] { return config.SetAutostart(enabled); };
effect = SettingEffect::next_login; 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(); if (CreateConnectionPeer() != 0) CloseConnectionPeer();
} }
HandleConnectionStatusChange(); HandleConnectionStatusChange();
HandleScreenLock();
HandleWindowsServiceIntegration(); HandleWindowsServiceIntegration();
devices_.UpdateInteractions(); devices_.UpdateInteractions();
ShareLocalCursorState(); ShareLocalCursorState();
@@ -71,6 +72,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
devices_.DestroyDevices(); devices_.DestroyDevices();
devices_.DestroyFactories(); devices_.DestroyFactories();
privacy_.Shutdown(); privacy_.Shutdown();
HandleScreenLock();
WriteUnattendedStatus(settings_.ActiveIdentity(), false, session); WriteUnattendedStatus(settings_.ActiveIdentity(), false, session);
SDL_Quit(); SDL_Quit();
return 0; return 0;
@@ -197,8 +197,9 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
optimistic.available = local_service_available_; optimistic.available = local_service_available_;
optimistic.interactive_stage = local_interactive_stage_; optimistic.interactive_stage = local_interactive_stage_;
const auto message = BuildWindowsServiceStatusAction(optimistic).to_json(); const auto message = BuildWindowsServiceStatusAction(optimistic).to_json();
SendReliableDataFrame(peer_, message.data(), message.size(), for (const auto& id : ConnectedControllers())
control_data_label_.c_str()); SendControllerData(id, message.data(), message.size(),
control_data_label_.c_str());
} }
result.reset(); result.reset();
} }
@@ -293,10 +294,11 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
RemoteAction remote_action = RemoteAction remote_action =
BuildWindowsServiceStatusAction(broadcast_status); BuildWindowsServiceStatusAction(broadcast_status);
std::string msg = remote_action.to_json(); std::string msg = remote_action.to_json();
int ret = SendReliableDataFrame(peer_, msg.data(), msg.size(), for (const auto& id : ConnectedControllers()) {
control_data_label_.c_str()); const int ret = SendControllerData(id, msg.data(), msg.size(),
if (ret != 0) { control_data_label_.c_str());
LOG_WARN("Broadcast Windows service status failed, ret={}", ret); if (ret != 0)
LOG_WARN("Windows service status send failed, peer={}, ret={}", id, ret);
} }
#endif #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 "platform.h"
#include <Winsock2.h> #include <Winsock2.h>
#include <Windows.h>
#include <iphlpapi.h> #include <iphlpapi.h>
#include <cstdio> #include <cstdio>
@@ -25,19 +26,38 @@ std::string GetMac() {
} }
std::string GetHostName() { std::string GetHostName() {
WSADATA data = {}; // Read Unicode directly; Winsock's narrow hostname is not guaranteed UTF-8.
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) { DWORD size = 0;
LOG_ERROR("WSAStartup failed"); if (!GetComputerNameExW(ComputerNameDnsHostname, nullptr, &size) &&
GetLastError() != ERROR_MORE_DATA) {
LOG_ERROR("GetComputerNameExW size query failed: {}", GetLastError());
return {}; return {};
} }
char hostname[256] = {}; if (size == 0) return {};
if (gethostname(hostname, sizeof(hostname)) == SOCKET_ERROR) {
LOG_ERROR("gethostname failed: {}", WSAGetLastError()); std::wstring hostname(size, L'\0');
WSACleanup(); if (!GetComputerNameExW(ComputerNameDnsHostname, hostname.data(), &size)) {
LOG_ERROR("GetComputerNameExW failed: {}", GetLastError());
return {}; return {};
} }
WSACleanup(); if (size == 0) return {};
return hostname;
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; } bool IsWaylandSession() { return false; }
+168 -102
View File
@@ -11,11 +11,14 @@
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
#include <cmath>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <fstream> #include <fstream>
#include <iomanip> #include <iomanip>
#include <iostream> #include <iostream>
#include <map> #include <map>
#include <memory>
#include <sstream>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -29,53 +32,41 @@
#include "stb_image_write.h" #include "stb_image_write.h"
namespace crossdesk { namespace crossdesk {
namespace {
void ScaleNv12ToABGR(char* src, int src_w, int src_h, int dst_w, int dst_h, // Keep enough detail for high-DPI cards, independently of the display used
char* dst_rgba) { // when the session closes. Preserve the source aspect ratio without bars.
uint8_t* y = reinterpret_cast<uint8_t*>(src); constexpr int kPreviewMaxEdge = 1024;
uint8_t* uv = y + src_w * src_h;
float src_aspect = float(src_w) / src_h; bool ScaleNv12ToABGR(const char* src, int src_w, int src_h, int dst_w,
float dst_aspect = float(dst_w) / dst_h; int dst_h, uint8_t* dst_rgba) {
int fit_w = dst_w, fit_h = dst_h; const auto* y = reinterpret_cast<const uint8_t*>(src);
if (src_aspect > dst_aspect) { const auto* uv = y + size_t(src_w) * src_h;
fit_h = int(dst_w / src_aspect); const int src_cw = (src_w + 1) / 2, src_ch = (src_h + 1) / 2;
} else { const int dst_cw = (dst_w + 1) / 2, dst_ch = (dst_h + 1) / 2;
fit_w = int(dst_h * src_aspect); 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> y_fit(size_t(dst_w) * dst_h);
std::vector<uint8_t> u_i420((src_w / 2) * (src_h / 2)); std::vector<uint8_t> u_fit(size_t(dst_cw) * dst_ch);
std::vector<uint8_t> v_i420((src_w / 2) * (src_h / 2)); std::vector<uint8_t> v_fit(u_fit.size());
libyuv::NV12ToI420(y, src_w, uv, src_w, y_i420.data(), src_w, u_i420.data(), if (libyuv::I420Scale(y_i420.data(), src_w, u_i420.data(), src_cw,
src_w / 2, v_i420.data(), src_w / 2, src_w, src_h); 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,
std::vector<uint8_t> y_fit(fit_w * fit_h); dst_h, libyuv::kFilterBox) != 0)
std::vector<uint8_t> u_fit((fit_w + 1) / 2 * (fit_h + 1) / 2); return false;
std::vector<uint8_t> v_fit((fit_w + 1) / 2 * (fit_h + 1) / 2); return libyuv::I420ToABGR(y_fit.data(), dst_w, u_fit.data(), dst_cw,
libyuv::I420Scale(y_i420.data(), src_w, u_i420.data(), src_w / 2, v_fit.data(), dst_cw, dst_rgba, dst_w * 4,
v_i420.data(), src_w / 2, src_w, src_h, y_fit.data(), fit_w, dst_w, dst_h) == 0;
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);
}
} }
} // namespace
Thumbnail::Thumbnail(std::string save_path) { Thumbnail::Thumbnail(std::string save_path) {
if (!save_path.empty()) { if (!save_path.empty()) {
save_path_ = save_path; save_path_ = save_path;
@@ -97,19 +88,6 @@ Thumbnail::Thumbnail(std::string save_path, unsigned char* aes128_key,
std::filesystem::create_directories(save_path_); 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 { namespace {
bool IsRemoteId(const std::string& id) { bool IsRemoteId(const std::string& id) {
@@ -118,7 +96,14 @@ bool IsRemoteId(const std::string& id) {
[](unsigned char c) { return c >= '0' && c <= '9'; }); [](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) { 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") + return connection.remote_id + (connection.remember_password ? "Y" : "N") +
connection.remote_host_name + connection.remote_host_name +
(connection.remember_password ? "@" + connection.password : ""); (connection.remember_password ? "@" + connection.password : "");
@@ -126,17 +111,37 @@ std::string ConnectionKey(const Thumbnail::RecentConnection& connection) {
} // namespace } // 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) { 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 = const auto path =
std::filesystem::path(save_path_) / (connection.remote_id + ".json"); std::filesystem::path(save_path_) / (connection.remote_id + ".json");
const auto temporary = path.string() + ".tmp"; const auto temporary = path.string() + ".tmp";
const nlohmann::json record = { 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}, {"remote_id", connection.remote_id},
{"host_name", connection.remote_host_name}, {"host_name", connection.remote_host_name},
{"password", {"password", password},
connection.remember_password
? AES_encrypt(connection.password, aes128_key_, aes128_iv_)
: ""},
{"platform", HostPlatformName(connection.platform)}}; {"platform", HostPlatformName(connection.platform)}};
std::ofstream output(temporary, std::ios::binary | std::ios::trunc); std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
output << record.dump(); output << record.dump();
@@ -167,14 +172,25 @@ bool Thumbnail::ReadRecord(const std::filesystem::path& path,
if (!record.is_object()) return false; if (!record.is_object()) return false;
try { try {
connection->remote_id = record.value("remote_id", ""); connection->remote_id = record.value("remote_id", "");
if (!IsRemoteId(connection->remote_id) || const auto kind = record.value("kind", "cloud_id");
path.stem() != connection->remote_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; return false;
if (path.stem() != connection->remote_id) return false;
connection->remote_host_name = record.value("host_name", ""); connection->remote_host_name = record.value("host_name", "");
const auto password = record.value("password", ""); const auto password = record.value("password", "");
connection->remember_password = !password.empty();
connection->password = connection->password =
password.empty() ? "" : AES_decrypt(password, aes128_key_, aes128_iv_); password.empty() ? "" : AES_decrypt(password, aes128_key_, aes128_iv_);
connection->remember_password = !connection->password.empty();
connection->platform = connection->platform =
ParseHostPlatform(record.value("platform", "unknown")); ParseHostPlatform(record.value("platform", "unknown"));
const auto image = path.parent_path() / (connection->remote_id + ".png"); 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& remote_id,
const std::string& host_name, const std::string& host_name,
const std::string& password, const std::string& password,
HostPlatform platform) { HostPlatform platform, const LanTarget* lan) {
std::lock_guard lock(mutex_); std::lock_guard lock(mutex_);
// Successful connections are history even without saved credentials. // 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; RecentConnection connection;
ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"), ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"),
&connection); &connection);
connection.remote_id = remote_id; connection.remote_id = remote_id;
if (!host_name.empty()) connection.remote_host_name = host_name; if (!host_name.empty()) connection.remote_host_name = host_name;
connection.lan = lan ? *lan : LanTarget{};
connection.password = password; connection.password = password;
connection.remember_password = !password.empty(); connection.remember_password = !connection.password.empty();
if (platform != HostPlatform::Unknown) connection.platform = platform; if (platform != HostPlatform::Unknown) connection.platform = platform;
if (!WriteRecord(connection)) return -1; if (!WriteRecord(connection)) return -1;
const auto image = std::filesystem::path(save_path_) / (remote_id + ".png"); const auto image = std::filesystem::path(save_path_) / (remote_id + ".png");
if (save_previews_ && nv12 && width > 0 && height > 0) { if (save_previews_ && nv12 && width > 0 && height > 0) {
if (!rgba_buffer_) { const double scale = std::min(1.0, double(kPreviewMaxEdge) /
rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4]; std::max(width, height));
} const int image_width = std::max(1, int(std::lround(width * scale)));
ScaleNv12ToABGR(const_cast<char*>(nv12), width, height, thumbnail_width_, const int image_height = std::max(1, int(std::lround(height * scale)));
thumbnail_height_, rgba_buffer_); std::vector<uint8_t> rgba(size_t(image_width) * image_height * 4);
if (!stbi_write_png(image.string().c_str(), thumbnail_width_, if (!ScaleNv12ToABGR(nv12, width, height, image_width, image_height,
thumbnail_height_, 4, rgba_buffer_, rgba.data()) ||
thumbnail_width_ * 4)) !stbi_write_png(image.string().c_str(), image_width, image_height, 4,
rgba.data(), image_width * 4))
return -1; return -1;
} else if (!save_previews_) { } else if (!save_previews_) {
std::error_code error; std::error_code error;
@@ -235,11 +256,8 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height,
} }
int Thumbnail::LoadThumbnail( int Thumbnail::LoadThumbnail(
std::vector<std::pair<std::string, RecentConnection>>& recent_connections, std::vector<std::pair<std::string, RecentConnection>>& recent_connections) {
int* width, int* height) {
std::lock_guard lock(mutex_); std::lock_guard lock(mutex_);
if (width) *width = thumbnail_width_;
if (height) *height = thumbnail_height_;
return LoadThumbnailLocked(recent_connections); return LoadThumbnailLocked(recent_connections);
} }
@@ -323,32 +341,80 @@ int Thumbnail::LoadThumbnailLocked(
} }
bool Thumbnail::DecodeImage(const std::filesystem::path& image_path, bool Thumbnail::DecodeImage(const std::filesystem::path& image_path,
int target_width, int target_height,
std::vector<unsigned char>* rgba, int* width, std::vector<unsigned char>* rgba, int* width,
int* height) const { int* height) const {
if (!rgba || !width || !height) { if (!rgba || !width || !height || target_width <= 0 || target_height <= 0) {
return false; return false;
} }
rgba->clear();
int channels = 0; *width = *height = 0;
unsigned char* pixels = int source_width = 0, source_height = 0, channels = 0;
stbi_load(image_path.string().c_str(), width, height, &channels, 4); std::unique_lock lock(mutex_);
if (!pixels || *width <= 0 || *height <= 0) { const std::unique_ptr<unsigned char, decltype(&stbi_image_free)> pixels(
if (pixels) { stbi_load(image_path.string().c_str(), &source_width, &source_height,
stbi_image_free(pixels); &channels, 4), stbi_image_free);
} lock.unlock();
rgba->clear(); if (!pixels || source_width <= 0 || source_height <= 0)
return false; return false;
}
const size_t byte_count = // Match image-fit: cover, cropping once from the original cached image.
static_cast<size_t>(*width) * static_cast<size_t>(*height) * 4; int crop_width = source_width, crop_height = source_height;
rgba->assign(pixels, pixels + byte_count); if (int64_t(source_width) * target_height >
stbi_image_free(pixels); 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; return true;
} }
int Thumbnail::DeleteThumbnail(const std::string& filename_keyword) { int Thumbnail::DeleteThumbnail(const std::string& filename_keyword) {
std::lock_guard lock(mutex_); 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); const auto remote_id = filename_keyword.substr(0, 9);
if (!IsRemoteId(remote_id)) return -1; if (!IsRemoteId(remote_id)) return -1;
for (const auto& entry : std::filesystem::directory_iterator(save_path_)) { 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(); ctx = EVP_CIPHER_CTX_new();
if (!ctx) { if (!ctx) {
LOG_ERROR("Error in EVP_CIPHER_CTX_new"); LOG_ERROR("Error in EVP_CIPHER_CTX_new");
return plaintext; return {};
} }
ret = EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv); ret = EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv);
if (1 != ret) { if (1 != ret) {
LOG_ERROR("Error in EVP_EncryptInit_ex"); LOG_ERROR("Error in EVP_EncryptInit_ex");
EVP_CIPHER_CTX_free(ctx); EVP_CIPHER_CTX_free(ctx);
return plaintext; return {};
} }
ret = EVP_EncryptUpdate( ret = EVP_EncryptUpdate(
@@ -428,7 +494,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
if (1 != ret) { if (1 != ret) {
LOG_ERROR("Error in EVP_EncryptUpdate"); LOG_ERROR("Error in EVP_EncryptUpdate");
EVP_CIPHER_CTX_free(ctx); EVP_CIPHER_CTX_free(ctx);
return plaintext; return {};
} }
ciphertext_len = len; ciphertext_len = len;
@@ -436,7 +502,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
if (1 != ret) { if (1 != ret) {
LOG_ERROR("Error in EVP_EncryptFinal_ex"); LOG_ERROR("Error in EVP_EncryptFinal_ex");
EVP_CIPHER_CTX_free(ctx); EVP_CIPHER_CTX_free(ctx);
return plaintext; return {};
} }
ciphertext_len += len; ciphertext_len += len;
@@ -448,7 +514,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
if (1 != ret) { if (1 != ret) {
LOG_ERROR("Error in OPENSSL_buf2hexstr_ex"); LOG_ERROR("Error in OPENSSL_buf2hexstr_ex");
EVP_CIPHER_CTX_free(ctx); EVP_CIPHER_CTX_free(ctx);
return plaintext; return {};
} }
EVP_CIPHER_CTX_free(ctx); 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'); &ciphertext_buf_len, ciphertext.c_str(), '\0');
if (1 != ret) { if (1 != ret) {
LOG_ERROR("Error in OPENSSL_hexstr2buf_ex"); LOG_ERROR("Error in OPENSSL_hexstr2buf_ex");
return ciphertext; return {};
} }
ctx = EVP_CIPHER_CTX_new(); ctx = EVP_CIPHER_CTX_new();
if (!ctx) { if (!ctx) {
LOG_ERROR("Error in EVP_CIPHER_CTX_new"); LOG_ERROR("Error in EVP_CIPHER_CTX_new");
return ciphertext; return {};
} }
ret = EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv); 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"); LOG_ERROR("Error in EVP_DecryptInit_ex");
EVP_CIPHER_CTX_free(ctx); EVP_CIPHER_CTX_free(ctx);
return ciphertext; return {};
} }
ret = EVP_DecryptUpdate(ctx, plaintext, &plaintext_len, ciphertext_buf, 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"); LOG_ERROR("Error in EVP_DecryptUpdate");
EVP_CIPHER_CTX_free(ctx); EVP_CIPHER_CTX_free(ctx);
return ciphertext; return {};
} }
ret = ret =
@@ -503,7 +569,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext,
LOG_ERROR("Error in EVP_DecryptFinal_ex"); LOG_ERROR("Error in EVP_DecryptFinal_ex");
EVP_CIPHER_CTX_free(ctx); EVP_CIPHER_CTX_free(ctx);
return ciphertext; return {};
} }
plaintext_len += plaintext_final_len; plaintext_len += plaintext_final_len;
+15 -14
View File
@@ -1,7 +1,7 @@
/* /*
* @Author: DI JUNKUN * @Author: DI JUNKUN
* @Date: 2024-11-07 * @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_ #ifndef _THUMBNAIL_H_
@@ -20,12 +20,18 @@ namespace crossdesk {
class Thumbnail { class Thumbnail {
public: public:
struct LanTarget {
std::string address, device_id;
unsigned port = 52888;
};
static std::string LanRecordId(const std::string& device_id);
struct RecentConnection { struct RecentConnection {
std::filesystem::path image_path; std::filesystem::path image_path;
std::string remote_id; std::string remote_id;
std::string remote_host_name; std::string remote_host_name;
std::string password; std::string password;
bool remember_password = false; bool remember_password = false;
LanTarget lan;
HostPlatform platform = HostPlatform::Unknown; HostPlatform platform = HostPlatform::Unknown;
}; };
@@ -33,10 +39,6 @@ class Thumbnail {
Thumbnail(std::string save_path); Thumbnail(std::string save_path);
explicit Thumbnail(std::string save_path, unsigned char* aes128_key, explicit Thumbnail(std::string save_path, unsigned char* aes128_key,
unsigned char* aes128_iv); unsigned char* aes128_iv);
~Thumbnail();
public:
int SetThumbnailDpiScale(float dpi_scale);
// Serialized with writes so disabling also clears any in-flight preview. // Serialized with writes so disabling also clears any in-flight preview.
int SetSavePreviews(bool enabled); int SetSavePreviews(bool enabled);
@@ -45,16 +47,18 @@ class Thumbnail {
// credentials (and clears any remembered password from a previous session). // credentials (and clears any remembered password from a previous session).
int SaveToThumbnail(const char* nv12, int width, int height, int SaveToThumbnail(const char* nv12, int width, int height,
const std::string& remote_id, const std::string& remote_id,
const std::string& host_name, const std::string& host_name, const std::string& password,
const std::string& password, HostPlatform platform = HostPlatform::Unknown,
HostPlatform platform = HostPlatform::Unknown); const LanTarget* lan = nullptr);
int LoadThumbnail( int LoadThumbnail(
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>& std::vector<std::pair<std::string, Thumbnail::RecentConnection>>&
recent_connections, recent_connections);
int* width, int* height);
// 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, bool DecodeImage(const std::filesystem::path& image_path,
int target_width, int target_height,
std::vector<unsigned char>* rgba, int* width, std::vector<unsigned char>* rgba, int* width,
int* height) const; int* height) const;
@@ -94,11 +98,8 @@ class Thumbnail {
unsigned char* iv); unsigned char* iv);
private: private:
std::mutex mutex_; mutable std::mutex mutex_;
bool save_previews_ = true; bool save_previews_ = true;
int thumbnail_width_ = 160;
int thumbnail_height_ = 90;
char* rgba_buffer_ = nullptr;
std::string save_path_ = "thumbnails/"; std::string save_path_ = "thumbnails/";
unsigned char aes128_key_[16]; unsigned char aes128_key_[16];
+57 -2
View File
@@ -37,6 +37,7 @@ rule("slint")
before_build_file(function(target, sourcefile, opt) before_build_file(function(target, sourcefile, opt)
import("core.project.depend") import("core.project.depend")
import("core.project.project")
import("core.tools.gcc.parse_deps", {alias = "parse_deps"}) import("core.tools.gcc.parse_deps", {alias = "parse_deps"})
import("utils.progress") import("utils.progress")
@@ -70,8 +71,46 @@ rule("slint")
return files return files
end end
local depopts = {dependfile = target:dependfile(outputs.header), local function input_hashes(files)
values = argv, files = dependencies(), -- Rapid edits can share an mtime with the last generation too.
local hashes = {}
for _, file in ipairs(files) do
if file ~= compiler then
table.insert(hashes, os.isfile(file) and hash.sha256(file) or "missing")
end
end
return hashes
end
local function invalidate_consumers(changed_files)
for _, consumer in pairs(project.targets()) do
local uses_target = consumer == target
for _, dependency in ipairs(consumer:orderdeps()) do
uses_target = uses_target or dependency == target
end
if uses_target then
for _, dependfile in ipairs(consumer:dependfiles()) do
local info = depend.load(dependfile, {target = consumer})
for _, file in ipairs(info and info.files or {}) do
if changed_files[path.absolute(file)] then
os.tryrm(dependfile)
break
end
end
end
-- Archiving/linking uses the same coarse mtime comparison.
if consumer:targetfile() then
os.tryrm(consumer:dependfile())
end
end
end
end
local dependfile = target:dependfile(outputs.header)
local previous = depend.load(dependfile) or {}
local files = dependencies()
local depopts = {dependfile = dependfile,
values = {argv, input_hashes(files)}, files = files,
changed = target:is_rebuilt() or missing_output or not os.isfile(depfile)} changed = target:is_rebuilt() or missing_output or not os.isfile(depfile)}
-- Slint preserves timestamps of unchanged outputs. Use the dependency -- Slint preserves timestamps of unchanged outputs. Use the dependency
-- cache's successful-generation time, not the oldest output's time. -- cache's successful-generation time, not the oldest output's time.
@@ -79,8 +118,24 @@ rule("slint")
progress.show(opt.progress, "${color.build.object}generating.slint %s", sourcefile) progress.show(opt.progress, "${color.build.object}generating.slint %s", sourcefile)
os.mkdir(path.directory(outputs.header)) os.mkdir(path.directory(outputs.header))
os.vrunv(compiler, argv) os.vrunv(compiler, argv)
-- Xmake compares mtimes in whole seconds. A generated header can
-- change in the same second as a consumer's last compilation, so
-- ordering alone cannot prevent mixing incompatible UI bindings.
local generated, changed_files = {}, {}
for _, file in ipairs(table.join({outputs.header}, outputs.sources)) do
local absolute = path.absolute(file)
generated[absolute] = hash.sha256(absolute)
if generated[absolute] ~= (previous.generated or {})[absolute] then
changed_files[absolute] = true
end
end
if not table.empty(changed_files) then
invalidate_consumers(changed_files)
end
-- Save the freshly discovered dependencies on the first build too. -- Save the freshly discovered dependencies on the first build too.
depopts.files = dependencies() depopts.files = dependencies()
depopts.values = {argv, input_hashes(depopts.files)}
return {generated = generated}
end, depopts) end, depopts)
end) end)
rule_end() rule_end()
+9 -4
View File
@@ -86,11 +86,14 @@ function setup_targets()
add_packages("concurrentqueue") add_packages("concurrentqueue")
add_files("apps/desktop/src/common/*.cpp") add_files("apps/desktop/src/common/*.cpp")
if is_os("windows") then 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 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 elseif is_os("linux") then
add_files("apps/desktop/src/platform/linux/system_info.cpp", 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") "apps/desktop/src/platform/linux/common/*.cpp")
add_includedirs("apps/desktop/src/platform/linux/common") add_includedirs("apps/desktop/src/platform/linux/common")
end end
@@ -563,7 +566,9 @@ function setup_targets()
-- version and use fmt's supported runtime-parser fallback here. -- version and use fmt's supported runtime-parser fallback here.
add_defines("FMT_CONSTEVAL=") add_defines("FMT_CONSTEVAL=")
add_packages("slint", {public = true}) 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 if not is_config("CROSSDESK_PORTABLE", true) then
add_defines("CROSSDESK_HAS_UPDATER=1", {public = true}) add_defines("CROSSDESK_HAS_UPDATER=1", {public = true})
add_deps("updater_core") add_deps("updater_core")
@@ -599,7 +604,7 @@ function setup_targets()
add_deps("windows_unattended_config") add_deps("windows_unattended_config")
add_files("apps/desktop/src/platform/windows/gui/application/unattended_application.cpp") add_files("apps/desktop/src/platform/windows/gui/application/unattended_application.cpp")
add_links("opengl32") 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", 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/slint_backend.cpp",
"apps/desktop/src/platform/windows/gui/window_icons.cpp", "apps/desktop/src/platform/windows/gui/window_icons.cpp",
@@ -274,7 +274,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh; shellPath = /bin/zsh;
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\"\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\" --platform \"${PLATFORM_NAME}\"\n"; shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/verify_native_build.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --xmake-version \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\" --platform \"${PLATFORM_NAME}\"\n";
}; };
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
+3 -1
View File
@@ -750,8 +750,10 @@ private struct PasswordPromptView: View {
SecureField("访问密码", text: $password) SecureField("访问密码", text: $password)
} }
} }
.keyboardType(.numberPad) .keyboardType(.asciiCapable)
.textContentType(.password) .textContentType(.password)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($passwordFocused) .focused($passwordFocused)
Button { Button {
+13 -13
View File
@@ -145,7 +145,7 @@ workflow on branch/tag pushes and manual runs, and waits for its result before
publishing a version-tag release. publishing a version-tag release.
The workflow builds with the minimum supported Xmake version (3.1.1) and retains The workflow builds with the minimum supported Xmake version (3.1.1) and retains
the pinned Xcode/SDK, dependency cache, license catalog validation, unsigned arm64 the pinned Xcode/SDK, dependency cache, native input validation, unsigned arm64
Release build, and app-bundle verification. Unit tests run locally as needed. Release build, and app-bundle verification. Unit tests run locally as needed.
It uploads It uploads
`crossdesk-ios-arm64-unsigned-v<version>-<date>.zip`, `crossdesk-ios-arm64-unsigned-v<version>-<date>.zip`,
@@ -366,16 +366,16 @@ collected from the reviewed runtime source directories; the catalog is an
attribution superset, not a claim that every source file survives linker attribution superset, not a claim that every source file survives linker
dead stripping. dead stripping.
Normal Xcode builds verify the checked-in resource, local license files and Builds package the license resources without validating their contents, hashes
package recipes offline before compiling native code. After compilation they or agreement with resolved dependency versions. License generation is a manual
also compare all resolved dependency versions (including header-only packages) maintenance step and is not run by CI or Xcode. Update the catalog and regenerate
with the catalog and check the merged archive's input hashes and pinned the resource when the notices need to change; regeneration does not select a
toolchain. A missing, stale or altered catalog fails the build. Ordinary MiniRTC new dependency version.
code changes do not require changing the notice-review revision or regenerating
the catalog. Changes to dependencies, license texts or embedded third-party `scripts/verify_native_build.py` independently checks the native archive input
notices still require reviewing and updating the affected catalog entries; hashes and pinned Xmake toolchain after compilation. These checks do not read
regeneration alone does not select a new dependency version. the license catalog. Downloaded source archives retain their SHA-256 checks
`MINIRTC_ENABLE_AOM=true` also requires a reviewed AOM entry. when manually regenerating notices.
The source screen links to the exact application tag/commit, MiniRTC commit, The source screen links to the exact application tag/commit, MiniRTC commit,
and custom dependency recipes/patches. `SourceMetadata.json` is generated in and custom dependency recipes/patches. `SourceMetadata.json` is generated in
@@ -395,8 +395,8 @@ archives available for recipients of that release.
To change a library, edit MiniRTC in its submodule, or modify the relevant To change a library, edit MiniRTC in its submodule, or modify the relevant
package recipe/patch under `deps/submodules/minirtc/thirdparty`. If a package package recipe/patch under `deps/submodules/minirtc/thirdparty`. If a package
recipe changes, change its package configuration revision (or rebuild that recipe changes, change its package configuration revision (or rebuild that
package) so Xmake does not reuse a previous binary. Review affected notices, package) so Xmake does not reuse a previous binary. Update affected notices
update recipe hashes, and commit MiniRTC. When the notice catalog changes, and commit MiniRTC. When the notice catalog changes,
record the reviewed revision in `sources.json` and regenerate the catalog. record the reviewed revision in `sources.json` and regenerate the catalog.
Build the application again to relink it with Build the application again to relink it with
the modified library. Use your own team and bundle identifier to install the the modified library. Use your own team and bundle identifier to install the
+47 -114
View File
@@ -14,15 +14,12 @@
}, },
"documents": [ "documents": [
{ {
"path": "asio/COPYING", "path": "asio/COPYING"
"sha256": "5cc69ca9afd3417ed8e58bcd9c0bb730375e9e3e92b5aaf6483b93a2837e0cac"
}, },
{ {
"path": "asio/LICENSE_1_0.txt", "path": "asio/LICENSE_1_0.txt"
"sha256": "c9bff75738922193e67fa726fa225535870d2aa1059f91452c411736284ad566"
} }
], ],
"recipe_sha256": "52bddc12cf0837117e935ea5d71aa5c35e7fd6ae802aa7401c6dba598cae0989",
"recipe_path": "xmake-repo/packages/a/asio/xmake.lua", "recipe_path": "xmake-repo/packages/a/asio/xmake.lua",
"package": "asio", "package": "asio",
"notice_prefixes": [ "notice_prefixes": [
@@ -41,11 +38,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE.md", "path": "LICENSE.md"
"sha256": "22331571e5e3dbe4c79ad715e23127bdf3beabd75052c953ddabf191ef799e14"
} }
], ],
"recipe_sha256": "23c69dc09488527b0d6adf1d25f795a9f0afdc7a1d594c44603c2dd07162faa4",
"recipe_path": "xmake-repo/packages/c/concurrentqueue/xmake.lua", "recipe_path": "xmake-repo/packages/c/concurrentqueue/xmake.lua",
"package": "concurrentqueue", "package": "concurrentqueue",
"notice_prefixes": [ "notice_prefixes": [
@@ -66,12 +61,10 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE", "path": "LICENSE"
"sha256": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
}, },
{ {
"path": "apps/ios/licenses/OPEN_SOURCE_NOTICE.txt", "path": "apps/ios/licenses/OPEN_SOURCE_NOTICE.txt"
"sha256": "34720d126e6373603d0c1802a8c3276802b93f2a8fbbf2b61837c6fdefea8877"
} }
] ]
}, },
@@ -88,12 +81,10 @@
}, },
"documents": [ "documents": [
{ {
"path": "COPYING", "path": "COPYING"
"sha256": "b327887de263238deaa80c34cdd2ff3e0ba1d35db585ce14a37ce3e74ee389e9"
}, },
{ {
"path": "doc/PATENTS", "path": "doc/PATENTS"
"sha256": "335eca574598bf4ca181b12f708d6669e5a5e78c8e1513e5b35fa1f03901484b"
} }
], ],
"package": "dav1d", "package": "dav1d",
@@ -114,39 +105,30 @@
}, },
"documents": [ "documents": [
{ {
"path": "COPYING", "path": "COPYING"
"sha256": "fa6f36630bb1e0c571d34b2bbdf188d08495c9dbf58f28cac112f303fc1f58fb"
}, },
{ {
"path": "LICENSES/Apache-2.0.txt", "path": "LICENSES/Apache-2.0.txt"
"sha256": "074e6e32c86a4c0ef8b3ed25b721ca23aca83df277cd88106ef7177c354615ff"
}, },
{ {
"path": "LICENSES/CC0-1.0.txt", "path": "LICENSES/CC0-1.0.txt"
"sha256": "a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499"
}, },
{ {
"path": "LICENSES/LGPL-2.1-only.txt", "path": "LICENSES/LGPL-2.1-only.txt"
"sha256": "fa6f36630bb1e0c571d34b2bbdf188d08495c9dbf58f28cac112f303fc1f58fb"
}, },
{ {
"path": "LICENSES/LLVM-exception.txt", "path": "LICENSES/LLVM-exception.txt"
"sha256": "e34c58338bd89d43e709e226610d8f32b3e3c47f4ad9a99a8dc1d4ac7842488e"
}, },
{ {
"path": "LICENSES/MIT.txt", "path": "LICENSES/MIT.txt"
"sha256": "b85dcd3e453d05982552c52b5fc9e0bdd6d23c6f8e844b984a88af32570b0cc0"
}, },
{ {
"path": "LICENSES/MPL-1.1.txt", "path": "LICENSES/MPL-1.1.txt"
"sha256": "6214f8b1300bb9f37b16ebc146f8f61af0187e0025042ff95c8b3030744a8795"
}, },
{ {
"path": "subprojects/gvdb/COPYING", "path": "subprojects/gvdb/COPYING"
"sha256": "bdbcdaa7858872393e9d6b216bbdc8a23e99d98dd6cdb7cda19458025b9e19fc"
} }
], ],
"recipe_sha256": "650e2f9a1c4b5079694a5626cfcb625c650a938f32878cf0caab3b0cf58216b9",
"recipe_path": "deps/submodules/minirtc/thirdparty/glib/xmake.lua", "recipe_path": "deps/submodules/minirtc/thirdparty/glib/xmake.lua",
"package": "glib", "package": "glib",
"notice_prefixes": [ "notice_prefixes": [
@@ -169,8 +151,7 @@
}, },
"documents": [ "documents": [
{ {
"path": "deps/submodules/minirtc/src/inih/LICENSE", "path": "deps/submodules/minirtc/src/inih/LICENSE"
"sha256": "bd2a675a5a26fbff77f171f3a15afb1a9c7855be1a01c7e3fd16b8d2617acdda"
} }
] ]
}, },
@@ -186,11 +167,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE.MIT", "path": "LICENSE.MIT"
"sha256": "86b998c792894ccb911a1cb7994f7a9652894e7a094c0b5e45be2f553f45cf14"
} }
], ],
"recipe_sha256": "eadd211f0521a18207e9544c0c5a08c41dca578fce99df5c2577f6fcf2a0fdce",
"recipe_path": "xmake-repo/packages/n/nlohmann_json/xmake.lua", "recipe_path": "xmake-repo/packages/n/nlohmann_json/xmake.lua",
"package": "nlohmann_json", "package": "nlohmann_json",
"notice_prefixes": [ "notice_prefixes": [
@@ -209,11 +188,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE", "path": "LICENSE"
"sha256": "6587fd02a924b97dbd6758b0573ddec056fb494a84f3709c5aaa33ce1f7f029c"
} }
], ],
"recipe_sha256": "f86cac2c4b884f3347a39a59f3dd8de566c257c7fd481c6e49b05f4730f12c4f",
"recipe_path": "xmake-repo/packages/k/kcp/xmake.lua", "recipe_path": "xmake-repo/packages/k/kcp/xmake.lua",
"package": "kcp", "package": "kcp",
"notice_prefixes": [ "notice_prefixes": [
@@ -233,11 +210,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE", "path": "LICENSE"
"sha256": "fab3dd6bdab226f1c08630b1dd917e11fcb4ec5e1e020e2c16f83a0a13863e85"
} }
], ],
"recipe_sha256": "b3ec15652a47d65e0a1f85f62d02c098c5cf5b8d8c3cf7b03ff099efa7016326",
"recipe_path": "deps/submodules/minirtc/thirdparty/libdatachannel/xmake.lua", "recipe_path": "deps/submodules/minirtc/thirdparty/libdatachannel/xmake.lua",
"package": "libdatachannel", "package": "libdatachannel",
"notice_prefixes": [ "notice_prefixes": [
@@ -257,11 +232,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE", "path": "LICENSE"
"sha256": "67894089811f93fca47a76f85e017da6f8582d4ba0905963c6e0f1ad6df7a195"
} }
], ],
"recipe_sha256": "de19ac4e3e91ef5715ac582c75e0d10404dc14ec4e33b2e764dc2637a78d24e9",
"recipe_path": "xmake-repo/packages/l/libffi/xmake.lua", "recipe_path": "xmake-repo/packages/l/libffi/xmake.lua",
"package": "libffi", "package": "libffi",
"notice_prefixes": [ "notice_prefixes": [
@@ -281,23 +254,18 @@
}, },
"documents": [ "documents": [
{ {
"path": "COPYING", "path": "COPYING"
"sha256": "e5504e5ffe894870ea1a652df957a120ead9c3f9a5e9d314ca41ba06f2d72024"
}, },
{ {
"path": "COPYING.LGPL", "path": "COPYING.LGPL"
"sha256": "a9bdde5616ecdd1e980b44f360600ee8783b1f99b8cc83a2beb163a0a390e861"
}, },
{ {
"path": "COPYING.MPL", "path": "COPYING.MPL"
"sha256": "3b3c75ae6c663c4f93987647eb5463d33dc1602a1aded3ac479773f2c3928e3a"
}, },
{ {
"path": "AUTHORS", "path": "AUTHORS"
"sha256": "d72eb6fa9d5e93abb11d13b6c6f04fda2ba183d0705e703887cda243f6f1eead"
} }
], ],
"recipe_sha256": "bc4b8d10ff76a79fc7cbffe3fe9b8c0272d320fc549cba4044d2b1d3337e0116",
"recipe_path": "deps/submodules/minirtc/thirdparty/libnice/xmake.lua", "recipe_path": "deps/submodules/minirtc/thirdparty/libnice/xmake.lua",
"package": "libnice", "package": "libnice",
"notice_prefixes": [ "notice_prefixes": [
@@ -320,11 +288,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE", "path": "LICENSE"
"sha256": "8e19d42a1eec9561f3f347253ddf2e385c55f392f025bb0fd41b88dbf38db5ae"
} }
], ],
"recipe_sha256": "9629edb8d395ea3017af1702cafea57173ca0a6a1ea305a6174b1936598faec6",
"recipe_path": "deps/submodules/minirtc/thirdparty/libsrtp/xmake.lua", "recipe_path": "deps/submodules/minirtc/thirdparty/libsrtp/xmake.lua",
"package": "libsrtp", "package": "libsrtp",
"notice_prefixes": [ "notice_prefixes": [
@@ -346,16 +312,13 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE", "path": "LICENSE"
"sha256": "2b2cc1180c7e6988328ad2033b04b80117419db9c4c584918bbb3cfec7e9364f"
}, },
{ {
"path": "PATENTS", "path": "PATENTS"
"sha256": "4da84211f947c6f9f7dd8fbce513e1522e01833407ae732b8c9a2bbb75fed495"
}, },
{ {
"path": "AUTHORS", "path": "AUTHORS"
"sha256": "0c4a51c72a06b3431bb2b750e3b04dbe3694b22e2fcae6e370216424eec56d52"
} }
], ],
"package": "libyuv", "package": "libyuv",
@@ -376,8 +339,7 @@
}, },
"documents": [ "documents": [
{ {
"path": "deps/submodules/minirtc/LICENSE", "path": "deps/submodules/minirtc/LICENSE"
"sha256": "ea8af5e789cb2d4e9b10bce3874982ade163b749b6bfbdb32e2df21c4d106de1"
} }
] ]
}, },
@@ -394,11 +356,9 @@
"documents": [ "documents": [
{ {
"path": "LICENCE_CeCILL-C_V1-en.txt", "path": "LICENCE_CeCILL-C_V1-en.txt",
"sha256": "b48f5dc16f144cd79ddaadce3cc8ef183778a0b4f75ab4362e134b347ca20f3d",
"encoding": "iso-8859-1" "encoding": "iso-8859-1"
} }
], ],
"recipe_sha256": "a9bc4a460195c77543a91c0a2b40a094f4a3869dc917e4eb98e7e8005ea441ab",
"recipe_path": "deps/submodules/minirtc/thirdparty/openfec/xmake.lua", "recipe_path": "deps/submodules/minirtc/thirdparty/openfec/xmake.lua",
"package": "openfec", "package": "openfec",
"notice_prefixes": [ "notice_prefixes": [
@@ -422,11 +382,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE.txt", "path": "LICENSE.txt"
"sha256": "7d5450cb2d142651b8afa315b5f238efc805dad827d91ba367d8516bc9d49e7a"
} }
], ],
"recipe_sha256": "230631f3cddc72674fa21d2639044b00120ee346ef186eddfb5dc04bf4df3ee9",
"recipe_path": "deps/submodules/minirtc/thirdparty/openssl/xmake.lua", "recipe_path": "deps/submodules/minirtc/thirdparty/openssl/xmake.lua",
"package": "openssl3", "package": "openssl3",
"notice_prefixes": [ "notice_prefixes": [
@@ -448,11 +406,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "COPYING", "path": "COPYING"
"sha256": "01e1167d54a096d123cf6dfbbeb19587278845c6481d2d66d545669846079551"
} }
], ],
"recipe_sha256": "c965643eb4e093858c27c402e6cde544620460f2169c89d8ff2bab01111d022b",
"recipe_path": "xmake-repo/packages/l/libopus/xmake.lua", "recipe_path": "xmake-repo/packages/l/libopus/xmake.lua",
"package": "libopus", "package": "libopus",
"notice_prefixes": [ "notice_prefixes": [
@@ -475,19 +431,15 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENCE.md", "path": "LICENCE.md"
"sha256": "197d8a73ffee0d6b09adba2f9c677b5f5aede24edf89258a68e48248d010d811"
}, },
{ {
"path": "COPYING", "path": "COPYING"
"sha256": "99272c55f3dcfa07a8a7e15a5c1a33096e4727de74241d65fa049fccfdd59507"
}, },
{ {
"path": "deps/sljit/LICENSE", "path": "deps/sljit/LICENSE"
"sha256": "5f216505c0f6ea3273caec89e766eef93cdeb7bbb0c429f9360116d7c938feeb"
} }
], ],
"recipe_sha256": "821f1fbc91434f4324be88eeea754db09c5d7be31446242764861ca2aee136b7",
"recipe_path": "xmake-repo/packages/p/pcre2/xmake.lua", "recipe_path": "xmake-repo/packages/p/pcre2/xmake.lua",
"package": "pcre2", "package": "pcre2",
"notice_prefixes": [ "notice_prefixes": [
@@ -508,11 +460,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE", "path": "LICENSE"
"sha256": "e4d01796524cbc13b1571a1f0914823c6a23b316ba039e7014f47a4eef7fd4c3"
} }
], ],
"recipe_sha256": "deb525d09b5b79351eeb524ab3bc6a3009e434974e585e5e8852c491d7fa07c8",
"recipe_path": "xmake-repo/packages/p/plog/xmake.lua", "recipe_path": "xmake-repo/packages/p/plog/xmake.lua",
"package": "plog", "package": "plog",
"notice_prefixes": [ "notice_prefixes": [
@@ -532,8 +482,7 @@
}, },
"documents": [ "documents": [
{ {
"path": "COPYING", "path": "COPYING"
"sha256": "d245807f90032872d1438d741ed21e2490e1175dc8aa3afa5ddb6c8e529b58e5"
} }
], ],
"bundled_by": "glib", "bundled_by": "glib",
@@ -555,11 +504,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE", "path": "LICENSE"
"sha256": "4ccecab18d1ff0b61174fe3d6c430541625d3ddb865b0d5887db296f883c76e7"
} }
], ],
"recipe_sha256": "5b299effb366420b4920c5b89e6535b76173136d91ee01f8f7d3423486a93549",
"recipe_path": "xmake-repo/packages/s/spdlog/xmake.lua", "recipe_path": "xmake-repo/packages/s/spdlog/xmake.lua",
"package": "spdlog", "package": "spdlog",
"notice_prefixes": [ "notice_prefixes": [
@@ -579,24 +526,19 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE.md", "path": "LICENSE.md"
"sha256": "0acc2fcb27472bdc9aaf8b71f37055bbdac4f54671b7d922f241bd7fcd0dd3e6"
}, },
{ {
"path": "LICENSE-BSD2.md", "path": "LICENSE-BSD2.md"
"sha256": "c582fcbeb9cf972a60d298bfaf41dc5adff2dd14208946edcb9bb56a281e8438"
}, },
{ {
"path": "PATENTS.md", "path": "PATENTS.md"
"sha256": "20678ab10402659106dc4c147c97b2a6e94b5c0695415e15a8f195ebc3547922"
}, },
{ {
"path": "third_party/fastfeat/LICENSE", "path": "third_party/fastfeat/LICENSE"
"sha256": "043dcfd059386f9facd376351b2bd79325778744aa442177390cdfcca54babed"
}, },
{ {
"path": "third_party/safestringlib/LICENSE", "path": "third_party/safestringlib/LICENSE"
"sha256": "c0f99a208799ca20c9ccf31a30533d49334eb458dca5a3d14f9afe3e884e5d7d"
} }
], ],
"package": "svt-av1", "package": "svt-av1",
@@ -618,11 +560,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE.md", "path": "LICENSE.md"
"sha256": "fa53711b25af4b9a9b8dadfea3cb38166ec4b96760c8d62b284055554537d9ef"
} }
], ],
"recipe_sha256": "e3dac7d1db8e5bc79d99851e2486371af19962a4b8fca6eaac3156c00eb73b92",
"recipe_path": "xmake-repo/packages/u/usrsctp/xmake.lua", "recipe_path": "xmake-repo/packages/u/usrsctp/xmake.lua",
"package": "usrsctp", "package": "usrsctp",
"notice_prefixes": [ "notice_prefixes": [
@@ -641,12 +581,10 @@
}, },
"documents": [ "documents": [
{ {
"path": "deps/submodules/minirtc/thirdparty/webrtc/LICENSE", "path": "deps/submodules/minirtc/thirdparty/webrtc/LICENSE"
"sha256": "ab00a482b6a3902e40211b43c5d0441962ea99b6cc7c25c0f243fa270b78d482"
}, },
{ {
"path": "deps/submodules/minirtc/thirdparty/webrtc/PATENTS", "path": "deps/submodules/minirtc/thirdparty/webrtc/PATENTS"
"sha256": "01462e2068d1a04c2274f3389773014c14ed9bc3446b28303543bd3e3c064145"
} }
] ]
}, },
@@ -662,11 +600,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "COPYING", "path": "COPYING"
"sha256": "b31e597bc777d884a39663c11d0557a0af40780e1f451de11ea5103924a69667"
} }
], ],
"recipe_sha256": "beac245a717c2bc3bc8d17265d9fc9b54f699f3833b11513f8723c4dbaffd539",
"recipe_path": "deps/submodules/minirtc/thirdparty/websocketpp/xmake.lua", "recipe_path": "deps/submodules/minirtc/thirdparty/websocketpp/xmake.lua",
"package": "websocketpp", "package": "websocketpp",
"notice_prefixes": [ "notice_prefixes": [
@@ -685,11 +621,9 @@
}, },
"documents": [ "documents": [
{ {
"path": "LICENSE", "path": "LICENSE"
"sha256": "e32ff4e00d9d94930537635291da39e7e612703334bf6fde8c7f1686fe8a45a2"
} }
], ],
"recipe_sha256": "7832bcb086709a0739ae1abeff035f7a103c7169dc8af61ebf91dd887fcbd054",
"recipe_path": "xmake-repo/packages/z/zlib/xmake.lua", "recipe_path": "xmake-repo/packages/z/zlib/xmake.lua",
"package": "zlib", "package": "zlib",
"notice_prefixes": [ "notice_prefixes": [
@@ -697,7 +631,6 @@
] ]
} }
], ],
"bundle_sha256": "a27c89beaaee2397a306417ff133d7f48c74fe6db9041e145fc08f18ec69a080",
"toolchain": { "toolchain": {
"xmake_min_version": "3.1.1", "xmake_min_version": "3.1.1",
"xmake_repository_revision": "121109352da272cd1a3d4ed52f2dcda34f839955" "xmake_repository_revision": "121109352da272cd1a3d4ed52f2dcda34f839955"
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Collect iOS notices from pinned source archives; validate them offline at build time.""" """Generate iOS notice resources from pinned source archives."""
import argparse import argparse
from collections import defaultdict from collections import defaultdict
@@ -8,7 +8,6 @@ import hashlib
import json import json
from pathlib import Path from pathlib import Path
import re import re
import subprocess
import sys import sys
import tarfile import tarfile
import urllib.request import urllib.request
@@ -82,7 +81,7 @@ def source_files(component, cache):
def component_documents(component, names, read): def component_documents(component, names, read):
documents = [] documents = []
for item in component["documents"]: for item in component["documents"]:
data = checked(read(item["path"]), item["sha256"], item["path"]) data = read(item["path"])
text = data.decode(item.get("encoding", "utf-8")) text = data.decode(item.get("encoding", "utf-8"))
if "end_before" in item: if "end_before" in item:
if item["end_before"] not in text: if item["end_before"] not in text:
@@ -134,79 +133,22 @@ def generate(lock, cache):
return json_bytes({"schemaVersion": 1, "components": components}) return json_bytes({"schemaVersion": 1, "components": components})
def verify(lock, inputs, target_info, xmake_repository=None, xmake_version=None):
checked(OUTPUT.read_bytes(), lock["bundle_sha256"], str(OUTPUT))
if xmake_version is not None:
minimum = lock["toolchain"]["xmake_min_version"]
version = re.search(r"xmake v(\d+)\.(\d+)\.(\d+)(?:\+|[,\s]|$)", xmake_version.read_text())
if not version or tuple(map(int, version.groups())) < tuple(map(int, minimum.split("."))):
raise ValueError(f"Xmake {minimum} or newer is required; see apps/ios/README.md")
if xmake_repository is not None:
revision = subprocess.check_output(
["git", "-C", str(xmake_repository), "rev-parse", "HEAD"], text=True).strip()
if revision != lock["toolchain"]["xmake_repository_revision"]:
raise ValueError("Xmake package repository differs from the reviewed iOS source catalog")
changes = subprocess.check_output(
["git", "-C", str(xmake_repository), "status", "--porcelain", "--untracked-files=normal"],
text=True).strip()
if changes:
raise ValueError("Xmake package repository has local changes; use the unmodified pinned revision")
for component in lock["components"]:
recipe = component.get("recipe_path", "")
if recipe.startswith("deps/"):
checked((REPO / recipe).read_bytes(), component["recipe_sha256"], recipe)
elif recipe.startswith("xmake-repo/") and xmake_repository is not None:
checked((xmake_repository / Path(recipe).relative_to("xmake-repo")).read_bytes(),
component["recipe_sha256"], recipe)
if component["source"]["kind"] == "repository":
for item in component["documents"]:
checked((REPO / item["path"]).read_bytes(), item["sha256"], item["path"])
if inputs is None:
return
if target_info is None:
raise ValueError("--target-info is required with --inputs (includes header-only dependencies)")
known = {c["package"]: c["version"] for c in lock["components"] if "package" in c}
seen = {}
# Package names/versions come from resolved Xmake link and include paths,
# never from an inventory of all packages left in the installation cache.
pattern = r"/packages(?:-simulator)?/[^/\s]+/([^/\s]+)/([^/\s]+)/[a-f0-9]+/"
for name, version in re.findall(pattern, inputs.read_text() + target_info.read_text()):
if name not in known or known[name] != version:
raise ValueError(f"Unreviewed iOS dependency: {name} {version}; regenerate licenses from its source")
seen[name] = version
if set(seen) != set(known):
raise ValueError(f"License/build dependency mismatch; missing: {sorted(set(known) - set(seen))}")
for line in inputs.read_text().splitlines():
sha, path = line.split(maxsplit=1)
checked(Path(path).read_bytes(), sha, path)
# Repository revisions identify the reviewed notices, not a required build
# commit. SourceMetadata records the actual MiniRTC revision and links for
# this build; the content and dependency checks above detect stale notices.
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--generate", action="store_true", help="download/check pinned sources and regenerate the bundled JSON") parser.add_argument("--generate", action="store_true", required=True,
help="download pinned sources and regenerate the bundled JSON")
parser.add_argument("--cache", type=Path, default=Path.home() / ".cache/crossdesk/licenses") parser.add_argument("--cache", type=Path, default=Path.home() / ".cache/crossdesk/licenses")
parser.add_argument("--inputs", type=Path, help="native archive .inputs.sha256 manifest")
parser.add_argument("--target-info", type=Path, help="resolved Xmake minirtc target information")
parser.add_argument("--xmake-repository", type=Path, help="verify the active pinned package repository and recipes")
parser.add_argument("--xmake-version", type=Path, help="Xmake version output recorded by the native build")
args = parser.parse_args() args = parser.parse_args()
lock = json.loads(LOCK.read_text()) lock = json.loads(LOCK.read_text())
if args.generate: data = generate(lock, args.cache)
data = generate(lock, args.cache) OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.parent.mkdir(parents=True, exist_ok=True) OUTPUT.write_bytes(data)
OUTPUT.write_bytes(data) print(f"Generated {len(lock['components'])} iOS license components")
lock["bundle_sha256"] = digest(data)
LOCK.write_bytes(json_bytes(lock))
verify(lock, args.inputs, args.target_info, args.xmake_repository, args.xmake_version)
print(f"Verified {len(lock['components'])} iOS license components")
if __name__ == "__main__": if __name__ == "__main__":
try: try:
main() main()
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: except (OSError, ValueError, KeyError) as error:
print(f"Third-party notices: {error}", file=sys.stderr) print(f"Third-party notices: {error}", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -2,7 +2,6 @@
"""Record the source corresponding to an iOS build without changing the checkout.""" """Record the source corresponding to an iOS build without changing the checkout."""
import argparse import argparse
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
@@ -66,8 +65,6 @@ def source_metadata(require_release=False, tag=None):
"miniRTCRevision": mini_revision, "miniRTCRevision": mini_revision,
"miniRTCSourceURL": f"{MINIRTC_URL}/tree/{mini_revision}", "miniRTCSourceURL": f"{MINIRTC_URL}/tree/{mini_revision}",
"componentSources": component_sources(lock, mini_revision), "componentSources": component_sources(lock, mini_revision),
"licenseCatalogSHA256": lock["bundle_sha256"],
"sourceCatalogSHA256": hashlib.sha256(lock_path.read_bytes()).hexdigest(),
"toolchain": lock["toolchain"], "toolchain": lock["toolchain"],
} }
+3 -16
View File
@@ -38,12 +38,6 @@ def verify(app, require_release=False):
for category, codes in required.items(): for category, codes in required.items():
if set(reasons.get(category, [])) != codes: if set(reasons.get(category, [])) != codes:
raise ValueError(f"Missing or changed reviewed privacy reasons: {category}") raise ValueError(f"Missing or changed reviewed privacy reasons: {category}")
data = (app / "ThirdPartyLicenses.json").read_bytes()
catalog = json.loads(data)
ids = {c["id"] for c in catalog["components"]}
if not {"crossdesk", "minirtc", "openfec"} <= ids or "openh264" in ids:
raise ValueError("Unexpected iOS license catalog")
openssl = next(c for c in catalog["components"] if c["id"] == "openssl3")
framework = app / "Frameworks/OpenSSL.framework" framework = app / "Frameworks/OpenSSL.framework"
sdk_info = plistlib.loads((framework / "Info.plist").read_bytes()) sdk_info = plistlib.loads((framework / "Info.plist").read_bytes())
sdk_privacy_data = (framework / "PrivacyInfo.xcprivacy").read_bytes() sdk_privacy_data = (framework / "PrivacyInfo.xcprivacy").read_bytes()
@@ -57,14 +51,9 @@ def verify(app, require_release=False):
if sdk_privacy != expected_privacy: if sdk_privacy != expected_privacy:
raise ValueError("OpenSSL SDK privacy declaration differs from the reviewed iOS configuration") raise ValueError("OpenSSL SDK privacy declaration differs from the reviewed iOS configuration")
sdk_build = json.loads((framework / "BuildMetadata.json").read_text()) sdk_build = json.loads((framework / "BuildMetadata.json").read_text())
if (sdk_info["CFBundleShortVersionString"] != openssl["version"] if (sdk_info["CFBundleShortVersionString"] != sdk_build["version"]
or sdk_build["version"] != openssl["version"]
or sdk_build["sourceURL"] != openssl["sourceURL"]
or sdk_build["buildSourceURL"] != openssl["buildSourceURL"]
or sdk_build["privacySHA256"] != hashlib.sha256(sdk_privacy_data).hexdigest()): or sdk_build["privacySHA256"] != hashlib.sha256(sdk_privacy_data).hexdigest()):
raise ValueError("OpenSSL SDK resources do not match the source/license catalog") raise ValueError("OpenSSL SDK resources do not match its build metadata")
if (framework / "LICENSE.txt").read_text() != "\n\n".join(d["text"] for d in openssl["documents"]):
raise ValueError("OpenSSL SDK license resource differs from its source notices")
embedded_binary = framework / sdk_info["CFBundleExecutable"] embedded_binary = framework / sdk_info["CFBundleExecutable"]
# Xcode removes the archive and may generate a small codeless-framework # Xcode removes the archive and may generate a small codeless-framework
# stub. It must not ship a second static archive or dynamic OpenSSL copy. # stub. It must not ship a second static archive or dynamic OpenSSL copy.
@@ -78,13 +67,11 @@ def verify(app, require_release=False):
if "OpenSSL.framework" in linked: if "OpenSSL.framework" in linked:
raise ValueError("OpenSSL must be statically linked into the application") raise ValueError("OpenSSL must be statically linked into the application")
source = json.loads((app / "SourceMetadata.json").read_text()) source = json.loads((app / "SourceMetadata.json").read_text())
if source["licenseCatalogSHA256"] != hashlib.sha256(data).hexdigest():
raise ValueError("Bundled licenses differ from the recorded source metadata")
if require_release and (source["isModified"] or not source["tag"]): if require_release and (source["isModified"] or not source["tag"]):
raise ValueError("The app was not built from an unmodified version tag") raise ValueError("The app was not built from an unmodified version tag")
if not re.fullmatch(r"[0-9a-f]{40}", source["revision"]): if not re.fullmatch(r"[0-9a-f]{40}", source["revision"]):
raise ValueError("Invalid source revision") raise ValueError("Invalid source revision")
print("Verified arm64 app, source/license resources, app privacy declarations and OpenSSL SDK privacy packaging") print("Verified arm64 app, source metadata, app privacy declarations and OpenSSL SDK privacy packaging")
print("Signing, App Store validation and license authorization require separate review.") print("Signing, App Store validation and license authorization require separate review.")
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Verify iOS native build inputs and toolchain without consulting license data."""
import argparse
import hashlib
from pathlib import Path
import re
import subprocess
import sys
XMAKE_MIN_VERSION = "3.1.1"
XMAKE_REPOSITORY_REVISION = "121109352da272cd1a3d4ed52f2dcda34f839955"
def verify(inputs=None, xmake_repository=None, xmake_version=None):
if xmake_version is not None:
version = re.search(r"xmake v(\d+)\.(\d+)\.(\d+)(?:\+|[,\s]|$)", xmake_version.read_text())
if not version or tuple(map(int, version.groups())) < tuple(map(int, XMAKE_MIN_VERSION.split("."))):
raise ValueError(f"Xmake {XMAKE_MIN_VERSION} or newer is required; see apps/ios/README.md")
if xmake_repository is not None:
revision = subprocess.check_output(
["git", "-C", str(xmake_repository), "rev-parse", "HEAD"], text=True).strip()
if revision != XMAKE_REPOSITORY_REVISION:
raise ValueError("Xmake package repository differs from the pinned iOS toolchain")
changes = subprocess.check_output(
["git", "-C", str(xmake_repository), "status", "--porcelain", "--untracked-files=normal"],
text=True).strip()
if changes:
raise ValueError("Xmake package repository has local changes; use the unmodified pinned revision")
if inputs is not None:
for line in inputs.read_text().splitlines():
sha, name = line.split(maxsplit=1)
if hashlib.sha256(Path(name).read_bytes()).hexdigest() != sha:
raise ValueError(f"Native input checksum mismatch: {name}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inputs", type=Path, help="native archive .inputs.sha256 manifest")
parser.add_argument("--xmake-repository", type=Path, help="active pinned Xmake package repository")
parser.add_argument("--xmake-version", type=Path, help="Xmake version output recorded by the native build")
args = parser.parse_args()
if not any((args.inputs, args.xmake_repository, args.xmake_version)):
parser.error("provide --inputs, --xmake-repository or --xmake-version")
verify(args.inputs, args.xmake_repository, args.xmake_version)
print("Verified iOS native build inputs and toolchain")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print(f"iOS native build: {error}", file=sys.stderr)
sys.exit(1)
+11 -54
View File
@@ -1,13 +1,13 @@
"""Regression coverage for notice validation and build-specific source links.""" """Regression coverage for native validation and build-specific source links."""
import copy import copy
import hashlib
import importlib.util import importlib.util
import json import json
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch
def load_script(name): def load_script(name):
@@ -18,11 +18,11 @@ def load_script(name):
return module return module
licenses = load_script("generate_third_party_licenses") native = load_script("verify_native_build")
metadata = load_script("prepare_source_metadata") metadata = load_script("prepare_source_metadata")
class LicenseValidationTest(unittest.TestCase): class NativeValidationTest(unittest.TestCase):
def setUp(self): def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="crossdesk-ios-licenses-") temporary = tempfile.TemporaryDirectory(prefix="crossdesk-ios-licenses-")
self.addCleanup(temporary.cleanup) self.addCleanup(temporary.cleanup)
@@ -47,34 +47,24 @@ class LicenseValidationTest(unittest.TestCase):
self.archive.parent.mkdir(parents=True) self.archive.parent.mkdir(parents=True)
self.archive.write_bytes(b"reviewed archive") self.archive.write_bytes(b"reviewed archive")
self.inputs = self.root / "inputs.sha256" self.inputs = self.root / "inputs.sha256"
self.inputs.write_text(f"{licenses.digest(self.archive.read_bytes())} {self.archive}\n") self.inputs.write_text(f"{hashlib.sha256(self.archive.read_bytes()).hexdigest()} {self.archive}\n")
self.target = self.root / "target-info.txt"
self.target.write_text(str(self.root / "packages/a/asio/1.0/abcd/include") + "\n")
self.lock = { self.lock = {
"bundle_sha256": licenses.digest(self.bundle.read_bytes()),
"toolchain": {"xmake_min_version": "3.1.1"},
"components": [ "components": [
{ {
"id": "minirtc", "version": self.reviewed_revision, "id": "minirtc", "version": self.reviewed_revision,
"source": {"kind": "repository", "url": metadata.MINIRTC_URL, "source": {"kind": "repository", "url": metadata.MINIRTC_URL,
"revision": self.reviewed_revision}, "revision": self.reviewed_revision},
"documents": [{"path": str(self.notice.relative_to(self.root)), "documents": [{"path": str(self.notice.relative_to(self.root))}],
"sha256": licenses.digest(self.notice.read_bytes())}],
}, },
{ {
"id": "opus", "package": "opus", "version": "1.0", "id": "opus", "package": "opus", "version": "1.0",
"source": {"kind": "archive", "url": "https://example.com/opus.tar.gz"}, "source": {"kind": "archive", "url": "https://example.com/opus.tar.gz"},
"recipe_path": str(self.recipe.relative_to(self.root)), "recipe_path": str(self.recipe.relative_to(self.root)),
"recipe_sha256": licenses.digest(self.recipe.read_bytes()),
}, },
{"id": "asio", "package": "asio", "version": "1.0", {"id": "asio", "package": "asio", "version": "1.0",
"source": {"kind": "archive", "url": "https://example.com/asio.tar.gz"}}, "source": {"kind": "archive", "url": "https://example.com/asio.tar.gz"}},
], ],
} }
for name, value in (("REPO", self.root), ("OUTPUT", self.bundle)):
patcher = patch.object(licenses, name, value)
patcher.start()
self.addCleanup(patcher.stop)
def git(self, *args): def git(self, *args):
return subprocess.check_output( return subprocess.check_output(
@@ -82,9 +72,8 @@ class LicenseValidationTest(unittest.TestCase):
"-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", *args], "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", *args],
text=True).strip() text=True).strip()
def verify(self, preflight=False, **kwargs): def verify(self, **kwargs):
licenses.verify(self.lock, None if preflight else self.inputs, native.verify(self.inputs, **kwargs)
None if preflight else self.target, **kwargs)
def test_source_only_commit_keeps_reviewed_catalog_and_uses_actual_links(self): def test_source_only_commit_keeps_reviewed_catalog_and_uses_actual_links(self):
self.source.write_text("int transport() { return 2; }\n") self.source.write_text("int transport() { return 2; }\n")
@@ -94,7 +83,6 @@ class LicenseValidationTest(unittest.TestCase):
self.assertNotEqual(current, self.reviewed_revision) self.assertNotEqual(current, self.reviewed_revision)
original_lock = copy.deepcopy(self.lock) original_lock = copy.deepcopy(self.lock)
original_bundle = self.bundle.read_bytes() original_bundle = self.bundle.read_bytes()
self.verify(preflight=True)
self.verify() self.verify()
sources = metadata.component_sources(self.lock, current) sources = metadata.component_sources(self.lock, current)
self.assertEqual(sources["minirtc"]["version"], current) self.assertEqual(sources["minirtc"]["version"], current)
@@ -104,49 +92,18 @@ class LicenseValidationTest(unittest.TestCase):
self.assertEqual(self.lock, original_lock) self.assertEqual(self.lock, original_lock)
self.assertEqual(self.bundle.read_bytes(), original_bundle) self.assertEqual(self.bundle.read_bytes(), original_bundle)
def test_changed_license_is_rejected_before_compilation(self): def test_simulator_cache_keeps_archive_validation(self):
self.notice.write_text("Changed license text\n")
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*LICENSE"):
self.verify(preflight=True)
def test_changed_recipe_is_rejected_before_compilation(self):
self.recipe.write_text('package("different-opus")\n')
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*xmake.lua"):
self.verify(preflight=True)
def test_changed_bundle_is_rejected_before_compilation(self):
self.bundle.write_text("{}\n")
with self.assertRaisesRegex(ValueError, "Source checksum mismatch:"):
self.verify(preflight=True)
def test_unreviewed_header_only_dependency_is_rejected(self):
self.target.write_text(str(self.root / "packages/a/asio/2.0/abcd/include") + "\n")
with self.assertRaisesRegex(ValueError, "Unreviewed iOS dependency: asio 2.0"):
self.verify()
def test_missing_dependency_is_rejected(self):
self.target.write_text("")
with self.assertRaisesRegex(ValueError, "License/build dependency mismatch; missing:.*asio"):
self.verify()
def test_simulator_cache_keeps_dependency_and_archive_validation(self):
(self.root / "packages").rename(self.root / "packages-simulator") (self.root / "packages").rename(self.root / "packages-simulator")
self.archive = Path(str(self.archive).replace("/packages/", "/packages-simulator/")) self.archive = Path(str(self.archive).replace("/packages/", "/packages-simulator/"))
self.inputs.write_text(self.inputs.read_text().replace("/packages/", "/packages-simulator/")) self.inputs.write_text(self.inputs.read_text().replace("/packages/", "/packages-simulator/"))
self.target.write_text(self.target.read_text().replace("/packages/", "/packages-simulator/"))
self.verify() self.verify()
valid_target = self.target.read_text()
self.target.write_text(valid_target.replace("/asio/1.0/", "/asio/2.0/"))
with self.assertRaisesRegex(ValueError, "Unreviewed iOS dependency: asio 2.0"):
self.verify()
self.target.write_text(valid_target)
self.archive.write_bytes(b"changed simulator archive") self.archive.write_bytes(b"changed simulator archive")
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*libopus.a"): with self.assertRaisesRegex(ValueError, "Native input checksum mismatch: .*libopus.a"):
self.verify() self.verify()
def test_changed_native_archive_is_rejected(self): def test_changed_native_archive_is_rejected(self):
self.archive.write_bytes(b"changed archive") self.archive.write_bytes(b"changed archive")
with self.assertRaisesRegex(ValueError, "Source checksum mismatch: .*libopus.a"): with self.assertRaisesRegex(ValueError, "Native input checksum mismatch: .*libopus.a"):
self.verify() self.verify()
def test_minimum_and_newer_xmake_versions_are_accepted(self): def test_minimum_and_newer_xmake_versions_are_accepted(self):
+18
View File
@@ -138,6 +138,7 @@ PipeWire 不属于强制运行时依赖:程序在运行时检测宿主系统
--USE_DRM=true/false: 在 Linux 上启用 DRM 捕获,默认不启用 --USE_DRM=true/false: 在 Linux 上启用 DRM 捕获,默认不启用
--CROSSDESK_PORTABLE=true/false: 构建便携版本,默认不启用 --CROSSDESK_PORTABLE=true/false: 构建便携版本,默认不启用
--CROSSDESK_VERSION=xxx: 指定 CrossDesk 的版本 --CROSSDESK_VERSION=xxx: 指定 CrossDesk 的版本
--CROSSDESK_UPDATE_PUBLIC_KEY=xxx: 可选的 Ed25519 更新验签公钥,64 个小写十六进制字符;默认为空,不启用自动安装更新
--MINIRTC_ENABLE_AOM=true/false: 编译可选 libaom 后端,默认不启用;AV1 仍使用 SVT-AV1 / dav1d --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 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 环境下的开发支持
对于**未安装 CUDA 环境的 Linux 开发者,如果希望编译后的成果物拥有硬件编解码能力**,这里提供了预配置的 [Ubuntu 20.04 兼容构建镜像](https://hub.docker.com/r/crossdesk/ubuntu20.04)。该镜像内置必要的构建依赖,可生成兼容 glibc 2.31 的两种 Linux 安装包。 对于**未安装 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 --USE_DRM=true/false: Enable DRM capture on Linux, disabled by default
--CROSSDESK_PORTABLE=true/false: Build the portable variant, disabled by default --CROSSDESK_PORTABLE=true/false: Build the portable variant, disabled by default
--CROSSDESK_VERSION=xxx: Set the CrossDesk version --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 # Example
xmake f --CROSSDESK_VERSION=1.4.3 --USE_CUDA=true xmake f --CROSSDESK_VERSION=1.4.3 --USE_CUDA=true
@@ -155,6 +156,23 @@ Run:
xmake r crossdesk 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 ## 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. 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),尽量让两端使用兼容的近期版本。 公共服务的兼容性变更也会影响旧客户端;请核对 [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). | | 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. | | 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. | | 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). | | 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). | | 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-配置信任证书) 在两端导入根证书,再检查连接状态。 使用自签证书时,先按 [证书信任步骤](#3-配置信任证书) 在两端导入根证书,再检查连接状态。
1. 断开现有会话,点击主窗口右上角 **☰ → 设置**。 1. 断开现有会话,点击主窗口右上角 **☰ → 设置**。
2. 在设置列表中向下滚动,点击 **自托管配置**。 2. 在设置列表中向下滚动,点击 **自托管配置** 一行的 **编辑** 按钮。
3. 填写 **服务器地址**、**信令服务端口**,点击“确认”。地址支持主机名或 IP(如 `my-server.example.com`),也支持带路径的信令地址(如 `my-server.example.com/signal-path`)或 `https://` / `wss://` URL。URL 内明确填写的端口优先;否则使用单独填写的信令服务端口。所有连接均使用 WSS,带路径的地址需要反向代理将该路径转发到信令 WebSocket 服务,普通网页地址不能直接用于信令连接。 3. 填写 **服务器地址**、**信令服务端口**,点击“确认”。地址支持主机名或 IP(如 `my-server.example.com`),也支持带路径的信令地址(如 `my-server.example.com/signal-path`)或 `https://` / `wss://` URL。URL 内明确填写的端口优先;否则使用单独填写的信令服务端口。所有连接均使用 WSS,带路径的地址需要反向代理将该路径转发到信令 WebSocket 服务,普通网页地址不能直接用于信令连接。
4. 回到设置窗口,勾选 **自托管配置** 右侧复选框,再点击“确认”保存。 4. 回到设置窗口,打开 **自托管配置** 右侧开关,再点击“保存”。
5. 在控制端和被控端执行相同配置。等待两端都显示“已连接服务器”,再使用被控端当前显示的 ID 发起连接。 5. 在控制端和被控端执行相同配置。等待两端都显示“已连接服务器”,再使用被控端当前显示的 ID 发起连接。
地址与端口在最后确认设置时一起保存;格式无效或保存失败时会保留输入并显示错误。 地址与端口在最后确认设置时一起保存;格式无效或保存失败时会保留输入并显示错误。
@@ -95,7 +95,7 @@ sudo security add-trusted-cert -d -r trustRoot \
- **未连接服务器 / TLS 错误:** 先检查地址、信令端口、证书有效期、证书主机名及根证书信任。 - **未连接服务器 / TLS 错误:** 先检查地址、信令端口、证书有效期、证书主机名及根证书信任。
- **已连服务器但设备离线:** 确认两端连接同一服务、被控端仍在运行,并重新复制当前 ID。 - **已连服务器但设备离线:** 确认两端连接同一服务、被控端仍在运行,并重新复制当前 ID。
- **P2P 连接失败:** 检查“启用中继服务”、Coturn 状态、TURN 端口和媒体端口范围,并核对客户端与服务端版本兼容性。 - **P2P 连接失败:** 默认已允许中继回退;可开启“强制中继连接”排查直连问题。检查 Coturn 状态、TURN 端口和媒体端口范围,并核对客户端与服务端版本兼容性。
- **配置修改未生效:** 在配置目录重新执行 `sudo docker compose up -d`;单纯重启容器不会应用修改过的环境变量。 - **配置修改未生效:** 在配置目录重新执行 `sudo docker compose up -d`;单纯重启容器不会应用修改过的环境变量。
日志、备份和迁移操作请参考 [服务端维护说明](https://github.com/kunkundi/crossdesk-server)。 日志、备份和迁移操作请参考 [服务端维护说明](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. 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**. 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. 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. 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"> <p align="center">
+33 -6
View File
@@ -4,7 +4,7 @@
首次使用请先查看 [下载与安装](../README.md#download)。本文介绍当前源码中的客户端操作;安装包的可用功能以对应 Release 为准。 首次使用请先查看 [下载与安装](../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)。 截图使用演示设备和凭据,来源与生成方式见 [截图说明](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**,点击 **连接**。 2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 **9 位 ID**,点击 **连接**。
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。 3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连。点击卡片右上角的“…”菜单可编辑别名、修改分组或删除记录,右下角按钮用于连接 / 断开;标题右侧可按分组筛选设备。 4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连。点击卡片右上角的“…”菜单可编辑别名、修改分组或删除记录,右下角按钮用于连接 / 断开;标题右侧可按分组筛选设备。
本机密码旁的眼睛按钮用于显示 / 隐藏密码,刷新按钮用于生成新的随机密码,铅笔按钮用于手动修改。密码统一为 **6 位数字或英文字母**。修改或刷新时需保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。 本机密码旁的眼睛按钮用于显示 / 隐藏密码,刷新按钮用于生成新的随机密码,铅笔按钮用于手动修改。密码统一为 **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 运行,并确认已连接服务器。 1. 保持被控电脑上的 CrossDesk 运行,并确认已连接服务器。
@@ -127,14 +148,20 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
| 语言 | 中文、English、Русский | | 语言 | 中文、English、Русский |
| 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 | | 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 |
| 视频编码格式 | H.264 / AV1;H.264 自动优先使用硬件编解码,不可用或初始化失败时回退到软件;桌面端 AV1 使用软件编解码 | | 视频编码格式 | 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. 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. 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 ## 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**. 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. 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. 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. 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 ### Connect from a browser
1. Keep CrossDesk running and connected to the server on the host computer. 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, Русский | | Language | 中文, English, Русский |
| Capture Method (Windows) | Auto / DXGI / WGC / GDI; choose Auto for automatic headless adaptation | | 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 | | 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 | | 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 | Require relay connections when TURN is enabled; turning TURN off also disables this option | | 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 | | 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 | | 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 | | Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
| File Save Path | Choose where received files are saved | | 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 ### 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. 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` 再次演变成职责混杂的大类。 本文档说明 `apps/desktop/src/gui` 的目录结构、核心类型、依赖方向和代码归属规则,用于避免 `Render`、`GuiApplication` 或 `GuiRuntime` 再次演变成职责混杂的大类。
ID 连接与 IP 直连的网络拓扑、认证和传输流程见 [连接模式架构](connection-architecture.md)。
## 目录结构 ## 目录结构
```text ```text
@@ -160,7 +162,7 @@ Render::Run
→ Tick 协调连接、处理后台事件并同步窗口状态 → Tick 协调连接、处理后台事件并同步窗口状态
``` ```
### 建立远端连接 ### 建立 ID 远端连接
```text ```text
MainWindow::connect_requested MainWindow::connect_requested
@@ -173,6 +175,21 @@ MainWindow::connect_requested
→ SyncStreamWindow 创建串流窗口并开始呈现视频 → 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 ```text
+114 -63
View File
@@ -4,96 +4,141 @@
## 来源 / Sources ## 来源 / Sources
- 两张常规设置截图(`desktop-settings-zh.png` / `desktop-settings-en.png`)于 **2026-10-22** 从 `f8f4aa3c` 加当前工作区修改重新渲染,移除硬件编解码开关;使用现有 `slint_ui_smoke_test`、macOS arm64 和 2× 软件渲染,PPM 中间文件存放在工程外临时目录。 - **2026-10-26** 从 `e551e04e` 的实际 Slint 组件重新生成中英文 README 引用的 6 张截图:ID 连接主窗口、IP 直连主窗口与六设备桌面墙,展示更新后的柔和阴影、输入框与按钮配色。沿用原有演示数据、图片尺寸及 2× 软件渲染。
- 16 张中英文桌面截图更新于 **2026-10-19**,UI 源代码为 `1ef372d2`,使用 Slint 1.17.1、macOS arm64 和软件渲染器。图片直接渲染仓库中的 Slint 组件。 - **2026-10-25** 删除“启用中继服务”开关,并将自托管配置拆分为普通标签、“编辑”按钮和开关后,从当前工作区组件重新生成常规设置与网络设置共 4 张中英文截图。
- 捕获程序来自现有 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp),在**工程外的临时目录**中复制源文件,仅为桌面墙调整演示设备数量、单元格宽度和网格窗口高度。Slint 组件没有改动,工程内没有新增或修改测试文件。 - **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 `123 456 789`、密码 `123456`、设备名称、在线 / 连接状态及记录均为演示数据。主窗口展示三台设备,桌面墙展示六台;它们不是真实设备凭据或真实远程会话。示例配置不代表所有运行时默认值。 - 主窗口展示 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` 作为切换示例。截图中勾选的“记住密码”是演示选择,配置状态不代表全部运行时默认值。
- 桌面墙放大视图和会话画面设置共 4 张继续沿用 **2026-10-19** 从 `1ef372d2` 渲染的截图。桌面墙展示六台演示设备,主窗口展示三台。
- “关于”中的 `v1.5.2` 和“已是最新版本”是捕获工具的示例值,不代表当前发布版本或实际在线检查结果。会话图使用公开页面作为远端画面,展示真实工具栏与画面设置菜单;隐私屏按钮处于关闭状态。 - “关于”中的 `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。它们只用于展示预览效果,不代表这些页面正在对应设备上运行,页面中的版本信息也不代表最新发布。 - 缩略图沿用既有公开页面截图:`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 页面;桌面设备预览继续复用这张图片。 - `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. On **2026-10-26**, the six images referenced by the Chinese and English READMEs (ID mode, Direct IP mode, and the six-device DeskHub grid) were refreshed from the actual Slint components at `e551e04e`, showing the updated soft shadows and input and button colors. The existing demonstration data, image dimensions, and 2× software rendering were retained.
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 four DeskHub enlarged-view and session-video images 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 ## 文件 / Files
| 文件(`zh` / `en`) | 页面 | PNG 尺寸 | | 文件(`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-*.png`(不含 `focus`) | 六设备桌面墙 / Six-device DeskHub grid | 1920 × 1000 |
| `desktop-deskhub-focus-*.png` | 桌面墙放大视图 / Enlarged DeskHub view | 1920 × 1396 | | `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-settings-deskhub-*.png` | 桌面墙传输设置 / DeskHub video settings | 1280 × 944 |
| `desktop-self-hosted-*.png` | 自托管设置 / Self-hosting | 1280 × 944 | | `desktop-self-hosted-*.png` | 自托管设置 / Self-hosting | 1280 × 944 |
| `desktop-stream-video-settings-*.png` | 会话工具栏与画面设置 / Session video settings | 1920 × 1200 | | `desktop-stream-video-settings-*.png` | 会话工具栏与画面设置 / Session video settings | 1920 × 1200 |
| `desktop-about-*.png` | 关于与检查更新 / About and update check | 1280 × 944 | | `desktop-about-*.png` | 关于与检查更新 / About and update check | 1280 × 944 |
| `web-client.png`(中文) | Web 连接首页 / Web connection page | 1280 × 720 | | `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 ## 重新生成 / 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 ```bash
set -e set -e
xmake b -y slint_ui_smoke_test xmake b -y crossdesk_ui
capture_dir=$(mktemp -d "${TMPDIR:-/tmp}/crossdesk-readme-capture.XXXXXX") capture_dir=$(mktemp -d "${TMPDIR:-/tmp}/crossdesk-readme-capture.XXXXXX")
export capture_dir 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' python3 - <<'PY'
import os
from pathlib import Path from pathlib import Path
import os
source = Path('apps/desktop/tests/slint_ui_smoke_test.cpp').read_text() root = Path.cwd()
changes = [ out = Path(os.environ['capture_dir'])
('options.page != "main-recent-scrolled") {', source = (root / 'apps/desktop/tests/slint_ui_smoke_test.cpp').read_text()
'options.page != "main-recent-scrolled" &&\n' source = source[:source.index('// Sample the native window while')]
' !options.page.starts_with("gallery")) {'), old = ' // Preview DeskHub with the same demo images, without opening any sessions.'
('window->set_gallery_mode(true);\n std::vector', new = ''' // Documentation-only state; no runtime, listeners, or remote sessions.
'window->set_gallery_mode(true);\n' window->set_local_password("Ab12Cd");
' window->set_gallery_tile_width(300);\n std::vector'), window->set_ip_direct_enabled(true);
('698.0f;\n#else\n 720.0f;', window->set_ip_direct_enabled_draft(true);
'options.page == "gallery" ? 500.0f : 698.0f;\n#else\n' if (options.page == "settings-network") window->set_settings_open(true);
' options.page == "gallery" ? 500.0f : 720.0f;'), if (options.page == "lan" || options.page == "lan-password") {
] window->set_lan_mode(true);
for old, new in changes: window->set_lan_listener_ip("192.168.1.10");
assert source.count(old) == 1, 'Capture source changed; review the fixture adjustment.' window->set_lan_listener_port("52888");
source = source.replace(old, new, 1) window->set_lan_local_port("52888");
Path(os.environ['capture_dir'], 'readme_capture.cpp').write_text(source) 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)
# Preserve the six-device DeskHub composition used in the README.
old = 'if (connections.size() == 3 && options.page != "main-recent-overflow" &&'
new = 'if (connections.size() == 3 && !options.page.starts_with("gallery") && options.page != "main-recent-overflow" &&'
assert source.count(old) == 1
source = source.replace(old,new)
old = ' window->set_gallery_mode(true);'
assert source.count(old) == 1
source = source.replace(old,old + '\n window->set_gallery_tile_width(300);')
old = ' 698.0f;'
assert source.count(old) == 1
source = source.replace(old,' options.page == "gallery" ? 500.0f : 698.0f;')
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 PY
``` ```
以下编译路径对应本次的 `macosx / arm64 / debug` 配置。其他配置先用 `xmake show -t slint_ui_smoke_test` 查看当前生成头文件和库目录,再替换路径;不要在工程内添加临时构建入口。 ### 编译与捕获
以下路径对应 `macosx / arm64 / debug` 配置;其他配置先用 `xmake show -t slint_ui_smoke_test` 查看生成头文件与库路径,再调整。捕获按顺序执行,避免窗口互相抢占焦点。
```bash ```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())') 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 +156,10 @@ xcrun clang++ -std=c++20 \
for locale in zh en; do for locale in zh en; do
language=0 language=0
if [ "$locale" = en ]; then language=1; fi if [ "$locale" = en ]; then language=1; fi
for page in gallery gallery-focus; do for page in main lan gallery; do
name=deskhub image_page="$page"
if [ "$page" = gallery-focus ]; then name=deskhub-focus; fi if [ "$page" = gallery ]; then image_page=deskhub; fi
snapshot="$capture_dir/desktop-$name-$locale.ppm" snapshot="$capture_dir/desktop-$image_page-$locale.ppm"
SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \ SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \
CROSSDESK_UI_CAPTURE=1 \ CROSSDESK_UI_CAPTURE=1 \
CROSSDESK_UI_CAPTURE_DEMO_ASSETS="$PWD/docs/images" \ CROSSDESK_UI_CAPTURE_DEMO_ASSETS="$PWD/docs/images" \
@@ -123,9 +168,15 @@ for locale in zh en; do
CROSSDESK_UI_CAPTURE_SNAPSHOT="$snapshot" \ CROSSDESK_UI_CAPTURE_SNAPSHOT="$snapshot" \
"$capture_dir/readme_capture" "$capture_dir/readme_capture"
sips -s format png "$snapshot" \ sips -s format png "$snapshot" \
--out "docs/images/desktop-$name-$locale.png" --out "$capture_dir/desktop-$image_page-$locale.png"
done done
done done
``` ```
更新后逐张检查文字、布局、演示数据和文档引用,并同步日期、源码版本与图片尺寸。若更新 Web 图片,应打开 Web 客户端,等待表单与底部版本号加载完成,保持 ID 与密码为空,再截取页面视口;替换后也需重新生成桌面图以同步缩略图。 逐张检查语言、页面、文字裁切、尺寸和演示数据后,将这些 PNG 复制到 `docs/images`,并更新日期与源码版本。桌面墙设置的标签在窗口初始化后设置,网络页滚动到对应分组,避免捕获尚未稳定的页面。
### 其他页面
使用指南中的截图可用相同程序捕获,将循环中的页面列表换为 `lan-password settings settings-network settings-deskhub self-hosted about stream-video-settings`。桌面墙放大视图使用 `gallery-focus` 页面,输出文件命名为 `desktop-deskhub-focus-*`。生成器已为桌面墙保留六台演示设备、300px 磁贴宽度和 500px 网格窗口内容高度;放大视图保持 698px(macOS)。这些设置仅影响临时捕获程序的示例数据和窗口尺寸。
若更新 Web 图片,应打开 Web 客户端,等待表单与底部版本号加载完成,保持 ID 与密码为空,再截取页面视口;替换后也需重新生成桌面图以同步缩略图。
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 50 KiB

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

Some files were not shown because too many files have changed in this diff Show More