Compare commits

..
28 Commits
Author SHA1 Message Date
dijunkun b15925f857 [feat] add mobile privacy controls and Windows secure text input 2026-10-27 02:37:23 +08:00
dijunkun 1e25ec15e1 [fix] support hotfix versions in Android signing 2026-10-26 17:50:11 +08:00
dijunkun 9f7411e5c0 [fix] update MiniRTC for WebSocket++ C++20 compatibility 2026-10-26 16:38:15 +08:00
dijunkun 8fd7ca1e8d [fix] correct Windows LAN identity file ownership 2026-10-26 15:52:21 +08:00
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
146 changed files with 7160 additions and 1681 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
+5 -5
View File
@@ -4,7 +4,7 @@ on:
workflow_call: workflow_call:
inputs: inputs:
version: version:
description: "Android artifact version, for example v1.6.0-20261002." description: "Android artifact version, for example v1.7.3-20261026 or v1.7.3-1-20261026."
required: true required: true
type: string type: string
secrets: secrets:
@@ -19,7 +19,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
source_tag: source_tag:
description: "Existing Android release to sign without rebuilding, for example android-v1.6.0-20261002." description: "Existing Android release to sign without rebuilding, for example android-v1.7.3-1-20261026."
required: true required: true
type: string type: string
@@ -61,11 +61,11 @@ jobs:
if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" and not version: if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" and not version:
tag = os.environ["SOURCE_TAG"] tag = os.environ["SOURCE_TAG"]
if not tag.startswith("android-v"): if not tag.startswith("android-v"):
raise ValueError("Use an existing android-v<app-version>-YYYYMMDD release") raise ValueError("Use an existing android-v<app-version>[-<patch>]-YYYYMMDD release")
version = tag.removeprefix("android-") version = tag.removeprefix("android-")
match = re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+-([0-9]{8})", version) match = re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9]+)?-([0-9]{8})", version)
if not match: if not match:
raise ValueError("Invalid Android artifact version") raise ValueError("Invalid Android artifact version; expected v<app-version>[-<patch>]-YYYYMMDD")
datetime.datetime.strptime(match[1], "%Y%m%d") datetime.datetime.strptime(match[1], "%Y%m%d")
with open(os.environ["GITHUB_OUTPUT"], "a") as stream: with open(os.environ["GITHUB_OUTPUT"], "a") as stream:
stream.write(f"version={version}\n") stream.write(f"version={version}\n")
+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
+31 -8
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):常规设置、桌面墙传输及版本检查。
@@ -118,7 +138,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
### Android ### Android
原生控制端支持触控与外接键鼠、多屏切换、文件收发、文本剪贴板和会话画面设置。H.264 / AV1 优先使用硬件解码,不支持或运行失败时回退到软件解码;切换应用时优先通过画中画保持会话,画中画不可用时尝试前台服务保活。当前不提供手机被控或隐私屏功能。 原生控制端支持触控与外接键鼠、多屏切换、文件收发、文本剪贴板和会话画面设置。H.264 / AV1 优先使用硬件解码,不支持或运行失败时回退到软件解码;切换应用时优先通过画中画保持会话,画中画不可用时尝试前台服务保活。当前不提供手机被控功能。悬浮菜单中的“隐私屏”按钮可直接开启或关闭电脑本地遮罩,按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠。
[使用说明](docs/USER_GUIDE.md#android) · [源码构建](apps/android/README.md) [使用说明](docs/USER_GUIDE.md#android) · [源码构建](apps/android/README.md)
@@ -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
+31 -8
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.
@@ -118,7 +138,7 @@ The native controller supports touch and a virtual mouse, multiple monitors, rem
### Android ### Android
The native controller supports touch and external keyboards/mice, monitor switching, file transfers, text clipboard, and video settings. H.264 / AV1 use hardware decoding when available, with software fallback when unsupported or when decoding fails. Active sessions use Picture in Picture when switching apps, with a foreground service as a fallback when Picture in Picture is unavailable. Phone-as-host and privacy screens are not currently supported. The native controller supports touch and external keyboards/mice, monitor switching, file transfers, text clipboard, and video settings. H.264 / AV1 use hardware decoding when available, with software fallback when unsupported or when decoding fails. Active sessions use Picture in Picture when switching apps, with a foreground service as a fallback when Picture in Picture is unavailable. Phone-as-host is not currently supported. The floating menu’s **Privacy screen** button toggles the computer’s local cover directly, labels the host-confirmed on/off state without changing colors, and shows inline feedback only for errors or unavailable status. Enabling privacy also blocks local keyboard/mouse input, matching the desktop controller.
[User guide](docs/USER_GUIDE_EN.md#android) · [Build from source](apps/android/README.md) (Chinese) [User guide](docs/USER_GUIDE_EN.md#android) · [Build from source](apps/android/README.md) (Chinese)
@@ -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
+6 -3
View File
@@ -14,6 +14,7 @@
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。 - 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。 - 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
- 悬浮菜单提供“隐私屏”直接开关,无需进入详情页;按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示。开启时与桌面端一致屏蔽电脑本地键鼠;声明控制能力后可使用电脑的连接时自动开启设置。状态超过 5 秒未更新时停止显示成功,指令 15 秒未确认时提示超时;后台保活与画中画恢复沿用同一会话状态。
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。 - 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。 - 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。 - 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
@@ -25,7 +26,7 @@
- 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。 - 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。
- “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。 - “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。
当前不包含安卓被控端、隐私屏或 iOS 全部附加功能。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。 当前不包含安卓被控端或 iOS 全部附加功能。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
## 本机更新提示 ## 本机更新提示
@@ -152,7 +153,9 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。 身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。Windows 通过当前已解锁用户桌面的 Unicode 键盘事件输入文本,无需切换电脑输入法或覆盖剪贴板;锁屏和 UAC 安全桌面不接受此文本通道。Windows 被控端也必须更新到包含文本输入支持的版本,仅更新 Android 不会启用该能力。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。 会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
Windows 文本输入使用 Unicode 键盘事件,无需切换电脑输入法或覆盖剪贴板。本次开发版将完整 UTF-8 文本通过 Windows 服务转发到锁屏、登录与 UAC 安全桌面,保留数字、大小写字母、符号、空格及中文等字符,不将密码转换成数字或 ASCII 按键。该通道需要同时更新 Windows 客户端、CrossDesk Service 和会话辅助程序;仅更新 Android,或仅确认旧服务正在运行,均不会启用修复。旧版可能已声明 `supports_text_input`,但只支持普通桌面文本输入,因此“电脑键盘可输入、搜狗等系统输入法不可输入”仍可能出现。锁屏实际输入效果需在更新后的 Windows 环境验证。
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。 “发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
@@ -246,4 +249,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 文件。
@@ -80,6 +80,7 @@ final class BackgroundSession implements NativeSession.Listener {
if (view != null) view.fileTransfer(name, progress, sending, received); if (view != null) view.fileTransfer(name, progress, sending, received);
} }
@Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); } @Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); }
@Override public void privacyChanged() { if (view != null) view.privacyChanged(); }
@Override public void presence(JSONObject message) { if (view != null) view.presence(message); } @Override public void presence(JSONObject message) { if (view != null) view.presence(message); }
@Override public void announcement(JSONObject message) { if (view != null) view.announcement(message); } @Override public void announcement(JSONObject message) { if (view != null) view.announcement(message); }
@Override public void announcementFailed(String id) { if (view != null) view.announcementFailed(id); } @Override public void announcementFailed(String id) { if (view != null) view.announcementFailed(id); }
@@ -218,7 +218,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
remote.setOnFocusChangeListener((view,focused)->{GradientDrawable shape=ui.background(BACKGROUND,12);shape.setStroke(dp(focused?2:1),focused?BLUE:LINE);remote.setBackground(shape);}); remote.setOnFocusChangeListener((view,focused)->{GradientDrawable shape=ui.background(BACKGROUND,12);shape.setStroke(dp(focused?2:1),focused?BLUE:LINE);remote.setBackground(shape);});
remote.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)});remote.setText(formatId(preferences.getString("lastRemote",""))); remote.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)});remote.setText(formatId(preferences.getString("lastRemote","")));
entry.addView(remote,new LinearLayout.LayoutParams(0,-1,1)); entry.addView(remote,new LinearLayout.LayoutParams(0,-1,1));
TextView connect=ui.primary("连接 →",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams); TextView connect=ui.primary("连接",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run(); Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run();
remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}}); remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}});
LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p); LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p);
@@ -295,7 +295,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
View eye=ui.iconButton("eye","显示密码",()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18); View eye=ui.iconButton("eye","显示密码",()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18);
LinearLayout save=row(),saveText=column();saveText.addView(text("保存密码",15,true));ui.gap(saveText,3);TextView security=text("密码将加密保存在本机 Android Keystore 中",11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription("保存密码");save.addView(remember);sheet.addView(save); LinearLayout save=row(),saveText=column();saveText.addView(text("保存密码",15,true));ui.gap(saveText,3);TextView security=text("密码将加密保存在本机 Android Keystore 中",11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription("保存密码");save.addView(remember);sheet.addView(save);
remember.setChecked(rememberDefault); remember.setChecked(rememberDefault);
ui.gap(sheet,18);TextView connect=ui.primary("连接 →",()->{ ui.gap(sheet,18);TextView connect=ui.primary("连接",()->{
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;} String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret); rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48)); });connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
@@ -331,6 +331,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
controls=new SessionControls(this,ui,new SessionControls.Actions(){ controls=new SessionControls(this,ui,new SessionControls.Actions(){
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();} public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();} public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
public void togglePrivacy(){if(ready&&session!=null)session.togglePrivacy();}
public void key(int code){tapKey(code);} public void key(int code){tapKey(code);}
public boolean textEdit(int count,String value){return sendTextEdit(count,value);} public boolean textEdit(int count,String value){return sendTextEdit(count,value);}
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);} public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
@@ -339,6 +340,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));} public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
});controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); });controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput)); controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput));
privacyChanged();
video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();}; video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();};
controls.requestApplyInsets(); controls.requestApplyInsets();
video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{ video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
@@ -655,6 +657,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info); if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
} }
} }
@Override public void privacyChanged(){if(controls!=null&&session!=null)controls.privacyScreen(session.privacyScreen);}
@Override public void videoSize(int width,int height){videoWidth=width;videoHeight=height;if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);pictureInPicture.videoSize(width,height);updatePictureInPicture();} @Override public void videoSize(int width,int height){videoWidth=width;videoHeight=height;if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);pictureInPicture.videoSize(width,height);updatePictureInPicture();}
@Override public void clipboard(String value){remoteClipboard=value;} @Override public void clipboard(String value){remoteClipboard=value;}
@Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();} @Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();}
@@ -782,7 +785,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onPictureInPictureUiStateChanged(state); super.onPictureInPictureUiStateChanged(state);
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true); if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
} }
@Override protected void onResume(){super.onResume();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();} @Override protected void onResume(){super.onResume();privacyChanged();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice. // onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onPause(){remoteOrientation.pause();releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();} @Override protected void onPause(){remoteOrientation.pause();releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();} @Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();}
@@ -30,6 +30,7 @@ final class NativeSession implements AutoCloseable {
void statistics(RemoteNetworkStatistics.Snapshot value); void statistics(RemoteNetworkStatistics.Snapshot value);
default void fileTransfer(String name,double progress,boolean sending,File received) { } default void fileTransfer(String name,double progress,boolean sending,File received) { }
default void controlFeedback(String message) { } default void controlFeedback(String message) { }
default void privacyChanged() { }
default void signaling(int state, String deviceId) { } default void signaling(int state, String deviceId) { }
default void presence(JSONObject message) { } default void presence(JSONObject message) { }
default void announcement(JSONObject message) { } default void announcement(JSONObject message) { }
@@ -49,6 +50,7 @@ final class NativeSession implements AutoCloseable {
private long handle; private long handle;
private boolean signalReady, joining, started; private boolean signalReady, joining, started;
private volatile boolean connected, supportsTextInput; private volatile boolean connected, supportsTextInput;
final RemotePrivacyScreen privacyScreen = new RemotePrivacyScreen();
private int signalState; private int signalState;
private volatile long signalGeneration; private volatile long signalGeneration;
private boolean presenceRequested; private boolean presenceRequested;
@@ -63,6 +65,8 @@ final class NativeSession implements AutoCloseable {
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics(); private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
private final Runnable statisticsTick=new Runnable(){public void run(){ private final Runnable statisticsTick=new Runnable(){public void run(){
if(closed)return; if(closed)return;
privacyScreen.tick(android.os.SystemClock.elapsedRealtime());
listener.privacyChanged();
listener.statistics(networkStatistics.snapshot(RemoteNetworkStatistics.now())); listener.statistics(networkStatistics.snapshot(RemoteNetworkStatistics.now()));
if(!closed)main.postDelayed(this,1000); if(!closed)main.postDelayed(this,1000);
}}; }};
@@ -193,7 +197,13 @@ final class NativeSession implements AutoCloseable {
} else if (type == 4) { } else if (type == 4) {
JSONObject info=event.optJSONObject("host_info"); JSONObject info=event.optJSONObject("host_info");
if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false); if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false);
ui(() -> listener.data(event)); ui(() -> {
if(event.optInt("type",-1)==10){
privacyScreen.receive(event,android.os.SystemClock.elapsedRealtime());
listener.privacyChanged();
}
listener.data(event);
});
} }
else if (type == 5) { else if (type == 5) {
int width = event.getInt("width"), height = event.getInt("height"); int width = event.getInt("width"), height = event.getInt("height");
@@ -266,6 +276,16 @@ final class NativeSession implements AutoCloseable {
if (type == 2 && player != null) player.setEnabled(value != 0); if (type == 2 && player != null) player.setEnabled(value != 0);
execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} }); execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} });
} }
void togglePrivacy() {
if(closed||!connected)return;
boolean enable=!privacyScreen.active();
long request=privacyScreen.begin(android.os.SystemClock.elapsedRealtime());
if(request<0)return;
listener.privacyChanged();
execute(()->{if(connected&&!nPrivacy(handle,enable?1:2,true))ui(()->{
privacyScreen.failed(request);listener.privacyChanged();
});});
}
void clipboard(String value) { void clipboard(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8); byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 128 * 1024) return; if (bytes.length == 0 || bytes.length > 128 * 1024) return;
@@ -319,6 +339,7 @@ final class NativeSession implements AutoCloseable {
private static native boolean nPresenceRequest(long handle,byte[] data); private static native boolean nPresenceRequest(long handle,byte[] data);
private static native void nClipboard(long handle, byte[] data); private static native void nClipboard(long handle, byte[] data);
private static native boolean nSecureAttention(long handle); private static native boolean nSecureAttention(long handle);
private static native boolean nPrivacy(long handle,int flag,boolean blockLocalInput);
private static native void nFileDirectory(long handle,String directory); private static native void nFileDirectory(long handle,String directory);
private static native boolean nSendFile(long handle,String path,byte[] name); private static native boolean nSendFile(long handle,String path,byte[] name);
private static native boolean nPumpFiles(long handle); private static native boolean nPumpFiles(long handle);
@@ -0,0 +1,79 @@
package cn.crossdesk.mobile;
import org.json.JSONObject;
/** Host-confirmed privacy state; all access belongs to the main thread. */
final class RemotePrivacyScreen {
boolean received, supported, inputBlockSupported, overlay, inputBlocked, remotePaused, pending;
int state;
long revision, requestRevision, requestId, receivedAt, requestedAt;
String reason = "", failure = "";
void receive(JSONObject message, long now) {
try {
if (message.getInt("type") != 10) return;
JSONObject value = message.getJSONObject("privacy_status");
int next = value.getInt("state");
long rev = value.getLong("revision");
if (next < 0 || next > 5 || rev < 0 || rev > 0xffffffffL ||
received && (int)(rev - revision) < 0) return;
boolean support = value.getBoolean("supported"), cover = value.getBoolean("overlay_active");
boolean paused = value.getBoolean("remote_paused");
if (next == 3 && (!support || !cover || paused)) return;
boolean blockSupport = value.getBoolean("input_block_supported"), blocked = value.getBoolean("input_blocked");
String detail = value.getString("reason");
state = next; revision = rev; supported = support; overlay = cover; remotePaused = paused;
inputBlockSupported = blockSupport; inputBlocked = blocked; reason = detail;
received = true; receivedAt = now; failure = "";
if (pending && (int)(revision - requestRevision) > 0 && state != 2 && state != 4) pending = false;
} catch (org.json.JSONException ignored) { }
}
boolean active() { return overlay || remotePaused || state == 2 || state == 3 || state == 4; }
boolean fresh(long now) { return received && now - receivedAt <= 5000; }
boolean enabled(long now) { return fresh(now) && state == 3 && overlay && !remotePaused; }
String buttonTitle(long now) {
if (enabled(now)) return "隐私屏已开启";
if (fresh(now) && supported && state == 1 && !active()) return "隐私屏已关闭";
return "隐私屏";
}
String feedback(long now) {
if (failure.isEmpty() && (pending || fresh(now) && (state == 2 || state == 4))) return "";
if (failure.isEmpty() && !pending && fresh(now) && supported &&
(enabled(now) || state == 1 && !active())) return "";
String detail = detail(now);
return status(now) + (detail.isEmpty() ? "" : ":" + detail);
}
boolean canToggle(long now) {
return !pending && fresh(now) && state != 4 && (active() || supported && state == 1);
}
long begin(long now) {
tick(now);
if (!canToggle(now)) return -1;
pending = true; requestedAt = now; requestRevision = revision; failure = "";
return ++requestId;
}
void failed(long id) {
if (pending && requestId == id) { pending = false; failure = "指令发送失败,请重试"; }
}
void tick(long now) {
if (pending && now - requestedAt >= 15000) {
pending = false; failure = "请求超时,尚未确认电脑的隐私屏状态";
}
}
String status(long now) {
if (!failure.isEmpty()) return failure;
if (!received) return "等待电脑返回隐私屏状态;旧版电脑端可能不支持";
if (!fresh(now)) return "隐私屏状态已过期,等待电脑确认";
if (state == 2) return "正在开启隐私屏…";
if (state == 4) return "正在关闭隐私屏…";
if (pending) return "指令已发送,等待电脑确认…";
if (state == 5) return "隐私屏异常";
if (!supported || state == 0) return "隐私屏暂不可用";
if (state == 3 && overlay && !remotePaused)
return "隐私屏已开启";
return state == 1 && !active() ? "隐私屏已关闭" : "隐私屏状态待确认";
}
String detail(long now) {
return fresh(now) && (state == 0 || state == 5 || !supported) ? reason : "";
}
}
@@ -15,6 +15,7 @@ final class SessionControls extends FrameLayout {
default boolean textEdit(int deletions,String value){return false;} default boolean textEdit(int deletions,String value){return false;}
void videoSettings(int field,int value); void videoSettings(int field,int value);
void sendFile(); void saveFile(); void secureAttention(); void sendFile(); void saveFile(); void secureAttention();
default void togglePrivacy() { }
} }
private final MobileUi ui; private final MobileUi ui;
private final Actions actions; private final Actions actions;
@@ -36,6 +37,9 @@ final class SessionControls extends FrameLayout {
keyboardInsets(height,height>ui.dp(80),0); keyboardInsets(height,height>ui.dp(80),0);
} }
private TextView networkLabel,videoFeedback; private TextView networkLabel,videoFeedback;
private RemotePrivacyScreen privacy=new RemotePrivacyScreen();
private View privacyButton;
private TextView privacyFeedback,privacyCaption;
private TextView transferLabel,saveFileButton; private TextView transferLabel,saveFileButton;
private String transferStatus=""; private String transferStatus="";
private boolean hasReceivedFile; private boolean hasReceivedFile;
@@ -100,12 +104,13 @@ final class SessionControls extends FrameLayout {
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));} void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();} void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();} void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
void privacyScreen(RemotePrivacyScreen value){privacy=value;renderPrivacy();}
void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();} void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();}
private void renderFileStatus(){ private void renderFileStatus(){
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);} if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE); if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
} }
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();} private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;privacyFeedback=privacyCaption=null;privacyButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();}
private void openMenu(String page){ private void openMenu(String page){
cancelMouseInput(); cancelMouseInput();
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1)); closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
@@ -121,13 +126,15 @@ final class SessionControls extends FrameLayout {
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0); scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
} }
if(page.equals("controls")){ if(page.equals("controls")){
String[] labels={"键盘",mouseVisible?"收起鼠标":"虚拟鼠标",relative?"相对鼠标":"绝对鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"}; String[] labels={"键盘",mouseVisible?"收起鼠标":"虚拟鼠标",relative?"相对鼠标":"绝对鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板","隐私屏"};
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard"}; String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard","lock"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},this::toggleMouse,actions::mouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard}; Runnable[] clicks={()->{closeMenu();toggleKeyboard();},this::toggleMouse,actions::mouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard,actions::togglePrivacy};
menuColumns=controlColumns(); menuColumns=controlColumns();
for(int row=0;row<(labels.length+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int index=row*menuColumns+col;if(index>=labels.length){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run()); for(int row=0;row<(labels.length+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int index=row*menuColumns+col;if(index>=labels.length){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
if(index==10){privacyButton=control;privacyCaption=caption;}
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p); LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
}body.addView(line);if((row+1)*menuColumns<labels.length)ui.gap(body,7);} }body.addView(line);if((row+1)*menuColumns<labels.length)ui.gap(body,7);}
privacyFeedback=ui.text("",11,false);privacyFeedback.setTextColor(SECONDARY);privacyFeedback.setPadding(0,ui.dp(7),0,0);privacyFeedback.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(privacyFeedback);renderPrivacy();
ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel); ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel);
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus(); saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
}else if(page.equals("network")){ }else if(page.equals("network")){
@@ -141,6 +148,16 @@ final class SessionControls extends FrameLayout {
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单"); addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
positionMouse(); positionMouse();
} }
private void renderPrivacy(){
if(privacyButton==null)return;
long now=android.os.SystemClock.elapsedRealtime();privacy.tick(now);
boolean enabled=privacy.enabled(now);String action=privacy.active()?"关闭隐私屏":"开启隐私屏";
privacyCaption.setText(privacy.buttonTitle(now));
privacyButton.setSelected(enabled);
privacyButton.setContentDescription(action+","+privacy.status(now));
privacyButton.setEnabled(privacy.canToggle(now));privacyButton.setAlpha(privacyButton.isEnabled()?1f:.45f);
String feedback=privacy.feedback(now);privacyFeedback.setText(feedback);privacyFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);
}
private final class SettingRow { private final class SettingRow {
final int field;final int[] values;final SegmentedControl group; final int field;final int[] values;final SegmentedControl group;
SettingRow(LinearLayout body,int field,String title,String[] labels,int[] values){ SettingRow(LinearLayout body,int field,String title,String[] labels,int[] values){
@@ -10,6 +10,7 @@
#include "../../../libs/controller/include/mobile_controller_protocol.h" #include "../../../libs/controller/include/mobile_controller_protocol.h"
namespace crossdesk::android_controller { namespace crossdesk::android_controller {
using mobile_controller::SecureAttentionMessage; using mobile_controller::SecureAttentionMessage;
using mobile_controller::PrivacyMessage;
using mobile_controller::VideoSettingsMessage; using mobile_controller::VideoSettingsMessage;
using mobile_controller::NetworkReport; using mobile_controller::NetworkReport;
} }
+9 -1
View File
@@ -370,7 +370,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
} }
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) { extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
auto* s = From(handle); if (!s || !s->controller) return; auto* s = From(handle); if (!s || !s->controller) return;
auto action = MakeHostInformation("CrossDesk Android", {}, false, "1.6.0", HostPlatform::Android); auto action = MakeHostInformation("CrossDesk Android", {}, true, "1.6.0", HostPlatform::Android);
s->Send(kControlStream, action.to_json()); FreeRemoteAction(action); s->Send(kControlStream, action.to_json()); FreeRemoteAction(action);
} }
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)( extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)(
@@ -429,6 +429,14 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jcla
const auto data = android_controller::SecureAttentionMessage(); const auto data = android_controller::SecureAttentionMessage();
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0; return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
} }
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
JNIEnv*, jclass, jlong handle, jint flag, jboolean block_input) {
auto* s = From(handle);
if (!s || !s->controller || s->stopping) return false;
const auto data = android_controller::PrivacyMessage(flag, block_input);
return !data.empty() &&
SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) { extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) {
auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory)); auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory));
} }
+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;
@@ -21,6 +21,8 @@
#include "interactive_desktop.h" #include "interactive_desktop.h"
#include "service_host.h" #include "service_host.h"
#include "unattended_config.h" #include "unattended_config.h"
#include "platform/windows/input/windows_input_injector.h"
#include "platform/windows/input/windows_text_input.h"
#endif #endif
namespace crossdesk { namespace crossdesk {
@@ -275,11 +277,51 @@ InputOutcome KeyboardController::ApplyRemoteText(const std::string& remote_id,
std::lock_guard input_lock(remote_input_mutex_); std::lock_guard input_lock(remote_input_mutex_);
if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored; if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed; if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
#if _WIN32
std::vector<INPUT> inputs;
if (!BuildWindowsTextInputs(text, inputs)) return InputOutcome::failed;
if (owner_.privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable())
owner_.privacy_.SuspendForDesktop();
static const bool process_elevated = IsAdministratorProcess();
const bool injected = DispatchDesktopInput(
[&] {
return PreferUserDesktopInput(IsCurrentSessionUserDesktopActive(),
owner_.windows_consent_ui_.load(),
owner_.local_service_available_.load(),
process_elevated);
},
[&] {
const UINT sent = SendWindowsTextInputs(
inputs, [](INPUT* batch, UINT count) {
return SendInputOnUserDesktop(batch, count);
});
const DWORD error = GetLastError();
const bool blocked =
error == ERROR_ACCESS_DENIED || error == ERROR_GEN_FAILURE;
// A text commit is a batch: only zero insertion permits a retry.
return DesktopInputResult{
sent == inputs.size(),
sent == 0 && (IsDesktopTransitionInputError(error) || blocked),
sent == 0 && blocked};
},
[&]() -> DesktopInputResult {
const auto response = SendCrossDeskSecureDesktopTextInput(text, 1000);
const auto json = nlohmann::json::parse(response, nullptr, false);
if (json.is_object() && json.value("ok", false)) return {true, false};
const std::string error =
json.is_object() ? json.value("error", "") : "invalid_response";
// No text or raw reply in diagnostics; commits may be passwords.
LOG_WARN("Secure desktop text injection failed: error={}", error);
return {false, IsDesktopInputSetupPending(error)};
});
return injected ? InputOutcome::applied : InputOutcome::failed;
#else
if (!owner_.devices_.SendTextCommand(text)) { if (!owner_.devices_.SendTextCommand(text)) {
LOG_WARN("Failed to inject committed remote text"); LOG_WARN("Failed to inject committed remote text");
return InputOutcome::failed; return InputOutcome::failed;
} }
return InputOutcome::applied; return InputOutcome::applied;
#endif
} }
InputOutcome KeyboardController::ApplyRemoteState(const std::string& remote_id, InputOutcome KeyboardController::ApplyRemoteState(const std::string& remote_id,
@@ -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,9 +72,19 @@ 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_;
}; };
@@ -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,684 @@
#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');
});
}
#ifdef _WIN32
std::vector<unsigned char> CurrentUser() {
HANDLE token = nullptr;
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
"Cannot open user token");
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<unsigned char> data(size);
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
CloseHandle(token);
Require(ok, "Cannot read user token");
return data;
}
PSECURITY_DESCRIPTOR PrivateSecurityDescriptor(bool directory) {
auto user = CurrentUser();
LPWSTR sid = nullptr;
Require(ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
"Cannot read user SID");
// An elevated token can default to Administrators as the owner even when
// its DACL grants access only to TokenUser. Always set both explicitly.
const auto acl = L"O:" + std::wstring(sid) + L"D:P(A;" +
(directory ? L"OICI" : L"") + L";FA;;;" + sid + L")";
LocalFree(sid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
"Cannot protect LAN file");
return descriptor;
}
#endif
void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
const auto temporary = path.parent_path() / (".write-" + RandomHex(16));
#ifdef _WIN32
auto descriptor = PrivateSecurityDescriptor(false);
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &security,
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
LocalFree(descriptor);
Require(file != INVALID_HANDLE_VALUE, "Cannot create LAN file");
DWORD written = 0;
bool ok = WriteFile(file, text.data(), static_cast<DWORD>(text.size()),
&written, nullptr) &&
written == text.size() && FlushFileBuffers(file);
CloseHandle(file);
if (!ok || !MoveFileExW(temporary.c_str(), path.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
DeleteFileW(temporary.c_str());
throw std::runtime_error("Cannot persist LAN file");
}
#else
int fd =
open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
Require(fd >= 0, "Cannot create private LAN file");
size_t offset = 0;
while (offset < text.size()) {
const auto count = write(fd, text.data() + offset, text.size() - offset);
if (count <= 0) break;
offset += static_cast<size_t>(count);
}
bool ok = offset == text.size() && fsync(fd) == 0;
close(fd);
if (!ok || rename(temporary.c_str(), path.c_str()) != 0) {
unlink(temporary.c_str());
throw std::runtime_error("Cannot persist LAN file");
}
#endif
}
#ifdef _WIN32
using FileHandle = std::unique_ptr<void, decltype(&CloseHandle)>;
FileHandle OpenLanPath(const std::filesystem::path& path, DWORD access) {
// Keep the object open without FILE_SHARE_DELETE so validation and legacy
// owner repair cannot operate on a replacement at the same path.
HANDLE file = CreateFileW(
path.c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
nullptr);
Require(file != INVALID_HANDLE_VALUE, "Cannot open LAN path");
return {file, CloseHandle};
}
void CheckPrivateWindowsPath(const std::filesystem::path& path) {
auto file = OpenLanPath(path, READ_CONTROL);
BY_HANDLE_FILE_INFORMATION info{};
Require(GetFileInformationByHandle(file.get(), &info) &&
!(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT),
"LAN path cannot be a reparse point");
auto user = CurrentUser();
auto sid = reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid;
PSID owner = nullptr;
PACL acl = nullptr;
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(GetSecurityInfo(
file.get(), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS,
"Cannot read LAN file permissions");
std::unique_ptr<void, decltype(&LocalFree)> security(descriptor, LocalFree);
bool ok = acl && acl->AceCount > 0;
// Only the exact protected, full-access DACL written by older versions is
// eligible for owner repair. Never adopt a shared or foreign-user profile.
SECURITY_DESCRIPTOR_CONTROL control{};
DWORD revision = 0;
bool legacy = owner && IsWellKnownSid(owner, WinBuiltinAdministratorsSid) &&
GetSecurityDescriptorControl(descriptor, &control, &revision) &&
(control & SE_DACL_PROTECTED) && acl && acl->AceCount == 1;
for (DWORD i = 0; ok && i < acl->AceCount; ++i) {
void* raw = nullptr;
if (!GetAce(acl, i, &raw)) {
ok = false;
break;
}
const auto* ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
EqualSid(const_cast<DWORD*>(&ace->SidStart), sid);
const BYTE flags = (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
? OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
: 0;
legacy =
legacy && ace->Mask == FILE_ALL_ACCESS && ace->Header.AceFlags == flags;
}
Require(ok && owner && (EqualSid(owner, sid) || legacy),
"LAN files must be accessible only by the current user");
if (legacy) {
auto writable = OpenLanPath(path, WRITE_OWNER);
BY_HANDLE_FILE_INFORMATION current{};
Require(GetFileInformationByHandle(writable.get(), &current) &&
current.dwVolumeSerialNumber == info.dwVolumeSerialNumber &&
current.nFileIndexHigh == info.nFileIndexHigh &&
current.nFileIndexLow == info.nFileIndexLow &&
SetSecurityInfo(writable.get(), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION, sid, nullptr,
nullptr, nullptr) == ERROR_SUCCESS,
"Cannot repair LAN file owner");
}
}
void CreatePrivateDirectory(const std::filesystem::path& path) {
if (!std::filesystem::exists(path)) {
auto descriptor = PrivateSecurityDescriptor(true);
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
bool ok = CreateDirectoryW(path.c_str(), &security);
LocalFree(descriptor);
Require(ok, "Cannot create LAN directory");
}
CheckPrivateWindowsPath(path);
}
#endif
std::string Read(const std::filesystem::path& path) {
#ifdef _WIN32
CheckPrivateWindowsPath(path);
#else
struct stat status{};
Require(lstat(path.c_str(), &status) == 0 && S_ISREG(status.st_mode) &&
status.st_uid == getuid() && (status.st_mode & 0077) == 0,
"LAN file must be owned by this user with mode 0600");
#endif
Require(std::filesystem::file_size(path) <= 64 * 1024, "LAN file too large");
std::ifstream input(path, std::ios::binary);
Require(input.good(), "Cannot read LAN file");
return {std::istreambuf_iterator<char>(input), {}};
}
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
} }
@@ -7,6 +7,7 @@
#include "windows_input_injector.h" #include "windows_input_injector.h"
#include "windows_input_marker.h" #include "windows_input_marker.h"
#include "windows_key_metadata.h" #include "windows_key_metadata.h"
#include "windows_text_input.h"
namespace crossdesk { namespace crossdesk {
namespace { namespace {
@@ -393,52 +394,17 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
} }
int PlatformKeyboardCapturer::SendTextCommand(const std::string& text) { int PlatformKeyboardCapturer::SendTextCommand(const std::string& text) {
if (text.empty() || text.size() > kMaxTextInputBytes ||
text.find('\0') != std::string::npos) {
return -1;
}
// Validate the entire UTF-8 commit before injecting any characters.
const int byte_count = static_cast<int>(text.size());
const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
text.data(), byte_count, nullptr, 0);
if (length == 0) return -1;
std::wstring characters(length, L'\0');
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(),
byte_count, characters.data(), length) != length) {
return -1;
}
std::vector<INPUT> inputs; std::vector<INPUT> inputs;
inputs.reserve(characters.size() * 2); if (!BuildWindowsTextInputs(text, inputs)) return -1;
for (size_t index = 0; index < characters.size(); ++index) {
const wchar_t character = characters[index];
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.dwExtraInfo = static_cast<ULONG_PTR>(kInjectedKeyboardInputMarker);
if (character == L'\r' || character == L'\n' || character == L'\t') {
input.ki.wVk = character == L'\t' ? VK_TAB : VK_RETURN;
if (character == L'\r' && index + 1 < characters.size() &&
characters[index + 1] == L'\n') {
++index;
}
} else {
// VK_PACKET carries UTF-16 units, including both halves of emoji.
input.ki.wScan = static_cast<WORD>(character);
input.ki.dwFlags = KEYEVENTF_UNICODE;
}
inputs.push_back(input);
input.ki.dwFlags |= KEYEVENTF_KEYUP;
inputs.push_back(input);
}
// One batch keeps surrogate pairs and key releases together with the text. // One batch keeps surrogate pairs and key releases together with the text.
const UINT count = static_cast<UINT>(inputs.size()); const UINT count = static_cast<UINT>(inputs.size());
const UINT sent = SendInputOnUserDesktop(inputs.data(), count); const UINT sent = SendWindowsTextInputs(
inputs, [](INPUT* batch, UINT size) {
return SendInputOnUserDesktop(batch, size);
});
if (sent != count) { if (sent != count) {
const DWORD error = GetLastError(); const DWORD error = GetLastError();
if (sent < count && sent % 2 != 0) {
SendInputOnUserDesktop(inputs[sent]); // Best-effort release.
}
LOG_WARN( LOG_WARN(
"SendInput failed for committed text, sent={}, expected={}, err={}", "SendInput failed for committed text, sent={}, expected={}, err={}",
sent, count, error); sent, count, error);
@@ -0,0 +1,71 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-26
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOWS_TEXT_INPUT_H_
#define _WINDOWS_TEXT_INPUT_H_
#include <Windows.h>
#include <string>
#include <vector>
#include "remote_action.h"
#include "windows_input_marker.h"
namespace crossdesk {
inline bool BuildWindowsTextInputs(const std::string& text,
std::vector<INPUT>& inputs) {
inputs.clear();
if (text.empty() || text.size() > kMaxTextInputBytes ||
text.find('\0') != std::string::npos) return false;
// Validate the entire commit before injecting any characters.
const int bytes = static_cast<int>(text.size());
const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
text.data(), bytes, nullptr, 0);
if (length == 0) return false;
std::wstring characters(length, L'\0');
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), bytes,
characters.data(), length) != length) return false;
inputs.reserve(characters.size() * 2);
for (size_t index = 0; index < characters.size(); ++index) {
const wchar_t character = characters[index];
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.dwExtraInfo = static_cast<ULONG_PTR>(kInjectedKeyboardInputMarker);
if (character == L'\r' || character == L'\n' || character == L'\t') {
input.ki.wVk = character == L'\t' ? VK_TAB : VK_RETURN;
if (character == L'\r' && index + 1 < characters.size() &&
characters[index + 1] == L'\n') ++index;
} else {
input.ki.wScan = static_cast<WORD>(character);
input.ki.dwFlags = KEYEVENTF_UNICODE;
}
inputs.push_back(input);
input.ki.dwFlags |= KEYEVENTF_KEYUP;
inputs.push_back(input);
}
return true;
}
// A partial batch must never be replayed. Release its final key if necessary,
// retaining the original count and error for the desktop-routing decision.
template <typename Sender>
UINT SendWindowsTextInputs(std::vector<INPUT>& inputs, Sender send) {
const UINT count = static_cast<UINT>(inputs.size());
SetLastError(ERROR_SUCCESS);
const UINT sent = send(inputs.data(), count);
if (sent == count) return sent;
const DWORD error = GetLastError();
if (sent < count && sent % 2 != 0) send(&inputs[sent], 1);
SetLastError(error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE);
return sent;
}
} // namespace crossdesk
#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
@@ -0,0 +1,60 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-26
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SECURE_TEXT_INPUT_H_
#define _SECURE_TEXT_INPUT_H_
#include <nlohmann/json.hpp>
#include <string>
#include "remote_action.h"
namespace crossdesk {
inline constexpr char kSecureTextInputCommandPrefix[] = "text:";
// JSON can escape each text byte as six ASCII bytes. Leave room for metadata.
inline constexpr size_t kSecureInputCommandBufferBytes =
6 * kMaxTextInputBytes + 1024;
struct SecureTextInputRequest {
std::string text;
std::string stage;
std::string desktop;
};
inline std::string BuildSecureTextInputCommand(
const std::string& text, const std::string& stage = {},
const std::string& desktop = {}) {
return kSecureTextInputCommandPrefix +
nlohmann::json{{"text", text}, {"stage", stage},
{"desktop", desktop}}.dump();
}
inline bool ParseSecureTextInputCommand(const std::string& command,
SecureTextInputRequest& request) {
request = {};
if (command.size() > kSecureInputCommandBufferBytes ||
command.rfind(kSecureTextInputCommandPrefix, 0) != 0) return false;
const auto json = nlohmann::json::parse(
command.substr(sizeof(kSecureTextInputCommandPrefix) - 1), nullptr, false);
if (!json.is_object() || !json.contains("text") ||
!json["text"].is_string() || !json.contains("stage") ||
!json["stage"].is_string() || !json.contains("desktop") ||
!json["desktop"].is_string()) return false;
const auto& text = json["text"].get_ref<const std::string&>();
const auto& stage = json["stage"].get_ref<const std::string&>();
const auto& desktop = json["desktop"].get_ref<const std::string&>();
if (text.empty() || text.size() > kMaxTextInputBytes ||
text.find('\0') != std::string::npos || stage.size() > 64 ||
desktop.size() > 256 || stage.find('\0') != std::string::npos ||
desktop.find('\0') != std::string::npos) return false;
request = {text, stage, desktop};
return true;
}
} // namespace crossdesk
#endif
@@ -23,6 +23,7 @@
#include "path_manager.h" #include "path_manager.h"
#include "rd_log.h" #include "rd_log.h"
#include "session_helper_shared.h" #include "session_helper_shared.h"
#include "secure_text_input.h"
#include "unattended_config.h" #include "unattended_config.h"
#include "unattended_policy.h" #include "unattended_policy.h"
#include "rdp_session_handoff.h" #include "rdp_session_handoff.h"
@@ -1421,7 +1422,7 @@ void CrossDeskServiceHost::IpcServerLoop() {
} }
} }
char buffer[1024] = {0}; char buffer[kSecureInputCommandBufferBytes] = {0};
DWORD bytes_read = 0; DWORD bytes_read = 0;
DWORD transfer_error = ERROR_SUCCESS; DWORD transfer_error = ERROR_SUCCESS;
if (pipe_deadline_detail::Transfer( if (pipe_deadline_detail::Transfer(
@@ -2235,6 +2236,14 @@ void CrossDeskServiceHost::RecordSessionEvent(DWORD event_type,
std::string CrossDeskServiceHost::HandleIpcCommand(const std::string& command, std::string CrossDeskServiceHost::HandleIpcCommand(const std::string& command,
DWORD client_session_id, DWORD client_session_id,
HANDLE client_pipe) { HANDLE client_pipe) {
// Password text is case/whitespace sensitive. Parse before normalizing the
// legacy command verbs, and never accept the caller's desktop selection.
if (command.rfind(kSecureTextInputCommandPrefix, 0) == 0) {
SecureTextInputRequest request;
if (!ParseSecureTextInputCommand(command, request))
return BuildErrorJson("invalid_text_input");
return SendSecureDesktopTextInput(client_session_id, request.text);
}
std::string normalized = ToLower(Trim(command)); std::string normalized = ToLower(Trim(command));
if (normalized == kRdpHandoffCommand) { if (normalized == kRdpHandoffCommand) {
bool observed_rdp = false; bool observed_rdp = false;
@@ -2618,6 +2627,16 @@ std::string CrossDeskServiceHost::SendSecureDesktopMouseInput(
1000); 1000);
} }
std::string CrossDeskServiceHost::SendSecureDesktopTextInput(
DWORD client_session_id, const std::string& text) {
SecureInputTarget target;
const auto error = ResolveSecureInputTarget(client_session_id, target);
if (!error.empty()) return error;
return QueryNamedPipeMessage(
GetCrossDeskSecureInputHelperPipeName(target.session_id),
BuildSecureTextInputCommand(text, target.stage, target.desktop), 1000);
}
bool InstallCrossDeskService(const std::wstring& binary_path) { bool InstallCrossDeskService(const std::wstring& binary_path) {
SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS); SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS);
if (manager == nullptr) { if (manager == nullptr) {
@@ -2892,4 +2911,9 @@ std::string SendCrossDeskSecureDesktopMouseInput(int x, int y, int wheel,
BuildSecureDesktopMouseIpcCommand(x, y, wheel, flag), timeout_ms); BuildSecureDesktopMouseIpcCommand(x, y, wheel, flag), timeout_ms);
} }
std::string SendCrossDeskSecureDesktopTextInput(const std::string& text,
DWORD timeout_ms) {
return QueryDesktopInput(BuildSecureTextInputCommand(text), timeout_ms);
}
} // namespace crossdesk } // namespace crossdesk
@@ -90,6 +90,8 @@ class CrossDeskServiceHost {
bool extended = false); bool extended = false);
std::string SendSecureDesktopMouseInput(DWORD client_session_id, std::string SendSecureDesktopMouseInput(DWORD client_session_id,
int x, int y, int wheel, int flag); int x, int y, int wheel, int flag);
std::string SendSecureDesktopTextInput(DWORD client_session_id,
const std::string& text);
static void WINAPI ServiceMain(DWORD argc, LPWSTR* argv); static void WINAPI ServiceMain(DWORD argc, LPWSTR* argv);
static BOOL WINAPI ConsoleControlHandler(DWORD control_type); static BOOL WINAPI ConsoleControlHandler(DWORD control_type);
@@ -197,6 +199,8 @@ std::string SendCrossDeskSecureDesktopKeyInput(int key_code, bool is_down,
std::string SendCrossDeskSecureDesktopMouseInput(int x, int y, int wheel, std::string SendCrossDeskSecureDesktopMouseInput(int x, int y, int wheel,
int flag, int flag,
DWORD timeout_ms = 1000); DWORD timeout_ms = 1000);
std::string SendCrossDeskSecureDesktopTextInput(const std::string& text,
DWORD timeout_ms = 1000);
} // namespace crossdesk } // namespace crossdesk
@@ -28,6 +28,7 @@
#include "platform/path_backend.h" #include "platform/path_backend.h"
#include "rd_log.h" #include "rd_log.h"
#include "session_helper_shared.h" #include "session_helper_shared.h"
#include "secure_text_input.h"
#include "secure_desktop_dxgi_capture.h" #include "secure_desktop_dxgi_capture.h"
#include "secure_desktop_frame_pacer.h" #include "secure_desktop_frame_pacer.h"
#include "secure_desktop_nv12_cache.h" #include "secure_desktop_nv12_cache.h"
@@ -40,6 +41,7 @@
#include "windows_cursor_state.h" #include "windows_cursor_state.h"
#include "cursor_draw.h" #include "cursor_draw.h"
#include "windows_input_marker.h" #include "windows_input_marker.h"
#include "windows_text_input.h"
#include "windows_key_metadata.h" #include "windows_key_metadata.h"
#include "../windows_thread_dpi.h" #include "../windows_thread_dpi.h"
@@ -948,6 +950,31 @@ InputInjectionResult InjectKeyboardInput(
return BuildInputSuccess(std::move(desktop_switch)); return BuildInputSuccess(std::move(desktop_switch));
} }
InputInjectionResult InjectTextInput(
const crossdesk::SecureTextInputRequest& request) {
std::vector<INPUT> inputs;
if (!crossdesk::BuildWindowsTextInputs(request.text, inputs)) {
return BuildInputFailure("invalid_text_input", ERROR_INVALID_PARAMETER,
BuildDesktopSwitchDetails(request.stage,
request.desktop));
}
ScopedInteractiveDesktop desktop;
DesktopSwitchDetails desktop_switch;
if (!EnsureThreadInteractiveDesktopForStage(request.stage, request.desktop,
&desktop, &desktop_switch)) {
return BuildInputFailure("switch_interactive_desktop_failed", GetLastError(),
desktop_switch);
}
const UINT sent = crossdesk::SendWindowsTextInputs(
inputs, [](INPUT* batch, UINT count) {
return SendInput(count, batch, sizeof(INPUT));
});
if (sent != inputs.size()) {
return BuildInputFailure("send_input_failed", GetLastError(), desktop_switch);
}
return BuildInputSuccess(std::move(desktop_switch));
}
void ParseInteractionTail(const std::string& tail, void ParseInteractionTail(const std::string& tail,
std::string* interactive_stage_out, std::string* interactive_stage_out,
std::string* interactive_desktop_out) { std::string* interactive_desktop_out) {
@@ -1833,6 +1860,21 @@ std::vector<uint8_t> StartSecureDesktopSharedCapture(
std::vector<uint8_t> HandleSecureInputHelperCommand( std::vector<uint8_t> HandleSecureInputHelperCommand(
const std::string& command, const std::string& command,
SecureSharedCaptureState* capture_state) { SecureSharedCaptureState* capture_state) {
if (command.rfind(crossdesk::kSecureTextInputCommandPrefix, 0) == 0) {
crossdesk::SecureTextInputRequest request;
if (!crossdesk::ParseSecureTextInputCommand(command, request))
return BuildTextResponseBytes(BuildErrorJson("invalid_text_input"));
const auto result = InjectTextInput(request);
if (!result.ok) {
// Text may contain a login password. Never echo it in logs or replies.
LOG_WARN("Secure input helper text injection failed: error={}, code={}, "
"stage={}, target={}, current={}",
result.error, result.error_code, result.desktop.stage,
result.desktop.target_desktop, result.desktop.current_desktop);
return BuildTextResponseBytes(BuildInputFailureJson(result).dump());
}
return BuildTextResponseBytes("{\"ok\":true,\"injected\":\"text\"}");
}
if (command == "ping") { if (command == "ping") {
return BuildTextResponseBytes("{\"ok\":true,\"reply\":\"pong\"}"); return BuildTextResponseBytes("{\"ok\":true,\"reply\":\"pong\"}");
} }
@@ -1922,7 +1964,7 @@ std::vector<uint8_t> HandleSecureInputHelperCommand(
void HandleSecureInputHelperPipeClient( void HandleSecureInputHelperPipeClient(
HANDLE pipe, HANDLE event_handle, HANDLE pipe, HANDLE event_handle,
std::shared_ptr<SecureSharedCaptureState> capture_state) { std::shared_ptr<SecureSharedCaptureState> capture_state) {
char buffer[1024] = {0}; char buffer[crossdesk::kSecureInputCommandBufferBytes] = {0};
DWORD bytes_read = 0; DWORD bytes_read = 0;
DWORD error = ERROR_SUCCESS; DWORD error = ERROR_SUCCESS;
if (crossdesk::pipe_deadline_detail::Transfer( if (crossdesk::pipe_deadline_detail::Transfer(
@@ -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()
+10 -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",
@@ -659,6 +664,7 @@ function setup_targets()
target("crossdesk_service") target("crossdesk_service")
set_kind("binary") set_kind("binary")
add_packages("nlohmann_json") add_packages("nlohmann_json")
add_includedirs("libs/wire/include")
add_deps("rd_log", "path_manager", "windows_unattended_config") add_deps("rd_log", "path_manager", "windows_unattended_config")
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv") add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
add_files("apps/desktop/src/platform/windows/service/main.cpp", add_files("apps/desktop/src/platform/windows/service/main.cpp",
+1
View File
@@ -26,6 +26,7 @@ apps/harmony/.native/mobile-project/entry/build/default/outputs/default/entry-de
| Android 功能 | 鸿蒙实现 | | Android 功能 | 鸿蒙实现 |
| --- | --- | | --- | --- |
| 前台自动登录、空闲后台停止、服务器隔离 | 独立 identity peer;只有主动远控才创建 controller peer;拒绝任何入站被控连接 | | 前台自动登录、空闲后台停止、服务器隔离 | 独立 identity peer;只有主动远控才创建 controller peer;拒绝任何入站被控连接 |
| 电脑隐私屏 | 悬浮菜单直接开关,无详情页;按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠;5 秒状态失效、15 秒指令超时与跨会话隔离 |
| 首次隐私确认、撤回与离线阅读 | 同意前不发起身份、公告、在线状态或版本网络请求;撤回结束会话并清除预览 | | 首次隐私确认、撤回与离线阅读 | 同意前不发起身份、公告、在线状态或版本网络请求;撤回结束会话并清除预览 |
| 加密身份、可选记住访问密码 | 系统 Asset Store,设备首次解锁后可读,禁止跨设备同步;普通 Preferences 不存密码 | | 加密身份、可选记住访问密码 | 系统 Asset Store,设备首次解锁后可读,禁止跨设备同步;普通 Preferences 不存密码 |
| 最近连接、删除、在线状态 | 最近 20 台、在线优先、真实信令订阅、30 秒刷新和 60 秒过期;删除同步清理密码与预览 | | 最近连接、删除、在线状态 | 最近 20 台、在线优先、真实信令订阅、30 秒刷新和 60 秒过期;删除同步清理密码与预览 |
@@ -1,3 +1,4 @@
import { RemotePrivacyScreen, PrivacyStatus } from './RemotePrivacyScreen';
import bridge from 'libcrossdesk.so'; import bridge from 'libcrossdesk.so';
import { common, bundleManager } from '@kit.AbilityKit'; import { common, bundleManager } from '@kit.AbilityKit';
import { display, promptAction } from '@kit.ArkUI'; import { display, promptAction } from '@kit.ArkUI';
@@ -13,7 +14,7 @@ interface Command {
logs?: string; certificates?: string; directory?: string; previewDirectory?: string; remote?: string; password?: string; version?: string; logs?: string; certificates?: string; directory?: string; previewDirectory?: string; remote?: string; password?: string; version?: string;
id?: string; x?: number; y?: number; flag?: number; scroll?: number; code?: number; down?: boolean; id?: string; x?: number; y?: number; flag?: number; scroll?: number; code?: number; down?: boolean;
index?: number; muted?: boolean; visible?: boolean; text?: string; message?: Object; quality?: number; frameRate?: number; index?: number; muted?: boolean; visible?: boolean; text?: string; message?: Object; quality?: number; frameRate?: number;
preference?: number; request?: number; path?: string; name?: string; body?: string; local?: boolean; preference?: number; blockLocalInput?: boolean; request?: number; path?: string; name?: string; body?: string; local?: boolean;
} }
interface NativeEvent { interface NativeEvent {
type: string; session: number; connection?: number; status?: number; controller?: boolean; generation?: number; type: string; session: number; connection?: number; status?: number; controller?: boolean; generation?: number;
@@ -25,7 +26,7 @@ export interface RemoteDisplay { name: string; }
interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; } interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; }
interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; } interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; }
interface ServiceStatus { available: boolean; } interface ServiceStatus { available: boolean; }
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; } interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; }
interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; } interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; }
interface SignalType { type: string; } interface SignalType { type: string; }
@@ -73,6 +74,12 @@ export class MobileState {
videoSelectedPreference: number = 1; videoSelectedPreference: number = 1;
videoSupported: boolean = false; videoSupported: boolean = false;
videoFeedback: string = ''; videoFeedback: string = '';
privacyStatus: string = '';
privacyButtonTitle: string = '隐私屏';
privacyFeedback: string = '';
privacyEnabled: boolean = false;
privacyActive: boolean = false;
privacyCanToggle: boolean = false;
networkTime: number = 0; networkTime: number = 0;
unread: number = 0; unread: number = 0;
announcements: AnnouncementItem[] = []; announcements: AnnouncementItem[] = [];
@@ -86,6 +93,7 @@ export class MobileController {
store: MobileStore; store: MobileStore;
platform: MobilePlatform; platform: MobilePlatform;
video: VideoSettings = new VideoSettings(); video: VideoSettings = new VideoSettings();
private privacyScreen: RemotePrivacyScreen = new RemotePrivacyScreen();
network: NetworkStatistics = new NetworkStatistics(); network: NetworkStatistics = new NetworkStatistics();
presence: RecentPresence; presence: RecentPresence;
inbox: AnnouncementInbox; inbox: AnnouncementInbox;
@@ -159,7 +167,7 @@ export class MobileController {
this.state.update = ''; this.state.updateStatus = ''; this.state.page = 'home'; this.state.modal = 'consent'; this.state.update = ''; this.state.updateStatus = ''; this.state.page = 'home'; this.state.modal = 'consent';
} }
resume(): void { resume(): void {
this.foreground = true; this.platform.resume(); this.foreground = true; this.platform.resume(); this.syncPrivacy();
if (this.state.consent) { this.ensureSignal(); this.checkLocal(); this.inbox.connect(this.state.signal === 1); } if (this.state.consent) { this.ensureSignal(); this.checkLocal(); this.inbox.connect(this.state.signal === 1); }
else this.state.modal = 'consent'; else this.state.modal = 'consent';
} }
@@ -236,6 +244,7 @@ export class MobileController {
this.network = new NetworkStatistics(); let edge = 1920; this.network = new NetworkStatistics(); let edge = 1920;
try { const screen = display.getDefaultDisplaySync(); edge = Math.max(screen.width, screen.height); } catch {} try { const screen = display.getDefaultDisplaySync(); edge = Math.max(screen.width, screen.height); } catch {}
this.video = new VideoSettings(this.state.videoPreference, edge); this.syncVideo(); this.video = new VideoSettings(this.state.videoPreference, edge); this.syncVideo();
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
this.store.put('lastRemote', this.state.remote); this.state.password = ''; this.store.put('lastRemote', this.state.remote); this.state.password = '';
this.ensureSignal(); this.joinIfReady(); this.ensureSignal(); this.joinIfReady();
} }
@@ -247,6 +256,7 @@ export class MobileController {
end(reason: string): void { end(reason: string): void {
this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect(); this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect();
this.connection++; this.uiEpoch++; this.pendingPassword = ''; this.joinSent = false; this.sendingFile = false; this.connection++; this.uiEpoch++; this.pendingPassword = ''; this.joinSent = false; this.sendingFile = false;
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
this.state.connecting = this.state.ready = false; this.state.page = 'home'; this.state.modal = ''; this.state.password = ''; this.state.connecting = this.state.ready = false; this.state.page = 'home'; this.state.modal = ''; this.state.password = '';
this.state.receivedPath = this.state.receivedName = this.state.remoteClipboard = this.state.clipboardText = ''; this.remoteVersionRequest = 0; this.state.receivedPath = this.state.receivedName = this.state.remoteClipboard = this.state.clipboardText = ''; this.remoteVersionRequest = 0;
if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.settingsTimeout = -1; if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.settingsTimeout = -1;
@@ -339,6 +349,21 @@ export class MobileController {
this.state.documentTitle = title; this.state.documentBack = back; this.state.page = 'document'; this.state.modal = ''; this.state.documentTitle = title; this.state.documentBack = back; this.state.page = 'document'; this.state.modal = '';
} catch { this.toast('无法读取文档'); } } catch { this.toast('无法读取文档'); }
} }
togglePrivacy(): void {
if (!this.state.ready || !this.state.consent) return;
const enable = !this.privacyScreen.active(), request = this.privacyScreen.begin(this.now());
if (request < 0) return;
this.send({ op: 'privacy', flag: enable ? 1 : 2, request: request,
blockLocalInput: true });
this.syncPrivacy();
}
private syncPrivacy(): void {
const now = this.now(); this.privacyScreen.tick(now);
this.state.privacyStatus = this.privacyScreen.status(now); this.state.privacyFeedback = this.privacyScreen.feedback(now);
this.state.privacyButtonTitle = this.privacyScreen.buttonTitle(now);
this.state.privacyEnabled = this.privacyScreen.enabled(now);
this.state.privacyActive = this.privacyScreen.active(); this.state.privacyCanToggle = this.privacyScreen.canToggle(now);
}
private syncVideo(): void { private syncVideo(): void {
this.state.videoQuality = this.video.selection.quality; this.state.videoRate = this.video.selection.frameRate; this.state.videoQuality = this.video.selection.quality; this.state.videoRate = this.video.selection.frameRate;
this.state.videoSelectedPreference = this.video.selection.preference; this.state.videoSupported = this.video.supported; this.state.videoSelectedPreference = this.video.selection.preference; this.state.videoSupported = this.video.supported;
@@ -348,6 +373,7 @@ export class MobileController {
try { const events = JSON.parse(bridge.mobilePoll()) as NativeEvent[]; events.forEach((event: NativeEvent) => this.event(event)); } try { const events = JSON.parse(bridge.mobilePoll()) as NativeEvent[]; events.forEach((event: NativeEvent) => this.event(event)); }
catch { this.state.signalText = '原生通信库不可用,请重新构建并安装'; } catch { this.state.signalText = '原生通信库不可用,请重新构建并安装'; }
if (++this.seconds % 20 === 0) { if (++this.seconds % 20 === 0) {
if (this.state.ready) this.syncPrivacy();
this.state.networkTime = this.now(); this.presence.tick(this.state.networkTime); this.history(); this.state.networkTime = this.now(); this.presence.tick(this.state.networkTime); this.history();
} }
} }
@@ -395,7 +421,8 @@ export class MobileController {
if (e.sending && ((e.progress ?? 0) < 0 || (e.progress ?? 0) >= 1)) this.sendingFile = false; if (e.sending && ((e.progress ?? 0) < 0 || (e.progress ?? 0) >= 1)) this.sendingFile = false;
this.state.transfer = (e.progress ?? 0) < 0 ? `${e.name} 传输失败` : `${e.sending ? '发送' : '接收'} ${e.name} · ${(e.progress ?? 0) >= 1 ? '已完成' : Math.floor((e.progress ?? 0) * 100) + '%'}`; this.state.transfer = (e.progress ?? 0) < 0 ? `${e.name} 传输失败` : `${e.sending ? '发送' : '接收'} ${e.name} · ${(e.progress ?? 0) >= 1 ? '已完成' : Math.floor((e.progress ?? 0) * 100) + '%'}`;
if (e.path) { this.state.receivedPath = e.path; this.state.receivedName = e.name ?? 'received-file'; } if (e.path) { this.state.receivedPath = e.path; this.state.receivedName = e.name ?? 'received-file'; }
} else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); } } else if (e.type === 'privacyFailed') { this.privacyScreen.failed(Number(e.request)); this.syncPrivacy(); }
else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
else if (e.type === 'preview') { else if (e.type === 'preview') {
this.previewPending = false; this.previewPending = false;
if (e.ok && e.path && this.previewEligible && this.state.previews) { if (e.ok && e.path && this.previewEligible && this.state.previews) {
@@ -435,6 +462,7 @@ export class MobileController {
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion); if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
} }
private data(message: ControlData): void { private data(message: ControlData): void {
if (message.type === 10 && message.privacy_status) { this.privacyScreen.receive(message.privacy_status, this.now()); this.syncPrivacy(); return; }
if (message.type === 5) { this.serviceAvailable = message.service_status?.available === true; return; } if (message.type === 5) { this.serviceAvailable = message.service_status?.available === true; return; }
if (message.type === 12 && message.video_settings) { if (message.type === 12 && message.video_settings) {
const v = message.video_settings; const v = message.video_settings;
@@ -133,9 +133,10 @@ export struct MobileRemoteView {
if (index === 7) this.menu = 'network'; if (index === 7) this.menu = 'network';
if (index === 8) this.controller?.sas(); if (index === 8) this.controller?.sas();
if (index === 9) { this.state.modal = 'clipboard'; this.menu = ''; } if (index === 9) { this.state.modal = 'clipboard'; this.menu = ''; }
if (index === 10) this.controller?.togglePrivacy();
} }
private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板']; } private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板', this.state.privacyButtonTitle]; }
private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard']; } private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard', 'lock']; }
private columns(): number { return this.viewHeight < 400 && this.viewWidth >= 400 ? 4 : 3; } private columns(): number { return this.viewHeight < 400 && this.viewWidth >= 400 ? 4 : 3; }
private menuWidth(): number { return Math.max(1, Math.min(this.viewWidth - 16, this.columns() === 4 ? 380 : Math.max(280, Math.min(340, this.viewWidth - 160)))); } private menuWidth(): number { return Math.max(1, Math.min(this.viewWidth - 16, this.columns() === 4 ? 380 : Math.max(280, Math.min(340, this.viewWidth - 160)))); }
private menuX(): number { return clamp(this.orbX > this.viewWidth / 2 ? this.orbX - this.menuWidth() - 12 : this.orbX + 66, 8, this.viewWidth - this.menuWidth() - 8); } private menuX(): number { return clamp(this.orbX > this.viewWidth / 2 ? this.orbX - this.menuWidth() - 12 : this.orbX + 66, 8, this.viewWidth - this.menuWidth() - 8); }
@@ -212,12 +213,20 @@ export struct MobileRemoteView {
GridRow({ columns: this.columns(), gutter: 7 }) { GridRow({ columns: this.columns(), gutter: 7 }) {
ForEach(this.labels(), (label: string, index: number) => { ForEach(this.labels(), (label: string, index: number) => {
GridCol() { GridCol() {
Column({ space: 4 }) { MobileIcon({ name: this.icons()[index], iconExtent: 19 }); Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1) } Column({ space: 4 }) {
.height(52).width('100%').justifyContent(FlexAlign.Center).backgroundColor('#F0F0F2').borderRadius(10) MobileIcon({ name: this.icons()[index], iconExtent: 19 })
.accessibilityText(label).accessibilityLevel('yes').onClick(() => this.action(index)) Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1)
}
.height(52).width('100%').justifyContent(FlexAlign.Center)
.backgroundColor('#F0F0F2').borderRadius(10)
.enabled(index !== 10 || this.state.ready && this.state.privacyCanToggle)
.opacity(index === 10 && (!this.state.ready || !this.state.privacyCanToggle) ? 0.45 : 1)
.accessibilitySelected(index === 10 && this.state.privacyEnabled)
.accessibilityText(index === 10 ? `${label},${this.state.privacyStatus}` : label).accessibilityLevel('yes').onClick(() => this.action(index))
} }
}, (label: string) => label) }, (label: string) => label)
} }
if (this.state.privacyFeedback) { Text(this.state.privacyFeedback).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
if (this.state.transfer) { Text(this.state.transfer).fontSize(11).fontColor(MobileTheme.secondary).width('100%') } if (this.state.transfer) { Text(this.state.transfer).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
if (this.state.receivedPath) { Button('保存收到的文件', { type: ButtonType.Normal }).fontSize(14).fontColor(MobileTheme.blue).backgroundColor(Color.White).height(42).width('100%').onClick(() => this.controller?.saveFile()) } if (this.state.receivedPath) { Button('保存收到的文件', { type: ButtonType.Normal }).fontSize(14).fontColor(MobileTheme.blue).backgroundColor(Color.White).height(42).width('100%').onClick(() => this.controller?.saveFile()) }
} else if (this.menu === 'video') { } else if (this.menu === 'video') {
@@ -0,0 +1,87 @@
export interface PrivacyStatus {
state: number; supported: boolean; input_block_supported: boolean;
overlay_active: boolean; input_blocked: boolean; remote_paused: boolean;
revision: number; reason: string;
}
export class RemotePrivacyScreen {
snapshot?: PrivacyStatus;
pending: boolean = false;
requestId: number = 0;
private requestRevision: number = 0;
private receivedAt: number = 0;
private requestedAt: number = 0;
private failure: string = '';
receive(s: PrivacyStatus, now: number): void {
if (!Number.isInteger(s.state) || s.state < 0 || s.state > 5 ||
!Number.isInteger(s.revision) || s.revision < 0 || s.revision > 0xffffffff ||
typeof s.supported !== 'boolean' || typeof s.input_block_supported !== 'boolean' ||
typeof s.overlay_active !== 'boolean' || typeof s.input_blocked !== 'boolean' ||
typeof s.remote_paused !== 'boolean' || typeof s.reason !== 'string' || s.reason.includes('\0')) return;
if (s.state === 3 && (!s.supported || !s.overlay_active || s.remote_paused)) return;
if (this.snapshot && ((s.revision - this.snapshot.revision) | 0) < 0) return;
this.snapshot = s; this.receivedAt = now; this.failure = '';
if (this.pending && ((s.revision - this.requestRevision) | 0) > 0 && s.state !== 2 && s.state !== 4) this.pending = false;
}
active(): boolean {
const s = this.snapshot;
return s !== undefined && (s.overlay_active || s.remote_paused || s.state === 2 || s.state === 3 || s.state === 4);
}
fresh(now: number): boolean { return this.snapshot !== undefined && now - this.receivedAt <= 5000; }
enabled(now: number): boolean {
const s = this.snapshot;
return s !== undefined && this.fresh(now) && s.state === 3 && s.overlay_active && !s.remote_paused;
}
buttonTitle(now: number): string {
if (this.enabled(now)) return '隐私屏已开启';
const s = this.snapshot;
if (this.fresh(now) && s?.supported && s.state === 1 && !this.active()) return '隐私屏已关闭';
return '隐私屏';
}
feedback(now: number): string {
const s = this.snapshot;
if (!this.failure && (this.pending || this.fresh(now) && (s?.state === 2 || s?.state === 4))) return '';
if (!this.failure && !this.pending && this.fresh(now) && s?.supported &&
(this.enabled(now) || s.state === 1 && !this.active())) return '';
const detail = this.detail(now);
return this.status(now) + (detail ? ':' + detail : '');
}
canToggle(now: number): boolean {
const s = this.snapshot;
return s !== undefined && !this.pending && this.fresh(now) && s.state !== 4 && (this.active() || s.supported && s.state === 1);
}
begin(now: number): number {
this.tick(now);
if (!this.canToggle(now) || !this.snapshot) return -1;
this.pending = true; this.requestedAt = now; this.requestRevision = this.snapshot.revision; this.failure = '';
this.requestId = (this.requestId + 1) >>> 0;
return this.requestId;
}
failed(id: number): void {
if (this.pending && this.requestId === id) { this.pending = false; this.failure = '指令发送失败,请重试'; }
}
tick(now: number): void {
if (this.pending && now - this.requestedAt >= 15000) {
this.pending = false; this.failure = '请求超时,尚未确认电脑的隐私屏状态';
}
}
status(now: number): string {
if (this.failure) return this.failure;
const s = this.snapshot;
if (!s) return '等待电脑返回隐私屏状态;旧版电脑端可能不支持';
if (!this.fresh(now)) return '隐私屏状态已过期,等待电脑确认';
if (s.state === 2) return '正在开启隐私屏…';
if (s.state === 4) return '正在关闭隐私屏…';
if (this.pending) return '指令已发送,等待电脑确认…';
if (s.state === 5) return '隐私屏异常';
if (!s.supported || s.state === 0) return '隐私屏暂不可用';
if (s.state === 3 && s.overlay_active && !s.remote_paused)
return '隐私屏已开启';
return s.state === 1 && !this.active() ? '隐私屏已关闭' : '隐私屏状态待确认';
}
detail(now: number): string {
const s = this.snapshot;
return s && this.fresh(now) && (s.state === 0 || s.state === 5 || !s.supported) ? s.reason : '';
}
}
@@ -149,7 +149,7 @@ struct Mobile {
.padding({ left: 14, right: 14 }).onFocus(() => this.idFocused = true).onBlur(() => this.idFocused = false) .padding({ left: 14, right: 14 }).onFocus(() => this.idFocused = true).onBlur(() => this.idFocused = false)
.onChange((value: string) => this.state.remote = this.deviceId(value)).onSubmit(() => this.controller?.requestConnection(this.state.remote)) .onChange((value: string) => this.state.remote = this.deviceId(value)).onSubmit(() => this.controller?.requestConnection(this.state.remote))
Column() { Column() {
MobilePrimaryButton({ label: '连接', arrow: true, buttonHeight: 54, available: this.state.remote.length > 0, MobilePrimaryButton({ label: '连接', buttonHeight: 54, available: this.state.remote.length > 0,
action: () => this.controller?.requestConnection(this.state.remote) }) action: () => this.controller?.requestConnection(this.state.remote) })
}.width(96) }.width(96)
}.width('100%') }.width('100%')
@@ -486,7 +486,7 @@ struct Mobile {
Toggle({ type: ToggleType.Switch, isOn: this.state.remember }).selectedColor(MobileTheme.green) Toggle({ type: ToggleType.Switch, isOn: this.state.remember }).selectedColor(MobileTheme.green)
.onChange((value: boolean) => this.state.remember = value).accessibilityText('保存密码') .onChange((value: boolean) => this.state.remember = value).accessibilityText('保存密码')
}.width('100%').margin({ top: 18, bottom: 18 }) }.width('100%').margin({ top: 18, bottom: 18 })
MobilePrimaryButton({ label: '连接', arrow: true, available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) }) MobilePrimaryButton({ label: '连接', available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) })
}.padding({ left: 22, right: 22, top: 10, bottom: 22 }).width('100%') }.padding({ left: 22, right: 22, top: 10, bottom: 22 }).width('100%')
}.width(this.viewWidth < 600 ? '100%' : Math.min(520, this.viewWidth - 48)).constraintSize({ maxHeight: this.viewHeight * 0.92 }) }.width(this.viewWidth < 600 ? '100%' : Math.min(520, this.viewWidth - 48)).constraintSize({ maxHeight: this.viewHeight * 0.92 })
.backgroundColor(MobileTheme.background).borderRadius(24).scrollBar(BarState.Off) .backgroundColor(MobileTheme.background).borderRadius(24).scrollBar(BarState.Off)
+7 -1
View File
@@ -143,7 +143,7 @@ class MobileSession {
s->Event({{"type", "connection"}, {"status", status}}, true); s->Event({{"type", "connection"}, {"status", status}}, true);
if (status == Connected && !s->connected_) { if (status == Connected && !s->connected_) {
s->connecting_ = false; s->connected_ = true; s->connecting_ = false; s->connected_ = true;
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, false, s->version_, HostPlatform::HarmonyOS); auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, true, s->version_, HostPlatform::HarmonyOS);
s->Send(info); FreeRemoteAction(info); s->Send(info); FreeRemoteAction(info);
RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio); RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio);
s->audio_.Start(); s->audio_.Start();
@@ -328,6 +328,12 @@ class MobileSession {
auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>()); auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>());
if(SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0) if(SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0)
Event({{"type","settingsFailed"},{"request",v.at("request")}},true); Event({{"type","settingsFailed"},{"request",v.at("request")}},true);
} else if (op=="privacy") {
const auto bytes = mobile_controller::PrivacyMessage(
v.at("flag").get<int>(), v.at("blockLocalInput").get<bool>());
if (bytes.empty() ||
SendReliableDataFrame(controller_, bytes.data(), bytes.size(), kControlStream) != 0)
Event({{"type", "privacyFailed"}, {"request", v.at("request")}}, true);
} else if (op=="sas") { } else if (op=="sas") {
const auto bytes=mobile_controller::SecureAttentionMessage(); const auto bytes=mobile_controller::SecureAttentionMessage();
const bool ok=SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)==0; const bool ok=SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)==0;
+4 -1
View File
@@ -61,11 +61,14 @@ def bootstrap(env):
seed = Path.home() / '.xmake/repositories/xmake-repo' seed = Path.home() / '.xmake/repositories/xmake-repo'
source = seed if (seed / '.git').exists() else 'https://github.com/xmake-io/xmake-repo.git' source = seed if (seed / '.git').exists() else 'https://github.com/xmake-io/xmake-repo.git'
run(['git', 'clone', '--no-hardlinks', source, repository], env=env) run(['git', 'clone', '--no-hardlinks', source, repository], env=env)
run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env) run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env)
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip() revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip()
if revision != PACKAGE_REVISION: if revision != PACKAGE_REVISION:
run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, env=env) run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, env=env)
run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, env=env) run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, env=env)
# Mark this pinned checkout as ready so Xmake's first-run repository update
# cannot replace it with a mirror or advance it beyond PACKAGE_REVISION.
(repository / 'updated').touch()
def mobile_project(): def mobile_project():
@@ -7,6 +7,7 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F20000000000000000001 /* RemotePrivacyScreen.swift */; };
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; }; 100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; }; 10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; }; 10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
@@ -37,6 +38,7 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
C10F20000000000000000001 /* RemotePrivacyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePrivacyScreen.swift; sourceTree = "<group>"; };
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; }; 200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; }; 20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; }; 20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
@@ -118,6 +120,7 @@
20000000000000000000001C /* AppUpdateChecker.swift */, 20000000000000000000001C /* AppUpdateChecker.swift */,
200000000000000000000018 /* RecentConnection.swift */, 200000000000000000000018 /* RecentConnection.swift */,
200000000000000000000016 /* RemoteVideoSettings.swift */, 200000000000000000000016 /* RemoteVideoSettings.swift */,
C10F20000000000000000001 /* RemotePrivacyScreen.swift */,
200000000000000000000017 /* RemoteNetworkStatistics.swift */, 200000000000000000000017 /* RemoteNetworkStatistics.swift */,
200000000000000000000003 /* ContentView.swift */, 200000000000000000000003 /* ContentView.swift */,
20000000000000000000000E /* ThirdPartyLicensesView.swift */, 20000000000000000000000E /* ThirdPartyLicensesView.swift */,
@@ -274,7 +277,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 */
@@ -291,6 +294,7 @@
10000000000000000000001C /* AppUpdateChecker.swift in Sources */, 10000000000000000000001C /* AppUpdateChecker.swift in Sources */,
100000000000000000000018 /* RecentConnection.swift in Sources */, 100000000000000000000018 /* RecentConnection.swift in Sources */,
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */, 100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */,
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */, 100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
100000000000000000000003 /* ContentView.swift in Sources */, 100000000000000000000003 /* ContentView.swift in Sources */,
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */, 100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */,

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