mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-27 08:12:18 +08:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ea4eb8fc | ||
|
|
38d0ce7728 | ||
|
|
778c3575cd | ||
|
|
dad8384666 | ||
|
|
9496bc8f1c | ||
|
|
e551e04e8f | ||
|
|
e3b3f78b06 | ||
|
|
5db213d126 | ||
|
|
bdd04603c7 | ||
|
|
f3d8689b55 | ||
|
|
0fb4b031c7 | ||
|
|
7fc21f01bc | ||
|
|
86bae6e1f4 | ||
|
|
c03c8a63d4 | ||
|
|
ec9ac6a7cd | ||
|
|
2a0b14035d | ||
|
|
652378afa8 | ||
|
|
0cbdbb0c96 | ||
|
|
63bc5648c0 | ||
|
|
859aaa29d4 | ||
|
|
cc48eb651f | ||
|
|
ae6f81e83a | ||
|
|
2445f79f92 | ||
|
|
bd7f9505d9 | ||
|
|
4650e48ea3 | ||
|
|
182899d238 | ||
|
|
1d465239cf | ||
|
|
80ea75ff78 | ||
|
|
ff7b954949 | ||
|
|
0fbefd309f | ||
|
|
f269c56e5b | ||
|
|
c6a2f6a35b | ||
|
|
193dcc4be0 | ||
|
|
f8f4aa3cea | ||
|
|
c25e7266e5 | ||
|
|
5e494bfa3b | ||
|
|
f6a2637540 | ||
|
|
621f2ff205 | ||
|
|
5ac6e153c8 | ||
|
|
dd5c572852 |
@@ -7,124 +7,32 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请先查看 [常见问题 / FAQ](https://github.com/kunkundi/crossdesk/blob/deskhub/docs/FAQ.md) 并搜索 [已有问题 / Existing issues](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
|
||||
Please check the FAQ and existing issues first. You can write in Chinese or English.
|
||||
只需描述问题,其余选填。中文或英文均可。
|
||||
Only a problem description is required. Chinese or English is welcome.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 问题与预期 / Problem and expected behavior
|
||||
description: 描述实际发生的情况、错误提示,以及期望的行为。Describe what happened, any error messages, and what you expected.
|
||||
placeholder: |
|
||||
实际结果 / Actual behavior:
|
||||
预期结果 / Expected behavior:
|
||||
label: 问题描述 / Problem description
|
||||
description: 遇到了什么问题?如方便,也请说明触发操作或错误提示。What went wrong? If possible, include what you were doing or any error message.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: 复现步骤 / Steps to reproduce
|
||||
description: 说明操作步骤和出现频率;偶发问题可描述发生前的操作。Include the steps and frequency. For intermittent issues, describe what you were doing.
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
出现频率 / Frequency:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: clients
|
||||
attributes:
|
||||
label: 涉及客户端 / Affected clients
|
||||
description: 可多选;Web 与原生移动端请分别选择。Select all that apply; distinguish Web from native mobile apps.
|
||||
multiple: true
|
||||
options:
|
||||
- 桌面客户端(Windows / macOS / Linux) / Desktop client
|
||||
- iOS / iPadOS(原生 / Native)
|
||||
- Android(原生 / Native)
|
||||
- 鸿蒙 PC / HarmonyOS PC
|
||||
- 鸿蒙手机或平板 / HarmonyOS mobile
|
||||
- Web 客户端 / Web client
|
||||
- 服务端 / Server
|
||||
- 其他 / Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: controller
|
||||
attributes:
|
||||
label: 控制端或本机环境 / Controller or local environment
|
||||
description: |
|
||||
控制端是发起远程操作的设备。请填写完整系统和客户端版本;Web 用户另填浏览器及其版本。自行构建请附提交号。
|
||||
仅涉及安装、启动或服务端时,在此填写发生问题的本机环境。
|
||||
The controller initiates remote access. Include full OS and client versions; Web users should also include the browser and its version. For source builds, include the commit.
|
||||
For installation, startup, or server-only issues, describe the affected local environment here.
|
||||
placeholder: |
|
||||
操作系统及版本 / OS and version:
|
||||
CPU 架构(x64 / arm64 等) / CPU architecture:
|
||||
CrossDesk 版本或提交号 / CrossDesk version or commit:
|
||||
浏览器及版本(仅 Web) / Browser and version (Web only):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: host
|
||||
attributes:
|
||||
label: 被控端环境 / Remote host environment
|
||||
description: |
|
||||
被控端是接受远程操作的电脑。连接或会话问题请补充这一端的信息;不涉及远程设备时可留空。
|
||||
The host is the computer being controlled. Include its details for connection or session issues; leave empty if no remote device is involved.
|
||||
placeholder: |
|
||||
操作系统及版本 / OS and version:
|
||||
CPU 架构(x64 / arm64 等) / CPU architecture:
|
||||
CrossDesk 版本或提交号 / CrossDesk version or commit:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: service
|
||||
attributes:
|
||||
label: 服务部署方式 / Server deployment
|
||||
options:
|
||||
- 公共服务 / Public service
|
||||
- 自托管 / Self-hosted
|
||||
- 不确定 / Unknown
|
||||
- 不适用 / Not applicable
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: connection
|
||||
attributes:
|
||||
label: 连接方式 / Connection type
|
||||
description: 可在会话的网络状态中查看;无法确认时选择“不确定”。Check the session's network status, or choose Unknown.
|
||||
options:
|
||||
- P2P 直连 / Direct P2P
|
||||
- TURN 中继 / TURN relay
|
||||
- 尚未建立连接 / Connection not established
|
||||
- 不确定 / Unknown
|
||||
- 不适用 / Not applicable
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: 补充环境 / Additional environment
|
||||
description: |
|
||||
按问题补充相关信息,例如普通会话或桌面墙、自托管服务端版本、局域网或公网、VPN;画面问题可补充显卡、编码格式、硬件编解码设置和显示器情况。
|
||||
Include relevant details such as a normal session or DeskHub, self-hosted server version, LAN or Internet, and VPN use. For video issues, add GPU, codec, hardware codec settings, and display configuration.
|
||||
label: 使用环境(选填) / Environment (optional)
|
||||
description: 操作系统和 CrossDesk 版本;连接问题可填写两端信息。OS and CrossDesk version; include both devices for connection issues.
|
||||
placeholder: Windows 11 → macOS 15; CrossDesk x.y.z
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: attachments
|
||||
attributes:
|
||||
label: 日志与截图 / Logs and screenshots
|
||||
label: 截图或日志(选填) / Screenshots or logs (optional)
|
||||
description: |
|
||||
可粘贴相关日志,或拖入日志文件、截图和录屏。上传前请隐去连接密码等敏感信息。
|
||||
Paste relevant logs or attach log files, screenshots, and recordings. Redact connection passwords and other private information before uploading.
|
||||
可直接拖入截图、录屏或日志文件,请隐去密码等敏感信息。
|
||||
Drag in screenshots, recordings, or logs. Hide passwords and other sensitive information.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -7,66 +7,21 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
提交前请搜索 [已有建议 / Existing requests](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
|
||||
Please search existing requests before submitting. You can write in Chinese or English.
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: 使用场景与问题 / Use case and problem
|
||||
description: 你希望完成什么?目前遇到什么限制?What are you trying to do, and what limitation do you encounter?
|
||||
validations:
|
||||
required: true
|
||||
只需描述你的建议,其余选填。中文或英文均可。
|
||||
Only a description of your idea is required. Chinese or English is welcome.
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: 功能建议与预期效果 / Proposal and expected outcome
|
||||
description: 描述希望新增或改进的行为,以及理想的使用方式。Describe the behavior you would like to add or improve and how you expect to use it.
|
||||
label: 你的建议 / Your idea
|
||||
description: 希望新增或改进什么?如方便,也请说明使用场景。What would you like to add or improve? If possible, describe when you would use it.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: 适用范围 / Scope
|
||||
description: 可多选;通用建议可选择“跨平台或通用”。Select all that apply, or choose Cross-platform or general.
|
||||
multiple: true
|
||||
options:
|
||||
- 跨平台或通用 / Cross-platform or general
|
||||
- 桌面客户端(Windows / macOS / Linux) / Desktop client
|
||||
- iOS / iPadOS
|
||||
- Android
|
||||
- 鸿蒙 PC / HarmonyOS PC
|
||||
- 鸿蒙手机或平板 / HarmonyOS mobile
|
||||
- Web 客户端 / Web client
|
||||
- 桌面墙 / DeskHub
|
||||
- 自托管服务 / Self-hosted services
|
||||
- 文档或安装体验 / Documentation or installation
|
||||
- 其他 / Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: 影响范围与使用频率 / Impact and frequency
|
||||
description: 哪些用户或场景会受益?你会多频繁地使用?Who would benefit, in which situations, and how often would you use this?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 替代方案 / Alternatives
|
||||
description: 目前如何处理这个需求?是否考虑过其他实现方式?How do you handle this today, and what other approaches have you considered?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: 参考资料与补充说明 / References and additional context
|
||||
description: 可提供参考链接、截图、示意图或其他要求。Include links, screenshots, mockups, or any additional requirements.
|
||||
label: 补充说明(选填) / Additional context (optional)
|
||||
description: 可附截图、参考链接或其他说明。Add screenshots, links, or any other details.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -36,7 +36,6 @@ on:
|
||||
- "apps/android/**"
|
||||
- "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"
|
||||
- "apps/ios/licenses/**"
|
||||
- "apps/ios/scripts/generate_third_party_licenses.py"
|
||||
- "deps/submodules/minirtc"
|
||||
- "libs/wire/**"
|
||||
|
||||
@@ -155,9 +154,6 @@ jobs:
|
||||
cache-provider: basic
|
||||
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
|
||||
shell: python
|
||||
env:
|
||||
|
||||
@@ -169,9 +169,6 @@ jobs:
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Verify iOS license catalog
|
||||
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
|
||||
|
||||
- name: Install xmake
|
||||
uses: xmake-io/github-action-setup-xmake@v1
|
||||
with:
|
||||
@@ -181,7 +178,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
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"
|
||||
|
||||
- name: Pin xmake package repository
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
CrossDesk
|
||||
SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
|
||||
<h1>CrossDesk</h1>
|
||||
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk 标志:图标在左,双色文字在右" />
|
||||
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
|
||||
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
|
||||
</div>
|
||||
@@ -22,13 +21,14 @@
|
||||
|
||||
CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
|
||||
|
||||
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
|
||||
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [IP 直连](#lan-direct) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## 主要功能
|
||||
|
||||
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
|
||||
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
|
||||
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
|
||||
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
|
||||
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
|
||||
@@ -61,22 +61,42 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
|
||||
|
||||
## 快速连接
|
||||
|
||||
1. **准备被控电脑。** 安装并运行 CrossDesk,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
|
||||
### 通过设备 ID 连接
|
||||
|
||||
1. **准备被控电脑。** 安装并运行 CrossDesk,切换至 ID 连接模式,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
|
||||
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
|
||||
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
|
||||
|
||||
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
|
||||
|
||||
<a id="lan-direct"></a>
|
||||
|
||||
### 通过局域网 IP 直连
|
||||
|
||||
1. **切换模式。** 两台电脑均运行支持 IP 直连的桌面客户端,点击左上角模式按钮切换到 **IP 直连模式**。若按钮未显示,在 **设置 → 常规 → 网络连接** 中勾选 **启用 IP 直连** 并保存。
|
||||
2. **填写地址。** 在控制端输入被控电脑显示的 `IP:端口`,例如 `192.168.1.20:52888`,点击“连接”。只输入 IP 时使用默认端口 **52888**;请以对方界面显示的实际端口为准。
|
||||
3. **输入密码。** 输入被控电脑在 IP 直连模式下显示的 **6 位英文字母或数字密码**,可勾选“记住密码”。切换模式时,“近期连接”同步切换为对应模式的设备记录。
|
||||
|
||||
两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改各 IP 的端口和密码;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。
|
||||
|
||||
<a id="preview"></a>
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 桌面客户端
|
||||
|
||||
本机凭据、远程连接和近期设备集中在主窗口,左上角进入桌面墙。
|
||||
本机凭据、远程连接和近期设备集中在主窗口,左上角提供连接模式切换和桌面墙入口。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk 主窗口:本机凭据、远程连接入口和近期设备;数据为演示" />
|
||||
<img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk ID 连接模式:模式切换、本机 ID、远程连接入口和近期设备;数据为演示" />
|
||||
</p>
|
||||
|
||||
### IP 直连模式
|
||||
|
||||
切换本机 IP、修改端口与密码,通过对方地址连接;下方展示独立的局域网近期设备。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-lan-zh.png" width="800" alt="CrossDesk IP 直连模式:本机 IP 和端口编辑入口、六位密码、远端地址输入和局域网近期设备;数据为演示" />
|
||||
</p>
|
||||
|
||||
<a id="deskhub"></a>
|
||||
@@ -96,7 +116,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
|
||||
|
||||
## 会话操作与设置
|
||||
|
||||
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置编码、中继、自托管与桌面墙画面预算。
|
||||
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。
|
||||
|
||||
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
|
||||
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
|
||||
@@ -151,9 +171,12 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
|
||||
|
||||
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
|
||||
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
|
||||
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
|
||||
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
|
||||
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
|
||||
|
||||
桌面端的 `CROSSDESK_UPDATE_PUBLIC_KEY` 是可选编译参数,用于验证应用内升级清单的 Ed25519 签名。不配置仍可正常编译、运行和检查版本,但更新只能手动下载;需要应用内自动安装更新时,须配置与发布私钥匹配的公钥。配置方式见 [更新公钥](docs/BUILD.md#update-public-key),密钥生成与发布流程见 [桌面端应用内升级](docs/UPDATER.md)。
|
||||
|
||||
<a id="sponsor"></a>
|
||||
|
||||
## 支持 CrossDesk
|
||||
|
||||
+30
-7
@@ -1,6 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
|
||||
<h1>CrossDesk</h1>
|
||||
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk logo with the icon on the left and a two-tone wordmark on the right" />
|
||||
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
|
||||
<p><a 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>
|
||||
@@ -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.
|
||||
|
||||
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
|
||||
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Direct IP](#lan-direct) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## Features
|
||||
|
||||
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
|
||||
- **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode.
|
||||
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution.
|
||||
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
|
||||
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
|
||||
@@ -61,22 +61,42 @@ Requirements follow the current source and build configuration. Check the releas
|
||||
|
||||
## Quick start
|
||||
|
||||
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the server-connected status, then find its ID and password under **Local Desktop**.
|
||||
### Connect by device ID
|
||||
|
||||
1. **Prepare the host.** Install and run CrossDesk on the computer to control, select ID mode, and wait for the server-connected status, then find its ID and password under **Local Desktop**.
|
||||
2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect.
|
||||
3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
|
||||
|
||||
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems.
|
||||
|
||||
<a id="lan-direct"></a>
|
||||
|
||||
### Connect directly over LAN
|
||||
|
||||
1. **Switch modes.** Run a desktop client with direct IP support on both computers. Click the top-left mode button to select **Direct IP mode**. If the button is hidden, select **Enable direct IP** under **Settings → General → Network** and save.
|
||||
2. **Enter the address.** On the controller, enter the host's displayed `IP:port`, for example `192.168.1.20:52888`, and click **Connect**. Omitting the port uses **52888**; use the actual port shown on the host.
|
||||
3. **Enter the password.** Use the host's **six-character ASCII letter/digit password** shown in Direct IP mode. You can select **Remember password**. Switching modes also switches the recent device list.
|
||||
|
||||
Both computers must be reachable over the LAN. Internet access may be available or absent; direct sessions do not depend on a cloud signaling service. The main window lets you select the local IP, edit its port, and change the password. Disabling **Enable direct IP** stops listeners, disconnects direct IP sessions, and hides the mode button. See the [direct IP guide](docs/USER_GUIDE_EN.md#lan-direct).
|
||||
|
||||
<a id="preview"></a>
|
||||
|
||||
## Preview
|
||||
|
||||
### Desktop client
|
||||
|
||||
Local credentials, remote connections, and recent devices share the main window. Open DeskHub from the top-left corner.
|
||||
Local credentials, remote connections, and recent devices share the main window. The top-left controls switch connection modes and open DeskHub.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-main-en.png" width="800" alt="CrossDesk main window with local credentials, remote connection controls, and recent devices; demonstration data" />
|
||||
<img src="docs/images/desktop-main-en.png" width="800" alt="CrossDesk ID mode with the mode switch, local ID, remote connection controls, and recent devices; demonstration data" />
|
||||
</p>
|
||||
|
||||
### Direct IP mode
|
||||
|
||||
Select the local IP, edit its port and password, and connect using the other desktop's address. Recent LAN devices have their own list below.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-lan-en.png" width="800" alt="CrossDesk Direct IP mode with local IP and port editing, a six-character password, remote address input, and recent LAN devices; demonstration data" />
|
||||
</p>
|
||||
|
||||
<a id="deskhub"></a>
|
||||
@@ -96,7 +116,7 @@ Group control sends input to all connected devices; closing DeskHub disconnects
|
||||
|
||||
## Session controls and settings
|
||||
|
||||
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure codecs, relay, self-hosting, and the DeskHub video budget.
|
||||
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure direct IP availability, codecs, relay, self-hosting, and the DeskHub video budget.
|
||||
|
||||
- [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens.
|
||||
- [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
|
||||
@@ -151,9 +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.
|
||||
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
|
||||
- [Connection architecture](docs/connection-architecture.md) (Chinese): discovery, authentication, negotiation, data paths, and lifecycle for ID and direct IP modes.
|
||||
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
|
||||
- [Report an issue or suggestion](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
|
||||
|
||||
The optional desktop build parameter `CROSSDESK_UPDATE_PUBLIC_KEY` verifies Ed25519 signatures on in-app update manifests. Without it, the app still builds, runs, and checks for new versions, but updates require manual download. In-app update installation requires a public key matching the release signing private key. See [Update public key](docs/BUILD_EN.md#update-public-key) for configuration and the [desktop updater guide](docs/UPDATER.md) (Chinese) for key generation and publishing.
|
||||
|
||||
<a id="sponsor"></a>
|
||||
|
||||
## Support CrossDesk
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。
|
||||
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。
|
||||
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
|
||||
- “设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。手机进入会话后先保持横屏,检测到手机实际横过来后才恢复跟随设备方向;竖持或平放不会立即切回竖屏。关闭后沿用自动旋转,平板与多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向。
|
||||
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
|
||||
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
|
||||
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
|
||||
@@ -245,4 +246,4 @@ AV1 硬件路径尚无对应实机验证;Retroid 上使用 dav1d 软件解码
|
||||
|
||||
设置 → 关于使用与 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 文件。
|
||||
|
||||
@@ -19,6 +19,7 @@ final class BackgroundSession implements NativeSession.Listener {
|
||||
RemoteVideoSettings videoSettings;
|
||||
int signalState, selectedDisplay, width, height;
|
||||
boolean muted, remoteServiceAvailable, background, ended;
|
||||
boolean waitingForLandscape;
|
||||
File receivedFile;
|
||||
|
||||
BackgroundSession(Context context, NativeSession session, String host, int port, String remote) {
|
||||
|
||||
@@ -72,6 +72,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private int videoWidth,videoHeight;
|
||||
private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture();
|
||||
private RemoteVideoView video;
|
||||
private RemoteOrientation remoteOrientation;
|
||||
private SessionControls controls;
|
||||
private Dialog passwordDialog, progressDialog, privacyDialog;
|
||||
private AlertDialog disconnectDialog;
|
||||
@@ -95,7 +96,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private Runnable backAction;
|
||||
|
||||
@Override public void onCreate(Bundle state){
|
||||
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);
|
||||
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);remoteOrientation=new RemoteOrientation(this);
|
||||
pageHost=new PageHost(this);setContentView(pageHost);
|
||||
preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099);
|
||||
recentPresence.sender=ids->{if(signaling!=null&&!signaling.isClosed())signaling.presenceRequest(ids);};
|
||||
@@ -178,14 +179,18 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item);
|
||||
return item;
|
||||
}
|
||||
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
|
||||
private void updateHomeOrientation(){
|
||||
private boolean isTablet(){
|
||||
boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600;
|
||||
if(android.os.Build.VERSION.SDK_INT>=30){
|
||||
android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds();
|
||||
tablet|=Math.min(bounds.width(),bounds.height())>=dp(600);
|
||||
}
|
||||
int orientation=tablet||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
|
||||
return tablet;
|
||||
}
|
||||
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
|
||||
private void updateHomeOrientation(){
|
||||
remoteOrientation.end();
|
||||
int orientation=isTablet()||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
|
||||
if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation);
|
||||
}
|
||||
private void home(String message){
|
||||
@@ -321,8 +326,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
ProgressBar spinner=new ProgressBar(this);spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));body.addView(spinner,ui.size(34,34));ui.gap(body,14);TextView title=text("正在连接",20,true);title.setGravity(Gravity.CENTER);body.addView(title);ui.gap(body,6);status=text("正在连接信令服务器…",14,false);status.setGravity(Gravity.CENTER);status.setTextColor(SECONDARY);status.setMinHeight(dp(36));body.addView(status);card.addView(body,ui.size(-1,-2));ui.divider(card);card.addView(ui.action("取消连接",RED,()->end("")),ui.size(-1,46));
|
||||
dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2);
|
||||
}
|
||||
private void sessionScreen(){
|
||||
installRoot("session",true);setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
|
||||
private void sessionScreen(boolean waitForLandscape){
|
||||
installRoot("session",true);remoteOrientation.begin(waitForLandscape&&!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
|
||||
controls=new SessionControls(this,ui,new SessionControls.Actions(){
|
||||
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
|
||||
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
|
||||
@@ -343,6 +348,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
}
|
||||
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
|
||||
private void end(String reason){
|
||||
remoteOrientation.end();
|
||||
if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);}
|
||||
boolean closeWindow=isInPictureInPictureMode();
|
||||
pictureInPicture.disconnect();updatePictureInPicture();
|
||||
@@ -362,6 +368,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
mouse.addView(segments(new String[]{"相对位置","绝对位置"},relative?0:1,true,index->preferences.edit().putBoolean("relativeMouse",index==0).apply()));
|
||||
LinearLayout picture=section(content,"画面偏好",MobileUi.SETTINGS_CARD_RADIUS);picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1)));
|
||||
picture.addView(segments(RemoteVideoSettings.PREFERENCES,preference,true,index->preferences.edit().putInt("videoPreference",index).apply()));ui.gap(content,28);
|
||||
LinearLayout direction=section(content,"屏幕方向",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout directionRow=row();directionRow.setPadding(dp(16),dp(10),dp(16),dp(10));directionRow.addView(text("连接后自动横屏",16,false),new LinearLayout.LayoutParams(0,-2,1));
|
||||
Switch autoLandscape=new Switch(this);autoLandscape.setContentDescription("连接后自动横屏");autoLandscape.setChecked(preferences.getBoolean("autoLandscape",true));autoLandscape.setOnCheckedChangeListener((button,checked)->preferences.edit().putBoolean("autoLandscape",checked).apply());directionRow.addView(autoLandscape);direction.addView(directionRow);
|
||||
footer(content,"手机连接后先进入横屏,将手机横过来后恢复跟随设备方向。关闭后始终跟随设备方向。平板和多窗口不受影响。");
|
||||
LinearLayout server=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099;
|
||||
link(server,"服务器",custom?"自定义":"默认",()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields);
|
||||
LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text("使用自定义服务器",16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription("使用自定义服务器");toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow);
|
||||
@@ -593,7 +602,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
// Transport readiness can be reported again after ICE changes its selected path.
|
||||
// The first success consumes pendingPassword; a duplicate must not save it again.
|
||||
if(session==null||ready)return;ready=true;if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}
|
||||
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen();
|
||||
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen(preferences.getBoolean("autoLandscape",true));
|
||||
if(!canUsePictureInPicture())prepareBackgroundSession(true);
|
||||
if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection);
|
||||
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected();
|
||||
@@ -670,6 +679,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
super.onConfigurationChanged(config);
|
||||
releaseKeys();
|
||||
if(!page.equals("session"))updateHomeOrientation();
|
||||
else if(!isInPictureInPictureMode()&&(isTablet()||isInMultiWindowMode()))remoteOrientation.followDevice();
|
||||
sizePasswordDialog();
|
||||
if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize();
|
||||
if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels);
|
||||
@@ -688,6 +698,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable;
|
||||
retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile;
|
||||
retained.width=videoWidth;retained.height=videoHeight;
|
||||
retained.waitingForLandscape=remoteOrientation.waitingForLandscape();
|
||||
}
|
||||
private boolean prepareBackgroundSession(boolean inform){
|
||||
if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false;
|
||||
@@ -723,7 +734,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay;
|
||||
remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile;
|
||||
videoSettings=retained.videoSettings;ready=true;retained.attach(this);
|
||||
if(rebuild)sessionScreen();
|
||||
if(rebuild)sessionScreen(retained.waitingForLandscape);
|
||||
if(retained.width>0&&retained.height>0)videoSize(retained.width,retained.height);
|
||||
if(controls!=null){controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
|
||||
retained.setBackground(false);signaling(signalState,localIdentity);
|
||||
@@ -771,9 +782,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
super.onPictureInPictureUiStateChanged(state);
|
||||
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
|
||||
}
|
||||
@Override protected void onResume(){super.onResume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
|
||||
@Override protected void onResume(){super.onResume();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.
|
||||
@Override protected void onPause(){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 onStop(){if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
|
||||
if(connectionDialog!=null)connectionDialog.dismiss();
|
||||
@@ -791,5 +802,5 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
|
||||
if(backgroundSession!=null)backgroundSession.detach(this);
|
||||
if(!retained){if(session!=null)session.close();stopSignaling();}else{mainHandler.removeCallbacks(presenceTick);session=signaling=null;}
|
||||
super.onDestroy();}
|
||||
remoteOrientation.end();super.onDestroy();}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.view.OrientationEventListener;
|
||||
|
||||
/** Holds the initial landscape until the phone itself has been turned sideways. */
|
||||
final class RemoteOrientation {
|
||||
private final Activity activity;
|
||||
private final OrientationEventListener sensor;
|
||||
private boolean waitingForLandscape, resumed;
|
||||
|
||||
RemoteOrientation(Activity activity) {
|
||||
this.activity = activity;
|
||||
sensor = new OrientationEventListener(activity) {
|
||||
@Override public void onOrientationChanged(int degrees) {
|
||||
if (!resumed || !waitingForLandscape || activity.isInPictureInPictureMode()) return;
|
||||
// Use physical orientation, never the window rotated by our own request.
|
||||
// Ignore flat/unknown poses and keep a margin away from the diagonal.
|
||||
if ((degrees >= 60 && degrees <= 120) || (degrees >= 240 && degrees <= 300)) {
|
||||
followDevice();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void begin(boolean autoLandscape) {
|
||||
sensor.disable();
|
||||
waitingForLandscape = autoLandscape && sensor.canDetectOrientation();
|
||||
apply();
|
||||
if (resumed && waitingForLandscape) sensor.enable();
|
||||
}
|
||||
|
||||
boolean waitingForLandscape() { return waitingForLandscape; }
|
||||
|
||||
void resume() {
|
||||
resumed = true;
|
||||
if (waitingForLandscape && !activity.isInPictureInPictureMode()) sensor.enable();
|
||||
}
|
||||
|
||||
void pause() { resumed = false; sensor.disable(); }
|
||||
|
||||
void end() { waitingForLandscape = false; sensor.disable(); }
|
||||
|
||||
void followDevice() {
|
||||
waitingForLandscape = false;
|
||||
sensor.disable();
|
||||
apply();
|
||||
}
|
||||
|
||||
private void apply() {
|
||||
int orientation = waitingForLandscape
|
||||
? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
: ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
|
||||
if (activity.getRequestedOrientation() != orientation) activity.setRequestedOrientation(orientation);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package existing reviewed notices for the Android runtime dependency set."""
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
@@ -70,8 +69,7 @@ def write_pages(output, catalog, additional, revision, minirtc):
|
||||
target = output / asset
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(document['text'], encoding='utf-8')
|
||||
documents.append({'title': document['title'], 'asset': asset,
|
||||
'sha256': hashlib.sha256(document['text'].encode('utf-8')).hexdigest()})
|
||||
documents.append({'title': document['title'], 'asset': asset})
|
||||
component['documents'] = documents
|
||||
(output / 'ThirdPartyLicenses.json').write_text(json.dumps(
|
||||
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"""Check the shipped native ABI, ELF load alignment and APK zip alignment."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
@@ -40,33 +39,15 @@ def verify(path):
|
||||
name, extra = struct.unpack('<HH', raw.read(4))
|
||||
if (library.header_offset + 30 + name + extra) % 16384:
|
||||
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():
|
||||
raise ValueError('Missing or outdated bundled privacy policy')
|
||||
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.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:
|
||||
raise ValueError('Missing source revision')
|
||||
for document in source['buildDocuments']:
|
||||
if not archive.read('assets/'+document['asset']):
|
||||
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__':
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity version="1.0.0.0" name="CrossDesk.SessionHelper" type="win32" />
|
||||
<description>CrossDesk Session Helper</description>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -4,13 +4,10 @@ IDI_ICON1 ICON "crossdesk.ico"
|
||||
#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1
|
||||
#define RT_MANIFEST 24
|
||||
|
||||
#ifdef CROSSDESK_PORTABLE
|
||||
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest"
|
||||
#elif defined(CROSSDESK_DEBUG)
|
||||
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest"
|
||||
#else
|
||||
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
|
||||
#endif
|
||||
// The service supplies either the user's token (desktop observer) or a SYSTEM
|
||||
// token (secure capture/input). Requiring elevation here prevents the observer
|
||||
// from starting with CreateProcessAsUserW in installed release builds (740).
|
||||
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_session_helper.manifest"
|
||||
|
||||
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper"
|
||||
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "platform/windows/gui/slint_backend.h"
|
||||
#include "service_host.h"
|
||||
#include "service_lifetime.h"
|
||||
#include "shutdown_order.h"
|
||||
#include "unattended_config.h"
|
||||
#endif
|
||||
|
||||
@@ -207,8 +208,6 @@ int HandleServiceCliCommand(const std::string& command) {
|
||||
destination.SetTurnMode(source.GetTurnMode()) == 0 &&
|
||||
destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) ==
|
||||
0 &&
|
||||
destination.SetHardwareVideoCodec(source.IsHardwareVideoCodec()) ==
|
||||
0 &&
|
||||
destination.SetScreenCaptureMethod(
|
||||
crossdesk::ScreenCaptureMethod::Auto) == 0 &&
|
||||
destination.SetPrivacyScreen(false) == 0 &&
|
||||
@@ -406,6 +405,16 @@ static int RunApplication(
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
#ifdef _WIN32
|
||||
// Apply in every process, including the daemon and --child. Windows does
|
||||
// not inherit shutdown priority from a parent process.
|
||||
const DWORD shutdown_error = crossdesk::ConfigureWindowsShutdownOrder(
|
||||
crossdesk::WindowsShutdownRole::host);
|
||||
if (shutdown_error != ERROR_SUCCESS) {
|
||||
std::cerr << "Could not configure Windows host shutdown order, error="
|
||||
<< shutdown_error << '\n';
|
||||
}
|
||||
#endif
|
||||
#ifdef __linux__
|
||||
// Run before GUI, configuration, logging or display initialization.
|
||||
if (argc == 2 &&
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +41,8 @@ int ConfigCenter::Load() {
|
||||
}
|
||||
|
||||
bool persist_config_migration = false;
|
||||
// Hardware codecs are always preferred, with software fallback in MiniRTC.
|
||||
persist_config_migration |= ini_->Delete(section_, "hardware_video_codec");
|
||||
// These options now belong only to the active remote connection. Discard
|
||||
// legacy local defaults so they cannot affect this host or new connections.
|
||||
persist_config_migration |= ini_->Delete(section_, "video_quality");
|
||||
@@ -66,6 +68,13 @@ int ConfigCenter::Load() {
|
||||
language_ = static_cast<LANGUAGE>(language_value);
|
||||
}
|
||||
|
||||
enable_ip_direct_ = ini_->GetBoolValue(section_, "enable_ip_direct", true);
|
||||
connection_mode_ =
|
||||
enable_ip_direct_ &&
|
||||
std::strcmp(ini_->GetValue(section_, "connection_mode", "id"), "ip") == 0
|
||||
? CONNECTION_MODE::DIRECT_IP
|
||||
: CONNECTION_MODE::DEVICE_ID;
|
||||
|
||||
const long screen_capture_method_value = ini_->GetLongValue(
|
||||
section_, "screen_capture_method",
|
||||
static_cast<long>(ScreenCaptureMethod::Auto));
|
||||
@@ -78,8 +87,6 @@ int ConfigCenter::Load() {
|
||||
ini_->GetLongValue(section_, "video_encode_format",
|
||||
static_cast<long>(video_encode_format_)));
|
||||
|
||||
hardware_video_codec_ = ini_->GetBoolValue(section_, "hardware_video_codec",
|
||||
hardware_video_codec_);
|
||||
const char* turn_mode_value = ini_->GetValue(section_, "turn_mode", nullptr);
|
||||
if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) {
|
||||
const long parsed_turn_mode = ini_->GetLongValue(
|
||||
@@ -130,6 +137,8 @@ int ConfigCenter::Load() {
|
||||
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
enable_privacy_screen_.store(
|
||||
ini_->GetBoolValue(section_, "enable_privacy_screen", true));
|
||||
lock_on_disconnect_.store(
|
||||
ini_->GetBoolValue(section_, "lock_on_disconnect", true));
|
||||
save_remote_previews_.store(
|
||||
ini_->GetBoolValue(section_, "save_remote_previews", true));
|
||||
portable_service_prompt_suppressed_ =
|
||||
@@ -201,11 +210,13 @@ int ConfigCenter::StoreValues(
|
||||
|
||||
int ConfigCenter::Save() {
|
||||
ini_->SetLongValue(section_, "language", static_cast<long>(language_));
|
||||
ini_->SetValue(section_, "connection_mode",
|
||||
connection_mode_ == CONNECTION_MODE::DIRECT_IP ? "ip" : "id");
|
||||
ini_->SetBoolValue(section_, "enable_ip_direct", enable_ip_direct_);
|
||||
ini_->SetLongValue(section_, "screen_capture_method",
|
||||
static_cast<long>(screen_capture_method_));
|
||||
ini_->SetLongValue(section_, "video_encode_format",
|
||||
static_cast<long>(video_encode_format_));
|
||||
ini_->SetBoolValue(section_, "hardware_video_codec", hardware_video_codec_);
|
||||
ini_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
ini_->SetBoolValue(section_, "enable_turn",
|
||||
turn_mode_ != TURN_MODE::DISABLED);
|
||||
@@ -223,6 +234,7 @@ int ConfigCenter::Save() {
|
||||
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
ini_->SetBoolValue(section_, "enable_privacy_screen",
|
||||
enable_privacy_screen_.load());
|
||||
ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
|
||||
ini_->SetBoolValue(section_, "save_remote_previews",
|
||||
save_remote_previews_.load());
|
||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
@@ -250,6 +262,34 @@ int ConfigCenter::SetLanguage(LANGUAGE language) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetConnectionMode(CONNECTION_MODE mode) {
|
||||
if (mode != CONNECTION_MODE::DEVICE_ID && mode != CONNECTION_MODE::DIRECT_IP) {
|
||||
return -1;
|
||||
}
|
||||
if (mode == CONNECTION_MODE::DIRECT_IP && !enable_ip_direct_) return -1;
|
||||
if (mode == connection_mode_) return 0;
|
||||
if (StoreValues({{"connection_mode",
|
||||
mode == CONNECTION_MODE::DIRECT_IP ? "ip" : "id"}}) != 0) {
|
||||
return -1;
|
||||
}
|
||||
connection_mode_ = mode;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetIpDirectEnabled(bool enabled) {
|
||||
if (enabled == enable_ip_direct_) return 0;
|
||||
const auto mode = enabled ? connection_mode_ : CONNECTION_MODE::DEVICE_ID;
|
||||
// Persist availability and the fallback mode together, so a failed save
|
||||
// leaves both the current runtime and the next launch unchanged.
|
||||
if (StoreValues({{"enable_ip_direct", enabled ? "true" : "false"},
|
||||
{"connection_mode", mode == CONNECTION_MODE::DIRECT_IP
|
||||
? "ip" : "id"}}) != 0)
|
||||
return -1;
|
||||
enable_ip_direct_ = enabled;
|
||||
connection_mode_ = mode;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
|
||||
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
|
||||
return -1;
|
||||
@@ -271,13 +311,6 @@ int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetHardwareVideoCodec(bool enabled) {
|
||||
if (enabled && !IsHardwareVideoCodecAvailable()) return -1;
|
||||
if (StoreValues({{"hardware_video_codec", enabled ? "true" : "false"}}) != 0) return -1;
|
||||
hardware_video_codec_ = enabled;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetTurnMode(TURN_MODE mode) {
|
||||
if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1;
|
||||
if (StoreValues({{"turn_mode", std::to_string(static_cast<int>(mode))},
|
||||
@@ -286,10 +319,7 @@ int ConfigCenter::SetTurnMode(TURN_MODE mode) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetTurn(bool enable_turn, bool force_relay) {
|
||||
if (!enable_turn) {
|
||||
return SetTurnMode(TURN_MODE::DISABLED);
|
||||
}
|
||||
int ConfigCenter::SetForceRelay(bool force_relay) {
|
||||
if (!force_relay) {
|
||||
return SetTurnMode(TURN_MODE::AUTO_UDP_TCP);
|
||||
}
|
||||
@@ -379,6 +409,17 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetLockOnDisconnect(bool enabled) {
|
||||
if (StoreValues({{"lock_on_disconnect", enabled ? "true" : "false"}}) != 0)
|
||||
return -1;
|
||||
lock_on_disconnect_.store(enabled);
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsLockOnDisconnect() const {
|
||||
return lock_on_disconnect_.load();
|
||||
}
|
||||
|
||||
int ConfigCenter::SetSaveRemotePreviews(bool enabled) {
|
||||
if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) {
|
||||
return -1;
|
||||
@@ -417,6 +458,12 @@ int ConfigCenter::SetSponsorshipPromptDismissedVersion(
|
||||
|
||||
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; }
|
||||
|
||||
ConfigCenter::CONNECTION_MODE ConfigCenter::GetConnectionMode() const {
|
||||
return connection_mode_;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsIpDirectEnabled() const { return enable_ip_direct_; }
|
||||
|
||||
ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
|
||||
return screen_capture_method_;
|
||||
}
|
||||
@@ -425,28 +472,10 @@ ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
|
||||
return video_encode_format_;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsHardwareVideoCodecAvailable() {
|
||||
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \
|
||||
!defined(__arm__) && USE_CUDA) || \
|
||||
defined(__APPLE__))
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsHardwareVideoCodec() const {
|
||||
return IsHardwareVideoCodecAvailable() && hardware_video_codec_;
|
||||
}
|
||||
|
||||
ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
|
||||
return turn_mode_;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsEnableTurn() const {
|
||||
return turn_mode_ != TURN_MODE::DISABLED;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsForceRelay() const {
|
||||
return turn_mode_ == TURN_MODE::FORCE_UDP || turn_mode_ == TURN_MODE::FORCE_TCP;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace crossdesk {
|
||||
class ConfigCenter {
|
||||
public:
|
||||
enum class LANGUAGE { CHINESE = 0, ENGLISH = 1, RUSSIAN = 2 };
|
||||
enum class CONNECTION_MODE { DEVICE_ID = 0, DIRECT_IP = 1 };
|
||||
enum class VIDEO_ENCODE_FORMAT { H264 = 0, AV1 = 1 };
|
||||
enum class TURN_MODE {
|
||||
DISABLED = 0,
|
||||
@@ -35,11 +36,12 @@ class ConfigCenter {
|
||||
|
||||
// write config
|
||||
int SetLanguage(LANGUAGE language);
|
||||
int SetConnectionMode(CONNECTION_MODE mode);
|
||||
int SetIpDirectEnabled(bool enabled);
|
||||
int SetScreenCaptureMethod(ScreenCaptureMethod method);
|
||||
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
|
||||
int SetHardwareVideoCodec(bool hardware_video_codec);
|
||||
int SetTurnMode(TURN_MODE turn_mode);
|
||||
int SetTurn(bool enable_turn, bool force_relay);
|
||||
int SetForceRelay(bool force_relay);
|
||||
int SetServerHost(const std::string& signal_server_host);
|
||||
int SetServerPort(int signal_server_port);
|
||||
int SetServerEndpoint(const std::string& address, int port);
|
||||
@@ -47,6 +49,7 @@ class ConfigCenter {
|
||||
int SetAutostart(bool enable_autostart);
|
||||
int SetDaemon(bool enable_daemon);
|
||||
int SetPrivacyScreen(bool enable_privacy_screen);
|
||||
int SetLockOnDisconnect(bool enabled);
|
||||
int SetSaveRemotePreviews(bool enabled);
|
||||
int SetPortableServicePromptSuppressed(bool suppressed);
|
||||
int SetSponsorshipPromptDismissedVersion(const std::string& version);
|
||||
@@ -55,13 +58,11 @@ class ConfigCenter {
|
||||
// read config
|
||||
|
||||
LANGUAGE GetLanguage() const;
|
||||
CONNECTION_MODE GetConnectionMode() const;
|
||||
bool IsIpDirectEnabled() const;
|
||||
ScreenCaptureMethod GetScreenCaptureMethod() const;
|
||||
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
|
||||
// Whether this build includes a hardware video codec backend.
|
||||
static bool IsHardwareVideoCodecAvailable();
|
||||
bool IsHardwareVideoCodec() const;
|
||||
TURN_MODE GetTurnMode() const;
|
||||
bool IsEnableTurn() const;
|
||||
bool IsForceRelay() const;
|
||||
bool IsEnableSrtp() const;
|
||||
std::string GetSignalServerHost() const;
|
||||
@@ -72,6 +73,7 @@ class ConfigCenter {
|
||||
bool IsEnableAutostart() const;
|
||||
bool IsEnableDaemon() const;
|
||||
bool IsEnablePrivacyScreen() const;
|
||||
bool IsLockOnDisconnect() const;
|
||||
bool IsSaveRemotePreviews() const;
|
||||
bool IsPortableServicePromptSuppressed() const;
|
||||
std::string GetSponsorshipPromptDismissedVersion() const;
|
||||
@@ -90,9 +92,10 @@ class ConfigCenter {
|
||||
const char* section_ = "Settings";
|
||||
|
||||
LANGUAGE language_ = LANGUAGE::CHINESE;
|
||||
CONNECTION_MODE connection_mode_ = CONNECTION_MODE::DEVICE_ID;
|
||||
bool enable_ip_direct_ = true;
|
||||
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
|
||||
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
|
||||
bool hardware_video_codec_ = false;
|
||||
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
|
||||
std::string signal_server_host_ = "";
|
||||
std::string signal_server_host_default_ = "api.crossdesk.cn";
|
||||
@@ -102,6 +105,7 @@ class ConfigCenter {
|
||||
bool enable_autostart_ = false;
|
||||
bool enable_daemon_ = false;
|
||||
std::atomic<bool> enable_privacy_screen_{true};
|
||||
std::atomic<bool> lock_on_disconnect_{true};
|
||||
std::atomic<bool> save_remote_previews_{true};
|
||||
bool portable_service_prompt_suppressed_ = false;
|
||||
std::string sponsorship_prompt_dismissed_version_;
|
||||
|
||||
@@ -32,6 +32,9 @@ class MouseController : public DeviceController {
|
||||
virtual int Destroy() = 0;
|
||||
virtual int SendMouseCommand(RemoteAction remote_action,
|
||||
int display_index) = 0;
|
||||
// Bits 0/1/2 are left/right/middle. Return the subset confirmed released,
|
||||
// including buttons already up. Must not require valid display geometry.
|
||||
virtual unsigned ReleasePressedButtons(unsigned mask = 7) = 0;
|
||||
virtual void UpdateDisplayInfoList(
|
||||
const std::vector<DisplayInfo>&) {}
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ struct InteractionState {
|
||||
bool audio_capture_ = false;
|
||||
int screen_width_ = 1280;
|
||||
int screen_height_ = 720;
|
||||
int selected_display_ = 0;
|
||||
std::atomic<int> selected_display_{0};
|
||||
char remote_id_display_[12] = "";
|
||||
unsigned char audio_buffer_[720]{};
|
||||
int audio_len_ = 0;
|
||||
@@ -77,7 +77,7 @@ struct UiState {
|
||||
bool show_connection_status_window_ = false;
|
||||
bool fullscreen_button_pressed_ = false;
|
||||
bool is_client_mode_ = false;
|
||||
bool is_server_mode_ = false;
|
||||
std::atomic<bool> is_server_mode_{false};
|
||||
bool reload_recent_connections_ = true;
|
||||
bool show_offline_warning_window_ = false;
|
||||
std::string offline_warning_text_;
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
#include "features/gallery/gallery_window_state.h"
|
||||
#include "features/gallery/gallery_workspace.h"
|
||||
#include "features/gallery/preview_bandwidth.h"
|
||||
#include "features/gallery/preview_resolution.h"
|
||||
#include "filesystem_utf8.h"
|
||||
#include "localization.h"
|
||||
#include "platform.h"
|
||||
@@ -56,6 +57,7 @@
|
||||
#include <windows.h>
|
||||
|
||||
#include "platform/windows/gui/slint_backend.h"
|
||||
#include "platform/windows/gui/shutdown_session_guard.h"
|
||||
#include "platform/windows/gui/tray/win_tray.h"
|
||||
#include "platform/windows/gui/window_icons.h"
|
||||
#elif defined(__APPLE__)
|
||||
@@ -465,6 +467,10 @@ std::string ConnectionStatusText(ConnectionStatus status, int language) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string FormatResolution(int width, int height) {
|
||||
return std::to_string(width) + " × " + std::to_string(height);
|
||||
}
|
||||
|
||||
std::string FormatBitrate(uint32_t bits_per_second) {
|
||||
char text[48];
|
||||
if (bits_per_second < 1000) {
|
||||
@@ -472,7 +478,7 @@ std::string FormatBitrate(uint32_t bits_per_second) {
|
||||
} else if (bits_per_second < 1000000) {
|
||||
std::snprintf(text, sizeof(text), "%u kbps", bits_per_second / 1000);
|
||||
} else {
|
||||
std::snprintf(text, sizeof(text), "%.1f mbps", bits_per_second / 1000000.0);
|
||||
std::snprintf(text, sizeof(text), "%.1f Mbps", bits_per_second / 1000000.0);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -923,6 +929,10 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
||||
} // namespace
|
||||
|
||||
struct GuiApplication::SlintUi {
|
||||
#if _WIN32
|
||||
std::unique_ptr<ShutdownSessionGuard> shutdown_guard;
|
||||
std::wstring_view shutdown_reason;
|
||||
#endif
|
||||
gallery::Workspace gallery;
|
||||
std::filesystem::path gallery_path;
|
||||
std::vector<std::string> gallery_ids;
|
||||
@@ -1025,6 +1035,9 @@ struct GuiApplication::SlintUi {
|
||||
bool portable_service_dialog_initialized = false;
|
||||
bool sponsorship_prompt_pending = false;
|
||||
std::string recent_model_signature;
|
||||
gallery::PreviewResolutionDebouncer recent_preview_resolution;
|
||||
gallery::PreviewResolution recent_preview_size;
|
||||
std::unordered_map<std::string, slint::Image> recent_preview_images;
|
||||
std::optional<std::string> recent_group_filter;
|
||||
std::vector<std::string> recent_filter_groups;
|
||||
slint::Timer timer;
|
||||
@@ -1153,6 +1166,7 @@ int GuiApplication::Run() {
|
||||
updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath());
|
||||
#endif
|
||||
InitializeUi();
|
||||
InitializeLan();
|
||||
#ifndef __OHOS__
|
||||
ui_->activation_receiver = std::move(activation_receiver);
|
||||
#endif
|
||||
@@ -1196,7 +1210,7 @@ void GuiApplication::InitializeSettings() {
|
||||
|
||||
bool GuiApplication::InitializeSDL() {
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
ConfigureLinuxCudaGraphics(config_center_->IsHardwareVideoCodec());
|
||||
ConfigureLinuxCudaGraphics();
|
||||
// Standard Wayland top-level windows cannot choose an absolute screen
|
||||
// position. Use the session's XWayland compatibility server for the GUI so
|
||||
// auxiliary windows such as the controlled-side status window can be
|
||||
@@ -1216,6 +1230,9 @@ bool GuiApplication::InitializeSDL() {
|
||||
"windows while retaining Wayland portal capture and input");
|
||||
}
|
||||
#endif
|
||||
// SDL disables the screensaver by default. A background remote desktop app
|
||||
// should leave automatic display sleep under the user's system settings.
|
||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) {
|
||||
LOG_ERROR("SDL initialization failed: {}", SDL_GetError());
|
||||
return false;
|
||||
@@ -1284,6 +1301,14 @@ void GuiApplication::InitializeUi() {
|
||||
slint::set_xdg_app_id("crossdesk");
|
||||
#endif
|
||||
ui_ = std::make_unique<SlintUi>();
|
||||
for (const auto& window : {ui_->main, ui_->wall})
|
||||
window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled());
|
||||
ui_->main->set_lan_mode(config_center_->GetConnectionMode() ==
|
||||
ConfigCenter::CONNECTION_MODE::DIRECT_IP);
|
||||
#if _WIN32
|
||||
ui_->shutdown_guard =
|
||||
std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_);
|
||||
#endif
|
||||
ui_->sponsorship_prompt_pending =
|
||||
config_center_->GetSponsorshipPromptDismissedVersion() != CROSSDESK_VERSION;
|
||||
ui_->video_presenter =
|
||||
@@ -1420,7 +1445,6 @@ void GuiApplication::InitializeSystemTray() {
|
||||
};
|
||||
auto exit_app = [this] {
|
||||
exit_ = true;
|
||||
slint::quit_event_loop();
|
||||
};
|
||||
#if _WIN32
|
||||
ui_->tray = std::make_unique<WinTray>(
|
||||
@@ -1468,12 +1492,8 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
||||
auto& main = ui_->Window(gallery_window);
|
||||
main->set_language_index(language_button_value_);
|
||||
main->set_codec_index(video_encode_format_button_value_);
|
||||
const bool hardware_codec_available =
|
||||
ConfigCenter::IsHardwareVideoCodecAvailable();
|
||||
main->set_hardware_codec_available(hardware_codec_available);
|
||||
main->set_hardware_codec_enabled(hardware_codec_available &&
|
||||
enable_hardware_video_codec_);
|
||||
main->set_turn_enabled(enable_turn_);
|
||||
main->set_ip_direct_enabled_draft(config_center_->IsIpDirectEnabled());
|
||||
main->set_settings_save_error("");
|
||||
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
||||
main->set_self_hosted_enabled(enable_self_hosted_);
|
||||
main->set_autostart_enabled(enable_autostart_);
|
||||
@@ -1484,6 +1504,7 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
||||
main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
|
||||
main->set_privacy_on_connect_enabled(
|
||||
config_center_->IsEnablePrivacyScreen());
|
||||
main->set_lock_on_disconnect_enabled(config_center_->IsLockOnDisconnect());
|
||||
#ifdef _WIN32
|
||||
main->set_capture_method_visible(true);
|
||||
main->set_capture_method_index(
|
||||
@@ -1491,8 +1512,10 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
||||
#endif
|
||||
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||
main->set_privacy_setting_available(true);
|
||||
main->set_lock_on_disconnect_available(true);
|
||||
#else
|
||||
main->set_privacy_setting_available(false);
|
||||
main->set_lock_on_disconnect_available(false);
|
||||
#endif
|
||||
#if defined(__OHOS__)
|
||||
// The sandbox default is not the system desktop. Show the actual receive
|
||||
@@ -1533,6 +1556,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
||||
SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta);
|
||||
});
|
||||
main->on_open_gallery([this] { ShowGalleryWindow(); });
|
||||
main->on_connection_mode_changed([this, main](bool lan_mode) {
|
||||
const auto mode = lan_mode ? ConfigCenter::CONNECTION_MODE::DIRECT_IP
|
||||
: ConfigCenter::CONNECTION_MODE::DEVICE_ID;
|
||||
if (config_center_->SetConnectionMode(mode) != 0) {
|
||||
LOG_WARN("Could not save connection mode preference");
|
||||
main->set_offline_warning(UiText(
|
||||
localization_language_index_ == 0
|
||||
? "无法保存连接模式,下次启动可能不会恢复当前模式。"
|
||||
: localization_language_index_ == 2
|
||||
? "Не удалось сохранить режим подключения. При следующем запуске он может не восстановиться."
|
||||
: "Could not save the connection mode. It may not be restored on the next launch."));
|
||||
}
|
||||
});
|
||||
main->on_toggle_gallery_fullscreen(
|
||||
[this] { ToggleGalleryFullscreen(); });
|
||||
main->on_gallery_action(
|
||||
@@ -1667,8 +1703,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
||||
show_password_ = !show_password_;
|
||||
main->set_password_visible(show_password_);
|
||||
});
|
||||
main->on_reset_password([this](slint::SharedString value) {
|
||||
return RequestPasswordChange(std::string(value));
|
||||
main->on_reset_password([this, main](slint::SharedString value) {
|
||||
const std::string password(value);
|
||||
if (!main->get_lan_mode()) return RequestPasswordChange(password);
|
||||
if (!lan::ValidPassword(password)) return false;
|
||||
if (!LanAvailable()) return true; // The initialization error is shown on the main view.
|
||||
try {
|
||||
lan_error_.clear();
|
||||
lan_->SetPassword(password);
|
||||
main->set_lan_password_pending(lan_->password_pending());
|
||||
} catch (const std::exception& error) {
|
||||
lan_error_ = error.what();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
main->on_refresh_password([this] {
|
||||
const auto password = GenerateRandomPassword(password_saved_);
|
||||
@@ -1680,6 +1727,106 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
||||
}
|
||||
RequestPasswordChange(*password);
|
||||
});
|
||||
main->on_lan_select_address([this, main](slint::SharedString address) {
|
||||
if (!LanAvailable()) {
|
||||
main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
lan_->identity().SelectAddress(std::string(address));
|
||||
const auto port =
|
||||
UiText(std::to_string(lan_->identity().PortFor(std::string(address))));
|
||||
main->set_lan_listener_ip(address);
|
||||
main->set_lan_listener_port(port);
|
||||
main->set_lan_local_port(port);
|
||||
main->set_lan_port_dirty(false);
|
||||
main->set_lan_endpoint_error("");
|
||||
return true;
|
||||
} catch (const std::exception& error) {
|
||||
main->set_lan_endpoint_error(UiText(error.what()));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
main->on_lan_configure([this, main](slint::SharedString address,
|
||||
slint::SharedString port) {
|
||||
if (!LanAvailable()) {
|
||||
main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const auto port_number = lan::ParsePort(std::string(port));
|
||||
lan_->Configure(std::string(address), port_number);
|
||||
const auto normalized_port = UiText(std::to_string(port_number));
|
||||
main->set_lan_listener_ip(address);
|
||||
main->set_lan_listener_port(normalized_port);
|
||||
main->set_lan_local_port(normalized_port);
|
||||
main->set_lan_port_dirty(false);
|
||||
lan_error_.clear();
|
||||
main->set_lan_endpoint_error("");
|
||||
return true;
|
||||
} catch (const std::exception& error) {
|
||||
main->set_lan_endpoint_error(UiText(error.what()));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
main->on_lan_reset_password([this] {
|
||||
if (LanAvailable()) {
|
||||
lan_error_.clear();
|
||||
lan_->ResetPassword();
|
||||
}
|
||||
});
|
||||
main->on_lan_copy_text([this](slint::SharedString value) {
|
||||
if (!SDL_SetClipboardText(std::string(value).c_str())) {
|
||||
lan_error_ = "Cannot copy text";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
main->on_lan_prepare_connect([this, main](slint::SharedString endpoint) {
|
||||
try {
|
||||
const auto [address, port] = lan::ParseEndpoint(Trim(std::string(endpoint)), 52888);
|
||||
if (!LanAvailable()) return UiText(lan_error_.empty() ? "Busy" : lan_error_);
|
||||
main->set_lan_remote_address(UiText(address));
|
||||
main->set_lan_remote_port(UiText(std::to_string(port)));
|
||||
main->set_lan_retry_after(lan_->RetryAfter(address, port));
|
||||
// History is newest first. Match both address and port, and only use
|
||||
// the latest matching record when an endpoint has been reused.
|
||||
for (const auto& [_, recent] : recent_connections_) {
|
||||
if (recent.lan.device_id.empty() || recent.lan.address != address ||
|
||||
recent.lan.port != port)
|
||||
continue;
|
||||
if (recent.remember_password && lan::ValidPassword(recent.password)) {
|
||||
main->set_lan_access_password(UiText(recent.password));
|
||||
main->set_lan_remember_password(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
lan_error_.clear();
|
||||
main->set_lan_message("");
|
||||
return slint::SharedString{};
|
||||
} catch (const std::exception& error) {
|
||||
return UiText(error.what());
|
||||
}
|
||||
});
|
||||
main->on_lan_cancel_connect([this, main] {
|
||||
if (lan_ && lan_connecting_) lan_->Close(lan_connecting_);
|
||||
lan_connecting_.reset();
|
||||
main->set_lan_connecting(false);
|
||||
lan_error_.clear();
|
||||
main->set_lan_message("");
|
||||
});
|
||||
main->on_lan_connect([this, main](slint::SharedString address,
|
||||
slint::SharedString port,
|
||||
slint::SharedString password, bool remember) {
|
||||
try {
|
||||
ConnectLan(std::string(address), lan::ParsePort(std::string(port)),
|
||||
std::string(password), remember);
|
||||
} catch (const std::exception& error) {
|
||||
lan_error_ = error.what();
|
||||
}
|
||||
main->set_lan_connecting(lan_connecting_ != nullptr);
|
||||
main->set_lan_message(UiText(lan_error_));
|
||||
});
|
||||
main->on_connect_requested(
|
||||
[this](slint::SharedString id) { ConnectFromUi(std::string(id)); });
|
||||
main->on_format_remote_id([](slint::SharedString value) {
|
||||
@@ -1692,7 +1839,7 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
||||
if (auto props = FindRemoteSession(remote_id);
|
||||
props && props->connection_status_ == ConnectionStatus::Connected &&
|
||||
!props->closing_) {
|
||||
CloseStreamTab(remote_id);
|
||||
CloseStreamTab(props->remote_id_);
|
||||
SyncMainWindow();
|
||||
}
|
||||
});
|
||||
@@ -2255,9 +2402,106 @@ void GuiApplication::BindServerCallbacks() {
|
||||
|
||||
void GuiApplication::Tick() {
|
||||
if (exit_) {
|
||||
slint::quit_event_loop();
|
||||
BeginShutdown();
|
||||
if (ShutdownDrained()) slint::quit_event_loop();
|
||||
return;
|
||||
}
|
||||
TickLan();
|
||||
if (LanAvailable()) {
|
||||
const auto address = lan_->identity().settings().value("address", "");
|
||||
ui_->main->set_lan_password_pending(lan_->password_pending());
|
||||
ui_->main->set_lan_listening(lan_->listening());
|
||||
if (lan_connecting_ && lan_connecting_->authorized) {
|
||||
ui_->main->set_lan_open(false);
|
||||
lan_connecting_.reset();
|
||||
} else if (lan_connecting_ && lan_connecting_->closing) {
|
||||
lan_connecting_.reset();
|
||||
}
|
||||
ui_->main->set_lan_connecting(lan_connecting_ != nullptr);
|
||||
int retry_after = 0;
|
||||
if (ui_->main->get_lan_open()) {
|
||||
try {
|
||||
retry_after = lan_->RetryAfter(
|
||||
std::string(ui_->main->get_lan_remote_address()),
|
||||
lan::ParsePort(std::string(ui_->main->get_lan_remote_port())));
|
||||
} catch (const std::exception&) {
|
||||
// An incomplete address has no cooldown; input validation handles it.
|
||||
}
|
||||
}
|
||||
ui_->main->set_lan_retry_after(retry_after);
|
||||
if (auto password = lan_->TakeNewPassword(); !password.empty())
|
||||
ui_->main->set_lan_new_password(UiText(password));
|
||||
const auto& available_interfaces = lan_->interfaces();
|
||||
const auto previous = ui_->main->get_lan_interfaces();
|
||||
bool changed = !previous || previous->row_count() != available_interfaces.size();
|
||||
for (size_t i = 0; !changed && i < available_interfaces.size(); ++i)
|
||||
changed = std::string_view(previous->row_data(i).value()) !=
|
||||
available_interfaces[i].address;
|
||||
if (changed) {
|
||||
std::vector<slint::SharedString> addresses;
|
||||
addresses.reserve(available_interfaces.size());
|
||||
for (const auto& network_interface : available_interfaces)
|
||||
addresses.push_back(UiText(network_interface.address));
|
||||
ui_->main->set_lan_interfaces(
|
||||
std::make_shared<slint::VectorModel<slint::SharedString>>(std::move(addresses)));
|
||||
}
|
||||
// Keep the current displayed address while it remains available, including
|
||||
// when a previously missing preferred address returns during a port edit.
|
||||
const auto previous_address = std::string(ui_->main->get_lan_listener_ip());
|
||||
auto display_address = previous_address.empty() ? address : previous_address;
|
||||
int interface_index = -1;
|
||||
for (size_t i = 0; i < available_interfaces.size(); ++i)
|
||||
if (available_interfaces[i].address == display_address) {
|
||||
interface_index = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
if (interface_index < 0) {
|
||||
interface_index = available_interfaces.empty() ? -1 : 0;
|
||||
display_address = interface_index == 0
|
||||
? available_interfaces.front().address : "";
|
||||
}
|
||||
const auto port =
|
||||
UiText(std::to_string(lan_->identity().PortFor(display_address)));
|
||||
if (previous_address != display_address) {
|
||||
// A disappearing address must not transfer its unfinished port edit to
|
||||
// another interface. Explicit selection commits the old edit first.
|
||||
if (!previous_address.empty() && ui_->main->get_lan_port_dirty()) {
|
||||
ui_->main->set_lan_access_warning_endpoint(UiText(previous_address));
|
||||
ui_->main->set_lan_access_warning("Selected LAN interface is unavailable");
|
||||
}
|
||||
ui_->main->set_lan_port_dirty(false);
|
||||
ui_->main->set_lan_endpoint_error("");
|
||||
ui_->main->set_lan_local_port(port);
|
||||
}
|
||||
ui_->main->set_lan_listener_ip(UiText(display_address));
|
||||
ui_->main->set_lan_listener_port(port);
|
||||
ui_->main->set_lan_interface_index(interface_index);
|
||||
ui_->main->set_lan_interface_draft_index(interface_index);
|
||||
if (!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty())
|
||||
ui_->main->set_lan_local_port(port);
|
||||
ui_->main->set_lan_listener_error(UiText(
|
||||
display_address.empty() ? "Selected LAN interface is unavailable"
|
||||
: lan_->ListenerError(display_address)));
|
||||
// Show failures one address at a time, without interrupting edits or another
|
||||
// dialog. A working listener must not clear another address's error.
|
||||
if (ui_->main->get_lan_mode() && ui_->main->get_can_show_sponsorship() &&
|
||||
!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty()) {
|
||||
if (auto issue = lan_->TakeListenerWarning()) {
|
||||
ui_->main->set_lan_access_warning_endpoint(
|
||||
UiText(issue->address + ":" + std::to_string(issue->port)));
|
||||
ui_->main->set_lan_access_warning(UiText(issue->error));
|
||||
}
|
||||
}
|
||||
}
|
||||
const auto lan_message = lan_error_.rfind("ConnectionFailed", 0) == 0
|
||||
? std::string("ConnectionFailed")
|
||||
: lan_error_;
|
||||
ui_->main->set_lan_message(UiText(lan_message));
|
||||
#if _WIN32
|
||||
if (ui_->shutdown_guard) {
|
||||
ui_->shutdown_guard->Update(ui_->shutdown_reason);
|
||||
}
|
||||
#endif
|
||||
PollUpdateCheck();
|
||||
HandleSessionCleanup();
|
||||
HandlePasswordChangeResult();
|
||||
@@ -2340,11 +2584,13 @@ void GuiApplication::Tick() {
|
||||
HandleRecentConnections();
|
||||
if (was_reloading_recent_connections && !reload_recent_connections_) {
|
||||
ui_->recent_model_signature.clear();
|
||||
ui_->recent_preview_images.clear();
|
||||
}
|
||||
HandleConnectionStatusChange();
|
||||
HandlePendingPresenceProbe();
|
||||
HandlePresenceProbeTimeout();
|
||||
HandlePrivacy();
|
||||
HandleScreenLock();
|
||||
HandleWindowsServiceIntegration();
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
SyncXWaylandWindowActivation();
|
||||
@@ -2445,7 +2691,7 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!is_server_mode_ || !peer_ || connected_controllers.empty()) {
|
||||
if (!is_server_mode_ || connected_controllers.empty()) {
|
||||
cursor_delivery_states_.clear();
|
||||
return;
|
||||
}
|
||||
@@ -2571,9 +2817,8 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
action.type = ControlType::cursor_state;
|
||||
action.cs = outgoing;
|
||||
const std::string message = action.to_json();
|
||||
const int result = SendReliableDataFrameToPeer(
|
||||
peer_, message.c_str(), message.size(), mouse_label_.c_str(),
|
||||
recipient.remote_id.c_str(), recipient.remote_id.size());
|
||||
const int result = SendControllerData(recipient.remote_id, message.c_str(),
|
||||
message.size(), mouse_label_.c_str());
|
||||
if (result != 0) {
|
||||
LOG_WARN("Send cursor state to [{}] failed, ret={}",
|
||||
recipient.remote_id, result);
|
||||
@@ -2815,6 +3060,15 @@ void GuiApplication::UpdateLocalization() {
|
||||
if (!ui_ || ui_->localized_language == language) {
|
||||
return;
|
||||
}
|
||||
#if _WIN32
|
||||
// All views refer to static literals; selecting the text needs no allocation
|
||||
// and only runs when the language changes. Tick still checks connection state.
|
||||
ui_->shutdown_reason = language == 0
|
||||
? L"远程控制连接中,保留连接以确认或取消关机。"
|
||||
: language == 2
|
||||
? L"Удалённый сеанс активен. Соединение сохранено для подтверждения или отмены выключения."
|
||||
: L"Remote session active. Keeping the connection available to confirm or cancel shutdown.";
|
||||
#endif
|
||||
ui_localization::ApplyMainWindowStrings(ui_->main, language);
|
||||
ui_localization::ApplyStreamWindowStrings(ui_->main, language);
|
||||
ui_localization::ApplyMainWindowStrings(ui_->wall, language);
|
||||
@@ -2983,8 +3237,9 @@ void GuiApplication::SyncMainWindow() {
|
||||
std::vector<slint::SharedString> recent_groups{
|
||||
tr_group("全部设备", "All devices", "Все устройства"),
|
||||
tr_group("未分组", "Ungrouped", "Без группы")};
|
||||
const bool lan_mode = ui_->main->get_lan_mode();
|
||||
int recent_group_index = 0;
|
||||
if (ui_->recent_group_filter) {
|
||||
if (!lan_mode && ui_->recent_group_filter) {
|
||||
if (ui_->recent_group_filter->empty()) recent_group_index = 1;
|
||||
else {
|
||||
const auto found = std::find(ui_->gallery.groups.begin(),
|
||||
@@ -3021,12 +3276,26 @@ void GuiApplication::SyncMainWindow() {
|
||||
return props && props->connection_status_ == ConnectionStatus::Connected &&
|
||||
!props->closing_;
|
||||
};
|
||||
const auto preview_size = ui_->recent_preview_resolution.Update(
|
||||
gallery::PreviewResolution::Viewport(
|
||||
ui_->main->get_recent_preview_width(),
|
||||
ui_->main->get_recent_preview_height(),
|
||||
ui_->main->window().scale_factor()),
|
||||
gallery::PreviewResolutionDebouncer::Clock::now());
|
||||
const bool save_previews = config_center_->IsSaveRemotePreviews();
|
||||
if (!(preview_size == ui_->recent_preview_size) || !save_previews) {
|
||||
ui_->recent_preview_size = preview_size;
|
||||
ui_->recent_preview_images.clear();
|
||||
}
|
||||
std::ostringstream signature_builder;
|
||||
signature_builder << config_center_->IsSaveRemotePreviews() << '\n'
|
||||
signature_builder << save_previews << '\n'
|
||||
<< preview_size.width << ':' << preview_size.height << '\n'
|
||||
<< lan_mode << '\n'
|
||||
<< bool(ui_->recent_group_filter) << '\0'
|
||||
<< ui_->recent_group_filter.value_or("") << '\n';
|
||||
for (const auto& [key, connection] : recent_connections_) {
|
||||
signature_builder << key << '\0' << connection.remote_id << '\0'
|
||||
<< connection.lan.address << ':' << connection.lan.port << '\0'
|
||||
<< connection.remote_host_name << '\0'
|
||||
<< static_cast<int>(connection.platform) << '\0'
|
||||
<< settings_.RecentConnectionDisplayName(connection)
|
||||
@@ -3047,7 +3316,9 @@ void GuiApplication::SyncMainWindow() {
|
||||
std::vector<ui::RecentConnection> model;
|
||||
model.reserve(recent_connections_.size());
|
||||
for (const auto& [_, connection] : recent_connections_) {
|
||||
if (!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter))
|
||||
if (lan_mode != !connection.lan.device_id.empty()) continue;
|
||||
if (!lan_mode &&
|
||||
!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter))
|
||||
continue;
|
||||
// Slint-generated scalar fields have no defaults. With no image,
|
||||
// has_thumbnail must remain false so the remote platform is rendered.
|
||||
@@ -3057,21 +3328,34 @@ void GuiApplication::SyncMainWindow() {
|
||||
item.group_names = UiText(group_labels[connection.remote_id]);
|
||||
item.display_name =
|
||||
UiText(settings_.RecentConnectionDisplayName(connection));
|
||||
item.online = device_presence_cache_.IsOnline(connection.remote_id);
|
||||
item.lan = !connection.lan.device_id.empty();
|
||||
item.endpoint = UiText(item.lan ? connection.lan.address + ":" +
|
||||
std::to_string(connection.lan.port)
|
||||
: "");
|
||||
item.online =
|
||||
!item.lan && device_presence_cache_.IsOnline(connection.remote_id);
|
||||
item.connected = is_connected(connection.remote_id);
|
||||
item.platform = UiText(HostPlatformName(connection.platform));
|
||||
std::vector<unsigned char> rgba;
|
||||
int image_width = 0;
|
||||
int image_height = 0;
|
||||
if (config_center_->IsSaveRemotePreviews() &&
|
||||
!connection.image_path.empty() &&
|
||||
thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width,
|
||||
&image_height)) {
|
||||
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
|
||||
image_height);
|
||||
std::memcpy(pixels.begin(), rgba.data(), rgba.size());
|
||||
item.thumbnail = slint::Image(std::move(pixels));
|
||||
item.has_thumbnail = true;
|
||||
if (save_previews && !connection.image_path.empty()) {
|
||||
const auto path = connection.image_path.string();
|
||||
auto cached = ui_->recent_preview_images.find(path);
|
||||
if (cached == ui_->recent_preview_images.end()) {
|
||||
std::vector<unsigned char> rgba;
|
||||
int image_width = 0, image_height = 0;
|
||||
if (thumbnail_->DecodeImage(connection.image_path, preview_size.width,
|
||||
preview_size.height, &rgba, &image_width,
|
||||
&image_height)) {
|
||||
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
|
||||
image_height);
|
||||
std::memcpy(pixels.begin(), rgba.data(), rgba.size());
|
||||
cached = ui_->recent_preview_images.emplace(
|
||||
path, slint::Image(std::move(pixels))).first;
|
||||
}
|
||||
}
|
||||
if (cached != ui_->recent_preview_images.end()) {
|
||||
item.thumbnail = cached->second;
|
||||
item.has_thumbnail = true;
|
||||
}
|
||||
}
|
||||
model.push_back(std::move(item));
|
||||
}
|
||||
@@ -3138,7 +3422,9 @@ void GuiApplication::SyncConnectionDialog() {
|
||||
} else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) {
|
||||
text = localization::connection_not_authenticated[localization_language_index_];
|
||||
} else {
|
||||
text = ConnectionStatusText(status, localization_language_index_);
|
||||
text = props->lan_session_ && !props->lan_session_->error.empty()
|
||||
? props->lan_session_->error
|
||||
: ConnectionStatusText(status, localization_language_index_);
|
||||
}
|
||||
if (auth.retry_after > 0) {
|
||||
char retry_text[256];
|
||||
@@ -3618,6 +3904,13 @@ void GuiApplication::SyncStreamWindow() {
|
||||
(*ui_->stream)->set_selected_display(props->selected_display_);
|
||||
|
||||
const auto net = props->net_traffic_stats_.Load();
|
||||
const auto codecs = props->net_traffic_stats_.LoadVideoCodecs();
|
||||
(*ui_->stream)->set_stats_encoder(UiText(
|
||||
status == ConnectionStatus::Connected && !codecs.remote_encoder.empty()
|
||||
? codecs.remote_encoder : "—"));
|
||||
(*ui_->stream)->set_stats_decoder(UiText(
|
||||
status == ConnectionStatus::Connected && !codecs.local_decoder.empty()
|
||||
? codecs.local_decoder : "—"));
|
||||
(*ui_->stream)->set_stats_encryption_enabled(
|
||||
status == ConnectionStatus::Connected && net.srtp_active);
|
||||
std::vector<ui::NetworkStatsRow> stats_rows;
|
||||
@@ -3641,8 +3934,8 @@ void GuiApplication::SyncStreamWindow() {
|
||||
net.total_inbound_stats, net.total_outbound_stats);
|
||||
ui::UpdateModelRows(*ui_->stats_model, std::move(stats_rows));
|
||||
(*ui_->stream)
|
||||
->set_stats_resolution(UiText(std::to_string(props->video_width_) + "x" +
|
||||
std::to_string(props->video_height_)));
|
||||
->set_stats_resolution(UiText(FormatResolution(props->video_width_,
|
||||
props->video_height_)));
|
||||
(*ui_->stream)
|
||||
->set_stats_connection_mode(
|
||||
UiText(props->traversal_mode_ == TraversalMode::P2P
|
||||
@@ -3730,8 +4023,7 @@ void GuiApplication::SyncStreamVideoFrame() {
|
||||
const auto result = ui_->video_presenter->Present(frame);
|
||||
if (result.width > 0 && result.height > 0) {
|
||||
(*ui_->stream)
|
||||
->set_stats_resolution(UiText(std::to_string(result.width) + "x" +
|
||||
std::to_string(result.height)));
|
||||
->set_stats_resolution(UiText(FormatResolution(result.width, result.height)));
|
||||
}
|
||||
if (result.new_frame) {
|
||||
++props->frame_count_;
|
||||
@@ -4229,6 +4521,7 @@ bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) {
|
||||
bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
||||
const auto previous_server = GetSignalServerSettings(*config_center_);
|
||||
auto& main = ui_->Window(gallery_window);
|
||||
main->set_settings_save_error("");
|
||||
const bool has_endpoint = main->get_self_hosted_enabled() ||
|
||||
!std::string(main->get_server_host()).empty() ||
|
||||
!std::string(main->get_server_port()).empty();
|
||||
@@ -4251,7 +4544,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
||||
if (!wall_settings || (wall_changed && !updated_wall.Save(ui_->gallery_path))) {
|
||||
main->set_gallery_settings_error(UiText(
|
||||
localization_language_index_ == 0 ? "保存失败,请重试"
|
||||
: localization_language_index_ == 2 ? "Не удалось сохранить"
|
||||
: localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
|
||||
: "Could not save; try again"));
|
||||
main->set_settings_tab(1);
|
||||
return false;
|
||||
@@ -4267,12 +4560,29 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
||||
main->set_self_host_settings_open(true);
|
||||
return false;
|
||||
}
|
||||
if (config_center_->SetIpDirectEnabled(main->get_ip_direct_enabled_draft()) != 0) {
|
||||
if (wall_changed && !ui_->gallery.Save(ui_->gallery_path))
|
||||
LOG_ERROR("Failed to restore DeskHub settings after IP direct save failure");
|
||||
main->set_settings_save_error(UiText(
|
||||
localization_language_index_ == 0 ? "无法保存 IP 连接设置,请重试。"
|
||||
: localization_language_index_ == 2 ? "Не удалось сохранить настройки подключения по IP. Повторите попытку."
|
||||
: "Could not save 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_ =
|
||||
localization::detail::ClampLanguageIndex(main->get_language_index());
|
||||
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
|
||||
enable_hardware_video_codec_ = ConfigCenter::IsHardwareVideoCodecAvailable() &&
|
||||
main->get_hardware_codec_enabled();
|
||||
enable_turn_ = main->get_turn_enabled();
|
||||
enable_self_hosted_ = main->get_self_hosted_enabled();
|
||||
#if !defined(__OHOS__)
|
||||
enable_autostart_ = main->get_autostart_enabled();
|
||||
@@ -4288,8 +4598,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
||||
config_center_->SetVideoEncodeFormat(
|
||||
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
|
||||
video_encode_format_button_value_));
|
||||
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_);
|
||||
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
|
||||
config_center_->SetForceRelay(main->get_force_relay_enabled());
|
||||
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
||||
#if !defined(__OHOS__)
|
||||
config_center_->SetAutostart(enable_autostart_);
|
||||
@@ -4355,8 +4664,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
||||
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
|
||||
localization_language_ = config_center_->GetLanguage();
|
||||
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
|
||||
enable_hardware_video_codec_ = config_center_->IsHardwareVideoCodec();
|
||||
enable_turn_ = config_center_->IsEnableTurn();
|
||||
enable_self_hosted_ = config_center_->IsSelfHosted();
|
||||
enable_autostart_ = config_center_->IsEnableAutostart();
|
||||
enable_daemon_ = config_center_->IsEnableDaemon();
|
||||
@@ -4370,8 +4677,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
||||
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
|
||||
language_button_value_last_ = language_button_value_;
|
||||
video_encode_format_button_value_last_ = video_encode_format_button_value_;
|
||||
enable_hardware_video_codec_last_ = enable_hardware_video_codec_;
|
||||
enable_turn_last_ = enable_turn_;
|
||||
enable_self_hosted_last_ = enable_self_hosted_;
|
||||
enable_autostart_last_ = enable_autostart_;
|
||||
enable_daemon_last_ = enable_daemon_;
|
||||
@@ -4381,25 +4686,25 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
||||
#endif
|
||||
if (ui_) {
|
||||
ui_->localized_language = -1;
|
||||
for (const auto& window : {ui_->main, ui_->wall})
|
||||
window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled());
|
||||
if (!ui_->main->get_settings_open()) ResetSettingsUi();
|
||||
if (!ui_->wall->get_settings_open()) ResetSettingsUi(true);
|
||||
}
|
||||
ApplyLanSettings();
|
||||
if (reconnect && !HasActiveSession()) {
|
||||
// Tick recreates the peer after asynchronous cleanup, using the committed
|
||||
// configuration. Keep the application and its console running throughout.
|
||||
CloseAllRemoteSessions();
|
||||
} else {
|
||||
const bool hardware = config_center_->IsHardwareVideoCodec();
|
||||
const bool av1 = config_center_->GetVideoEncodeFormat() ==
|
||||
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
|
||||
const auto turn = static_cast<TurnMode>(config_center_->GetTurnMode());
|
||||
if (hardware != params_.hardware_acceleration || av1 != params_.av1_encoding ||
|
||||
turn != params_.turn_mode) {
|
||||
if (av1 != params_.av1_encoding || turn != params_.turn_mode) {
|
||||
// Update the listening peer for incoming connections and the template
|
||||
// copied by ConnectTo for outgoing connections. Existing sessions retain
|
||||
// the codec/ICE snapshot with which they negotiated.
|
||||
if (!peer_ || UpdateConnectionSettings(peer_, hardware, av1, turn) == 0) {
|
||||
params_.hardware_acceleration = hardware;
|
||||
if (!peer_ || UpdateConnectionSettings(peer_, true, av1, turn) == 0) {
|
||||
params_.av1_encoding = av1;
|
||||
params_.turn_mode = turn;
|
||||
} else {
|
||||
@@ -4411,6 +4716,26 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
||||
|
||||
void GuiApplication::ConnectFromUi(const std::string& input) {
|
||||
ui_->gallery_auth_id.clear();
|
||||
if (input.rfind("lan-", 0) == 0) {
|
||||
if (!LanAvailable()) return;
|
||||
if (auto existing = FindRemoteSession(input);
|
||||
existing && existing->connection_established_) {
|
||||
existing->tab_opened_ = true;
|
||||
focused_remote_id_ = existing->remote_id_;
|
||||
need_to_create_stream_window_ = true;
|
||||
return;
|
||||
}
|
||||
for (const auto& [_, recent] : recent_connections_)
|
||||
if (recent.remote_id == input && !recent.lan.device_id.empty()) {
|
||||
ui_->main->invoke_request_lan_connection(
|
||||
UiText(recent.lan.address +
|
||||
(recent.lan.port == 52888
|
||||
? ""
|
||||
: ":" + std::to_string(recent.lan.port))));
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const std::string remote_id = CompactPeerId(input);
|
||||
if (!gallery::Workspace::ValidId(remote_id)) {
|
||||
ui_->main->set_connection_input_error(
|
||||
@@ -4793,6 +5118,7 @@ void GuiApplication::ReleasePressedInput() {
|
||||
SendRemoteInput(action);
|
||||
}
|
||||
ui_->pressed_mouse_buttons.clear();
|
||||
if (ui_->stream) (*ui_->stream)->invoke_reset_input_state();
|
||||
}
|
||||
|
||||
void GuiApplication::ReleaseControl() {
|
||||
@@ -4985,8 +5311,8 @@ bool GuiApplication::AddGalleryDevice(const std::string& input,
|
||||
password.find_first_not_of(
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") !=
|
||||
std::string::npos)
|
||||
return error("请输入不超过 6 位的字母或数字密码",
|
||||
"Use a password of 1–6 letters or digits",
|
||||
return error("请输入 1–6 位英文字母或数字密码",
|
||||
"Use 1–6 letters (A–Z, a–z) or digits (0–9)",
|
||||
"Пароль: 1–6 латинских букв или цифр");
|
||||
if (!signal_connected_)
|
||||
return error("尚未连接服务器,请稍后重试",
|
||||
@@ -5004,7 +5330,7 @@ bool GuiApplication::AddGalleryDevice(const std::string& input,
|
||||
updated.Add(id, Trim(group));
|
||||
if (!updated.Save(ui_->gallery_path))
|
||||
return error("保存失败,请重试", "Could not save; try again",
|
||||
"Не удалось сохранить");
|
||||
"Не удалось сохранить. Повторите попытку");
|
||||
ui_->gallery = std::move(updated);
|
||||
ui_->gallery_hidden_active_ids.erase(id);
|
||||
if (auto props = FindRemoteSession(id)) {
|
||||
@@ -5047,7 +5373,7 @@ bool GuiApplication::ImportGalleryRecent(const std::string& input_group) {
|
||||
if (!updated.Save(ui_->gallery_path)) {
|
||||
ui_->wall->set_gallery_notice(UiText(
|
||||
localization_language_index_ == 0 ? "保存失败,请重试"
|
||||
: localization_language_index_ == 2 ? "Не удалось сохранить"
|
||||
: localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
|
||||
: "Could not save; try again"));
|
||||
return false;
|
||||
}
|
||||
@@ -5129,7 +5455,7 @@ bool GuiApplication::SaveDeviceGroups(const std::string& new_group,
|
||||
if (!updated.SetGroups(id, names) || !updated.Save(ui_->gallery_path)) {
|
||||
main->set_device_group_error(UiText(
|
||||
localization_language_index_ == 0 ? "保存失败,请重试"
|
||||
: localization_language_index_ == 2 ? "Не удалось сохранить"
|
||||
: localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
|
||||
: "Could not save; try again"));
|
||||
return false;
|
||||
}
|
||||
@@ -5185,6 +5511,7 @@ void GuiApplication::GalleryAction(const std::string& action,
|
||||
std::set<std::string> seen;
|
||||
int available = 0;
|
||||
for (const auto& [_, recent] : recent_connections_) {
|
||||
if (!recent.lan.device_id.empty()) continue;
|
||||
if (!gallery::Workspace::ValidId(recent.remote_id) ||
|
||||
!seen.insert(recent.remote_id).second)
|
||||
continue;
|
||||
@@ -5388,7 +5715,7 @@ void GuiApplication::GalleryAction(const std::string& action,
|
||||
SyncGallery();
|
||||
};
|
||||
if (action == "file") {
|
||||
OpenFileDialog(std::string(tr("选择分发文件", "Select a file", "Выберите файл")),
|
||||
OpenFileDialog(std::string(tr("选择分发文件", "Select a file to send", "Выберите файл для отправки")),
|
||||
[enqueue](std::string file) {
|
||||
if (!file.empty()) enqueue(file);
|
||||
});
|
||||
@@ -5673,8 +6000,9 @@ void GuiApplication::SyncGallery() {
|
||||
};
|
||||
const auto name = [this](const std::string& id) {
|
||||
for (const auto& [_, recent] : recent_connections_)
|
||||
if (recent.remote_id == id)
|
||||
return settings_.RecentConnectionDisplayName(recent);
|
||||
if (recent.lan.device_id.empty())
|
||||
if (recent.remote_id == id)
|
||||
return settings_.RecentConnectionDisplayName(recent);
|
||||
Thumbnail::RecentConnection device{};
|
||||
device.remote_id = id;
|
||||
if (auto props = FindRemoteSession(id)) {
|
||||
@@ -5692,7 +6020,7 @@ void GuiApplication::SyncGallery() {
|
||||
{
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
for (const auto& [id, props] : remote_sessions_)
|
||||
if (props && !props->closing_ &&
|
||||
if (props && !props->lan_session_ && !props->closing_ &&
|
||||
props->connection_status_ == ConnectionStatus::Connected)
|
||||
connected_ids.push_back(id);
|
||||
}
|
||||
@@ -5708,9 +6036,9 @@ void GuiApplication::SyncGallery() {
|
||||
device.id = UiText(FormatPeerId(id.c_str()));
|
||||
const auto mode = props->traversal_mode_.load();
|
||||
device.mode = UiText(mode == TraversalMode::P2P
|
||||
? tr("直连", "Direct", "Напрямую")
|
||||
? tr("直连", "Direct", "Прямой")
|
||||
: mode == TraversalMode::UnknownMode
|
||||
? tr("等待统计", "Awaiting stats", "Ждём данные")
|
||||
? tr("等待统计", "Awaiting stats", "Ожидание статистики")
|
||||
: tr("中继", "Relay", "Ретрансляция"));
|
||||
device.inbound = device.outbound = device.loss = "—";
|
||||
device.video_latency = "—";
|
||||
@@ -5725,9 +6053,12 @@ void GuiApplication::SyncGallery() {
|
||||
}
|
||||
const auto video = props->received_video_stats_.Get(now);
|
||||
device.resolution = video.width > 0 && video.height > 0
|
||||
? UiText(std::to_string(video.width) + " × " + std::to_string(video.height))
|
||||
? UiText(FormatResolution(video.width, video.height))
|
||||
: UiText("—");
|
||||
device.fps = video.fps ? UiText(std::to_string(*video.fps) + " FPS") : UiText("—");
|
||||
const auto codecs = props->net_traffic_stats_.LoadVideoCodecs();
|
||||
device.encoder = UiText(codecs.remote_encoder.empty() ? "—" : codecs.remote_encoder);
|
||||
device.decoder = UiText(codecs.local_decoder.empty() ? "—" : codecs.local_decoder);
|
||||
const bool has_report = props->net_traffic_stats_.HasRecentReport(now);
|
||||
const auto stats = props->net_traffic_stats_.Load();
|
||||
// Use negotiated transport state, never the local encryption preference.
|
||||
@@ -5789,11 +6120,12 @@ void GuiApplication::SyncGallery() {
|
||||
bool remember = operation.remember_password;
|
||||
if (!operation.password)
|
||||
for (const auto& [_, recent] : recent_connections_)
|
||||
if (recent.remote_id == operation.id) {
|
||||
password = recent.password;
|
||||
remember = recent.remember_password;
|
||||
break;
|
||||
}
|
||||
if (recent.lan.device_id.empty())
|
||||
if (recent.remote_id == operation.id) {
|
||||
password = recent.password;
|
||||
remember = recent.remember_password;
|
||||
break;
|
||||
}
|
||||
// Join itself determines availability. Background probes must never
|
||||
// overwrite the interactive page's single pending presence probe.
|
||||
ConnectTo(operation.id, password.c_str(), remember, true, true);
|
||||
@@ -5943,6 +6275,7 @@ void GuiApplication::SyncGallery() {
|
||||
tile.selected = wall.selected.count(id);
|
||||
auto props = FindRemoteSession(id);
|
||||
for (const auto& [_, recent] : recent_connections_) {
|
||||
if (!recent.lan.device_id.empty()) continue;
|
||||
if (recent.remote_id != id) continue;
|
||||
tile.host_name = UiText(recent.remote_host_name);
|
||||
break;
|
||||
@@ -5968,7 +6301,7 @@ void GuiApplication::SyncGallery() {
|
||||
});
|
||||
// A queued retry takes precedence over errors from the previous session.
|
||||
if (queued)
|
||||
tile.status = UiText(tr("排队中", "Queued", "В очереди"));
|
||||
tile.status = UiText(tr("队列中", "Queued", "В очереди"));
|
||||
else if (props && props->connection_auth_.Get().password_required)
|
||||
tile.status = UiText(tr("需要密码", "Password required", "Нужен пароль"));
|
||||
else if (props && !tile.connected)
|
||||
@@ -6178,6 +6511,10 @@ void GuiApplication::SyncGallery() {
|
||||
}
|
||||
|
||||
void GuiApplication::Cleanup() {
|
||||
BeginShutdown();
|
||||
while (!ShutdownDrained())
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
remote_input_.Stop();
|
||||
#if _WIN32
|
||||
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
|
||||
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
|
||||
@@ -6191,6 +6528,9 @@ void GuiApplication::Cleanup() {
|
||||
return;
|
||||
}
|
||||
ui_->file_dialog_lifetime.reset();
|
||||
#if _WIN32
|
||||
ui_->shutdown_guard.reset();
|
||||
#endif
|
||||
#if defined(__APPLE__)
|
||||
CancelNativeFileDialog();
|
||||
#endif
|
||||
@@ -6214,6 +6554,7 @@ void GuiApplication::Cleanup() {
|
||||
keyboard_.ReleaseAllRemotePressedKeys("application_exit");
|
||||
devices_.DestroyDevices();
|
||||
privacy_.Shutdown();
|
||||
HandleScreenLock();
|
||||
devices_.DestroyFactories();
|
||||
CloseAllRemoteSessions();
|
||||
WaitForSessionCleanup();
|
||||
|
||||
@@ -21,23 +21,30 @@ struct TranslationRow {
|
||||
|
||||
// Single source of truth for all UI strings.
|
||||
#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(message_unread, u8"未读", "Unread", u8"Не прочитано") \
|
||||
X(message_read, u8"已读", "Read", u8"Прочитано") \
|
||||
X(message_read_all, u8"全部已读", "Mark all as read", 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_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_delete, u8"删除", "Delete", u8"Удалить") \
|
||||
X(message_delete_title, u8"删除公告", "Delete 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(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_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_online, u8"在线", "Online", u8"В сети") \
|
||||
X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
|
||||
@@ -77,14 +84,12 @@ struct TranslationRow {
|
||||
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_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_hardware_unavailable, u8"当前构建不支持硬件编解码,未保存。", "Hardware codecs are unavailable in this build; not saved.", 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_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_setting_language, u8"语言", "Language", u8"Язык") \
|
||||
X(console_setting_codec, u8"视频编码", "Video codec", u8"Видеокодек") \
|
||||
X(console_setting_hardware, u8"硬件编解码", "Hardware codecs", u8"Аппаратные кодеки") \
|
||||
X(console_setting_turn, u8"中继模式", "TURN relay mode", u8"Режим ретрансляции TURN") \
|
||||
X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \
|
||||
X(console_setting_host, u8"服务器地址", "Server host", u8"Адрес сервера") \
|
||||
@@ -93,7 +98,7 @@ struct TranslationRow {
|
||||
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
|
||||
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", 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(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
|
||||
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
|
||||
@@ -102,32 +107,35 @@ struct TranslationRow {
|
||||
X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
|
||||
X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \
|
||||
X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \
|
||||
X(local_id, u8"本机ID", "Local ID", u8"Локальный ID") \
|
||||
X(local_id, u8"本机 ID", "Local ID", u8"Локальный ID") \
|
||||
X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
|
||||
u8"Скопировано в буфер обмена") \
|
||||
X(password, u8"密码", "Password", u8"Пароль") \
|
||||
X(refresh_password, u8"刷新随机密码", "Refresh random password", \
|
||||
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", \
|
||||
u8"Удаленный рабочий стол") \
|
||||
X(device_name, u8"设备名称", "Device Name", u8"Имя устройства") \
|
||||
X(remote_id, u8"对端ID", "Remote ID", u8"Удаленный ID") \
|
||||
X(remote_id, u8"对端 ID", "Remote ID", u8"Удаленный ID") \
|
||||
X(connect, u8"连接", "Connect", u8"Подключиться") \
|
||||
X(recent_connections, u8"近期连接", "Recent Connections", \
|
||||
u8"Недавние подключения") \
|
||||
X(disconnect, u8"断开连接", "Disconnect", u8"Отключить") \
|
||||
X(stream_confirm_disconnect, u8"断开当前连接?", "Disconnect this session?", \
|
||||
u8"Отключить текущий сеанс?") \
|
||||
X(stream_disconnect_description, u8"当前远程控制会话将结束。", \
|
||||
"The current remote session will end.", \
|
||||
u8"Текущий сеанс удалённого управления будет завершён.") \
|
||||
X(select_display, u8"选择显示器", "Select Display", u8"Выбрать дисплей") \
|
||||
X(display_screen, u8"显示屏", "Display", u8"Экран") \
|
||||
X(expand_control_bar, u8"展开控制栏", "Expand Control Bar", \
|
||||
u8"Развернуть панель управления") \
|
||||
X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \
|
||||
u8"Свернуть панель управления") \
|
||||
X(fullscreen, u8"全屏", " Fullscreen", u8"Полный экран") \
|
||||
X(show_net_traffic_stats, u8"显示网络状态", "Show Net Traffic Stats", \
|
||||
u8"Показать статистику трафика") \
|
||||
X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide Net Traffic Stats", \
|
||||
u8"Скрыть статистику трафика") \
|
||||
X(fullscreen, u8"全屏", "Fullscreen", u8"На весь экран") \
|
||||
X(show_net_traffic_stats, u8"显示网络状态", "Show network status", u8"Показать состояние сети") \
|
||||
X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide network status", u8"Скрыть состояние сети") \
|
||||
X(video, u8"视频", "Video", u8"Видео") \
|
||||
X(audio, u8"音频", "Audio", u8"Аудио") \
|
||||
X(data, u8"数据", "Data", u8"Данные") \
|
||||
@@ -139,19 +147,15 @@ struct TranslationRow {
|
||||
u8"Выйти из полноэкранного режима") \
|
||||
X(control_mouse, u8"控制鼠标", "Control Mouse", u8"Управление мышью") \
|
||||
X(release_mouse, u8"释放鼠标", "Release Mouse", u8"Освободить мышь") \
|
||||
X(audio_capture, u8"播放声音", "Audio Capture", u8"Воспроизведение звука") \
|
||||
X(mute, u8" 静音", " Mute", u8"Без звука") \
|
||||
X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Сочетания клавиш") \
|
||||
X(send_sas, u8"发送SAS", "Send SAS", u8"Отправить SAS") \
|
||||
X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать") \
|
||||
X(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_secure_desktop_active, u8"远端已进入安全桌面", \
|
||||
"Remote secure desktop active", u8"Активен защищенный рабочий стол") \
|
||||
X(remote_service_unavailable, u8"远端Windows服务不可用", \
|
||||
X(audio_capture, u8"播放声音", "Play audio", u8"Воспроизведение звука") \
|
||||
X(mute, u8"静音", "Mute", u8"Без звука") \
|
||||
X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Отправить сочетание клавиш") \
|
||||
X(send_sas, u8"发送 SAS", "Send SAS", u8"Отправить SAS") \
|
||||
X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать удалённое устройство") \
|
||||
X(remote_password_box_visible, u8"远端密码框已出现", "Remote password box visible", u8"На удалённом устройстве появилось поле пароля") \
|
||||
X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送 SAS", "Remote lock screen visible, send SAS", u8"Удалённый экран заблокирован, можно отправить SAS") \
|
||||
X(remote_secure_desktop_active, u8"远端已进入安全桌面", "Remote secure desktop active", u8"На удалённом устройстве активен защищённый рабочий стол") \
|
||||
X(remote_service_unavailable, u8"远端 Windows 服务不可用", \
|
||||
"Remote Windows service unavailable", \
|
||||
u8"Служба Windows на удаленной стороне недоступна") \
|
||||
X(windows_service_setup_title, u8"安装 CrossDesk Service", \
|
||||
@@ -170,8 +174,7 @@ struct TranslationRow {
|
||||
u8"нормально; будет недоступно только управление этим компьютером на " \
|
||||
u8"экране блокировки.") \
|
||||
X(install_windows_service, u8"安装", "Install", u8"Установить") \
|
||||
X(windows_service_settings_label, u8"锁屏控制服务:", \
|
||||
"Lock Screen Service:", u8"Служба блокировки экрана:") \
|
||||
X(windows_service_settings_label, u8"锁屏控制服务:", "Lock-screen control service:", u8"Служба управления при блокировке:") \
|
||||
X(windows_service_installed, u8"已安装", "Installed", u8"Установлена") \
|
||||
X(do_not_remind_again, u8"不再提醒", "Do not remind again", \
|
||||
u8"Больше не напоминать") \
|
||||
@@ -207,13 +210,8 @@ struct TranslationRow {
|
||||
u8"Способ захвата:") \
|
||||
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
|
||||
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
|
||||
X(remote_update_title, u8"建议升级被控端", "Remote update recommended", \
|
||||
u8"Рекомендуется обновление") \
|
||||
X(remote_update, \
|
||||
u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", \
|
||||
"Update the remote device to the latest version for improved features and performance.", \
|
||||
u8"В новой версии улучшены функции и производительность. " \
|
||||
u8"Рекомендуем обновить CrossDesk на удалённом устройстве.") \
|
||||
X(remote_update_title, u8"建议升级被控端", "Remote update recommended", u8"Рекомендуем обновить удалённое устройство") \
|
||||
X(remote_update, u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", "Update the remote device to the latest version for improved features and performance.", u8"В новой версии улучшены функции и производительность. Рекомендуем обновить CrossDesk на удалённом устройстве до последней версии.") \
|
||||
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
|
||||
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
|
||||
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \
|
||||
@@ -230,48 +228,34 @@ struct TranslationRow {
|
||||
X(video_quality_high, u8"高", "High", u8"Высокое") \
|
||||
X(video_quality_medium, u8"中", "Medium", u8"Среднее") \
|
||||
X(video_quality_low, u8"低", "Low", u8"Низкое") \
|
||||
X(video_encode_format, u8"视频编码格式:", \
|
||||
"Video Encode Format:", u8"Формат кодека видео:") \
|
||||
X(video_encode_format, u8"视频编码格式:", "Video codec:", u8"Видеокодек:") \
|
||||
X(av1, u8"AV1", "AV1", "AV1") \
|
||||
X(h264, u8"H.264", "H.264", "H.264") \
|
||||
X(enable_hardware_video_codec, u8"启用硬件编解码器:", \
|
||||
"Enable Hardware Video Codec:", u8"Использовать аппаратный кодек:") \
|
||||
X(enable_turn, u8"启用中继服务:", \
|
||||
"Enable TURN Service:", u8"Включить TURN-сервис:") \
|
||||
X(force_relay, u8"强制中继连接:", "Force Relay:", u8"Только через реле:") \
|
||||
X(self_hosted_server_config, u8"自托管配置", "Self-Hosted Config", \
|
||||
u8"Конфигурация self-hosted") \
|
||||
X(self_hosted_server_settings, u8"自托管设置", "Self-Hosted Settings", \
|
||||
u8"Настройки self-hosted") \
|
||||
X(force_relay, u8"强制中继连接:", "Force relay connection:", u8"Только через ретранслятор:") \
|
||||
X(self_hosted_server_config, u8"自托管配置", "Self-hosted server", u8"Собственный сервер") \
|
||||
X(self_hosted_server_settings, u8"自托管设置", "Self-hosted server settings", u8"Настройки своего сервера") \
|
||||
X(self_hosted_server_address, u8"服务器地址:", \
|
||||
"Server Address:", u8"Адрес сервера:") \
|
||||
X(self_hosted_server_port, u8"信令服务端口:", \
|
||||
"Signal Service Port:", u8"Порт сигнального сервиса:") \
|
||||
X(self_hosted_server_port, u8"信令服务端口:", "Signaling 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_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(cancel, u8"取消", "Cancel", u8"Отмена") \
|
||||
X(new_password, u8"请输入六位密码:", \
|
||||
"Please input a six-char password:", u8"Введите шестизначный пароль:") \
|
||||
X(new_password, u8"请输入六位密码:", "Enter a six-character password:", u8"Введите пароль из 6 символов:") \
|
||||
X(input_password, u8"请输入密码:", \
|
||||
"Please input password:", u8"Введите пароль:") \
|
||||
X(validate_password, u8"验证密码中...", "Validate password ...", \
|
||||
u8"Проверка пароля...") \
|
||||
X(validate_password, u8"验证密码中...", "Validating password...", u8"Проверка пароля...") \
|
||||
X(reinput_password, u8"请重新输入密码", "Please input password again", \
|
||||
u8"Повторно введите пароль") \
|
||||
X(remember_password, u8"记住密码", "Remember password", \
|
||||
u8"Запомнить пароль") \
|
||||
X(signal_connected, u8"已连接服务器", "Connected", u8"Подключено к серверу") \
|
||||
X(signal_disconnected, u8"未连接服务器", "Disconnected", \
|
||||
u8"Нет подключения к серверу") \
|
||||
X(signal_connected, u8"已连接服务器", "Connected to server", u8"Подключено к серверу") \
|
||||
X(signal_disconnected, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
|
||||
X(signal_reconnect, u8"重新连接", "Reconnect", u8"Переподключиться") \
|
||||
X(signal_reconnecting, u8"正在重新连接...", "Reconnecting...", \
|
||||
u8"Переподключение...") \
|
||||
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", \
|
||||
"Certificate verification failed. Reinstall the self-hosted root " \
|
||||
"certificate.", \
|
||||
u8"Ошибка проверки сертификата. Переустановите корневой сертификат.") \
|
||||
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", "Certificate verification failed. Reinstall the self-hosted root certificate.", u8"Ошибка проверки сертификата. Переустановите корневой сертификат собственного сервера.") \
|
||||
X(p2p_connected, u8"对等连接已建立", "P2P Connected", u8"P2P подключено") \
|
||||
X(p2p_disconnected, u8"对等连接已断开", "P2P Disconnected", \
|
||||
u8"P2P отключено") \
|
||||
@@ -282,12 +266,12 @@ struct TranslationRow {
|
||||
X(receiving_screen, u8"画面接收中...", "Receiving screen...", \
|
||||
u8"Получение изображения...") \
|
||||
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_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", u8"Сеанс входа истёк. Подключитесь заново.") \
|
||||
X(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \
|
||||
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \
|
||||
X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \
|
||||
X(no_such_id, u8"无此 ID", "No such ID", u8"ID не найден") \
|
||||
X(about, u8"关于", "About", u8"О программе") \
|
||||
X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \
|
||||
X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
|
||||
@@ -318,26 +302,24 @@ struct TranslationRow {
|
||||
"Access Website: ", u8"Официальный сайт: ") \
|
||||
X(update, u8"更新", "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 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?", \
|
||||
"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 закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \
|
||||
X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \
|
||||
X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \
|
||||
X(connection_alias, u8"修改名称", "Edit Alias", \
|
||||
u8"Изменить имя подключения") \
|
||||
X(connection_alias, u8"修改名称", "Rename", u8"Переименовать") \
|
||||
X(delete_connection, u8"删除连接", "Delete Connection", \
|
||||
u8"Удалить подключение") \
|
||||
X(connect_to_this_connection, u8"发起连接", "Connect to this connection", \
|
||||
u8"Подключиться") \
|
||||
X(connect_to_this_connection, u8"发起连接", "Connect", u8"Подключиться") \
|
||||
X(input_connection_alias, u8"请输入连接名称:", \
|
||||
"Please input connection name:", u8"Введите имя подключения:") \
|
||||
X(confirm_delete_connection, u8"确认删除此连接", \
|
||||
"Confirm to delete this connection", u8"Удалить это подключение?") \
|
||||
X(confirm_delete_connection, u8"确认删除此连接", "Delete this connection?", u8"Удалить это подключение?") \
|
||||
X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \
|
||||
X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \
|
||||
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on connect:", \
|
||||
u8"Приватность при подключении:") \
|
||||
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on incoming connection:", u8"Приватный экран при подключении:") \
|
||||
X(lock_on_disconnect, u8"连接断开后锁屏:", "Lock screen on disconnect:", \
|
||||
u8"Блокировка при отключении:") \
|
||||
X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \
|
||||
u8"Вступит в силу после перезапуска") \
|
||||
X(select_file, u8"选择文件发送", "Select File to Send", \
|
||||
@@ -370,17 +352,19 @@ struct TranslationRow {
|
||||
"File Transfer Save Path:", u8"Путь сохранения файлов:") \
|
||||
X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \
|
||||
X(resolution, u8"分辨率", "Res", u8"Разрешение") \
|
||||
X(video_latency, u8"视频延时", "Video delay", u8"Задержка") \
|
||||
X(connection_latency, u8"连接延时", "Connection RTT", u8"Задержка соединения") \
|
||||
X(connection_mode, u8"连接模式", "Mode", u8"Режим") \
|
||||
X(video_latency, u8"视频延时", "Video latency", u8"Задержка видео") \
|
||||
X(connection_latency, u8"连接延时", "Connection latency", u8"Задержка соединения") \
|
||||
X(connection_mode, u8"连接模式", "Connection mode", 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(remote_video_encoder, u8"被控端编码器", "Remote encoder", u8"Кодер удалённой стороны") \
|
||||
X(local_video_decoder, u8"控制端解码器", "Local decoder", u8"Локальный декодер") \
|
||||
X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \
|
||||
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
|
||||
X(online, u8"在线", "Online", u8"Онлайн") \
|
||||
X(offline, u8"离线", "Offline", u8"Офлайн") \
|
||||
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство офлайн") \
|
||||
X(online, u8"在线", "Online", u8"В сети") \
|
||||
X(offline, u8"离线", "Offline", u8"Не в сети") \
|
||||
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство не в сети") \
|
||||
X(request_permissions, u8"权限请求", "Request Permissions", \
|
||||
u8"Запрос разрешений") \
|
||||
X(screen_recording_permission, u8"屏幕录制权限", \
|
||||
|
||||
@@ -226,15 +226,11 @@ int ClipboardController::SendToPeers(const std::string &text) {
|
||||
}
|
||||
}
|
||||
|
||||
if (owner_.peer_) {
|
||||
const int ret =
|
||||
SendReliableDataFrame(owner_.peer_, text.data(), text.size(),
|
||||
owner_.clipboard_label_.c_str());
|
||||
if (ret != 0) {
|
||||
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
|
||||
owner_.remote_id_display_, ret);
|
||||
return ret;
|
||||
}
|
||||
lock.unlock();
|
||||
for (const auto& id : owner_.ConnectedControllers()) {
|
||||
const int ret = owner_.SendControllerData(id, text.data(), text.size(),
|
||||
owner_.clipboard_label_.c_str());
|
||||
if (ret != 0) return ret;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
#include "features/devices/screen_sharing_power.h"
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#elif defined(__APPLE__)
|
||||
#include <IOKit/pwr_mgt/IOPMLib.h>
|
||||
#elif defined(__linux__) && !defined(__OHOS__)
|
||||
#include <dbus/dbus.h>
|
||||
#include <unistd.h>
|
||||
#else
|
||||
#include <SDL3/SDL.h>
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
namespace {
|
||||
|
||||
constexpr const char* kApplication = "CrossDesk";
|
||||
constexpr const char* kReason = "Sharing the desktop with a remote controller";
|
||||
constexpr int kDbusTimeoutMs = 1000;
|
||||
|
||||
struct ConnectionCloser {
|
||||
void operator()(DBusConnection* connection) const {
|
||||
dbus_connection_close(connection);
|
||||
dbus_connection_unref(connection);
|
||||
}
|
||||
};
|
||||
using BusConnection = std::unique_ptr<DBusConnection, ConnectionCloser>;
|
||||
using BusMessage = std::unique_ptr<DBusMessage, decltype(&dbus_message_unref)>;
|
||||
|
||||
BusConnection OpenBus(DBusBusType type) {
|
||||
DBusError error;
|
||||
dbus_error_init(&error);
|
||||
BusConnection connection(dbus_bus_get_private(type, &error));
|
||||
if (connection) {
|
||||
dbus_connection_set_exit_on_disconnect(connection.get(), false);
|
||||
} else {
|
||||
LOG_WARN("Cannot connect to power management bus: {}",
|
||||
error.message ? error.message : "unknown error");
|
||||
}
|
||||
dbus_error_free(&error);
|
||||
return connection;
|
||||
}
|
||||
|
||||
BusMessage Call(DBusConnection* connection, DBusMessage* message) {
|
||||
return BusMessage(dbus_connection_send_with_reply_and_block(
|
||||
connection, message, kDbusTimeoutMs, nullptr),
|
||||
dbus_message_unref);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
struct ScreenSharingPower::Impl {
|
||||
#ifdef _WIN32
|
||||
HANDLE request = INVALID_HANDLE_VALUE;
|
||||
bool display = false;
|
||||
bool system = false;
|
||||
|
||||
void Start() {
|
||||
wchar_t reason_text[] = L"CrossDesk is sharing the desktop";
|
||||
REASON_CONTEXT reason{};
|
||||
reason.Version = POWER_REQUEST_CONTEXT_VERSION;
|
||||
reason.Flags = POWER_REQUEST_CONTEXT_SIMPLE_STRING;
|
||||
reason.Reason.SimpleReasonString = reason_text;
|
||||
request = PowerCreateRequest(&reason);
|
||||
if (request == INVALID_HANDLE_VALUE) {
|
||||
LOG_WARN("Cannot create screen sharing power request: {}", GetLastError());
|
||||
return;
|
||||
}
|
||||
// Handle-based requests also work in the unattended host, which has no
|
||||
// SDL video subsystem, and do not depend on the calling thread's lifetime.
|
||||
system = PowerSetRequest(request, PowerRequestSystemRequired) != 0;
|
||||
if (!system)
|
||||
LOG_WARN("Cannot prevent system idle sleep: {}", GetLastError());
|
||||
display = PowerSetRequest(request, PowerRequestDisplayRequired) != 0;
|
||||
if (!display)
|
||||
LOG_WARN("Cannot prevent display idle sleep: {}", GetLastError());
|
||||
}
|
||||
|
||||
void Stop() {
|
||||
if (request == INVALID_HANDLE_VALUE) return;
|
||||
if (display) PowerClearRequest(request, PowerRequestDisplayRequired);
|
||||
if (system) PowerClearRequest(request, PowerRequestSystemRequired);
|
||||
CloseHandle(request);
|
||||
request = INVALID_HANDLE_VALUE;
|
||||
display = system = false;
|
||||
}
|
||||
#elif defined(__APPLE__)
|
||||
IOPMAssertionID display = kIOPMNullAssertionID;
|
||||
IOPMAssertionID system = kIOPMNullAssertionID;
|
||||
|
||||
void Start() {
|
||||
const auto reason = CFSTR("CrossDesk is sharing the desktop");
|
||||
auto result = IOPMAssertionCreateWithName(
|
||||
kIOPMAssertionTypePreventUserIdleSystemSleep, kIOPMAssertionLevelOn,
|
||||
reason, &system);
|
||||
if (result != kIOReturnSuccess)
|
||||
LOG_WARN("Cannot prevent system idle sleep: {}", result);
|
||||
result = IOPMAssertionCreateWithName(
|
||||
kIOPMAssertionTypePreventUserIdleDisplaySleep, kIOPMAssertionLevelOn,
|
||||
reason, &display);
|
||||
if (result != kIOReturnSuccess)
|
||||
LOG_WARN("Cannot prevent display idle sleep: {}", result);
|
||||
}
|
||||
|
||||
void Stop() {
|
||||
if (display != kIOPMNullAssertionID) IOPMAssertionRelease(display);
|
||||
if (system != kIOPMNullAssertionID) IOPMAssertionRelease(system);
|
||||
display = system = kIOPMNullAssertionID;
|
||||
}
|
||||
#elif defined(__linux__) && !defined(__OHOS__)
|
||||
BusConnection session;
|
||||
dbus_uint32_t cookie = 0;
|
||||
bool desktop_inhibited = false;
|
||||
bool gnome = false;
|
||||
int sleep_fd = -1;
|
||||
|
||||
bool InhibitDesktop(bool use_gnome) {
|
||||
const char* service = use_gnome ? "org.gnome.SessionManager"
|
||||
: "org.freedesktop.ScreenSaver";
|
||||
const char* path = use_gnome ? "/org/gnome/SessionManager"
|
||||
: "/org/freedesktop/ScreenSaver";
|
||||
BusMessage message(dbus_message_new_method_call(service, path, service,
|
||||
"Inhibit"),
|
||||
dbus_message_unref);
|
||||
if (!message) return false;
|
||||
dbus_bool_t appended;
|
||||
if (use_gnome) {
|
||||
const dbus_uint32_t window = 0;
|
||||
const dbus_uint32_t flags = 4 | 8; // Suspend and session idle.
|
||||
appended = dbus_message_append_args(
|
||||
message.get(), DBUS_TYPE_STRING, &kApplication, DBUS_TYPE_UINT32,
|
||||
&window, DBUS_TYPE_STRING, &kReason, DBUS_TYPE_UINT32, &flags,
|
||||
DBUS_TYPE_INVALID);
|
||||
} else {
|
||||
appended = dbus_message_append_args(
|
||||
message.get(), DBUS_TYPE_STRING, &kApplication, DBUS_TYPE_STRING,
|
||||
&kReason, DBUS_TYPE_INVALID);
|
||||
}
|
||||
if (!appended) return false;
|
||||
auto reply = Call(session.get(), message.get());
|
||||
if (!reply || !dbus_message_get_args(reply.get(), nullptr, DBUS_TYPE_UINT32,
|
||||
&cookie, DBUS_TYPE_INVALID))
|
||||
return false;
|
||||
gnome = use_gnome;
|
||||
desktop_inhibited = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Start() {
|
||||
if (!dbus_threads_init_default()) {
|
||||
LOG_WARN("Cannot initialize D-Bus for screen sharing power management");
|
||||
return;
|
||||
}
|
||||
// These session APIs work without an SDL window, including native Wayland
|
||||
// and a Slint window hidden in the tray. GNOME's API inhibits both kinds of
|
||||
// idle sleep; other desktops use ScreenSaver plus logind below.
|
||||
session = OpenBus(DBUS_BUS_SESSION);
|
||||
if (session && InhibitDesktop(true)) return;
|
||||
if (!session || !InhibitDesktop(false))
|
||||
LOG_WARN("Cannot inhibit desktop idle while sharing the screen");
|
||||
|
||||
auto system_bus = OpenBus(DBUS_BUS_SYSTEM);
|
||||
if (!system_bus) return;
|
||||
BusMessage message(dbus_message_new_method_call(
|
||||
"org.freedesktop.login1", "/org/freedesktop/login1",
|
||||
"org.freedesktop.login1.Manager", "Inhibit"),
|
||||
dbus_message_unref);
|
||||
const char* what = "sleep:idle";
|
||||
const char* mode = "block";
|
||||
if (!message || !dbus_message_append_args(
|
||||
message.get(), DBUS_TYPE_STRING, &what,
|
||||
DBUS_TYPE_STRING, &kApplication, DBUS_TYPE_STRING,
|
||||
&kReason, DBUS_TYPE_STRING, &mode, DBUS_TYPE_INVALID)) {
|
||||
LOG_WARN("Cannot create system sleep inhibition request");
|
||||
return;
|
||||
}
|
||||
auto reply = Call(system_bus.get(), message.get());
|
||||
// get_args duplicates the returned fd with CLOEXEC. Holding this fd keeps
|
||||
// the inhibitor alive independently of the reply or D-Bus connection.
|
||||
if (!reply || !dbus_message_get_args(reply.get(), nullptr, DBUS_TYPE_UNIX_FD,
|
||||
&sleep_fd, DBUS_TYPE_INVALID))
|
||||
LOG_WARN("Cannot inhibit system sleep while sharing the screen");
|
||||
}
|
||||
|
||||
void Stop() {
|
||||
if (sleep_fd >= 0) {
|
||||
close(sleep_fd);
|
||||
sleep_fd = -1;
|
||||
}
|
||||
if (session && desktop_inhibited &&
|
||||
dbus_connection_get_is_connected(session.get())) {
|
||||
const char* service = gnome ? "org.gnome.SessionManager"
|
||||
: "org.freedesktop.ScreenSaver";
|
||||
const char* path = gnome ? "/org/gnome/SessionManager"
|
||||
: "/org/freedesktop/ScreenSaver";
|
||||
BusMessage message(dbus_message_new_method_call(
|
||||
service, path, service,
|
||||
gnome ? "Uninhibit" : "UnInhibit"),
|
||||
dbus_message_unref);
|
||||
if (message && dbus_message_append_args(
|
||||
message.get(), DBUS_TYPE_UINT32, &cookie,
|
||||
DBUS_TYPE_INVALID)) {
|
||||
dbus_message_set_no_reply(message.get(), true);
|
||||
dbus_connection_send(session.get(), message.get(), nullptr);
|
||||
dbus_connection_flush(session.get());
|
||||
}
|
||||
}
|
||||
// A private connection also revokes the desktop inhibitor if UnInhibit
|
||||
// could not be sent. Nothing is retained by unrelated D-Bus clients.
|
||||
session.reset();
|
||||
desktop_inhibited = false;
|
||||
gnome = false;
|
||||
cookie = 0;
|
||||
}
|
||||
#else
|
||||
void Start() {
|
||||
if (!SDL_DisableScreenSaver())
|
||||
LOG_WARN("Cannot prevent display idle sleep: {}", SDL_GetError());
|
||||
}
|
||||
void Stop() { SDL_EnableScreenSaver(); }
|
||||
#endif
|
||||
};
|
||||
|
||||
ScreenSharingPower::ScreenSharingPower() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
ScreenSharingPower::~ScreenSharingPower() { SetSharing(false); }
|
||||
|
||||
void ScreenSharingPower::SetSharing(bool sharing) {
|
||||
if (sharing_ == sharing) return;
|
||||
sharing_ = sharing;
|
||||
if (sharing) {
|
||||
impl_->Start();
|
||||
LOG_INFO("Requested display and system idle-sleep inhibition for screen sharing");
|
||||
} else {
|
||||
impl_->Stop();
|
||||
LOG_INFO("Released screen sharing power inhibitors");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-24
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SCREEN_SHARING_POWER_H_
|
||||
#define _SCREEN_SHARING_POWER_H_
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Owns temporary display/system idle-sleep inhibitors for the shared capture
|
||||
// source. Call on the UI thread; repeated updates do not acquire extra requests.
|
||||
class ScreenSharingPower {
|
||||
public:
|
||||
ScreenSharingPower();
|
||||
~ScreenSharingPower();
|
||||
ScreenSharingPower(const ScreenSharingPower&) = delete;
|
||||
ScreenSharingPower& operator=(const ScreenSharingPower&) = delete;
|
||||
|
||||
void SetSharing(bool sharing);
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
bool sharing_ = false;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -21,7 +21,6 @@
|
||||
#ifdef _WIN32
|
||||
#include <nlohmann/json.hpp>
|
||||
#include "platform/windows/screen_capturer/screen_capturer_win.h"
|
||||
#include "platform/windows/input/mouse/mouse_controller.h"
|
||||
#include "service_host.h"
|
||||
#endif
|
||||
|
||||
@@ -44,18 +43,17 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
|
||||
SpeakerCapturerFactory().Create());
|
||||
},
|
||||
[this](unsigned char *data, size_t size, const char *) {
|
||||
if (!owner_.peer_) return;
|
||||
MiniRtcAudioFrame frame{};
|
||||
frame.data = reinterpret_cast<const char *>(data);
|
||||
frame.size = size;
|
||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||
for (const auto& [id, status] : owner_.connection_status_) {
|
||||
if (status == ConnectionStatus::Connected)
|
||||
SendAudioFrameToPeer(owner_.peer_, &frame,
|
||||
owner_.audio_label_.c_str(), id.data(),
|
||||
id.size());
|
||||
}
|
||||
for (const auto& id : owner_.ConnectedControllers())
|
||||
owner_.WithController(
|
||||
id, [&](PeerPtr* peer, const std::string& wire) {
|
||||
frame.captured_timestamp = GetSystemTimeMicros(peer);
|
||||
return SendAudioFrameToPeer(peer, &frame,
|
||||
owner_.audio_label_.c_str(),
|
||||
wire.data(), wire.size());
|
||||
});
|
||||
}) {}
|
||||
|
||||
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
|
||||
@@ -133,6 +131,10 @@ void SessionDeviceManager::Initialize() {
|
||||
DeviceControllerFactory::Device::Keyboard));
|
||||
}
|
||||
|
||||
int SessionDeviceManager::EnsureCaptureReady() {
|
||||
return screen_capturer_ ? 0 : InitializeScreenCapturer();
|
||||
}
|
||||
|
||||
int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
#ifdef __APPLE__
|
||||
if (!owner_.EnsureMacScreenRecordingPermission()) {
|
||||
@@ -194,16 +196,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
.count()
|
||||
: 0;
|
||||
|
||||
std::vector<std::string> connected_remote_ids;
|
||||
{
|
||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||
connected_remote_ids.reserve(owner_.connection_status_.size());
|
||||
for (const auto &[remote_id, status] : owner_.connection_status_) {
|
||||
if (status == ConnectionStatus::Connected) {
|
||||
connected_remote_ids.push_back(remote_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
const auto connected_remote_ids = owner_.ConnectedControllers();
|
||||
|
||||
// Capture can still deliver frames while ICE is gathering or after
|
||||
// the final controller disconnects. Do not broadcast those frames to
|
||||
@@ -233,28 +226,27 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs;
|
||||
const bool stream_changed = !last_video_frame_stream_id_.empty() &&
|
||||
last_video_frame_stream_id_ != stream_id;
|
||||
if (resumed_after_gap || stream_changed) {
|
||||
if (RequestVideoKeyFrame(owner_.peer_, stream_id.c_str()) == 0) {
|
||||
LOG_DEBUG("Request video key frame before sending captured frame, "
|
||||
"stream='{}', gap_ms={}, stream_changed={}",
|
||||
stream_id, duration_ms, stream_changed);
|
||||
}
|
||||
}
|
||||
|
||||
MiniRtcVideoFrame frame{};
|
||||
frame.data = reinterpret_cast<const char *>(data);
|
||||
frame.size = size;
|
||||
frame.width = width;
|
||||
frame.height = height;
|
||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
||||
|
||||
frame.native_frame = native_frame;
|
||||
const auto send_started = std::chrono::steady_clock::now();
|
||||
uint64_t send_errors = 0;
|
||||
for (const std::string &remote_id : connected_remote_ids) {
|
||||
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
|
||||
stream_id)) continue;
|
||||
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
|
||||
remote_id.data(), remote_id.size()) != 0)
|
||||
if (owner_.WithController(
|
||||
remote_id, [&](PeerPtr* peer, const std::string& wire) {
|
||||
if (resumed_after_gap || stream_changed)
|
||||
RequestVideoKeyFrame(peer, stream_id.c_str());
|
||||
frame.captured_timestamp = GetSystemTimeMicros(peer);
|
||||
return SendVideoFrameToPeer(peer, &frame, stream_id.c_str(),
|
||||
wire.data(), wire.size());
|
||||
}) != 0)
|
||||
++send_errors;
|
||||
}
|
||||
const auto send_us =
|
||||
@@ -299,6 +291,21 @@ bool SessionDeviceManager::ShouldCaptureCursor() const {
|
||||
});
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::HasConnectedController() const {
|
||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||
return std::any_of(owner_.connection_status_.begin(),
|
||||
owner_.connection_status_.end(), [](const auto& entry) {
|
||||
return entry.second == ConnectionStatus::Connected;
|
||||
});
|
||||
}
|
||||
|
||||
void SessionDeviceManager::UpdateScreenSharingPower() {
|
||||
// Wayland keeps capture warm after the last disconnect. Only live viewers
|
||||
// justify keeping the host awake; an outgoing viewing session does not.
|
||||
screen_sharing_power_.SetSharing(owner_.screen_capturer_is_started_ &&
|
||||
HasConnectedController());
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StartScreenCapturer() {
|
||||
#ifdef __APPLE__
|
||||
if (!owner_.EnsureMacScreenRecordingPermission()) {
|
||||
@@ -318,8 +325,10 @@ int SessionDeviceManager::StartScreenCapturer() {
|
||||
cursor_capture_dirty_.store(false, std::memory_order_relaxed);
|
||||
const bool show_cursor = ShouldCaptureCursor();
|
||||
LOG_INFO("Start screen capturer, show cursor: {}", show_cursor);
|
||||
screen_sharing_power_.SetSharing(HasConnectedController());
|
||||
const int ret = screen_capturer_->Start(show_cursor);
|
||||
if (ret != 0) {
|
||||
screen_sharing_power_.SetSharing(false);
|
||||
LOG_ERROR("Start screen capturer failed: {}", ret);
|
||||
}
|
||||
return ret;
|
||||
@@ -330,29 +339,49 @@ int SessionDeviceManager::StopScreenCapturer() {
|
||||
LOG_INFO("Stop screen capturer");
|
||||
screen_capturer_->Stop();
|
||||
}
|
||||
owner_.screen_capturer_is_started_ = false;
|
||||
screen_sharing_power_.SetSharing(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::ReleaseRemoteMouseButtons() {
|
||||
return remote_mouse_buttons_.ReleaseAll(
|
||||
[this](unsigned mask) { return ReleaseMouseButtonMask(mask); });
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::ReleaseRemoteMouseButtons(const std::string& remote_id,
|
||||
unsigned mask) {
|
||||
return remote_mouse_buttons_.ReleaseController(
|
||||
remote_id, [this](unsigned buttons) { return ReleaseMouseButtonMask(buttons); }, mask);
|
||||
}
|
||||
|
||||
unsigned SessionDeviceManager::ReleaseMouseButtonMask(unsigned mask) {
|
||||
unsigned released = mask;
|
||||
{
|
||||
std::lock_guard lock(mouse_controller_mutex_);
|
||||
if (mouse_controller_) released &= mouse_controller_->ReleasePressedButtons(mask);
|
||||
}
|
||||
#ifdef _WIN32
|
||||
void SessionDeviceManager::ReleaseRemoteMouseButtons() {
|
||||
if (auto mouse = dynamic_cast<PlatformMouseController*>(mouse_controller_))
|
||||
mouse->ReleasePressedButtons();
|
||||
std::lock_guard lock(service_mouse_mutex_);
|
||||
for (int button = 0; button < 3; ++button) {
|
||||
const unsigned bit = 1u << button;
|
||||
if (!(service_mouse_buttons_ & bit)) continue;
|
||||
if (!(service_mouse_buttons_ & mask & bit)) continue;
|
||||
const auto response = SendCrossDeskSecureDesktopMouseInput(
|
||||
service_mouse_x_, service_mouse_y_, 0, (button + 1) * 2, 1000);
|
||||
const auto json = nlohmann::json::parse(response, nullptr, false);
|
||||
if (json.is_object() && json.value("ok", false)) {
|
||||
service_mouse_buttons_ &= ~bit;
|
||||
} else {
|
||||
released &= ~bit;
|
||||
LOG_WARN("Service mouse button release failed: button={}, response={}",
|
||||
button, response);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return released;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
std::string SessionDeviceManager::SendServiceMouseCommand(int x, int y,
|
||||
int wheel, int flag) {
|
||||
std::lock_guard lock(service_mouse_mutex_);
|
||||
@@ -398,7 +427,10 @@ bool SessionDeviceManager::SyncDisplayInfo() {
|
||||
});
|
||||
if (changed) display_info_list_ = displays;
|
||||
}
|
||||
if (changed && mouse_controller_) mouse_controller_->UpdateDisplayInfoList(displays);
|
||||
if (changed) {
|
||||
std::lock_guard lock(mouse_controller_mutex_);
|
||||
if (mouse_controller_) mouse_controller_->UpdateDisplayInfoList(displays);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -434,6 +466,7 @@ int SessionDeviceManager::StartMouseController() {
|
||||
#endif
|
||||
|
||||
SyncDisplayInfo();
|
||||
std::lock_guard lock(mouse_controller_mutex_);
|
||||
mouse_controller_ =
|
||||
static_cast<MouseController *>(device_controller_factory_->Create(
|
||||
DeviceControllerFactory::Device::Mouse));
|
||||
@@ -452,10 +485,16 @@ int SessionDeviceManager::StartMouseController() {
|
||||
return init_ret;
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StopMouseController() {
|
||||
#ifdef _WIN32
|
||||
int SessionDeviceManager::StopMouseController(bool force) {
|
||||
// Retain the native button state across transient cleanup failures. The UI
|
||||
// can keep pumping while the input worker retries, including with no peers.
|
||||
if (!force && owner_.remote_input_.HasPendingReleases()) return 1;
|
||||
// A disconnect has revoked queued input. Finish its running injection and
|
||||
// release callback before destroying the native controller.
|
||||
owner_.remote_input_.WaitIdle();
|
||||
if (!force && owner_.remote_input_.HasPendingReleases()) return 1;
|
||||
ReleaseRemoteMouseButtons();
|
||||
#endif
|
||||
std::lock_guard lock(mouse_controller_mutex_);
|
||||
if (mouse_controller_) {
|
||||
mouse_controller_->Destroy();
|
||||
delete mouse_controller_;
|
||||
@@ -620,6 +659,8 @@ void SessionDeviceManager::PushAudio(const std::string& remote_id,
|
||||
}
|
||||
|
||||
void SessionDeviceManager::UpdateInteractions() {
|
||||
// Release even when pending remote-input cleanup makes this tick return early.
|
||||
UpdateScreenSharingPower();
|
||||
#ifdef _WIN32
|
||||
if (owner_.screen_capturer_is_started_ &&
|
||||
cursor_capture_dirty_.load(std::memory_order_relaxed)) {
|
||||
@@ -636,9 +677,9 @@ void SessionDeviceManager::UpdateInteractions() {
|
||||
owner_.screen_capturer_is_started_ && !owner_.start_mouse_controller_ &&
|
||||
owner_.mouse_controller_is_started_;
|
||||
if (stop_wayland_mouse_before_screen) {
|
||||
LOG_INFO("Stopping Wayland mouse controller before screen capturer to "
|
||||
if (StopMouseController() != 0) return;
|
||||
LOG_INFO("Stopped Wayland mouse controller before screen capturer to "
|
||||
"cleanly release the shared portal session");
|
||||
StopMouseController();
|
||||
owner_.mouse_controller_is_started_ = false;
|
||||
}
|
||||
#endif
|
||||
@@ -653,14 +694,15 @@ void SessionDeviceManager::UpdateInteractions() {
|
||||
owner_.screen_capturer_is_started_ = false;
|
||||
}
|
||||
|
||||
UpdateScreenSharingPower();
|
||||
|
||||
if (owner_.start_mouse_controller_ && !owner_.mouse_controller_is_started_) {
|
||||
if (StartMouseController() == 0) {
|
||||
owner_.mouse_controller_is_started_ = true;
|
||||
}
|
||||
} else if (!owner_.start_mouse_controller_ &&
|
||||
owner_.mouse_controller_is_started_) {
|
||||
StopMouseController();
|
||||
owner_.mouse_controller_is_started_ = false;
|
||||
if (StopMouseController() == 0) owner_.mouse_controller_is_started_ = false;
|
||||
}
|
||||
|
||||
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
|
||||
@@ -750,6 +792,7 @@ bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
|
||||
|
||||
bool SessionDeviceManager::SendMouseCommand(const RemoteAction &action,
|
||||
int selected_display) {
|
||||
std::lock_guard lock(mouse_controller_mutex_);
|
||||
if (mouse_controller_) {
|
||||
return mouse_controller_->SendMouseCommand(action, selected_display) == 0;
|
||||
}
|
||||
@@ -763,19 +806,20 @@ int SessionDeviceManager::SwitchDisplay(int display_id) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::ResetToInitialDisplay() {
|
||||
InputOutcome SessionDeviceManager::ResetToInitialDisplay() {
|
||||
if (!screen_capturer_) {
|
||||
return;
|
||||
return InputOutcome::ignored;
|
||||
}
|
||||
const int ret = screen_capturer_->ResetToInitialMonitor();
|
||||
if (ret != 0) {
|
||||
LOG_WARN("Reset to initial display failed, ret={}", ret);
|
||||
return;
|
||||
return InputOutcome::failed;
|
||||
}
|
||||
// Capture backends start at display 0. Reset the input mapping as well so
|
||||
// the next connection cannot show that display while controlling another.
|
||||
owner_.selected_display_ = 0;
|
||||
SyncDisplayInfo();
|
||||
return InputOutcome::applied;
|
||||
}
|
||||
|
||||
std::vector<DisplayInfo> SessionDeviceManager::display_info_list() const {
|
||||
@@ -795,8 +839,10 @@ std::vector<HostDisplay> SessionDeviceManager::host_display_list() const {
|
||||
}
|
||||
|
||||
void SessionDeviceManager::DestroyDevices() {
|
||||
owner_.screen_capturer_is_started_ = false;
|
||||
screen_sharing_power_.SetSharing(false);
|
||||
speaker_capture_.Shutdown();
|
||||
StopMouseController();
|
||||
StopMouseController(true);
|
||||
if (screen_capturer_) {
|
||||
screen_capturer_->Destroy();
|
||||
delete screen_capturer_;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <remote_action.h>
|
||||
@@ -21,6 +22,8 @@
|
||||
#include "device_controller.h"
|
||||
#include "display_info.h"
|
||||
#include "features/input/captured_keyboard_queue.h"
|
||||
#include "features/input/remote_mouse_buttons.h"
|
||||
#include "screen_sharing_power.h"
|
||||
#include "speaker_capture_controller.h"
|
||||
|
||||
struct SDL_AudioStream;
|
||||
@@ -51,10 +54,18 @@ public:
|
||||
void DestroyFactories();
|
||||
|
||||
int InitializeScreenCapturer();
|
||||
int EnsureCaptureReady();
|
||||
int StartScreenCapturer();
|
||||
int StopScreenCapturer();
|
||||
bool ReleaseRemoteMouseButtons();
|
||||
bool ReleaseRemoteMouseButtons(const std::string& remote_id,
|
||||
unsigned mask = 7);
|
||||
template <typename Send>
|
||||
InputOutcome ApplyRemoteMouse(const std::string& remote_id, MouseFlag flag,
|
||||
Send send) {
|
||||
return remote_mouse_buttons_.Apply(remote_id, flag, std::move(send));
|
||||
}
|
||||
#ifdef _WIN32
|
||||
void ReleaseRemoteMouseButtons();
|
||||
std::string SendServiceMouseCommand(int x, int y, int wheel, int flag);
|
||||
#endif
|
||||
// Requests are asynchronous; Stop drains/revokes audio delivery before
|
||||
@@ -62,7 +73,9 @@ public:
|
||||
void StartSpeakerCapturer();
|
||||
void StopSpeakerCapturer();
|
||||
int StartMouseController();
|
||||
int StopMouseController();
|
||||
// Returns 1 while background releases are pending. force is only used
|
||||
// during final destruction after the input dispatcher has been stopped.
|
||||
int StopMouseController(bool force = false);
|
||||
int StartKeyboardCapturer();
|
||||
int StopKeyboardCapturer();
|
||||
|
||||
@@ -79,7 +92,7 @@ public:
|
||||
bool extended);
|
||||
bool SendMouseCommand(const RemoteAction &action, int selected_display);
|
||||
int SwitchDisplay(int display_id);
|
||||
void ResetToInitialDisplay();
|
||||
InputOutcome ResetToInitialDisplay();
|
||||
|
||||
// Snapshot of the capture displays. The UI thread refreshes the list every
|
||||
// tick while transport threads build host information and map mouse input
|
||||
@@ -89,10 +102,13 @@ public:
|
||||
std::vector<HostDisplay> host_display_list() const;
|
||||
|
||||
private:
|
||||
unsigned ReleaseMouseButtonMask(unsigned mask);
|
||||
void DrainCapturedKeyboardInput();
|
||||
bool ShouldSendCapturedFrame(std::chrono::steady_clock::time_point now,
|
||||
int fps);
|
||||
bool ShouldCaptureCursor() const;
|
||||
bool HasConnectedController() const;
|
||||
void UpdateScreenSharingPower();
|
||||
bool SyncDisplayInfo();
|
||||
void SetDisplayInfoList(std::vector<DisplayInfo> displays);
|
||||
void RecordCaptureCadence(std::chrono::steady_clock::time_point now, int fps,
|
||||
@@ -103,10 +119,14 @@ private:
|
||||
std::unordered_map<std::string, SDL_AudioStream*> output_streams_;
|
||||
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
|
||||
ScreenCapturer *screen_capturer_ = nullptr;
|
||||
ScreenSharingPower screen_sharing_power_;
|
||||
std::atomic<bool> cursor_capture_dirty_{true};
|
||||
SpeakerCaptureController speaker_capture_;
|
||||
DeviceControllerFactory *device_controller_factory_ = nullptr;
|
||||
MouseController *mouse_controller_ = nullptr;
|
||||
RemoteMouseButtons remote_mouse_buttons_;
|
||||
// Input is executed on a worker while the UI starts/stops this controller.
|
||||
std::mutex mouse_controller_mutex_;
|
||||
KeyboardCapturer *keyboard_capturer_ = nullptr;
|
||||
// Guards display_info_list_ (UI thread writes, transport threads read).
|
||||
mutable std::mutex display_info_mutex_;
|
||||
|
||||
@@ -94,13 +94,19 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
|
||||
peer = props ? props->peer_ : owner_.peer_;
|
||||
events = props ? props->peer_events_ : owner_.peer_events_;
|
||||
}
|
||||
if (!props && file.remote_id.rfind("lan/", 0) == 0) {
|
||||
events = owner_.ControllerEvents(file.remote_id);
|
||||
peer = events ? events->DirectPeer() : nullptr;
|
||||
}
|
||||
auto session = std::make_shared<file_transfer::Session>(
|
||||
[peer, events, file, props](const char*, const char* data, size_t size) {
|
||||
if (!peer || !events || (props && props->closing_)) return -1;
|
||||
auto callback = events->EnterCallback();
|
||||
if (!callback) return -1;
|
||||
return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(),
|
||||
file.remote_id.c_str(), file.remote_id.size());
|
||||
if (!callback || !events->DataAllowed()) return -1;
|
||||
const auto wire_id = events->WireId(file.remote_id);
|
||||
return SendReliableDataFrameToPeer(
|
||||
peer, data, size, file.file_label.c_str(),
|
||||
wire_id.data(), wire_id.size());
|
||||
}, file_transfer::Session::Progress{});
|
||||
state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0;
|
||||
state.current_file_id_ = id;
|
||||
@@ -313,6 +319,10 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
||||
peer = props ? props->peer_ : owner_.peer_;
|
||||
events = props ? props->peer_events_ : owner_.peer_events_;
|
||||
}
|
||||
if (!props && remote.rfind("lan/", 0) == 0) {
|
||||
events = owner_.ControllerEvents(remote);
|
||||
peer = events ? events->DirectPeer() : nullptr;
|
||||
}
|
||||
if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
|
||||
std::shared_ptr<FileReceiver> receiver;
|
||||
std::filesystem::path default_directory;
|
||||
@@ -333,10 +343,12 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
||||
});
|
||||
entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) {
|
||||
auto callback = events->EnterCallback();
|
||||
if (!callback) return -1;
|
||||
if (!callback || !events->DataAllowed()) return -1;
|
||||
const auto bytes = EncodeFileTransferAck(ack);
|
||||
return SendReliableDataFrameToPeer(peer, bytes.data(), bytes.size(), kFileFeedbackStream,
|
||||
remote.c_str(), remote.size());
|
||||
const auto wire_id = events->WireId(remote);
|
||||
return SendReliableDataFrameToPeer(
|
||||
peer, bytes.data(), bytes.size(), kFileFeedbackStream,
|
||||
wire_id.data(), wire_id.size());
|
||||
});
|
||||
}
|
||||
receiver = entry.session;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-22
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _INPUT_OUTCOME_H_
|
||||
#define _INPUT_OUTCOME_H_
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Applied means the native/service API accepted the operation. It does not
|
||||
// assert that the foreground application has consumed the injected event.
|
||||
enum class InputOutcome { applied, ignored, failed };
|
||||
|
||||
inline const char* InputOutcomeName(InputOutcome outcome) {
|
||||
switch (outcome) {
|
||||
case InputOutcome::applied:
|
||||
return "applied";
|
||||
case InputOutcome::ignored:
|
||||
return "ignored";
|
||||
case InputOutcome::failed:
|
||||
return "failed";
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
@@ -249,14 +250,14 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
|
||||
extended);
|
||||
}
|
||||
|
||||
void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
|
||||
InputOutcome KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
|
||||
const RemoteAction& action) {
|
||||
std::lock_guard input_lock(remote_input_mutex_);
|
||||
const int key_code = static_cast<int>(action.k.key_value);
|
||||
const bool is_down = action.k.flag == KeyFlag::key_down;
|
||||
const bool injected =
|
||||
InjectRemoteKey(key_code, is_down, action.k.scan_code, action.k.extended);
|
||||
if (injected) pending_key_releases_.erase(key_code);
|
||||
if (injected) pending_key_releases_.Supersede(key_code);
|
||||
|
||||
auto& state = remote_states_[remote_id];
|
||||
state.last_seen_tick = static_cast<uint32_t>(SDL_GetTicks());
|
||||
@@ -266,24 +267,28 @@ void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
|
||||
} else if (!is_down && injected) {
|
||||
state.pressed_keys.erase(key_code);
|
||||
}
|
||||
return injected ? InputOutcome::applied : InputOutcome::failed;
|
||||
}
|
||||
|
||||
void KeyboardController::ApplyRemoteText(const std::string& remote_id,
|
||||
InputOutcome KeyboardController::ApplyRemoteText(const std::string& remote_id,
|
||||
const std::string& text) {
|
||||
std::lock_guard input_lock(remote_input_mutex_);
|
||||
if (!owner_.IsAuthorizedController(remote_id) ||
|
||||
!owner_.devices_.SupportsTextInput()) return;
|
||||
if (!owner_.devices_.SendTextCommand(text))
|
||||
if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
|
||||
if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
|
||||
if (!owner_.devices_.SendTextCommand(text)) {
|
||||
LOG_WARN("Failed to inject committed remote text");
|
||||
return InputOutcome::failed;
|
||||
}
|
||||
return InputOutcome::applied;
|
||||
}
|
||||
|
||||
void KeyboardController::ApplyRemoteState(const std::string& remote_id,
|
||||
InputOutcome KeyboardController::ApplyRemoteState(const std::string& remote_id,
|
||||
const RemoteAction& action) {
|
||||
std::lock_guard input_lock(remote_input_mutex_);
|
||||
auto& state = remote_states_[remote_id];
|
||||
if (action.ks.seq != 0 && state.last_seq != 0 &&
|
||||
static_cast<int32_t>(action.ks.seq - state.last_seq) <= 0) {
|
||||
return;
|
||||
return InputOutcome::ignored;
|
||||
}
|
||||
|
||||
state.last_seq = action.ks.seq;
|
||||
@@ -300,56 +305,60 @@ void KeyboardController::ApplyRemoteState(const std::string& remote_id,
|
||||
}
|
||||
const auto changes = ReconcileKeyboardState(state.pressed_keys, desired_keys);
|
||||
bool can_press = true;
|
||||
bool applied = true;
|
||||
for (const auto& key : changes) {
|
||||
if (key.is_down && !can_press) continue;
|
||||
if (!InjectRemoteKey(key.key_code, key.is_down, key.scan_code, key.extended)) {
|
||||
// Do not replay a shortcut with a stale/missing modifier. The next
|
||||
// heartbeat will retry from the state that was actually injected.
|
||||
can_press = false;
|
||||
applied = false;
|
||||
continue;
|
||||
}
|
||||
pending_key_releases_.erase(key.key_code);
|
||||
pending_key_releases_.Supersede(key.key_code);
|
||||
if (key.is_down)
|
||||
state.pressed_keys[key.key_code] =
|
||||
{key.key_code, key.scan_code, key.extended};
|
||||
else
|
||||
state.pressed_keys.erase(key.key_code);
|
||||
}
|
||||
return applied ? InputOutcome::applied : InputOutcome::failed;
|
||||
}
|
||||
|
||||
void KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
|
||||
bool KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
|
||||
const char* reason) {
|
||||
std::lock_guard input_lock(remote_input_mutex_);
|
||||
ReleaseRemotePressedKeysLocked(remote_id, reason);
|
||||
return ReleaseRemotePressedKeysLocked(remote_id, reason);
|
||||
}
|
||||
|
||||
void KeyboardController::ReleaseRemotePressedKeysLocked(
|
||||
bool KeyboardController::ReleaseRemotePressedKeysLocked(
|
||||
std::string remote_id, const char* reason) {
|
||||
const auto state_it = remote_states_.find(remote_id);
|
||||
if (state_it == remote_states_.end()) return;
|
||||
auto keys_to_release = std::move(state_it->second.pressed_keys);
|
||||
remote_states_.erase(state_it);
|
||||
|
||||
if (!keys_to_release.empty()) {
|
||||
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
|
||||
keys_to_release.size(), remote_id, reason ? reason : "unknown");
|
||||
}
|
||||
for (const auto& [code, key] : keys_to_release) {
|
||||
if (!InjectRemoteKey(key.key_code, false, key.scan_code, key.extended)) {
|
||||
pending_key_releases_[code] = key;
|
||||
} else {
|
||||
pending_key_releases_.erase(code);
|
||||
if (state_it != remote_states_.end()) {
|
||||
const auto& keys = state_it->second.pressed_keys;
|
||||
if (!keys.empty()) {
|
||||
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
|
||||
keys.size(), remote_id, reason ? reason : "unknown");
|
||||
}
|
||||
// Stage before erasing: subsequent dispatcher retries must still see the
|
||||
// keys if injection fails, even though active/heartbeat state is gone.
|
||||
pending_key_releases_.Queue(remote_id, keys);
|
||||
remote_states_.erase(state_it);
|
||||
}
|
||||
return pending_key_releases_.ReleaseController(
|
||||
remote_id, [this](const KeyboardKey& key) {
|
||||
return InjectRemoteKey(key.key_code, false, key.scan_code, key.extended);
|
||||
});
|
||||
}
|
||||
|
||||
void KeyboardController::ReleaseAllRemotePressedKeys(const char* reason) {
|
||||
{
|
||||
std::lock_guard input_lock(remote_input_mutex_);
|
||||
while (!remote_states_.empty())
|
||||
ReleaseRemotePressedKeysLocked(remote_states_.begin()->first, reason);
|
||||
}
|
||||
CheckRemoteTimeouts();
|
||||
std::lock_guard input_lock(remote_input_mutex_);
|
||||
auto ids = pending_key_releases_.ControllerIds();
|
||||
for (const auto& [id, _] : remote_states_) ids.push_back(id);
|
||||
std::sort(ids.begin(), ids.end());
|
||||
ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
|
||||
// A failure remains pending; shutdown must never spin until it succeeds.
|
||||
for (const auto& id : ids) ReleaseRemotePressedKeysLocked(id, reason);
|
||||
}
|
||||
|
||||
void KeyboardController::CheckRemoteTimeouts() {
|
||||
@@ -359,7 +368,7 @@ void KeyboardController::CheckRemoteTimeouts() {
|
||||
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
|
||||
// Retain failed releases in ordinary mode as well as privacy mode. Retry
|
||||
// only on the ordinary desktop, never through the security UI helper.
|
||||
if (!pending_key_releases_.empty() &&
|
||||
if (!pending_key_releases_.Empty() &&
|
||||
(last_release_retry_tick_ == 0 ||
|
||||
now - last_release_retry_tick_ >= kReleaseRetryIntervalMs)
|
||||
#ifdef _WIN32
|
||||
@@ -367,15 +376,10 @@ void KeyboardController::CheckRemoteTimeouts() {
|
||||
#endif
|
||||
) {
|
||||
last_release_retry_tick_ = now;
|
||||
for (auto it = pending_key_releases_.begin();
|
||||
it != pending_key_releases_.end();) {
|
||||
const auto& key = it->second;
|
||||
if (owner_.devices_.SendKeyboardCommand(
|
||||
key.key_code, false, key.scan_code, key.extended))
|
||||
it = pending_key_releases_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
pending_key_releases_.RetryAll([this](const KeyboardKey& key) {
|
||||
return owner_.devices_.SendKeyboardCommand(
|
||||
key.key_code, false, key.scan_code, key.extended);
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the timeout decision and release under the same lock, so a fresh
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <remote_action.h>
|
||||
|
||||
#include "keyboard_state.h"
|
||||
#include "input_outcome.h"
|
||||
#include "pending_key_releases.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -25,12 +27,12 @@ public:
|
||||
void ForceReleasePressedKeys();
|
||||
void RecoverCapturedState(const PressedKeys& pressed);
|
||||
void SendHeartbeat(bool force);
|
||||
void ApplyRemoteEvent(const std::string &remote_id,
|
||||
InputOutcome ApplyRemoteEvent(const std::string &remote_id,
|
||||
const RemoteAction &remote_action);
|
||||
void ApplyRemoteText(const std::string& remote_id, const std::string& text);
|
||||
void ApplyRemoteState(const std::string &remote_id,
|
||||
InputOutcome ApplyRemoteText(const std::string& remote_id, const std::string& text);
|
||||
InputOutcome ApplyRemoteState(const std::string &remote_id,
|
||||
const RemoteAction &remote_action);
|
||||
void ReleaseRemotePressedKeys(const std::string &remote_id,
|
||||
bool ReleaseRemotePressedKeys(const std::string &remote_id,
|
||||
const char *reason);
|
||||
void ReleaseAllRemotePressedKeys(const char* reason);
|
||||
void CheckRemoteTimeouts();
|
||||
@@ -48,7 +50,7 @@ private:
|
||||
bool InjectRemoteKey(int key_code, bool is_down, uint32_t scan_code,
|
||||
bool extended);
|
||||
// Caller holds remote_input_mutex_. Copy the id because this erases state.
|
||||
void ReleaseRemotePressedKeysLocked(std::string remote_id, const char* reason);
|
||||
bool ReleaseRemotePressedKeysLocked(std::string remote_id, const char* reason);
|
||||
|
||||
GuiRuntime &owner_;
|
||||
PressedKeys pressed_keys_;
|
||||
@@ -59,7 +61,7 @@ private:
|
||||
// Guards all remote key state and serializes injection, cleanup and retries.
|
||||
std::mutex remote_input_mutex_;
|
||||
// Successful new input supersedes cleanup of an older press of that key.
|
||||
PressedKeys pending_key_releases_;
|
||||
PendingKeyReleases pending_key_releases_;
|
||||
uint32_t last_release_retry_tick_ = 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-22
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PENDING_KEY_RELEASES_H_
|
||||
#define _PENDING_KEY_RELEASES_H_
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "keyboard_state.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Caller serializes access with KeyboardController's remote-input mutex.
|
||||
// Pending releases outlive the peer's active key/heartbeat state, but retain
|
||||
// their controller identity so cleanup cannot report success prematurely.
|
||||
class PendingKeyReleases {
|
||||
public:
|
||||
void Queue(const std::string& id, const PressedKeys& keys) {
|
||||
if (keys.empty()) return;
|
||||
auto& pending = peers_[id];
|
||||
for (const auto& [code, key] : keys) pending[code] = key;
|
||||
}
|
||||
|
||||
bool Empty() const { return peers_.empty(); }
|
||||
bool Contains(const std::string& id) const { return peers_.count(id) != 0; }
|
||||
|
||||
std::vector<std::string> ControllerIds() const {
|
||||
std::vector<std::string> ids;
|
||||
ids.reserve(peers_.size());
|
||||
for (const auto& [id, _] : peers_) ids.push_back(id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// A confirmed new event supersedes an older pending release of that key,
|
||||
// including one belonging to a disconnected controller.
|
||||
void Supersede(int key_code) {
|
||||
for (auto it = peers_.begin(); it != peers_.end();) {
|
||||
it->second.erase(key_code);
|
||||
if (it->second.empty())
|
||||
it = peers_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
template <typename SendUp>
|
||||
bool ReleaseController(const std::string& id, SendUp send_up) {
|
||||
auto peer = peers_.find(id);
|
||||
if (peer == peers_.end()) return true;
|
||||
auto& keys = peer->second;
|
||||
for (auto it = keys.begin(); it != keys.end();) {
|
||||
if (send_up(it->second))
|
||||
it = keys.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
if (!keys.empty()) return false;
|
||||
peers_.erase(peer);
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename SendUp>
|
||||
void RetryAll(SendUp send_up) {
|
||||
// One bounded pass, even when every native release fails.
|
||||
for (const auto& id : ControllerIds()) ReleaseController(id, send_up);
|
||||
}
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, PressedKeys> peers_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,343 @@
|
||||
#include "remote_input_dispatcher.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
RemoteInputDispatcher::RemoteInputDispatcher(Apply apply, Release release,
|
||||
Report report,
|
||||
ResetDisplay reset_display,
|
||||
ReleaseMouse release_mouse)
|
||||
: apply_(std::move(apply)),
|
||||
release_(std::move(release)),
|
||||
report_(std::move(report)),
|
||||
reset_display_(std::move(reset_display)),
|
||||
release_mouse_(std::move(release_mouse)),
|
||||
worker_([this] { Run(); }) {}
|
||||
|
||||
RemoteInputDispatcher::~RemoteInputDispatcher() { Stop(); }
|
||||
|
||||
bool RemoteInputDispatcher::Accepts(const RemoteAction& action) {
|
||||
return action.type == ControlType::mouse ||
|
||||
action.type == ControlType::keyboard ||
|
||||
action.type == ControlType::keyboard_state ||
|
||||
action.type == ControlType::text_input ||
|
||||
action.type == ControlType::display_id ||
|
||||
action.type == ControlType::service_command;
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::Connect(const std::string& id) {
|
||||
if (id.empty()) return;
|
||||
std::lock_guard lock(mutex_);
|
||||
if (stopping_ || !connected_.insert(id).second) return;
|
||||
logs_.erase(id); // A new session must get its first diagnostic.
|
||||
// In-flight resets finish before this session's input; queued old resets
|
||||
// must not change the new session's display. Failed releases are retained.
|
||||
RemoveResetsLocked();
|
||||
ready_.notify_one();
|
||||
}
|
||||
|
||||
RemoteInputDispatcher::Result RemoteInputDispatcher::Push(
|
||||
const std::string& id, const RemoteAction& action) {
|
||||
// HostInfo owns raw arrays; it must never enter this value-copy queue.
|
||||
if (!Accepts(action)) return Result::rejected;
|
||||
std::lock_guard lock(mutex_);
|
||||
if (stopping_ || !connected_.count(id)) return Result::rejected;
|
||||
if (IsMove(action) && !pending_.empty()) {
|
||||
auto& last = pending_.back();
|
||||
if (last.id == id && last.IsInput() && IsMove(*last.action)) {
|
||||
last.action = action;
|
||||
last.queued_at = Clock::now();
|
||||
return Result::coalesced;
|
||||
}
|
||||
}
|
||||
if (pending_counts_[id] >= kMaxPendingPerController) {
|
||||
QueueReleaseLocked(id);
|
||||
return Result::overflow;
|
||||
}
|
||||
pending_.push_back({id, action, Clock::now(), ItemKind::input});
|
||||
++pending_counts_[id];
|
||||
ready_.notify_one();
|
||||
return Result::queued;
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::Disconnect(const std::string& id) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (stopping_ || !connected_.erase(id)) return;
|
||||
QueueReleaseLocked(id);
|
||||
if (connected_.empty() && reset_display_) {
|
||||
RemoveResetsLocked();
|
||||
pending_.push_back(
|
||||
{{}, std::nullopt, Clock::now(), ItemKind::reset_display});
|
||||
}
|
||||
ready_.notify_one();
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::WaitIdle() {
|
||||
std::unique_lock lock(mutex_);
|
||||
idle_.wait(lock, [this] { return pending_.empty() && !executing_; });
|
||||
}
|
||||
|
||||
bool RemoteInputDispatcher::HasPendingReleases() {
|
||||
std::lock_guard lock(mutex_);
|
||||
return !blocked_.empty();
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::Stop() {
|
||||
std::call_once(stop_once_, [this] {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
stopping_ = true;
|
||||
auto ids = std::move(connected_);
|
||||
connected_.clear();
|
||||
ids.insert(blocked_.begin(), blocked_.end());
|
||||
for (const auto& item : pending_)
|
||||
if (!item.IsReset()) ids.insert(item.id);
|
||||
pending_.clear();
|
||||
pending_counts_.clear();
|
||||
retries_.clear();
|
||||
for (const auto& id : ids) {
|
||||
pending_.push_back({id, std::nullopt, Clock::now()});
|
||||
pending_counts_[id] = 1;
|
||||
}
|
||||
}
|
||||
ready_.notify_one();
|
||||
worker_.join();
|
||||
});
|
||||
}
|
||||
|
||||
bool RemoteInputDispatcher::IsMove(const RemoteAction& action) {
|
||||
return action.type == ControlType::mouse && action.m.flag == MouseFlag::move;
|
||||
}
|
||||
|
||||
unsigned RemoteInputDispatcher::MouseUpMask(const RemoteAction& action) {
|
||||
if (action.type != ControlType::mouse) return 0;
|
||||
switch (action.m.flag) {
|
||||
case MouseFlag::left_up:
|
||||
return 1;
|
||||
case MouseFlag::right_up:
|
||||
return 2;
|
||||
case MouseFlag::middle_up:
|
||||
return 4;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::RemoveResetsLocked() {
|
||||
pending_.erase(
|
||||
std::remove_if(pending_.begin(), pending_.end(),
|
||||
[](const Item& item) { return item.IsReset(); }),
|
||||
pending_.end());
|
||||
if (pending_.empty() && !executing_) idle_.notify_all();
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::QueueReleaseLocked(const std::string& id) {
|
||||
pending_.erase(
|
||||
std::remove_if(pending_.begin(), pending_.end(),
|
||||
[&](const Item& item) { return item.id == id; }),
|
||||
pending_.end());
|
||||
pending_counts_.erase(id);
|
||||
blocked_.insert(id);
|
||||
// Repeated overflow/disconnect must not defeat the retry backoff.
|
||||
if (auto retry = retries_.find(id); retry != retries_.end()) {
|
||||
retry->second.mouse_mask = 0; // Escalate a button repair on disconnect.
|
||||
} else {
|
||||
pending_.push_back({id, std::nullopt, Clock::now()});
|
||||
pending_counts_[id] = 1;
|
||||
}
|
||||
ready_.notify_one();
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::QueueMouseReleaseLocked(const std::string& id,
|
||||
unsigned mask) {
|
||||
// A concurrent disconnect/overflow already requested the broader cleanup.
|
||||
if (!blocked_.insert(id).second) return;
|
||||
// Keep subsequent clicks/keys queued, but don't apply them until the old
|
||||
// button is confirmed up. Never replay a timed-out down/click operation.
|
||||
pending_.push_front(
|
||||
{id, std::nullopt, Clock::now(), ItemKind::cleanup, mask});
|
||||
++pending_counts_[id];
|
||||
ready_.notify_one();
|
||||
}
|
||||
|
||||
std::optional<RemoteInputDispatcher::Item> RemoteInputDispatcher::NextLocked() {
|
||||
const auto now = Clock::now();
|
||||
auto next = retries_.end();
|
||||
for (auto it = retries_.begin(); it != retries_.end(); ++it)
|
||||
if (next == retries_.end() || it->second.due < next->second.due) next = it;
|
||||
auto it =
|
||||
std::find_if(pending_.begin(), pending_.end(), [&](const Item& item) {
|
||||
return !item.IsInput() || !blocked_.count(item.id);
|
||||
});
|
||||
// Alternate due retries with runnable queued work. Multiple slow cleanup
|
||||
// calls must not monopolize this worker; continuous input must not starve
|
||||
// recovery either. Input for the recovering controller remains blocked.
|
||||
if (next != retries_.end() && next->second.due <= now &&
|
||||
(it == pending_.end() || !last_was_retry_)) {
|
||||
last_was_retry_ = true;
|
||||
return Item{next->first, std::nullopt, next->second.due, ItemKind::cleanup,
|
||||
next->second.mouse_mask};
|
||||
}
|
||||
if (it == pending_.end()) return std::nullopt;
|
||||
last_was_retry_ = false;
|
||||
Item item = std::move(*it);
|
||||
pending_.erase(it);
|
||||
if (!item.IsReset() && --pending_counts_.at(item.id) == 0)
|
||||
pending_counts_.erase(item.id);
|
||||
return item;
|
||||
}
|
||||
|
||||
bool RemoteInputDispatcher::ShouldReportLocked(const Item& item,
|
||||
Clock::time_point now,
|
||||
int64_t queued_ms,
|
||||
int64_t execution_ms,
|
||||
InputOutcome outcome) {
|
||||
if (!report_) return false;
|
||||
auto& state = logs_[item.id];
|
||||
auto& last = outcome == InputOutcome::failed
|
||||
? state.failure[item.IsInput() ? 0 : 1]
|
||||
: state.slow;
|
||||
if (outcome != InputOutcome::failed && queued_ms < 50 && execution_ms < 50)
|
||||
return false;
|
||||
if (last != Clock::time_point{} && now - last < std::chrono::seconds(2))
|
||||
return false;
|
||||
last = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::RemoveQueuedCleanupLocked(const std::string& id) {
|
||||
// Cleanup queued during the running call covers the same old state. Merge
|
||||
// duplicates so they cannot bypass backoff or release a later session twice.
|
||||
const size_t before = pending_.size();
|
||||
pending_.erase(std::remove_if(pending_.begin(), pending_.end(),
|
||||
[&](const Item& queued) {
|
||||
return queued.id == id && queued.IsCleanup();
|
||||
}),
|
||||
pending_.end());
|
||||
const size_t removed = before - pending_.size();
|
||||
if (removed && (pending_counts_.at(id) -= removed) == 0)
|
||||
pending_counts_.erase(id);
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::ScheduleRetryLocked(const std::string& id,
|
||||
unsigned mouse_mask,
|
||||
Clock::time_point finished) {
|
||||
auto [retry, inserted] = retries_.try_emplace(id);
|
||||
retry->second.mouse_mask = mouse_mask;
|
||||
if (!inserted)
|
||||
retry->second.delay =
|
||||
std::min(retry->second.delay * 2, std::chrono::milliseconds(1000));
|
||||
retry->second.due = finished + retry->second.delay;
|
||||
blocked_.insert(id);
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::CompleteCleanupLocked(const Item& item,
|
||||
InputOutcome outcome,
|
||||
Clock::time_point finished) {
|
||||
if (stopping_) {
|
||||
retries_.erase(item.id);
|
||||
blocked_.erase(item.id);
|
||||
return;
|
||||
}
|
||||
// A successful button repair cannot discharge a concurrent request to
|
||||
// release all keyboard/mouse state after disconnect or queue overflow.
|
||||
const bool queued_full_cleanup =
|
||||
item.mouse_mask &&
|
||||
std::any_of(pending_.begin(), pending_.end(), [&](const Item& queued) {
|
||||
return queued.id == item.id && queued.IsCleanup() && !queued.mouse_mask;
|
||||
});
|
||||
if (queued_full_cleanup) {
|
||||
retries_.erase(item.id); // The queued full cleanup owns recovery now.
|
||||
return;
|
||||
}
|
||||
auto retry = retries_.find(item.id);
|
||||
if (item.mouse_mask && retry != retries_.end() && !retry->second.mouse_mask) {
|
||||
if (outcome == InputOutcome::failed)
|
||||
ScheduleRetryLocked(item.id, 0, finished);
|
||||
else
|
||||
retry->second.due = finished;
|
||||
return;
|
||||
}
|
||||
RemoveQueuedCleanupLocked(item.id);
|
||||
if (outcome == InputOutcome::failed) {
|
||||
ScheduleRetryLocked(item.id, item.mouse_mask, finished);
|
||||
} else {
|
||||
retries_.erase(item.id);
|
||||
blocked_.erase(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
InputOutcome RemoteInputDispatcher::Execute(const Item& item) {
|
||||
if (item.IsReset()) return reset_display_();
|
||||
if (item.IsInput()) return apply_(item.id, *item.action);
|
||||
if (item.mouse_mask && release_mouse_)
|
||||
return release_mouse_(item.id, item.mouse_mask);
|
||||
return release_(item.id);
|
||||
}
|
||||
|
||||
void RemoteInputDispatcher::Run() {
|
||||
std::unique_lock lock(mutex_);
|
||||
for (;;) {
|
||||
auto next = NextLocked();
|
||||
if (!next) {
|
||||
if (stopping_) return;
|
||||
auto due = Clock::time_point::max();
|
||||
for (const auto& [_, retry] : retries_) due = std::min(due, retry.due);
|
||||
if (due == Clock::time_point::max())
|
||||
ready_.wait(lock);
|
||||
else
|
||||
ready_.wait_until(lock, due);
|
||||
continue;
|
||||
}
|
||||
Item item = std::move(*next);
|
||||
executing_ = true;
|
||||
lock.unlock();
|
||||
const auto started = Clock::now();
|
||||
InputOutcome outcome = InputOutcome::failed;
|
||||
bool threw = false;
|
||||
try {
|
||||
outcome = Execute(item);
|
||||
} catch (...) {
|
||||
threw = true;
|
||||
}
|
||||
const auto finished = Clock::now();
|
||||
const auto queued_ms =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(started -
|
||||
item.queued_at)
|
||||
.count();
|
||||
const auto execution_ms =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(finished -
|
||||
started)
|
||||
.count();
|
||||
lock.lock();
|
||||
if (item.IsCleanup()) {
|
||||
CompleteCleanupLocked(item, outcome, finished);
|
||||
} else if (item.IsInput() && !stopping_) {
|
||||
if (threw && connected_.count(item.id)) {
|
||||
QueueReleaseLocked(item.id);
|
||||
} else if (outcome == InputOutcome::failed) {
|
||||
if (const unsigned mask = MouseUpMask(*item.action))
|
||||
QueueMouseReleaseLocked(item.id, mask);
|
||||
}
|
||||
}
|
||||
const bool report =
|
||||
ShouldReportLocked(item, finished, queued_ms, execution_ms, outcome);
|
||||
if (!connected_.count(item.id) && !blocked_.count(item.id) &&
|
||||
!pending_counts_.count(item.id))
|
||||
logs_.erase(item.id);
|
||||
lock.unlock();
|
||||
if (report) {
|
||||
try {
|
||||
report_(item.id, queued_ms, execution_ms, outcome);
|
||||
} catch (...) {
|
||||
} // Diagnostics must not terminate the input worker.
|
||||
}
|
||||
lock.lock();
|
||||
executing_ = false;
|
||||
if (pending_.empty()) idle_.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-22
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _REMOTE_INPUT_DISPATCHER_H_
|
||||
#define _REMOTE_INPUT_DISPATCHER_H_
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "input_outcome.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Serializes native input, recovery and display resets off the RTC thread.
|
||||
class RemoteInputDispatcher {
|
||||
public:
|
||||
using Apply =
|
||||
std::function<InputOutcome(const std::string&, const RemoteAction&)>;
|
||||
using Release = std::function<InputOutcome(const std::string&)>;
|
||||
using Report =
|
||||
std::function<void(const std::string&, int64_t, int64_t, InputOutcome)>;
|
||||
using ResetDisplay = std::function<InputOutcome()>;
|
||||
// Optional targeted adapter; legacy callers fall back to full Release.
|
||||
using ReleaseMouse =
|
||||
std::function<InputOutcome(const std::string&, unsigned)>;
|
||||
enum class Result { queued, coalesced, rejected, overflow };
|
||||
static constexpr size_t kMaxPendingPerController = 128;
|
||||
|
||||
RemoteInputDispatcher(Apply apply, Release release, Report report = {},
|
||||
ResetDisplay reset_display = {},
|
||||
ReleaseMouse release_mouse = {});
|
||||
~RemoteInputDispatcher();
|
||||
static bool Accepts(const RemoteAction& action);
|
||||
void Connect(const std::string& id);
|
||||
Result Push(const std::string& id, const RemoteAction& action);
|
||||
void Disconnect(const std::string& id);
|
||||
|
||||
// Scheduled recovery is not idle work. Check HasPendingReleases() as well,
|
||||
// or Stop() before destroying the devices used by callbacks.
|
||||
void WaitIdle();
|
||||
bool HasPendingReleases();
|
||||
// Cancels pending input/timers and makes a final bounded cleanup attempt.
|
||||
void Stop();
|
||||
|
||||
private:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
enum class ItemKind { input, cleanup, reset_display };
|
||||
struct Item {
|
||||
std::string id;
|
||||
std::optional<RemoteAction> action; // Present only for ordinary input.
|
||||
Clock::time_point queued_at;
|
||||
ItemKind kind = ItemKind::cleanup;
|
||||
// Zero requests full controller cleanup; otherwise only these mouse
|
||||
// buttons.
|
||||
unsigned mouse_mask = 0;
|
||||
bool IsReset() const { return kind == ItemKind::reset_display; }
|
||||
bool IsInput() const { return kind == ItemKind::input; }
|
||||
bool IsCleanup() const { return kind == ItemKind::cleanup; }
|
||||
};
|
||||
struct Retry {
|
||||
Clock::time_point due;
|
||||
std::chrono::milliseconds delay{100};
|
||||
unsigned mouse_mask = 0;
|
||||
};
|
||||
struct LogState {
|
||||
Clock::time_point slow{};
|
||||
std::array<Clock::time_point, 2> failure{};
|
||||
};
|
||||
|
||||
static bool IsMove(const RemoteAction& action);
|
||||
static unsigned MouseUpMask(const RemoteAction& action);
|
||||
void RemoveResetsLocked();
|
||||
void QueueReleaseLocked(const std::string& id);
|
||||
void QueueMouseReleaseLocked(const std::string& id, unsigned mask);
|
||||
void RemoveQueuedCleanupLocked(const std::string& id);
|
||||
void ScheduleRetryLocked(const std::string& id, unsigned mouse_mask,
|
||||
Clock::time_point finished);
|
||||
void CompleteCleanupLocked(const Item& item, InputOutcome outcome,
|
||||
Clock::time_point finished);
|
||||
std::optional<Item> NextLocked();
|
||||
InputOutcome Execute(const Item& item);
|
||||
bool ShouldReportLocked(const Item& item, Clock::time_point now,
|
||||
int64_t queued_ms, int64_t execution_ms,
|
||||
InputOutcome outcome);
|
||||
void Run();
|
||||
|
||||
Apply apply_;
|
||||
Release release_;
|
||||
Report report_;
|
||||
ResetDisplay reset_display_;
|
||||
ReleaseMouse release_mouse_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable ready_, idle_;
|
||||
std::deque<Item> pending_;
|
||||
std::unordered_set<std::string> connected_, blocked_;
|
||||
std::unordered_map<std::string, size_t> pending_counts_;
|
||||
std::unordered_map<std::string, Retry> retries_;
|
||||
std::unordered_map<std::string, LogState> logs_;
|
||||
bool last_was_retry_ = false;
|
||||
bool stopping_ = false;
|
||||
bool executing_ = false;
|
||||
std::once_flag stop_once_;
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-22
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _REMOTE_MOUSE_BUTTONS_H_
|
||||
#define _REMOTE_MOUSE_BUTTONS_H_
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <array>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "input_outcome.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// One physical button cannot belong to two controllers at once. A second
|
||||
// controller's down/up must not steal or release an existing drag.
|
||||
class RemoteMouseButtons {
|
||||
public:
|
||||
template <typename Send>
|
||||
InputOutcome Apply(const std::string& id, MouseFlag flag, Send send) {
|
||||
if (id.empty()) return InputOutcome::ignored;
|
||||
std::lock_guard lock(mutex_);
|
||||
const int value = static_cast<int>(flag);
|
||||
if (value < 1 || value > 6) return send();
|
||||
auto& owner = owners_[(value - 1) / 2];
|
||||
const bool down = value % 2 == 1;
|
||||
if ((!owner.empty() && owner != id) || (!down && owner.empty()))
|
||||
return InputOutcome::ignored;
|
||||
// A service timeout may follow successful injection. Retain an attempted
|
||||
// down until an up or explicit cleanup is confirmed, including exceptions.
|
||||
if (down) owner = id;
|
||||
const auto outcome = send();
|
||||
if (!down && outcome == InputOutcome::applied) owner.clear();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
template <typename Release>
|
||||
bool ReleaseController(const std::string& id, Release release,
|
||||
unsigned requested_mask = 7) {
|
||||
if (id.empty()) return true;
|
||||
std::lock_guard lock(mutex_);
|
||||
unsigned mask = 0;
|
||||
for (size_t i = 0; i < owners_.size(); ++i)
|
||||
if (owners_[i] == id && (requested_mask & (1u << i))) mask |= 1u << i;
|
||||
return ReleaseLocked(mask, release);
|
||||
}
|
||||
|
||||
template <typename Release>
|
||||
bool ReleaseAll(Release release) {
|
||||
std::lock_guard lock(mutex_);
|
||||
// Also clear any backend state retained after a desktop/route transition.
|
||||
return ReleaseLocked(7, release);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename Release>
|
||||
bool ReleaseLocked(unsigned mask, Release release) {
|
||||
if (!mask) return true;
|
||||
// The sink returns the subset confirmed released, so one failed button
|
||||
// does not retain ownership of other successfully released buttons.
|
||||
const unsigned released = release(mask) & mask;
|
||||
for (size_t i = 0; i < owners_.size(); ++i)
|
||||
if (released & (1u << i)) owners_[i].clear();
|
||||
return released == mask;
|
||||
}
|
||||
|
||||
std::mutex mutex_;
|
||||
std::array<std::string, 3> owners_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -465,9 +465,6 @@ int SettingsManager::Load() {
|
||||
static_cast<int>(owner_.config_center_->GetLanguage()));
|
||||
owner_.video_encode_format_button_value_ =
|
||||
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat());
|
||||
owner_.enable_hardware_video_codec_ =
|
||||
owner_.config_center_->IsHardwareVideoCodec();
|
||||
owner_.enable_turn_ = owner_.config_center_->IsEnableTurn();
|
||||
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
|
||||
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
|
||||
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
|
||||
@@ -486,9 +483,6 @@ int SettingsManager::Load() {
|
||||
owner_.language_button_value_last_ = owner_.language_button_value_;
|
||||
owner_.video_encode_format_button_value_last_ =
|
||||
owner_.video_encode_format_button_value_;
|
||||
owner_.enable_hardware_video_codec_last_ =
|
||||
owner_.enable_hardware_video_codec_;
|
||||
owner_.enable_turn_last_ = owner_.enable_turn_;
|
||||
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
|
||||
owner_.enable_autostart_last_ = owner_.enable_autostart_;
|
||||
|
||||
@@ -748,6 +742,8 @@ std::string SettingsManager::RecentConnectionDisplayName(
|
||||
connection.remote_host_name != "unknown") {
|
||||
return connection.remote_host_name;
|
||||
}
|
||||
if (!connection.lan.device_id.empty())
|
||||
return connection.lan.address + ":" + std::to_string(connection.lan.port);
|
||||
return connection.remote_id;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ private:
|
||||
int legacy_video_quality;
|
||||
int legacy_video_frame_rate;
|
||||
int video_encode_format;
|
||||
bool enable_hardware_video_codec;
|
||||
bool legacy_enable_hardware_video_codec;
|
||||
bool enable_turn;
|
||||
bool enable_srtp;
|
||||
unsigned char key[16];
|
||||
@@ -77,7 +77,7 @@ private:
|
||||
int legacy_video_quality;
|
||||
int legacy_video_frame_rate;
|
||||
int video_encode_format;
|
||||
bool enable_hardware_video_codec;
|
||||
bool legacy_enable_hardware_video_codec;
|
||||
bool enable_turn;
|
||||
bool enable_srtp;
|
||||
unsigned char key[16];
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "platform.h"
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rd_log.h"
|
||||
#include "screen_lock.h"
|
||||
#include "runtime/device_presence_request.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
|
||||
@@ -26,6 +27,7 @@ constexpr auto kPresenceRetryInterval = std::chrono::seconds(1);
|
||||
} // namespace
|
||||
|
||||
bool GuiRuntime::HasActiveSession() {
|
||||
if (lan_ && !lan_->Quiescent()) return true;
|
||||
if (stream_window_inited_) return true;
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
return std::any_of(connection_status_.begin(), connection_status_.end(),
|
||||
@@ -188,24 +190,21 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
|
||||
|
||||
void GuiRuntime::HandleServerControllerDisconnected(
|
||||
const std::string& remote_id, const char* reason) {
|
||||
transfers_.Cancel(peer_events_.get(), remote_id);
|
||||
if (auto events = ControllerEvents(remote_id))
|
||||
transfers_.Cancel(events.get(), remote_id);
|
||||
preview_subscriptions_.Remove(remote_id);
|
||||
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
|
||||
#ifdef _WIN32
|
||||
devices_.ReleaseRemoteMouseButtons();
|
||||
#endif
|
||||
{
|
||||
std::lock_guard lock(remote_pointer_input_mutex_);
|
||||
last_remote_pointer_input_time_.erase(remote_id);
|
||||
}
|
||||
|
||||
bool has_connected_controller = false;
|
||||
std::string remaining_controller_id;
|
||||
{
|
||||
std::unique_lock lock(connection_status_mutex_);
|
||||
connection_status_.erase(remote_id);
|
||||
disconnect_lock_.Disconnected(remote_id,
|
||||
config_center_->IsLockOnDisconnect());
|
||||
remote_input_.Disconnect(remote_id);
|
||||
devices_.OnControllerConnectionsChanged();
|
||||
connection_host_names_.erase(remote_id);
|
||||
codec_status_controllers_.erase(remote_id);
|
||||
privacy_sessions_.Disconnected(remote_id);
|
||||
for (const auto& [id, status] : connection_status_) {
|
||||
if (status != ConnectionStatus::Connected) {
|
||||
@@ -249,10 +248,28 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
||||
#endif
|
||||
devices_.StopSpeakerCapturer();
|
||||
start_mouse_controller_ = false;
|
||||
start_keyboard_capturer_ = false;
|
||||
{
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
start_keyboard_capturer_ = std::any_of(
|
||||
remote_sessions_.begin(), remote_sessions_.end(), [](const auto& item) {
|
||||
const auto& session = item.second;
|
||||
return session && !session->closing_ &&
|
||||
session->connection_established_ && !session->wall_managed_;
|
||||
});
|
||||
}
|
||||
remote_client_id_.clear();
|
||||
audio_capture_ = false;
|
||||
devices_.ResetToInitialDisplay();
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -301,10 +318,15 @@ int GuiRuntime::ForceSignalReconnect() {
|
||||
}
|
||||
|
||||
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
if (!props || props->closing_.exchange(true)) return;
|
||||
if (!props) return;
|
||||
if (props->lan_session_ && !props->lan_session_->closing) {
|
||||
if (lan_) lan_->Close(props->lan_session_);
|
||||
return;
|
||||
}
|
||||
if (props->closing_.exchange(true)) return;
|
||||
MuteRemoteAudio(props);
|
||||
if (props->peer_events_) {
|
||||
props->peer_events_->Deactivate();
|
||||
props->peer_events_->StopAccepting();
|
||||
transfers_.Cancel(props->peer_events_.get());
|
||||
}
|
||||
props->connection_established_ = false;
|
||||
@@ -357,7 +379,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask(
|
||||
[props, peer, frame_snapshot, video_width, video_height, platform,
|
||||
thumbnail = thumbnail_, queued_at]() mutable {
|
||||
if (peer) {
|
||||
if (props->peer_events_) props->peer_events_->Deactivate();
|
||||
if (peer && !props->lan_session_) {
|
||||
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
|
||||
props->remote_id_);
|
||||
LeaveConnection(peer, props->remote_id_.c_str());
|
||||
@@ -366,11 +389,20 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
if (thumbnail && props->was_connected_) {
|
||||
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
|
||||
? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr;
|
||||
const auto direct = props->lan_session_;
|
||||
const Thumbnail::LanTarget target =
|
||||
direct ? Thumbnail::LanTarget{direct->target.address,
|
||||
direct->target.device_id,
|
||||
direct->target.port}
|
||||
: Thumbnail::LanTarget{};
|
||||
const auto record_id = direct
|
||||
? Thumbnail::LanRecordId(target.device_id)
|
||||
: props->remote_id_;
|
||||
if (thumbnail->SaveToThumbnail(
|
||||
nv12, video_width, video_height, props->remote_id_,
|
||||
nv12, video_width, video_height, record_id,
|
||||
props->remote_host_name_,
|
||||
props->remember_password_ ? props->remote_password_ : "",
|
||||
platform) != 0) {
|
||||
platform, direct ? &target : nullptr) != 0) {
|
||||
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
|
||||
}
|
||||
}
|
||||
@@ -382,6 +414,17 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
}
|
||||
|
||||
void GuiRuntime::CloseServerController(const std::string& remote_id) {
|
||||
if (remote_id.rfind("lan/", 0) == 0) {
|
||||
std::shared_ptr<lan::Session> session;
|
||||
{
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
if (auto it = lan_controllers_.find(remote_id);
|
||||
it != lan_controllers_.end())
|
||||
session = it->second.session;
|
||||
}
|
||||
if (session && lan_) lan_->Close(session);
|
||||
return;
|
||||
}
|
||||
if (!peer_ || remote_id.empty() || controller_cleanup_tasks_.count(remote_id))
|
||||
return;
|
||||
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
|
||||
@@ -399,20 +442,17 @@ void GuiRuntime::CloseConnectionPeer() {
|
||||
announcements_.Reset();
|
||||
return;
|
||||
}
|
||||
peer_events_->Deactivate();
|
||||
peer_events_->StopAccepting();
|
||||
transfers_.Cancel(peer_events_.get());
|
||||
announcements_.Reset();
|
||||
std::vector<std::string> controllers;
|
||||
{
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
for (const auto& [id, status] : connection_status_) controllers.push_back(id);
|
||||
for (const auto& [id, status] : connection_status_)
|
||||
if (id.rfind("lan/", 0) != 0) controllers.push_back(id);
|
||||
}
|
||||
for (const auto& id : controllers)
|
||||
HandleServerControllerDisconnected(id, "peer_closed");
|
||||
devices_.StopMouseController();
|
||||
devices_.StopScreenCapturer();
|
||||
devices_.StopSpeakerCapturer();
|
||||
devices_.StopKeyboardCapturer();
|
||||
signal_connected_ = false;
|
||||
device_presence_cache_.SetSignalConnected(false);
|
||||
signal_status_ = SignalStatus::SignalClosed;
|
||||
@@ -425,6 +465,7 @@ void GuiRuntime::CloseConnectionPeer() {
|
||||
// these finish before the shared peer is destroyed.
|
||||
connection_peer_cleanup_ = session_cleanup_queue_.PostTask(
|
||||
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
|
||||
handler->Deactivate();
|
||||
LOG_INFO("[{}] Background connection peer cleanup", id);
|
||||
LeaveConnection(peer, id.c_str());
|
||||
DestroyPeer(&peer);
|
||||
@@ -558,6 +599,12 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
auto it = remote_sessions_.find(remote_id);
|
||||
if (it == remote_sessions_.end()) {
|
||||
if (remote_id.rfind("lan-", 0) == 0)
|
||||
for (const auto& [_, props] : remote_sessions_)
|
||||
if (props->lan_session_ && !props->closing_ &&
|
||||
Thumbnail::LanRecordId(props->lan_session_->target.device_id) ==
|
||||
remote_id)
|
||||
return props;
|
||||
return nullptr;
|
||||
}
|
||||
return it->second;
|
||||
|
||||
@@ -22,6 +22,7 @@ inline nlohmann::json BuildDevicePresenceRequest(
|
||||
const auto append = [&](const std::string& id) {
|
||||
// Recent connection keys contain Y/N plus display settings after the ID.
|
||||
const std::string pure_id = id.substr(0, id.find_first_of("YN"));
|
||||
if (pure_id.rfind("lan-", 0) == 0 || pure_id.rfind("lan/", 0) == 0) return;
|
||||
if (!pure_id.empty() &&
|
||||
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
|
||||
devices.push_back(pure_id);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _DISCONNECT_LOCK_POLICY_H_
|
||||
#define _DISCONNECT_LOCK_POLICY_H_
|
||||
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Guarded by GuiRuntime's connection_status_mutex_. Only incoming sessions
|
||||
// that reached Connected can request a lock; terminal callbacks may repeat.
|
||||
class DisconnectLockPolicy {
|
||||
public:
|
||||
void Connected(const std::string& remote_id) {
|
||||
controllers_.insert(remote_id);
|
||||
pending_ = false;
|
||||
}
|
||||
|
||||
void Disconnected(const std::string& remote_id, bool enabled) {
|
||||
if (controllers_.erase(remote_id) && controllers_.empty() && enabled)
|
||||
pending_ = true;
|
||||
}
|
||||
|
||||
bool TakeRequest(bool enabled) {
|
||||
return std::exchange(pending_, false) && enabled && controllers_.empty();
|
||||
}
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> controllers_;
|
||||
bool pending_ = false;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -17,6 +17,9 @@
|
||||
#include "localization.h"
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rd_log.h"
|
||||
#ifdef _WIN32
|
||||
#include "platform/windows/input/windows_input_injector.h"
|
||||
#endif
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
#include "platform/linux/headless/headless_console.h"
|
||||
#endif
|
||||
@@ -25,20 +28,56 @@ namespace crossdesk {
|
||||
|
||||
GuiRuntime::GuiRuntime()
|
||||
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this),
|
||||
keyboard_(*this) {}
|
||||
keyboard_(*this),
|
||||
remote_input_(
|
||||
[this](const std::string& id, const RemoteAction& action) {
|
||||
#ifdef _WIN32
|
||||
ScopedUserDesktopInputThread input_thread;
|
||||
#endif
|
||||
return ApplyRemoteInput(id, action);
|
||||
},
|
||||
[this](const std::string& id) {
|
||||
#ifdef _WIN32
|
||||
ScopedUserDesktopInputThread input_thread;
|
||||
#endif
|
||||
const bool keys_released =
|
||||
keyboard_.ReleaseRemotePressedKeys(id, "input_queue_reset");
|
||||
const bool mouse_released = devices_.ReleaseRemoteMouseButtons(id);
|
||||
std::lock_guard lock(remote_pointer_input_mutex_);
|
||||
last_remote_pointer_input_time_.erase(id);
|
||||
return keys_released && mouse_released ? InputOutcome::applied
|
||||
: InputOutcome::failed;
|
||||
},
|
||||
[](const std::string& id, int64_t queued_ms, int64_t execution_ms,
|
||||
InputOutcome outcome) {
|
||||
LOG_WARN("Remote input result: peer={} queue_ms={} execution_ms={} "
|
||||
"result={}",
|
||||
id, queued_ms, execution_ms, InputOutcomeName(outcome));
|
||||
},
|
||||
[this] { return devices_.ResetToInitialDisplay(); },
|
||||
[this](const std::string& id, unsigned mask) {
|
||||
#ifdef _WIN32
|
||||
ScopedUserDesktopInputThread input_thread;
|
||||
#endif
|
||||
return devices_.ReleaseRemoteMouseButtons(id, mask)
|
||||
? InputOutcome::applied : InputOutcome::failed;
|
||||
}) {}
|
||||
|
||||
GuiRuntime::~GuiRuntime() {
|
||||
remote_input_.Stop();
|
||||
#if _WIN32
|
||||
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
|
||||
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
|
||||
if (windows_service_worker_) windows_service_worker_->Reset();
|
||||
#endif
|
||||
lan_.reset();
|
||||
transfers_.Shutdown();
|
||||
WaitForSessionCleanup();
|
||||
}
|
||||
|
||||
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
|
||||
const bool mouse = action.type == ControlType::mouse;
|
||||
const bool mouse_move = mouse && action.m.flag == MouseFlag::move;
|
||||
if (!mouse && action.type != ControlType::keyboard &&
|
||||
action.type != ControlType::keyboard_state)
|
||||
return;
|
||||
@@ -72,9 +111,15 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
|
||||
}
|
||||
// Reuse exactly the leader's mapped coordinates and key metadata. Neither
|
||||
// gallery filters nor the follower's preview dimensions affect input.
|
||||
const int result = SendReliableDataFrame(
|
||||
props->peer_, message.data(), message.size(),
|
||||
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
|
||||
// Move updates supersede old positions and must not wait for KCP retries.
|
||||
// Use the negotiated unreliable stream; clicks and wheel events remain
|
||||
// on the reliable mouse stream, with their own absolute coordinates.
|
||||
const int result = mouse_move
|
||||
? SendDataFrame(props->peer_, message.data(), message.size(),
|
||||
props->data_label_.c_str())
|
||||
: SendReliableDataFrame(
|
||||
props->peer_, message.data(), message.size(),
|
||||
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
|
||||
if (result != 0)
|
||||
LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result);
|
||||
}
|
||||
@@ -193,7 +238,8 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
strncpy(params_.log_path, dll_log_path_.c_str(),
|
||||
sizeof(params_.log_path) - 1);
|
||||
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
|
||||
params_.hardware_acceleration = config_center_->IsHardwareVideoCodec();
|
||||
// MiniRTC selects the available hardware backend and falls back to software.
|
||||
params_.hardware_acceleration = true;
|
||||
#if defined(_WIN32) || (defined(__linux__) && !defined(__OHOS__)) || defined(__APPLE__)
|
||||
// Windows and Linux retain pooled CPU NV12 frames or CUDA device frames;
|
||||
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
|
||||
@@ -247,7 +293,7 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
LOG_INFO("Create peer [{}] instance failed", client_id_);
|
||||
}
|
||||
|
||||
if (0 == devices_.InitializeScreenCapturer()) {
|
||||
if (0 == devices_.EnsureCaptureReady()) {
|
||||
const auto &displays = devices_.display_info_list();
|
||||
for (size_t index = 0; index < displays.size(); ++index) {
|
||||
const std::string stream_id = MakeDisplayStreamId(index);
|
||||
@@ -289,10 +335,18 @@ void GuiRuntime::HandleRecentConnections() {
|
||||
// Record a successful connection before a preview or host info arrives.
|
||||
// The close path still updates its final thumbnail. Remember password
|
||||
// controls only credential storage, not whether this device is listed.
|
||||
const auto direct = props->lan_session_;
|
||||
const Thumbnail::LanTarget target =
|
||||
direct ? Thumbnail::LanTarget{direct->target.address,
|
||||
direct->target.device_id,
|
||||
direct->target.port}
|
||||
: Thumbnail::LanTarget{};
|
||||
const auto record_id =
|
||||
direct ? Thumbnail::LanRecordId(target.device_id) : props->remote_id_;
|
||||
if (thumbnail_->SaveToThumbnail(
|
||||
nullptr, 0, 0, props->remote_id_, host_name,
|
||||
nullptr, 0, 0, record_id, host_name,
|
||||
props->remember_password_ ? props->remote_password_ : "",
|
||||
platform) == 0)
|
||||
platform, direct ? &target : nullptr) == 0)
|
||||
reload_recent_connections_ = true;
|
||||
else
|
||||
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
|
||||
@@ -301,9 +355,7 @@ void GuiRuntime::HandleRecentConnections() {
|
||||
if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) {
|
||||
uint32_t now_time = SDL_GetTicks();
|
||||
if (now_time - recent_connection_image_save_time_ >= 50) {
|
||||
int ret = thumbnail_->LoadThumbnail(recent_connections_,
|
||||
&recent_connection_image_width_,
|
||||
&recent_connection_image_height_);
|
||||
int ret = thumbnail_->LoadThumbnail(recent_connections_);
|
||||
if (!ret) {
|
||||
LOG_INFO("Load recent connection thumbnails");
|
||||
}
|
||||
@@ -311,7 +363,8 @@ void GuiRuntime::HandleRecentConnections() {
|
||||
|
||||
recent_connection_ids_.clear();
|
||||
for (const auto &conn : recent_connections_) {
|
||||
recent_connection_ids_.push_back(conn.first);
|
||||
if (conn.second.lan.device_id.empty())
|
||||
recent_connection_ids_.push_back(conn.first);
|
||||
}
|
||||
need_to_send_recent_connections_ = true;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,12 @@
|
||||
#include "features/devices/session_device_manager.h"
|
||||
#include "features/file_transfer/file_transfer_manager.h"
|
||||
#include "features/input/keyboard_controller.h"
|
||||
#include "features/input/remote_input_dispatcher.h"
|
||||
#include "features/settings/settings_manager.h"
|
||||
#include "network/lan/lan_session_manager.h"
|
||||
#include "privacy_controller.h"
|
||||
#include "runtime/gui_state.h"
|
||||
#include "runtime/disconnect_lock_policy.h"
|
||||
#include "runtime/announcement_inbox.h"
|
||||
#include "runtime/peer_event_handler.h"
|
||||
#include "runtime/privacy_session_policy.h"
|
||||
@@ -49,6 +52,35 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
static void SdlCaptureAudioIn(void* userdata, uint8_t* stream, int len);
|
||||
static void SdlCaptureAudioOut(void* userdata, uint8_t* stream, int len);
|
||||
|
||||
void InitializeLan();
|
||||
void ApplyLanSettings();
|
||||
bool LanAvailable() const;
|
||||
void TickLan();
|
||||
void BeginShutdown();
|
||||
bool ShutdownDrained();
|
||||
void RegisterStreams(PeerPtr* peer, size_t display_count);
|
||||
void ConnectLan(const std::string& address, unsigned port,
|
||||
std::string password, bool remember_password = false);
|
||||
int WithController(
|
||||
const std::string& id,
|
||||
const std::function<int(PeerPtr*, const std::string&)>& operation);
|
||||
int SendControllerData(const std::string& id, const char* data, size_t size,
|
||||
const char* label);
|
||||
std::shared_ptr<PeerEventHandler> ControllerEvents(const std::string& id);
|
||||
std::vector<std::string> ConnectedControllers();
|
||||
std::unique_ptr<lan::SessionManager> lan_;
|
||||
std::string lan_error_;
|
||||
std::shared_ptr<lan::Session> lan_connecting_;
|
||||
bool lan_stopping_ = false;
|
||||
bool stopping_ = false;
|
||||
std::chrono::steady_clock::time_point shutdown_report_at_{};
|
||||
struct LanController {
|
||||
std::shared_ptr<lan::Session> session;
|
||||
std::shared_ptr<PeerEventHandler> events;
|
||||
};
|
||||
// Protected by remote_sessions_mutex_; snapshots acquire callback leases
|
||||
// before releasing that lock, so removal cannot race a raw Peer use.
|
||||
std::map<std::string, LanController> lan_controllers_;
|
||||
int CreateConnectionPeer();
|
||||
int ConnectTo(const std::string& remote_id, const char* password,
|
||||
bool remember_password, bool bypass_presence_check = false,
|
||||
@@ -71,6 +103,7 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
const char* reason);
|
||||
void HandleWindowsServiceIntegration();
|
||||
void HandlePrivacy();
|
||||
void HandleScreenLock();
|
||||
bool IsAuthorizedController(const std::string& remote_id);
|
||||
void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
|
||||
// Caller holds connection_status_mutex_ exclusively. Turns privacy off when
|
||||
@@ -90,6 +123,8 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
// UI thread: send mapped mouse/keyboard input to the current control target,
|
||||
// or every connected session while that target leads DeskHub group control.
|
||||
void SendRemoteInput(const RemoteAction& action);
|
||||
InputOutcome ApplyRemoteInput(const std::string& remote_id, const RemoteAction& action);
|
||||
InputOutcome InjectRemoteMouse(const RemoteAction& action, int display);
|
||||
std::string gallery_group_control_id_;
|
||||
|
||||
void ResetRemoteServiceStatus(RemoteSession& props);
|
||||
@@ -124,6 +159,7 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
bool unattended_host_ = false;
|
||||
AnnouncementInbox announcements_;
|
||||
PrivacySessionPolicy privacy_sessions_;
|
||||
DisconnectLockPolicy disconnect_lock_;
|
||||
std::mutex privacy_commands_mutex_;
|
||||
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
|
||||
uint32_t last_privacy_revision_ = 0;
|
||||
@@ -133,6 +169,7 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
FileTransferManager transfers_;
|
||||
SettingsManager settings_;
|
||||
KeyboardController keyboard_;
|
||||
RemoteInputDispatcher remote_input_;
|
||||
std::shared_ptr<PeerEventHandler> peer_events_;
|
||||
std::atomic<bool> video_frame_dirty_{false};
|
||||
std::unique_ptr<VideoRenderer> video_renderer_;
|
||||
|
||||
@@ -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(¶ms);
|
||||
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
|
||||
@@ -8,6 +8,9 @@
|
||||
#define _NET_TRAFFIC_STATS_CACHE_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "minirtc.h"
|
||||
#include "runtime/connection_latency.h"
|
||||
@@ -15,7 +18,8 @@
|
||||
namespace crossdesk::gui_detail {
|
||||
|
||||
// Fields are independent observations and may come from different reports.
|
||||
// Relaxed accesses keep each field safe without publishing any other state.
|
||||
// Relaxed accesses keep scalar fields safe without publishing other state;
|
||||
// codec names are copied under their own mutex.
|
||||
class NetTrafficStatsCache {
|
||||
static_assert(std::atomic<uint32_t>::is_always_lock_free &&
|
||||
std::atomic<float>::is_always_lock_free &&
|
||||
@@ -26,7 +30,31 @@ class NetTrafficStatsCache {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
struct VideoCodecNames {
|
||||
std::string remote_encoder;
|
||||
std::string local_decoder;
|
||||
};
|
||||
|
||||
void StoreRemoteVideoEncoder(const std::string& encoder) {
|
||||
std::lock_guard lock(codec_mutex_);
|
||||
codecs_.remote_encoder = encoder;
|
||||
}
|
||||
|
||||
VideoCodecNames LoadVideoCodecs() const {
|
||||
std::lock_guard lock(codec_mutex_);
|
||||
return codecs_;
|
||||
}
|
||||
|
||||
void Store(const MiniRtcNetTrafficStats& stats) {
|
||||
{
|
||||
std::lock_guard lock(codec_mutex_);
|
||||
local_encoder_.assign(
|
||||
stats.video_encoder,
|
||||
strnlen(stats.video_encoder, sizeof(stats.video_encoder)));
|
||||
codecs_.local_decoder.assign(
|
||||
stats.video_decoder,
|
||||
strnlen(stats.video_decoder, sizeof(stats.video_decoder)));
|
||||
}
|
||||
video_inbound_stats_.Store(stats.video_inbound_stats);
|
||||
video_outbound_stats_.Store(stats.video_outbound_stats);
|
||||
audio_inbound_stats_.Store(stats.audio_inbound_stats);
|
||||
@@ -46,6 +74,11 @@ class NetTrafficStatsCache {
|
||||
|
||||
MiniRtcNetTrafficStats Load() const {
|
||||
MiniRtcNetTrafficStats stats{};
|
||||
{
|
||||
std::lock_guard lock(codec_mutex_);
|
||||
local_encoder_.copy(stats.video_encoder, sizeof(stats.video_encoder) - 1);
|
||||
codecs_.local_decoder.copy(stats.video_decoder, sizeof(stats.video_decoder) - 1);
|
||||
}
|
||||
stats.video_inbound_stats = video_inbound_stats_.Load();
|
||||
stats.video_outbound_stats = video_outbound_stats_.Load();
|
||||
stats.audio_inbound_stats = audio_inbound_stats_.Load();
|
||||
@@ -80,6 +113,10 @@ class NetTrafficStatsCache {
|
||||
|
||||
void Reset() {
|
||||
Store(MiniRtcNetTrafficStats{});
|
||||
{
|
||||
std::lock_guard lock(codec_mutex_);
|
||||
codecs_ = {};
|
||||
}
|
||||
ResetLatency();
|
||||
last_report_ms_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
@@ -130,6 +167,9 @@ class NetTrafficStatsCache {
|
||||
std::atomic<double> rtt_ms_{-1};
|
||||
std::atomic<int64_t> last_report_ms_{0};
|
||||
ConnectionLatencyStats connection_latency_;
|
||||
mutable std::mutex codec_mutex_;
|
||||
std::string local_encoder_;
|
||||
VideoCodecNames codecs_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk::gui_detail
|
||||
|
||||
@@ -84,17 +84,52 @@ void LogSecureDesktopInputBlocked(uint32_t *last_tick, const char *side,
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PeerEventHandler::QueueEarlyData(const char* data, size_t size,
|
||||
const char* label, size_t label_size) {
|
||||
if (!direct_bound_) return false;
|
||||
auto session = direct_.lock();
|
||||
if (!session || session->closing) return true;
|
||||
std::lock_guard lock(early_data_mutex_);
|
||||
if (session->authorized) return false;
|
||||
// Keep bounded reliable metadata until the manager commits Connected. Mouse,
|
||||
// keyboard, clipboard and files never enter this pre-authorization queue.
|
||||
if (std::string_view(label, label_size) != owner_.control_data_label_)
|
||||
return true;
|
||||
if (size > 8192 || early_data_.size() >= 16 ||
|
||||
early_data_bytes_ + size > 65536) {
|
||||
session->failed = true;
|
||||
return true;
|
||||
}
|
||||
early_data_bytes_ += size;
|
||||
early_data_.emplace_back(data, size);
|
||||
return true;
|
||||
}
|
||||
void PeerEventHandler::FlushEarlyData() {
|
||||
std::vector<std::string> queued;
|
||||
{
|
||||
std::lock_guard lock(early_data_mutex_);
|
||||
queued.swap(early_data_);
|
||||
early_data_bytes_ = 0;
|
||||
}
|
||||
const auto session = direct_.lock();
|
||||
if (!session || !session->authorized || session->closing) return;
|
||||
const auto& label = owner_.control_data_label_;
|
||||
for (const auto& data : queued)
|
||||
OnReceiveDataBuffer(data.data(), data.size(), session->remote_id.data(),
|
||||
session->remote_id.size(), label.data(), label.size(),
|
||||
this);
|
||||
}
|
||||
|
||||
void PeerEventHandler::OnReceiveDataBuffer(
|
||||
const char *data, size_t size, const char *user_id, size_t user_id_size,
|
||||
const char *src_id, size_t src_id_size, void *user_data) {
|
||||
auto *handler = static_cast<PeerEventHandler *>(user_data);
|
||||
if (!handler) return;
|
||||
auto callback = handler->EnterCallback();
|
||||
if (!callback) return;
|
||||
GuiRuntime *runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime) {
|
||||
if (!callback || handler->QueueEarlyData(data, size, src_id, src_id_size) ||
|
||||
!handler->DataAllowed())
|
||||
return;
|
||||
}
|
||||
GuiRuntime* runtime = &handler->owner_;
|
||||
|
||||
std::string source_id = std::string(src_id, src_id_size);
|
||||
// The service-owned host runs as SYSTEM. Do not expose filesystem or
|
||||
@@ -105,11 +140,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
source_id == runtime->clipboard_label_))
|
||||
return;
|
||||
if (source_id == runtime->file_label_) {
|
||||
runtime->transfers_.Receive(data, size, handler, std::string(user_id, user_id_size));
|
||||
runtime->transfers_.Receive(data, size, handler,
|
||||
handler->ConnectionKey(user_id, user_id_size));
|
||||
return;
|
||||
} else if (source_id == runtime->clipboard_label_) {
|
||||
if (size > 0) {
|
||||
std::string remote_user_id(user_id, user_id_size);
|
||||
std::string remote_user_id =
|
||||
handler->ConnectionKey(user_id, user_id_size);
|
||||
auto props =
|
||||
runtime->FindRemoteSession(remote_user_id);
|
||||
if (props && !props->clipboard_allowed_.load()) {
|
||||
@@ -121,11 +158,15 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
return;
|
||||
} else if (source_id == runtime->file_feedback_label_) {
|
||||
runtime->transfers_.HandleAck(data, size, handler, std::string(user_id, user_id_size));
|
||||
runtime->transfers_.HandleAck(
|
||||
data, size, handler, handler->ConnectionKey(user_id, user_id_size));
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string remote_id(user_id, user_id_size);
|
||||
const std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
||||
const std::string wire_id = handler->WireId(remote_id);
|
||||
PeerPtr* bound_peer =
|
||||
handler->IsDirect() ? handler->DirectPeer() : runtime->peer_;
|
||||
std::string json_str(data, size);
|
||||
const auto preview_json =
|
||||
json_str.find("\"deskhub_preview") != std::string::npos
|
||||
@@ -145,29 +186,31 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
if (!previous || subscription->IsNewerThan(*previous)) {
|
||||
if (subscription->fps > 0)
|
||||
accepted = UpdateVideoSettings(
|
||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||
static_cast<VideoQuality>(subscription->quality),
|
||||
subscription->frame_rate,
|
||||
static_cast<VideoDegradationPreference>(
|
||||
subscription->EncoderPreference())) == 0;
|
||||
bound_peer, wire_id.data(), wire_id.size(),
|
||||
static_cast<VideoQuality>(subscription->quality),
|
||||
subscription->frame_rate,
|
||||
static_cast<VideoDegradationPreference>(
|
||||
subscription->EncoderPreference())) == 0;
|
||||
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
|
||||
subscription->max_bitrate_bps.value_or(0)))
|
||||
accepted = UpdateVideoBitrateLimit(
|
||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||
bound_peer, wire_id.data(), wire_id.size(),
|
||||
subscription->max_bitrate_bps.value_or(0)) == 0;
|
||||
// Rebalancing bandwidth alone must not force every device to send a
|
||||
// keyframe. The pause API requests its own keyframes on resume.
|
||||
if (accepted && (!previous || previous->width != subscription->width ||
|
||||
previous->height != subscription->height))
|
||||
accepted = UpdateVideoResolution(
|
||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||
subscription->width, subscription->height) == 0;
|
||||
accepted = UpdateVideoResolution(bound_peer, wire_id.data(),
|
||||
wire_id.size(), subscription->width,
|
||||
subscription->height) == 0;
|
||||
if (accepted)
|
||||
accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(),
|
||||
remote_id.size(), subscription->fps == 0) == 0;
|
||||
accepted =
|
||||
SetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(),
|
||||
subscription->fps == 0) == 0;
|
||||
if (accepted)
|
||||
accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(),
|
||||
remote_id.size(), !subscription->audio) == 0;
|
||||
accepted =
|
||||
SetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(),
|
||||
!subscription->audio) == 0;
|
||||
if (accepted && subscription->audio)
|
||||
runtime->devices_.StartSpeakerCapturer();
|
||||
}
|
||||
@@ -178,9 +221,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
{"request_id", subscription->request_id},
|
||||
{"accepted",
|
||||
accepted}}.dump();
|
||||
SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(),
|
||||
SendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(),
|
||||
runtime->control_data_label_.c_str(),
|
||||
remote_id.data(), remote_id.size());
|
||||
wire_id.data(), wire_id.size());
|
||||
return;
|
||||
}
|
||||
if (preview_json.is_object() &&
|
||||
@@ -209,6 +252,16 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
return;
|
||||
}
|
||||
|
||||
if (remote_action.type == ControlType::video_codec_status) {
|
||||
if (auto props = runtime->FindRemoteSession(remote_id);
|
||||
props && source_id == props->control_data_label_ &&
|
||||
!props->closing_ &&
|
||||
props->connection_status_ == ConnectionStatus::Connected) {
|
||||
props->net_traffic_stats_.StoreRemoteVideoEncoder(remote_action.vcs.encoder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (remote_action.type == ControlType::video_settings_status) {
|
||||
if (auto props = runtime->FindRemoteSession(remote_id)) {
|
||||
if (source_id != props->control_data_label_) return;
|
||||
@@ -243,7 +296,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
const auto& settings = remote_action.vs;
|
||||
remote_action.vs.accepted =
|
||||
UpdateVideoSettings(
|
||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||
bound_peer, wire_id.data(), wire_id.size(),
|
||||
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
|
||||
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
|
||||
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
|
||||
@@ -252,9 +305,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
settings.frame_rate, settings.preference, remote_action.vs.accepted);
|
||||
remote_action.type = ControlType::video_settings_status;
|
||||
const auto message = remote_action.to_json();
|
||||
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
|
||||
SendReliableDataFrameToPeer(bound_peer, message.data(), message.size(),
|
||||
runtime->control_data_label_.c_str(),
|
||||
remote_id.data(), remote_id.size());
|
||||
wire_id.data(), wire_id.size());
|
||||
return;
|
||||
}
|
||||
if (remote_action.type == ControlType::privacy_status) {
|
||||
@@ -280,29 +333,22 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
|
||||
return;
|
||||
}
|
||||
if (remote_action.type == ControlType::text_input) {
|
||||
// Share the ordered keyboard stream with delete/return and shortcuts.
|
||||
if (source_id == runtime->keyboard_label_ &&
|
||||
runtime->IsAuthorizedController(remote_id))
|
||||
runtime->keyboard_.ApplyRemoteText(remote_id, remote_action.text);
|
||||
if (RemoteInputDispatcher::Accepts(remote_action)) {
|
||||
if (!runtime->IsAuthorizedController(remote_id)) return;
|
||||
if (remote_action.type == ControlType::text_input &&
|
||||
source_id != runtime->keyboard_label_) return;
|
||||
if (runtime->remote_input_.Push(remote_id, remote_action) ==
|
||||
RemoteInputDispatcher::Result::overflow)
|
||||
LOG_WARN("Remote input queue overflow: peer={}, clearing stale input and "
|
||||
"releasing keys/buttons",
|
||||
remote_id);
|
||||
return;
|
||||
}
|
||||
const bool operates_host = remote_action.type == ControlType::mouse ||
|
||||
remote_action.type == ControlType::keyboard || remote_action.type == ControlType::keyboard_state ||
|
||||
remote_action.type == ControlType::service_command || remote_action.type == ControlType::display_id ||
|
||||
remote_action.type == ControlType::audio_capture;
|
||||
if (operates_host) {
|
||||
if (remote_action.type == ControlType::audio_capture) {
|
||||
if (!runtime->IsAuthorizedController(remote_id)) return;
|
||||
#ifdef _WIN32
|
||||
// KeyboardController checks the desktop immediately before each native
|
||||
// key injection, including state reconciliation and cleanup. Avoid a
|
||||
// second synchronous session query for every received keyboard packet.
|
||||
const bool keyboard_input = remote_action.type == ControlType::keyboard ||
|
||||
remote_action.type == ControlType::keyboard_state;
|
||||
if (!keyboard_input && runtime->privacy_.Engaged() &&
|
||||
!IsWindowsPrivacyDesktopAvailable()) {
|
||||
if (runtime->privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable())
|
||||
runtime->privacy_.SuspendForDesktop();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
if (remote_action.type == ControlType::service_status) {
|
||||
@@ -312,28 +358,6 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
return;
|
||||
}
|
||||
|
||||
if (remote_action.type == ControlType::service_command) {
|
||||
if (runtime->privacy_.Engaged()) {
|
||||
runtime->privacy_.Fail("Security shortcut requested; privacy screen will turn off");
|
||||
}
|
||||
#if _WIN32
|
||||
if (remote_action.c.flag == ServiceCommandFlag::send_sas) {
|
||||
runtime->pending_windows_service_sas_.store(true,
|
||||
std::memory_order_relaxed);
|
||||
} else if (remote_action.c.flag == ServiceCommandFlag::lock_workstation) {
|
||||
if (!LockWorkStation()) {
|
||||
LOG_WARN("Remote lock workstation request failed, error={}",
|
||||
GetLastError());
|
||||
}
|
||||
}
|
||||
#elif defined(__OHOS__)
|
||||
if (!SendHarmonySystemShortcut(remote_action.c.flag))
|
||||
LOG_WARN("Harmony system shortcut unsupported or unauthorized, command={}",
|
||||
static_cast<int>(remote_action.c.flag));
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
if (remote_action.type == ControlType::cursor_state) {
|
||||
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
||||
{
|
||||
@@ -426,8 +450,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
props->video_settings_request_tick_ = SDL_GetTicks();
|
||||
settings.vs = props->video_settings_;
|
||||
const auto message = settings.to_json();
|
||||
SendReliableDataFrame(props->peer_, message.data(), message.size(),
|
||||
props->control_data_label_.c_str());
|
||||
SendReliableDataFrame(
|
||||
handler->IsDirect() ? handler->DirectPeer() : props->peer_,
|
||||
message.data(), message.size(),
|
||||
props->control_data_label_.c_str());
|
||||
}
|
||||
}
|
||||
// client mode
|
||||
@@ -455,6 +481,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
{
|
||||
std::unique_lock lock(runtime->connection_status_mutex_);
|
||||
runtime->connection_host_names_[remote_id] = host_name;
|
||||
if (source_id == runtime->control_data_label_) {
|
||||
if (remote_action.i.supports_video_codec_status)
|
||||
runtime->codec_status_controllers_.insert(remote_id);
|
||||
else
|
||||
runtime->codec_status_controllers_.erase(remote_id);
|
||||
}
|
||||
}
|
||||
LOG_INFO("Remote hostname: [{}]", host_name);
|
||||
if (source_id == runtime->control_data_label_) {
|
||||
@@ -464,100 +496,151 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
FreeRemoteAction(remote_action);
|
||||
}
|
||||
} else {
|
||||
// remote
|
||||
if (runtime->is_server_mode_ &&
|
||||
remote_action.type == ControlType::mouse) {
|
||||
std::lock_guard lock(runtime->remote_pointer_input_mutex_);
|
||||
runtime->last_remote_pointer_input_time_[remote_id] =
|
||||
std::chrono::steady_clock::now();
|
||||
}
|
||||
#if _WIN32
|
||||
if (runtime->is_server_mode_ && remote_action.type == ControlType::mouse) {
|
||||
static const bool process_elevated = IsAdministratorProcess();
|
||||
std::string response;
|
||||
const bool delivered = DispatchDesktopInput(
|
||||
[&] {
|
||||
return PreferUserDesktopInput(
|
||||
IsCurrentSessionUserDesktopActive(),
|
||||
runtime->windows_consent_ui_.load(),
|
||||
runtime->local_service_available_.load(), process_elevated);
|
||||
},
|
||||
[&] {
|
||||
SetLastError(ERROR_SUCCESS);
|
||||
const bool sent = runtime->devices_.SendMouseCommand(
|
||||
remote_action, runtime->selected_display_);
|
||||
const DWORD error = GetLastError();
|
||||
const bool blocked =
|
||||
error == ERROR_ACCESS_DENIED || error == ERROR_GEN_FAILURE;
|
||||
return DesktopInputResult{
|
||||
sent, IsDesktopTransitionInputError(error) || blocked, blocked};
|
||||
},
|
||||
[&]() -> DesktopInputResult {
|
||||
int absolute_x = 0;
|
||||
int absolute_y = 0;
|
||||
if (!BuildAbsoluteMousePosition(
|
||||
runtime->devices_.display_info_list(),
|
||||
runtime->selected_display_, remote_action.m.x,
|
||||
remote_action.m.y, &absolute_x, &absolute_y)) {
|
||||
LOG_WARN(
|
||||
"Secure desktop mouse injection skipped, invalid display "
|
||||
"mapping: display_index={}, x={}, y={}",
|
||||
runtime->selected_display_, remote_action.m.x,
|
||||
remote_action.m.y);
|
||||
return {};
|
||||
}
|
||||
|
||||
response = runtime->devices_.SendServiceMouseCommand(
|
||||
absolute_x, absolute_y, remote_action.m.s,
|
||||
static_cast<int>(remote_action.m.flag));
|
||||
auto json = nlohmann::json::parse(response, nullptr, false);
|
||||
const bool sent = json.is_object() && json.value("ok", false);
|
||||
const std::string error =
|
||||
json.is_object() ? json.value("error", "") : "";
|
||||
return {sent, IsDesktopInputSetupPending(error)};
|
||||
});
|
||||
if (!delivered && !response.empty()) {
|
||||
LogSecureDesktopInputBlocked(
|
||||
&runtime->last_local_secure_input_block_log_tick_, "local",
|
||||
runtime->WindowsInputStage().c_str());
|
||||
LOG_WARN(
|
||||
"Secure desktop mouse injection failed, x={}, y={}, wheel={}, "
|
||||
"flag={}, response={}",
|
||||
remote_action.m.x, remote_action.m.y, remote_action.m.s,
|
||||
static_cast<int>(remote_action.m.flag), response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (remote_action.type == ControlType::mouse) {
|
||||
runtime->devices_.SendMouseCommand(remote_action,
|
||||
runtime->selected_display_);
|
||||
} else if (remote_action.type == ControlType::audio_capture) {
|
||||
if (remote_action.type == ControlType::audio_capture) {
|
||||
// A negotiated subscription remains authoritative if a legacy capture
|
||||
// command arrives after it (e.g. when unmuting an inactive window).
|
||||
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
|
||||
const bool paused = subscription ? !subscription->audio : !remote_action.a;
|
||||
const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(),
|
||||
remote_id.size(), paused);
|
||||
const int ret = SetAudioSendPaused(bound_peer, wire_id.data(),
|
||||
wire_id.size(), paused);
|
||||
if (remote_action.a)
|
||||
runtime->devices_.StartSpeakerCapturer();
|
||||
else if (ret != 0)
|
||||
// Legacy/web transports without the per-peer API retain their fallback.
|
||||
runtime->devices_.StopSpeakerCapturer();
|
||||
} else if (remote_action.type == ControlType::keyboard) {
|
||||
runtime->keyboard_.ApplyRemoteEvent(remote_id, remote_action);
|
||||
} else if (remote_action.type == ControlType::keyboard_state) {
|
||||
runtime->keyboard_.ApplyRemoteState(remote_id, remote_action);
|
||||
} else if (remote_action.type == ControlType::display_id) {
|
||||
const int ret = runtime->devices_.SwitchDisplay(remote_action.d);
|
||||
if (ret == 0) {
|
||||
runtime->selected_display_ = remote_action.d;
|
||||
} else {
|
||||
LOG_WARN("Display switch skipped, invalid display_id={}",
|
||||
remote_action.d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
InputOutcome GuiRuntime::ApplyRemoteInput(const std::string& remote_id,
|
||||
const RemoteAction& remote_action) {
|
||||
if (!IsAuthorizedController(remote_id)) return InputOutcome::ignored;
|
||||
if (remote_action.type == ControlType::text_input) {
|
||||
return keyboard_.ApplyRemoteText(remote_id, remote_action.text);
|
||||
}
|
||||
#ifdef _WIN32
|
||||
const bool keyboard_input = remote_action.type == ControlType::keyboard ||
|
||||
remote_action.type == ControlType::keyboard_state;
|
||||
if (!keyboard_input && privacy_.Engaged() &&
|
||||
!IsWindowsPrivacyDesktopAvailable())
|
||||
privacy_.SuspendForDesktop();
|
||||
#endif
|
||||
if (remote_action.type == ControlType::service_command) {
|
||||
if (privacy_.Engaged()) {
|
||||
privacy_.Fail(
|
||||
"Security shortcut requested; privacy screen will turn off");
|
||||
}
|
||||
#if _WIN32
|
||||
if (remote_action.c.flag == ServiceCommandFlag::send_sas) {
|
||||
pending_windows_service_sas_.store(true, std::memory_order_relaxed);
|
||||
return InputOutcome::applied;
|
||||
} else if (remote_action.c.flag == ServiceCommandFlag::lock_workstation) {
|
||||
if (!LockWorkStation()) {
|
||||
LOG_WARN("Remote lock workstation request failed, error={}",
|
||||
GetLastError());
|
||||
return InputOutcome::failed;
|
||||
}
|
||||
return InputOutcome::applied;
|
||||
}
|
||||
#elif defined(__OHOS__)
|
||||
if (!SendHarmonySystemShortcut(remote_action.c.flag)) {
|
||||
LOG_WARN(
|
||||
"Harmony system shortcut unsupported or unauthorized, command={}",
|
||||
static_cast<int>(remote_action.c.flag));
|
||||
return InputOutcome::failed;
|
||||
}
|
||||
return InputOutcome::applied;
|
||||
#endif
|
||||
return InputOutcome::ignored;
|
||||
}
|
||||
|
||||
const int display = selected_display_.load();
|
||||
if (is_server_mode_ && remote_action.type == ControlType::mouse) {
|
||||
std::lock_guard lock(remote_pointer_input_mutex_);
|
||||
last_remote_pointer_input_time_[remote_id] =
|
||||
std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
if (remote_action.type == ControlType::mouse) {
|
||||
return devices_.ApplyRemoteMouse(remote_id, remote_action.m.flag,
|
||||
[this, &remote_action, display] {
|
||||
return InjectRemoteMouse(remote_action, display);
|
||||
});
|
||||
} else if (remote_action.type == ControlType::keyboard) {
|
||||
return keyboard_.ApplyRemoteEvent(remote_id, remote_action);
|
||||
} else if (remote_action.type == ControlType::keyboard_state) {
|
||||
return keyboard_.ApplyRemoteState(remote_id, remote_action);
|
||||
} else if (remote_action.type == ControlType::display_id) {
|
||||
const int ret = devices_.SwitchDisplay(remote_action.d);
|
||||
if (ret == 0) {
|
||||
selected_display_ = remote_action.d;
|
||||
return InputOutcome::applied;
|
||||
} else {
|
||||
LOG_WARN("Display switch skipped, invalid display_id={}",
|
||||
remote_action.d);
|
||||
return InputOutcome::failed;
|
||||
}
|
||||
}
|
||||
return InputOutcome::ignored;
|
||||
}
|
||||
|
||||
InputOutcome GuiRuntime::InjectRemoteMouse(const RemoteAction& remote_action,
|
||||
int display) {
|
||||
#if _WIN32
|
||||
if (is_server_mode_) {
|
||||
static const bool process_elevated = IsAdministratorProcess();
|
||||
std::string response;
|
||||
const bool delivered = DispatchDesktopInput(
|
||||
[&] {
|
||||
return PreferUserDesktopInput(
|
||||
IsCurrentSessionUserDesktopActive(), windows_consent_ui_.load(),
|
||||
local_service_available_.load(), process_elevated);
|
||||
},
|
||||
[&] {
|
||||
SetLastError(ERROR_SUCCESS);
|
||||
const bool sent = devices_.SendMouseCommand(remote_action, display);
|
||||
const DWORD error = GetLastError();
|
||||
const bool blocked =
|
||||
error == ERROR_ACCESS_DENIED || error == ERROR_GEN_FAILURE;
|
||||
return DesktopInputResult{
|
||||
sent, IsDesktopTransitionInputError(error) || blocked, blocked};
|
||||
},
|
||||
[&]() -> DesktopInputResult {
|
||||
int absolute_x = 0;
|
||||
int absolute_y = 0;
|
||||
if (!BuildAbsoluteMousePosition(devices_.display_info_list(), display,
|
||||
remote_action.m.x, remote_action.m.y,
|
||||
&absolute_x, &absolute_y)) {
|
||||
LOG_WARN(
|
||||
"Secure desktop mouse injection skipped, invalid display "
|
||||
"mapping: display_index={}, x={}, y={}",
|
||||
display, remote_action.m.x, remote_action.m.y);
|
||||
return {};
|
||||
}
|
||||
|
||||
response = devices_.SendServiceMouseCommand(
|
||||
absolute_x, absolute_y, remote_action.m.s,
|
||||
static_cast<int>(remote_action.m.flag));
|
||||
auto json = nlohmann::json::parse(response, nullptr, false);
|
||||
const bool sent = json.is_object() && json.value("ok", false);
|
||||
const std::string error =
|
||||
json.is_object() ? json.value("error", "") : "";
|
||||
return {sent, IsDesktopInputSetupPending(error)};
|
||||
});
|
||||
if (!delivered && !response.empty()) {
|
||||
LogSecureDesktopInputBlocked(&last_local_secure_input_block_log_tick_,
|
||||
"local", WindowsInputStage().c_str());
|
||||
LOG_WARN(
|
||||
"Secure desktop mouse injection failed, x={}, y={}, wheel={}, "
|
||||
"flag={}, response={}",
|
||||
remote_action.m.x, remote_action.m.y, remote_action.m.s,
|
||||
static_cast<int>(remote_action.m.flag), response);
|
||||
}
|
||||
return delivered ? InputOutcome::applied : InputOutcome::failed;
|
||||
}
|
||||
#endif
|
||||
return devices_.SendMouseCommand(remote_action, display)
|
||||
? InputOutcome::applied : InputOutcome::failed;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
@@ -48,6 +49,22 @@ constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string PeerEventHandler::ConnectionKey(const char* id, size_t size) const {
|
||||
if (auto session = direct_.lock()) return session->key;
|
||||
return direct_bound_ ? std::string{} : std::string(id, size);
|
||||
}
|
||||
|
||||
std::string PeerEventHandler::WireId(const std::string& key) const {
|
||||
if (auto session = direct_.lock()) return session->remote_id;
|
||||
return key;
|
||||
}
|
||||
|
||||
bool PeerEventHandler::DataAllowed() const {
|
||||
if (!direct_bound_) return true;
|
||||
auto session = direct_.lock();
|
||||
return session && session->authorized && !session->closing;
|
||||
}
|
||||
|
||||
void PeerEventHandler::SendClientInfo(PeerPtr* peer,
|
||||
const std::string& client_id) {
|
||||
if (!peer) {
|
||||
@@ -73,8 +90,8 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
||||
if (!handler) return;
|
||||
auto callback = handler->EnterCallback();
|
||||
if (!callback) return;
|
||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime || !message || size == 0) {
|
||||
GuiRuntime* runtime = &handler->owner_;
|
||||
if (!message || size == 0) {
|
||||
return;
|
||||
}
|
||||
std::string s(message, size);
|
||||
@@ -178,10 +195,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
||||
if (!handler) return;
|
||||
auto callback = handler->EnterCallback();
|
||||
if (!callback) return;
|
||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
GuiRuntime* runtime = &handler->owner_;
|
||||
|
||||
std::string client_id(user_id, user_id_size);
|
||||
if (client_id == runtime->client_id_) {
|
||||
@@ -250,17 +264,30 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
||||
}
|
||||
|
||||
void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
const char* user_id,
|
||||
const size_t user_id_size,
|
||||
void* user_data) {
|
||||
const char* user_id, size_t size,
|
||||
void* context) {
|
||||
auto* handler = static_cast<PeerEventHandler*>(context);
|
||||
if (!handler) return;
|
||||
if (handler->direct_bound_) {
|
||||
auto callback = handler->EnterCallback();
|
||||
if (callback)
|
||||
if (auto session = handler->direct_.lock()) session->Status(status);
|
||||
return;
|
||||
}
|
||||
DispatchConnectionStatus(status, user_id, size, context);
|
||||
}
|
||||
|
||||
void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
||||
const char* user_id,
|
||||
const size_t user_id_size,
|
||||
void* user_data) {
|
||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||
if (!handler) return;
|
||||
auto callback = handler->EnterCallback();
|
||||
if (!callback) return;
|
||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime) return;
|
||||
GuiRuntime* runtime = &handler->owner_;
|
||||
|
||||
std::string remote_id(user_id, user_id_size);
|
||||
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
||||
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
|
||||
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
|
||||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
|
||||
@@ -295,6 +322,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
/*supports_privacy_screen=*/!runtime->unattended_host_,
|
||||
CROSSDESK_VERSION, kClientPlatform);
|
||||
remote_action.i.supports_video_settings = true;
|
||||
remote_action.i.supports_video_codec_status = true;
|
||||
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
|
||||
remote_action.i.supports_deskhub_preview = true;
|
||||
remote_action.i.supports_deskhub_resolution = true;
|
||||
@@ -430,6 +458,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
{
|
||||
std::unique_lock lock(runtime->connection_status_mutex_);
|
||||
if (status == ConnectionStatus::Connected) {
|
||||
runtime->disconnect_lock_.Connected(remote_id);
|
||||
// Local privacy requires explicit desktop capability. Host info may
|
||||
// arrive before Connected; consume that saved capability here.
|
||||
const bool automatic_enable = runtime->privacy_sessions_.Connected(
|
||||
@@ -437,6 +466,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
runtime->ApplyPrivacyAdmission(automatic_enable);
|
||||
}
|
||||
runtime->connection_status_[remote_id] = status;
|
||||
if (status == ConnectionStatus::Connected)
|
||||
runtime->remote_input_.Connect(remote_id);
|
||||
#if _WIN32
|
||||
runtime->windows_rdp_handoff_connected_->store(std::any_of(
|
||||
runtime->connection_status_.begin(), runtime->connection_status_.end(),
|
||||
@@ -458,6 +489,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
/*supports_privacy_screen=*/!runtime->unattended_host_,
|
||||
CROSSDESK_VERSION, kClientPlatform);
|
||||
remote_action.i.supports_video_settings = true;
|
||||
remote_action.i.supports_video_codec_status = true;
|
||||
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
|
||||
remote_action.i.supports_deskhub_preview = true;
|
||||
remote_action.i.supports_deskhub_resolution = true;
|
||||
@@ -469,8 +501,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
}
|
||||
std::string msg = remote_action.to_json();
|
||||
const int ret =
|
||||
SendReliableDataFrame(runtime->peer_, msg.data(), msg.size(),
|
||||
runtime->control_data_label_.c_str());
|
||||
runtime->SendControllerData(remote_id, msg.data(), msg.size(),
|
||||
runtime->control_data_label_.c_str());
|
||||
if (ret != 0) {
|
||||
LOG_WARN("[{}] host information send failed, ret={}", remote_id,
|
||||
ret);
|
||||
@@ -511,21 +543,12 @@ void PeerEventHandler::OnNetStatusReport(
|
||||
if (!handler) return;
|
||||
auto callback = handler->EnterCallback();
|
||||
if (!callback) return;
|
||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
GuiRuntime* runtime = &handler->owner_;
|
||||
|
||||
if (strchr(client_id, '@') != nullptr && strchr(user_id, '-') == nullptr) {
|
||||
std::string id, password;
|
||||
const char* at_pos = strchr(client_id, '@');
|
||||
if (at_pos == nullptr) {
|
||||
id = client_id;
|
||||
password.clear();
|
||||
} else {
|
||||
id.assign(client_id, at_pos - client_id);
|
||||
password = at_pos + 1;
|
||||
}
|
||||
if (const char* at_pos = handler->IsDirect() ? nullptr : strchr(client_id, '@');
|
||||
at_pos && strchr(user_id, '-') == nullptr) {
|
||||
const std::string id(client_id, at_pos - client_id);
|
||||
const std::string password(at_pos + 1);
|
||||
|
||||
bool is_self_hosted = runtime->config_center_->IsSelfHosted();
|
||||
|
||||
@@ -573,9 +596,30 @@ void PeerEventHandler::OnNetStatusReport(
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string remote_id(user_id, user_id_size);
|
||||
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
||||
auto props = runtime->FindRemoteSession(remote_id);
|
||||
if (!props) return;
|
||||
if (!props) {
|
||||
bool report_codecs = false;
|
||||
{
|
||||
std::shared_lock lock(runtime->connection_status_mutex_);
|
||||
const auto status = runtime->connection_status_.find(remote_id);
|
||||
report_codecs = runtime->codec_status_controllers_.count(remote_id) != 0 &&
|
||||
status != runtime->connection_status_.end() &&
|
||||
status->second == ConnectionStatus::Connected;
|
||||
}
|
||||
if (net_traffic_stats && report_codecs) {
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::video_codec_status;
|
||||
std::snprintf(action.vcs.encoder, sizeof(action.vcs.encoder), "%.*s",
|
||||
static_cast<int>(sizeof(net_traffic_stats->video_encoder)),
|
||||
net_traffic_stats->video_encoder);
|
||||
const auto message = action.to_json();
|
||||
// Report only to this controller, after releasing the status lock.
|
||||
runtime->SendControllerData(remote_id, message.data(), message.size(),
|
||||
runtime->control_data_label_.c_str());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (props->traversal_mode_ != mode) {
|
||||
props->net_traffic_stats_.ResetLatency();
|
||||
props->traversal_mode_ = mode;
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
#define _PEER_EVENT_HANDLER_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "minirtc.h"
|
||||
#include "runtime/session_lifecycle.h"
|
||||
@@ -16,12 +19,30 @@
|
||||
namespace crossdesk {
|
||||
|
||||
class GuiRuntime;
|
||||
namespace lan {
|
||||
struct Session;
|
||||
}
|
||||
|
||||
// Adapts MiniRTC C callbacks to the GUI runtime.
|
||||
class PeerEventHandler {
|
||||
public:
|
||||
explicit PeerEventHandler(GuiRuntime &owner);
|
||||
|
||||
void BindDirect(const std::shared_ptr<lan::Session>& session) {
|
||||
direct_ = session;
|
||||
direct_bound_ = true;
|
||||
}
|
||||
std::string ConnectionKey(const char* id, size_t size) const;
|
||||
std::string WireId(const std::string& key) const;
|
||||
bool DataAllowed() const;
|
||||
bool IsDirect() const { return direct_bound_; }
|
||||
void SetDirectPeer(PeerPtr* peer) { direct_peer_ = peer; }
|
||||
PeerPtr* DirectPeer() const { return direct_peer_; }
|
||||
void FlushEarlyData();
|
||||
void ApplyConnectionStatus(ConnectionStatus status, const std::string& id) {
|
||||
DispatchConnectionStatus(status, id.data(), id.size(), this);
|
||||
}
|
||||
void StopAccepting() { callbacks_.StopAccepting(); }
|
||||
bool IsActive() const { return callbacks_.IsActive(); }
|
||||
auto EnterCallback() { return callbacks_.Enter(); }
|
||||
void Deactivate() { callbacks_.Close(); }
|
||||
@@ -51,12 +72,22 @@ public:
|
||||
void *user_data);
|
||||
|
||||
private:
|
||||
static void SendClientInfo(PeerPtr *peer, const std::string &client_id);
|
||||
static void DispatchConnectionStatus(ConnectionStatus status, const char* id,
|
||||
size_t size, void* context);
|
||||
static void SendClientInfo(PeerPtr* peer, const std::string& client_id);
|
||||
bool QueueEarlyData(const char* data, size_t size, const char* label,
|
||||
size_t label_size);
|
||||
std::weak_ptr<lan::Session> direct_;
|
||||
bool direct_bound_ = false;
|
||||
PeerPtr* direct_peer_ = nullptr;
|
||||
std::mutex early_data_mutex_;
|
||||
std::vector<std::string> early_data_;
|
||||
size_t early_data_bytes_ = 0;
|
||||
|
||||
GuiRuntime &owner_;
|
||||
GuiRuntime& owner_;
|
||||
gui_detail::SessionCallbackGate callbacks_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -38,20 +38,21 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||
if (!handler) return;
|
||||
auto callback = handler->EnterCallback();
|
||||
if (!callback) return;
|
||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime || !video_frame) {
|
||||
if (!callback || !handler->DataAllowed()) return;
|
||||
GuiRuntime* runtime = &handler->owner_;
|
||||
if (!video_frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string remote_id(user_id, user_id_size);
|
||||
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
||||
auto props = runtime->FindRemoteSession(remote_id);
|
||||
if (!props) return;
|
||||
|
||||
if (props->connection_established_) {
|
||||
const auto timing_now = VideoLatencyFrame::Clock::now();
|
||||
// Pair the clocks before preview conversion so its cost is included.
|
||||
const auto local_now_us = GetSystemTimeMicros(props->peer_);
|
||||
const auto local_now_us = GetSystemTimeMicros(
|
||||
handler->IsDirect() ? handler->DirectPeer() : props->peer_);
|
||||
const auto* received_native = GetNativeVideoFrame(video_frame);
|
||||
props->received_video_stats_.Record(
|
||||
received_native ? received_native->width : video_frame->width,
|
||||
@@ -274,15 +275,15 @@ void PeerEventHandler::OnReceiveAudioBuffer(
|
||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||
if (!handler) return;
|
||||
auto callback = handler->EnterCallback();
|
||||
if (!callback) return;
|
||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime || runtime->unattended_host_) {
|
||||
if (!callback || !handler->DataAllowed()) return;
|
||||
GuiRuntime* runtime = &handler->owner_;
|
||||
if (runtime->unattended_host_) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
|
||||
const auto session =
|
||||
runtime->FindRemoteSession(std::string(user_id, user_id_size));
|
||||
runtime->FindRemoteSession(handler->ConnectionKey(user_id, user_id_size));
|
||||
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
|
||||
runtime->audio_buffer_fresh_ = true;
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
namespace crossdesk {
|
||||
|
||||
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
|
||||
if (remote_id.rfind("lan/", 0) == 0) {
|
||||
const auto events = ControllerEvents(remote_id);
|
||||
if (!events || !events->DataAllowed()) return false;
|
||||
}
|
||||
// Connections appear here only after the existing password/signalling/RTC
|
||||
// admission path. Outgoing/viewer sessions live in remote_sessions_ instead.
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
@@ -109,9 +113,9 @@ void GuiRuntime::HandlePrivacy() {
|
||||
"A connected controller does not support privacy screen");
|
||||
}
|
||||
}
|
||||
if (!peer_ || (last_privacy_status_tick_ != 0 &&
|
||||
status.revision == last_privacy_revision_ &&
|
||||
now - last_privacy_status_tick_ < 1000))
|
||||
if (last_privacy_status_tick_ != 0 &&
|
||||
status.revision == last_privacy_revision_ &&
|
||||
now - last_privacy_status_tick_ < 1000)
|
||||
return;
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::privacy_status;
|
||||
@@ -119,9 +123,8 @@ void GuiRuntime::HandlePrivacy() {
|
||||
const auto message = action.to_json();
|
||||
bool sent = true;
|
||||
for (const auto& id : controllers) {
|
||||
const int result = SendReliableDataFrameToPeer(
|
||||
peer_, message.data(), message.size(), control_data_label_.c_str(),
|
||||
id.data(), id.size());
|
||||
const int result = SendControllerData(id, message.data(), message.size(),
|
||||
control_data_label_.c_str());
|
||||
if (result != 0) {
|
||||
sent = false;
|
||||
LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result);
|
||||
|
||||
@@ -31,7 +31,12 @@
|
||||
#include "runtime/received_video_stats.h"
|
||||
#include "runtime/connection_auth_state.h"
|
||||
|
||||
namespace crossdesk { class PeerEventHandler; }
|
||||
namespace crossdesk {
|
||||
class PeerEventHandler;
|
||||
namespace lan {
|
||||
struct Session;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
|
||||
@@ -105,6 +110,7 @@ struct RemoteSession {
|
||||
// Decoder-thread scratch memory, never shared with the UI.
|
||||
std::mutex gallery_conversion_mutex_;
|
||||
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
|
||||
std::shared_ptr<lan::Session> lan_session_;
|
||||
Params params_;
|
||||
PeerPtr* peer_ = nullptr;
|
||||
std::shared_ptr<PeerEventHandler> peer_events_;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -58,8 +59,6 @@ struct RecentConnectionsState {
|
||||
recent_connections_;
|
||||
std::vector<std::string> recent_connection_ids_;
|
||||
std::vector<std::string> gallery_presence_ids_;
|
||||
int recent_connection_image_width_ = 160;
|
||||
int recent_connection_image_height_ = 90;
|
||||
uint32_t recent_connection_image_save_time_ = 0;
|
||||
DevicePresenceCache device_presence_cache_;
|
||||
std::atomic<bool> need_to_send_recent_connections_{true};
|
||||
@@ -149,15 +148,11 @@ struct UserSettingsState {
|
||||
char connection_login_identity_[17] = "";
|
||||
int language_button_value_ = 0;
|
||||
int video_encode_format_button_value_ = 0;
|
||||
bool enable_hardware_video_codec_ = true;
|
||||
bool enable_turn_ = true;
|
||||
char signal_server_ip_[256] = "api.crossdesk.cn";
|
||||
char signal_server_port_[6] = "9099";
|
||||
bool enable_self_hosted_ = false;
|
||||
int language_button_value_last_ = 0;
|
||||
int video_encode_format_button_value_last_ = 0;
|
||||
bool enable_hardware_video_codec_last_ = false;
|
||||
bool enable_turn_last_ = true;
|
||||
bool enable_self_hosted_last_ = false;
|
||||
bool enable_autostart_ = false;
|
||||
bool enable_autostart_last_ = false;
|
||||
@@ -208,6 +203,7 @@ struct ConnectionState {
|
||||
std::shared_mutex connection_status_mutex_;
|
||||
std::unordered_map<std::string, ConnectionStatus> connection_status_;
|
||||
std::unordered_map<std::string, std::string> connection_host_names_;
|
||||
std::unordered_set<std::string> codec_status_controllers_;
|
||||
// Cursor position is sampled asynchronously after remote mouse input has
|
||||
// been injected. Remember the input source so the sampled position is not
|
||||
// immediately echoed back to that same controller as stale feedback.
|
||||
|
||||
@@ -37,6 +37,11 @@ void SessionCallbackGate::EndCallback() {
|
||||
}
|
||||
}
|
||||
|
||||
void SessionCallbackGate::StopAccepting() {
|
||||
std::lock_guard lock(mutex_);
|
||||
accepting_callbacks_ = false;
|
||||
}
|
||||
|
||||
void SessionCallbackGate::Close() {
|
||||
std::unique_lock lock(mutex_);
|
||||
accepting_callbacks_ = false;
|
||||
|
||||
@@ -39,6 +39,7 @@ class SessionCallbackGate {
|
||||
CallbackGuard Enter();
|
||||
|
||||
// Called by the session owner, never from within this session's callbacks.
|
||||
void StopAccepting();
|
||||
void Close();
|
||||
|
||||
private:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||
import { ScrollView, TextEdit } from "std-widgets.slint";
|
||||
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
|
||||
import { WorkbenchStyle } from "workbench_controls.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
import { FontAwesomeIcons } from "common.slint";
|
||||
import { FontAwesomeIcons, ControlStyle } from "common.slint";
|
||||
import "../assets/fonts/message_spacing.ttf";
|
||||
|
||||
export enum AboutPage { overview, components, component, document, source }
|
||||
@@ -26,11 +27,11 @@ export global AboutStrings {
|
||||
out property <string> back: GalleryStrings.tr("返回", "Back", "Назад");
|
||||
}
|
||||
|
||||
export component AboutNavigationGroup inherits Rectangle {
|
||||
export component AboutNavigationGroup inherits FlatSurface {
|
||||
height: rows.preferred-height + 2px;
|
||||
background: WorkbenchStyle.surface;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: 1px; border-color: WorkbenchStyle.separator;
|
||||
outline-color: UiSurface.edge;
|
||||
clip: true;
|
||||
rows := VerticalLayout {
|
||||
x: 1px; y: 1px; width: parent.width - 2px;
|
||||
@@ -41,13 +42,13 @@ export component AboutNavigationGroup inherits Rectangle {
|
||||
|
||||
export component AboutRowSeparator inherits Rectangle {
|
||||
height: 1px;
|
||||
Rectangle {
|
||||
SurfaceSeparator {
|
||||
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> subtitle;
|
||||
in property <string> icon;
|
||||
@@ -60,7 +61,7 @@ export component AboutNavigationRow inherits Rectangle {
|
||||
background: touch.pressed ? WorkbenchStyle.pressed-surface
|
||||
: touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
|
||||
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-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "")
|
||||
+ (root.detail != "" ? ", " + root.detail : "");
|
||||
@@ -69,10 +70,10 @@ export component AboutNavigationRow inherits Rectangle {
|
||||
x: 12px; y: (parent.height - self.height) / 2;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: UiRadius.control;
|
||||
background: WorkbenchStyle.field-surface;
|
||||
background: ControlStyle.surface;
|
||||
Text {
|
||||
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;
|
||||
accessible-role: none;
|
||||
}
|
||||
@@ -226,7 +227,7 @@ export component AboutPanel inherits SettingsDialog {
|
||||
footer := VerticalLayout {
|
||||
x: 24px; y: parent.height - self.height - 24px;
|
||||
width: parent.width - 48px; height: self.preferred-height; spacing: 12px;
|
||||
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
|
||||
SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
|
||||
HorizontalLayout {
|
||||
alignment: end;
|
||||
SettingsButton { text: AboutStrings.back; clicked => { root.back(); } }
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
import { FlatSurface, UiSurface } from "ui_surface.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 {
|
||||
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
|
||||
// logical sizes reproduce those visual tiers on Slint's HiDPI renderer.
|
||||
export global ImGuiFontStyle {
|
||||
@@ -69,10 +94,10 @@ export component IconButton inherits Rectangle {
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> danger: false;
|
||||
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> hover-background: #edf0f5;
|
||||
in property <color> pressed-background: #d9dde5;
|
||||
in property <color> hover-background: ControlStyle.hover;
|
||||
in property <color> pressed-background: ControlStyle.pressed;
|
||||
in property <bool> slashed: false;
|
||||
callback clicked;
|
||||
|
||||
@@ -138,8 +163,8 @@ export component ImageButton inherits Rectangle {
|
||||
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: touch.pressed ? (danger ? #d92d20 : #d9dde5)
|
||||
: touch.has-hover ? (danger ? #f04438 : #edf0f5)
|
||||
background: touch.pressed ? (danger ? #d92d20 : ControlStyle.pressed)
|
||||
: touch.has-hover ? (danger ? #f04438 : ControlStyle.hover)
|
||||
: transparent;
|
||||
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-out property <int> current-index: 0;
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> subtle: false;
|
||||
in property <bool> show-arrow: true;
|
||||
in property <color> normal-border-color: root.subtle ? #dfe6ef : #bfc2c7;
|
||||
in property <color> hover-border-color: root.normal-border-color;
|
||||
in property <color> normal-outline-color: ButtonStyle.border;
|
||||
in property <color> hover-outline-color: ButtonStyle.hover-border;
|
||||
property <bool> popup-animation-enabled: false;
|
||||
property <float> popup-progress: 0.0;
|
||||
callback selected(int);
|
||||
@@ -205,43 +230,45 @@ export component CompactComboBox inherits Rectangle {
|
||||
|
||||
width: 73px;
|
||||
height: 24px;
|
||||
background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1;
|
||||
border-width: 1px;
|
||||
border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color;
|
||||
background: !root.enabled ? ControlStyle.disabled
|
||||
: combo-touch.pressed ? ButtonStyle.pressed
|
||||
: 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;
|
||||
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 {
|
||||
x: root.subtle ? 10px : 5px;
|
||||
width: parent.width - (root.show-arrow ? (root.subtle ? 36px : 24px) : (root.subtle ? 20px : 10px));
|
||||
x: root.subtle ? 10px : 8px;
|
||||
width: parent.width - self.x - (root.show-arrow ? 28px : self.x);
|
||||
height: parent.height;
|
||||
text: root.model[root.current-index];
|
||||
color: root.enabled ? #202124 : #8b8f95;
|
||||
font-size: root.subtle ? 12px : ImGuiFontStyle.body;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
if root.show-arrow: Rectangle {
|
||||
x: parent.width - (root.subtle ? 27px : 21px);
|
||||
width: 20px;
|
||||
height: parent.height - 2px;
|
||||
y: 1px;
|
||||
background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc;
|
||||
border-top-right-radius: UiRadius.control - 1px;
|
||||
border-bottom-right-radius: UiRadius.control - 1px;
|
||||
Text {
|
||||
text: FontAwesomeIcons.angle-down;
|
||||
color: root.subtle ? #79879a : #202124;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
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;
|
||||
}
|
||||
}
|
||||
if root.show-arrow: Text {
|
||||
x: parent.width - 26px;
|
||||
width: 18px; height: parent.height;
|
||||
// Switch glyphs so the software renderer also shows the open state.
|
||||
text: combo-popup.is-open ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down;
|
||||
color: !root.enabled ? #98a2b3 : combo-popup.is-open ? UiSurface.focus : ControlStyle.icon;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 10px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
combo-touch := TouchArea {
|
||||
enabled: root.enabled; mouse-cursor: pointer;
|
||||
@@ -269,12 +296,12 @@ export component CompactComboBox inherits Rectangle {
|
||||
|
||||
combo-popup := PopupWindow {
|
||||
x: 0px;
|
||||
y: root.height;
|
||||
y: root.height + 4px;
|
||||
width: root.width;
|
||||
height: root.model.length * 23px;
|
||||
height: root.model.length * 23px + 8px;
|
||||
close-policy: close-on-click-outside;
|
||||
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
// Keep the reveal anchored to the popup's top edge while the
|
||||
// content settles downward into its final position.
|
||||
y: (1.0 - root.popup-progress) * -8px;
|
||||
@@ -282,18 +309,33 @@ export component CompactComboBox inherits Rectangle {
|
||||
height: parent.height * root.popup-progress;
|
||||
opacity: root.popup-progress;
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: root.normal-border-color;
|
||||
outline-color: root.normal-outline-color;
|
||||
border-radius: UiRadius.menu;
|
||||
clip: true;
|
||||
for choice[index] in root.model: Rectangle {
|
||||
y: index * 23px;
|
||||
width: parent.width;
|
||||
x: 4px; y: 4px + index * 23px;
|
||||
width: parent.width - 8px;
|
||||
height: 23px;
|
||||
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
|
||||
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
border-radius: UiRadius.control;
|
||||
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 {
|
||||
enabled: root.enabled;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
popup-open-timer.running = false;
|
||||
root.popup-animation-enabled = false;
|
||||
@@ -306,12 +348,12 @@ export component CompactComboBox inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
background: transparent;
|
||||
border-width: 1px;
|
||||
border-color: root.normal-border-color;
|
||||
clip: true;
|
||||
outline-color: root.normal-outline-color;
|
||||
border-radius: UiRadius.menu;
|
||||
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 property <bool> enabled: true;
|
||||
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
background: root.enabled ? white : #f1f1f1;
|
||||
border-width: 1px;
|
||||
border-color: check-touch.has-hover && root.enabled ? #8cbef2 : #b9bdc2;
|
||||
background: !root.enabled ? ControlStyle.disabled
|
||||
: root.checked ? (check-touch.pressed ? #254cc0 : check-touch.has-hover ? #4574f0 : #3564e3)
|
||||
: 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;
|
||||
if root.checked: Text {
|
||||
text: FontAwesomeIcons.check;
|
||||
color: root.enabled ? #2e86de : #8b8f95;
|
||||
color: root.enabled ? white : #8b8f95;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
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 <length> text-size: ImGuiFontStyle.body;
|
||||
in property <bool> primary: false;
|
||||
@@ -378,13 +421,17 @@ export component CompactButton inherits Rectangle {
|
||||
|
||||
width: 34px;
|
||||
height: 24px;
|
||||
background: !root.enabled ? (root.quiet ? transparent : #eceef0)
|
||||
: button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd)
|
||||
: button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2)
|
||||
: root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7;
|
||||
border-width: 1px;
|
||||
border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0;
|
||||
background: !root.enabled ? (root.quiet ? transparent : ControlStyle.disabled)
|
||||
: button-touch.pressed ? (root.primary ? #82b8f2 : ButtonStyle.pressed)
|
||||
: button-touch.has-hover ? (root.primary ? #b8d9fb : ButtonStyle.hover)
|
||||
: root.primary ? #a8d0fa : root.quiet ? transparent : ButtonStyle.surface;
|
||||
outline-enabled: !root.quiet;
|
||||
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;
|
||||
animate background, outline-color { duration: 100ms; }
|
||||
Text {
|
||||
text: root.text;
|
||||
color: root.enabled ? #24272d : #8e9298;
|
||||
@@ -393,27 +440,30 @@ export component CompactButton inherits Rectangle {
|
||||
horizontal-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 <length> text-size: ImGuiFontStyle.body;
|
||||
in property <length> text-size: 12px;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked;
|
||||
|
||||
min-width: 72px;
|
||||
height: 30px;
|
||||
height: 32px;
|
||||
background: !root.enabled ? #b8c1d1
|
||||
: touch.pressed ? #1849a9
|
||||
: touch.has-hover ? #2970d6
|
||||
: #2463c7;
|
||||
: touch.pressed ? #254cc0
|
||||
: touch.has-hover ? #4574f0
|
||||
: #3564e3;
|
||||
outline-enabled: false;
|
||||
border-radius: UiRadius.control;
|
||||
animate background { duration: 100ms; }
|
||||
|
||||
Text {
|
||||
text: root.text;
|
||||
color: white;
|
||||
font-size: root.text-size;
|
||||
font-weight: 600;
|
||||
horizontal-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 <length> text-size: ImGuiFontStyle.body;
|
||||
in property <length> text-size: 12px;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked;
|
||||
|
||||
min-width: 72px;
|
||||
height: 30px;
|
||||
background: !root.enabled ? #f1f2f4
|
||||
: touch.pressed ? #dfe3e9
|
||||
: touch.has-hover ? #f3f4f6 : white;
|
||||
border-width: 1px;
|
||||
border-color: #c9ced7;
|
||||
height: 32px;
|
||||
background: !root.enabled ? ControlStyle.disabled
|
||||
: touch.pressed ? ButtonStyle.pressed
|
||||
: touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
|
||||
outline-color: !root.enabled ? ButtonStyle.disabled-border
|
||||
: touch.pressed ? ButtonStyle.pressed-border
|
||||
: touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
|
||||
border-radius: UiRadius.control;
|
||||
clip: true;
|
||||
animate background, outline-color { duration: 100ms; }
|
||||
|
||||
Text {
|
||||
text: root.text;
|
||||
color: root.enabled ? #24272d : #949aa5;
|
||||
color: root.enabled ? #303f55 : #949aa5;
|
||||
font-size: root.text-size;
|
||||
font-weight: 500;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
}
|
||||
@@ -495,9 +548,8 @@ export component SectionTitle inherits Text {
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
export component DialogSurface inherits Rectangle {
|
||||
export component DialogSurface inherits FlatSurface {
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
outline-color: UiSurface.edge;
|
||||
border-radius: UiRadius.surface;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FlatSurface, UiSurface } from "ui_surface.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.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";
|
||||
|
||||
export struct DeviceGroupOption {
|
||||
@@ -20,13 +21,13 @@ export component DeviceGroupDialog inherits Rectangle {
|
||||
callback cancel();
|
||||
background: #00000040;
|
||||
TouchArea {}
|
||||
dialog := Rectangle {
|
||||
dialog := FlatSurface {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(360px, parent.width - 40px);
|
||||
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
|
||||
background: white; border-radius: UiRadius.surface;
|
||||
border-width: 1px; border-color: #b2b2b2;
|
||||
outline-color: UiSurface.edge;
|
||||
TouchArea {}
|
||||
Text {
|
||||
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;
|
||||
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
|
||||
}
|
||||
group-list := Rectangle {
|
||||
group-list := FlatSurface {
|
||||
x: 18px; y: 95px; width: parent.width - 36px;
|
||||
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;
|
||||
ScrollView {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
@@ -52,16 +53,21 @@ export component DeviceGroupDialog inherits Rectangle {
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
for option[index] in root.options: Rectangle {
|
||||
private property <bool> selected: option.selected && (option.name != "" || root.new-group == "");
|
||||
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 {
|
||||
x: 9px; y: 6px; width: 20px; height: 20px;
|
||||
checked: option.selected && (option.name != "" || root.new-group == "");
|
||||
checked: parent.selected;
|
||||
}
|
||||
Text {
|
||||
x: 39px; width: parent.width - 48px; height: parent.height;
|
||||
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); } }
|
||||
}
|
||||
@@ -75,10 +81,10 @@ export component DeviceGroupDialog inherits Rectangle {
|
||||
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
|
||||
border-color: white;
|
||||
}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
width: parent.width; height: parent.height;
|
||||
border-width: 1px; border-radius: UiRadius.surface;
|
||||
border-color: #e1e5eb;
|
||||
border-radius: UiRadius.surface;
|
||||
outline-color: UiSurface.edge;
|
||||
}
|
||||
}
|
||||
RoundedLineEdit {
|
||||
|
||||
@@ -3,4 +3,16 @@ export global GalleryStrings {
|
||||
public pure function tr(zh: string, en: string, ru: string) -> string {
|
||||
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 { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint";
|
||||
import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint";
|
||||
@@ -26,7 +27,7 @@ export component FileTransferNotification inherits Window {
|
||||
DialogSurface {
|
||||
width: parent.width; height: parent.height;
|
||||
background: WorkbenchStyle.surface;
|
||||
border-color: WorkbenchStyle.separator;
|
||||
outline-color: UiSurface.edge;
|
||||
Text {
|
||||
x: 16px; y: 8px;
|
||||
width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px);
|
||||
@@ -50,9 +51,9 @@ export component FileTransferNotification inherits Window {
|
||||
pressed-background: WorkbenchStyle.pressed-surface;
|
||||
clicked => { root.dismissed(); }
|
||||
}
|
||||
Rectangle {
|
||||
SurfaceSeparator {
|
||||
x: 16px; y: 40px; width: parent.width - 32px; height: 1px;
|
||||
background: WorkbenchStyle.separator;
|
||||
outline-color: UiSurface.separator;
|
||||
}
|
||||
Text {
|
||||
x: 16px; y: 47px; width: parent.width - 32px; height: 18px;
|
||||
@@ -96,7 +97,7 @@ export component FileTransferNotification inherits Window {
|
||||
text: root.cancel-text;
|
||||
text-size: ImGuiFontStyle.base;
|
||||
text-color: WorkbenchStyle.muted;
|
||||
normal-border-color: WorkbenchStyle.border;
|
||||
normal-outline-color: UiSurface.edge;
|
||||
clicked => { root.cancel-requested(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.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 }
|
||||
|
||||
@@ -88,12 +89,11 @@ export component GalleryMenuButton inherits Rectangle {
|
||||
color: root.enabled ? #62666d : #8e9298;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
if root.compact-trigger: Rectangle {
|
||||
background: more-touch.pressed || popup.is-open ? #e5edfc : more-touch.has-hover ? white : #fffffff0;
|
||||
if root.compact-trigger: FlatSurface {
|
||||
background: more-touch.pressed || popup.is-open ? ControlStyle.selected : more-touch.has-hover ? ControlStyle.hover : white;
|
||||
border-radius: UiRadius.menu;
|
||||
border-width: 1px;
|
||||
border-color: trigger-focus.has-focus ? #3564e3 : #ffffff80;
|
||||
animate background, border-color { duration: 120ms; }
|
||||
outline-color: trigger-focus.has-focus ? UiSurface.focus : UiSurface.edge;
|
||||
animate background, outline-color { duration: 120ms; }
|
||||
for dot in 3: Rectangle {
|
||||
x: parent.width / 2 - 6px + dot * 5px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
@@ -117,11 +117,12 @@ export component GalleryMenuButton inherits Rectangle {
|
||||
forward-focus: popup-focus;
|
||||
popup-focus := FocusScope {
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
Rectangle {
|
||||
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu;
|
||||
if root.destructive-index > 0: Rectangle {
|
||||
FlatSurface {
|
||||
background: white; outline-color: UiSurface.edge; border-radius: UiRadius.menu;
|
||||
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;
|
||||
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 {
|
||||
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);
|
||||
width: parent.width - root.menu-padding * 2; height: root.row-height;
|
||||
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-action-default => { root.choose(index); }
|
||||
if self.has-icon: Text {
|
||||
@@ -148,7 +151,7 @@ export component GalleryMenuButton inherits Rectangle {
|
||||
text: item.label; color: parent.foreground;
|
||||
font-size: root.compact-menu ? 11px : 12px; vertical-alignment: center;
|
||||
}
|
||||
TouchArea {
|
||||
item-touch := TouchArea {
|
||||
enabled: item.enabled;
|
||||
mouse-cursor: pointer;
|
||||
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 { GalleryStrings } from "device_strings.slint";
|
||||
import { CompactButton } from "common.slint";
|
||||
@@ -15,6 +16,8 @@ export struct GalleryNetworkDevice {
|
||||
loss: string,
|
||||
resolution: string,
|
||||
fps: string,
|
||||
encoder: string,
|
||||
decoder: string,
|
||||
}
|
||||
|
||||
component NetworkRow inherits Rectangle {
|
||||
@@ -64,7 +67,7 @@ component NetworkRow inherits Rectangle {
|
||||
text: !root.device.encryption-known
|
||||
? GalleryStrings.tr("待确认", "Unknown", "Неизвестно")
|
||||
: root.device.encryption-enabled
|
||||
? GalleryStrings.tr("已加密", "Encrypted", "Защищено")
|
||||
? GalleryStrings.tr("已加密", "Encrypted", "Зашифровано")
|
||||
: GalleryStrings.tr("未加密", "Unencrypted", "Без шифр.");
|
||||
color: !root.device.encryption-known ? #7a8490
|
||||
: root.device.encryption-enabled ? #18794e : #8a6428;
|
||||
@@ -145,6 +148,26 @@ component NetworkRow inherits Rectangle {
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
if !root.heading: HorizontalLayout {
|
||||
x: 12px; y: 52px; width: parent.width - 24px; height: 20px;
|
||||
spacing: 12px;
|
||||
Rectangle {
|
||||
width: (parent.width - 12px) / 2;
|
||||
Text {
|
||||
width: parent.width; height: parent.height;
|
||||
text: GalleryStrings.tr("被控端编码器 ", "Remote encoder ", "Удалённый кодер ") + root.device.encoder;
|
||||
font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: (parent.width - 12px) / 2;
|
||||
Text {
|
||||
width: parent.width; height: parent.height;
|
||||
text: GalleryStrings.tr("控制端解码器 ", "Local decoder ", "Локальный декодер ") + root.device.decoder;
|
||||
font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component NetworkEmptyState inherits Rectangle {
|
||||
@@ -163,18 +186,18 @@ component NetworkEmptyState inherits Rectangle {
|
||||
|
||||
export component GalleryNetworkDialog inherits Rectangle {
|
||||
in property <[GalleryNetworkDevice]> devices;
|
||||
private property <length> row-height: 56px;
|
||||
private property <length> row-height: 78px;
|
||||
private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
|
||||
callback close();
|
||||
background: #00000042;
|
||||
TouchArea { clicked => { root.close(); } }
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(760px, parent.width - 32px);
|
||||
height: min(120px + root.list-height, parent.height - 32px);
|
||||
background: white; border-radius: UiRadius.surface;
|
||||
border-width: 1px; border-color: #c6cdd5;
|
||||
outline-color: UiSurface.edge;
|
||||
TouchArea {}
|
||||
FocusScope {
|
||||
init => { self.focus(); }
|
||||
@@ -185,7 +208,7 @@ export component GalleryNetworkDialog inherits Rectangle {
|
||||
}
|
||||
Text {
|
||||
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;
|
||||
}
|
||||
CompactButton {
|
||||
@@ -215,7 +238,7 @@ export component GalleryNetworkDialog inherits Rectangle {
|
||||
for device[index] in root.devices: NetworkRow {
|
||||
y: index * root.row-height; width: list.viewport-width; height: root.row-height;
|
||||
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 {
|
||||
|
||||
@@ -11,7 +11,7 @@ export component GallerySettings inherits Rectangle {
|
||||
y: 0px; width: parent.width;
|
||||
title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3;
|
||||
SettingsRow {
|
||||
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение");
|
||||
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Макс. разрешение");
|
||||
label-reserve: self.width - 112px;
|
||||
SettingsSelect {
|
||||
border-radius: UiRadius.control;
|
||||
@@ -33,7 +33,7 @@ export component GallerySettings inherits Rectangle {
|
||||
}
|
||||
}
|
||||
SettingsRow {
|
||||
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео");
|
||||
label: GalleryStrings.tr("视频总带宽", "Total bandwidth", "Общий битрейт");
|
||||
label-reserve: self.width - 112px; divider: false;
|
||||
SettingsSelect {
|
||||
border-radius: UiRadius.control;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { GalleryStrings } from "device_strings.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 { ScrollView } from "std-widgets.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 {
|
||||
border-radius: UiRadius.control;
|
||||
@@ -289,7 +290,7 @@ export component GalleryView inherits Rectangle {
|
||||
selected(index) => { root.action("group", index); }
|
||||
}
|
||||
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;
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; min-width: 0px; }
|
||||
@@ -335,8 +336,8 @@ export component GalleryView inherits Rectangle {
|
||||
: GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор");
|
||||
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; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
|
||||
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("断开", "Disconnect", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
|
||||
batch-menu := GalleryMenuButton {
|
||||
horizontal-padding: root.toolbar-button-padding;
|
||||
width: self.preferred-button-width; enabled: root.selected-count > 0;
|
||||
@@ -473,7 +474,7 @@ export component GalleryView inherits Rectangle {
|
||||
height: root.card-height;
|
||||
opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1;
|
||||
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 {
|
||||
x: root.card-inset; y: self.x;
|
||||
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);
|
||||
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;
|
||||
width: 18px; height: 18px; border-radius: 4px; border-width: 1px;
|
||||
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white;
|
||||
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8;
|
||||
animate background, border-color { duration: 120ms; }
|
||||
width: 18px; height: 18px; border-radius: 4px;
|
||||
background: tile.selected ? (tile-touch.pressed ? #254cc0 : tile-touch.has-hover ? #4574f0 : #3564e3)
|
||||
: tile-touch.pressed ? ControlStyle.pressed : tile-touch.has-hover ? ControlStyle.hover : ControlStyle.surface;
|
||||
outline-color: tile.selected ? UiSurface.focus : tile-touch.has-hover ? UiSurface.hover : UiSurface.edge;
|
||||
animate background, outline-color { duration: 120ms; }
|
||||
if tile.selected: Text {
|
||||
text: FontAwesomeIcons.check; color: white;
|
||||
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.
|
||||
for column in root.columns + 1: Rectangle {
|
||||
for column in root.columns + 1: SurfaceSeparator {
|
||||
visible: root.count > 0;
|
||||
x: min(column * root.card-width, grid.table-width - 1px);
|
||||
y: 0px;
|
||||
width: 1px; height: grid.table-height;
|
||||
background: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column)
|
||||
? #76aeea : #c9cdd2;
|
||||
vertical: true; outline-color: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column)
|
||||
? UiSurface.focus : UiSurface.separator;
|
||||
}
|
||||
for row in root.rows + 1: Rectangle {
|
||||
for row in root.rows + 1: SurfaceSeparator {
|
||||
visible: root.count > 0;
|
||||
x: 0px;
|
||||
y: min(row * root.card-height, grid.table-height - 1px);
|
||||
width: grid.table-width; height: 1px;
|
||||
background: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row)
|
||||
? #76aeea : #c9cdd2;
|
||||
outline-color: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row)
|
||||
? 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;
|
||||
y: floor(root.drag-target / root.columns) * root.card-height + 1px;
|
||||
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 {
|
||||
text: root.simplified
|
||||
@@ -577,7 +579,7 @@ export component GalleryView inherits Rectangle {
|
||||
background: root.controlling ? #e7f1fd : #f0f2f5;
|
||||
focus-state := Text {
|
||||
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.focused.connected && root.focused.has-frame && !root.focused.stale
|
||||
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
|
||||
@@ -588,8 +590,8 @@ export component GalleryView inherits Rectangle {
|
||||
horizontal-padding: root.toolbar-button-padding;
|
||||
primary: root.group-controlling;
|
||||
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", "Групп. ввод");
|
||||
alternate-text: root.group-controlling ? GalleryStrings.tr("开启群控", "Group control", "Групп. ввод") : GalleryStrings.tr("关闭群控", "Stop group", "Откл. группу");
|
||||
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 control", "Откл. групп. ввод");
|
||||
accessible-role: button;
|
||||
accessible-label: self.text + GalleryStrings.tr(":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах");
|
||||
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); } }
|
||||
}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px;
|
||||
width: parent.width; height: parent.height - self.y;
|
||||
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 {
|
||||
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
||||
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.
|
||||
width: tile.id == root.focused.id ? 0px : root.rail-slot-width;
|
||||
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", "В сети")
|
||||
: 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;
|
||||
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-label: self.details;
|
||||
accessible-action-default => { root.action("focus", tile.id); }
|
||||
@@ -714,9 +716,9 @@ export component GalleryView inherits Rectangle {
|
||||
visible: root.focus-rail-visible;
|
||||
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;
|
||||
Rectangle {
|
||||
SurfaceSeparator {
|
||||
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 {
|
||||
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 {
|
||||
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.
|
||||
footer := Rectangle {
|
||||
@@ -792,8 +794,8 @@ export component GalleryView inherits Rectangle {
|
||||
}
|
||||
network-button := SecondaryButton {
|
||||
x: connection-count.x + connection-count.width + 12px;
|
||||
min-width: 64px; width: 64px; height: parent.height;
|
||||
border-color: #c8cbd0; border-radius: UiRadius.control;
|
||||
width: 64px; height: parent.height;
|
||||
outline-color: UiSurface.edge; border-radius: UiRadius.control;
|
||||
text: GalleryStrings.tr("网络状态", "Network", "Сеть");
|
||||
accessible-role: button;
|
||||
accessible-label: self.text;
|
||||
@@ -823,6 +825,7 @@ export component GalleryView inherits Rectangle {
|
||||
IconButton {
|
||||
width: 24px; height: 24px;
|
||||
border-radius: UiRadius.control;
|
||||
normal-background: ControlStyle.surface;
|
||||
icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
|
||||
tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
|
||||
clicked => { root.toggle-fullscreen(); }
|
||||
@@ -840,11 +843,11 @@ export component GalleryView inherits Rectangle {
|
||||
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);
|
||||
y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px);
|
||||
width: 224px; height: 54px; z: 10;
|
||||
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: UiRadius.surface;
|
||||
width: 224px; height: max(54px, drag-hint.y + drag-hint.height + 9px); z: 10;
|
||||
background: white; outline-color: UiSurface.focus; border-radius: UiRadius.surface;
|
||||
Rectangle {
|
||||
x: 8px; y: 9px; width: 64px; height: 36px;
|
||||
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: 29px; width: parent.width - 88px; height: 16px;
|
||||
text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page", "Удерживайте для перелистывания")
|
||||
drag-hint := Text {
|
||||
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; release to cancel", "Удерживайте для перелистывания; отпустите для отмены")
|
||||
: root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена")
|
||||
: GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "На другое устройство");
|
||||
font-size: 10px; color: #527ca8; overflow: elide;
|
||||
: GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "Перетащите на другое устройство");
|
||||
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;
|
||||
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: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide;
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
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;
|
||||
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 {
|
||||
x: 8px; y: 8px; width: parent.width - 16px;
|
||||
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 {
|
||||
background: #00000042;
|
||||
TouchArea {}
|
||||
add-dialog := Rectangle {
|
||||
add-dialog := FlatSurface {
|
||||
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() {
|
||||
if root.add-device(device-id.text, password.text, group.text, remember.checked) {
|
||||
password.text = "";
|
||||
@@ -919,10 +922,10 @@ export component GalleryView inherits Rectangle {
|
||||
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; }
|
||||
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 {
|
||||
x: 254px; y: 260px; width: 128px; height: 28px; primary: true;
|
||||
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить");
|
||||
x: 218px; y: 260px; width: 164px; height: 28px; primary: true;
|
||||
text: GalleryStrings.tr("添加并连接", "Add and connect", "Добавить и подключить");
|
||||
enabled: device-id.text != "" && password.text != "";
|
||||
clicked => { add-dialog.submit(); }
|
||||
}
|
||||
@@ -931,25 +934,27 @@ export component GalleryView inherits Rectangle {
|
||||
if root.importing: Rectangle {
|
||||
background: #00000042;
|
||||
TouchArea {}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
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;
|
||||
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; }
|
||||
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: 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; }
|
||||
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", ""); } }
|
||||
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;
|
||||
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 {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
viewport-width: self.width - 12px;
|
||||
viewport-height: max(self.height, root.recent-devices.length * 44px);
|
||||
for device[index] in root.recent-devices: Rectangle {
|
||||
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; }
|
||||
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; }
|
||||
@@ -962,7 +967,7 @@ export component GalleryView inherits Rectangle {
|
||||
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 {
|
||||
x: -UiRadius.surface; y: -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-color: white;
|
||||
}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
width: parent.width; height: parent.height;
|
||||
border-width: 1px; border-radius: UiRadius.surface;
|
||||
border-color: #d8dadd;
|
||||
border-radius: UiRadius.surface;
|
||||
outline-color: UiSurface.edge;
|
||||
}
|
||||
}
|
||||
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)", "Группа (необязательно)"); }
|
||||
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: 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 {
|
||||
background: #00000042; TouchArea {}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
|
||||
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 {
|
||||
x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
|
||||
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
|
||||
@@ -997,12 +1002,12 @@ export component GalleryView inherits Rectangle {
|
||||
Rectangle {
|
||||
x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
|
||||
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;
|
||||
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 {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
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;
|
||||
background: device.connected ? #eaf6ee : #f0f2f5;
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||
import { UiRadius } from "ui_radius.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.
|
||||
export component GroupFilter inherits Rectangle {
|
||||
export component GroupFilter inherits FlatSurface {
|
||||
in property <[string]> model;
|
||||
in property <int> system-count: 2;
|
||||
in property <bool> enabled: true;
|
||||
in property <color> normal-border-color: #c9cdd2;
|
||||
in property <color> hover-border-color: root.normal-border-color;
|
||||
in property <color> focus-border-color: #8db5df;
|
||||
// An embedded picker shares its enclosing field's outline and focus state.
|
||||
in property <bool> embedded: false;
|
||||
in property <bool> show-indicator: true;
|
||||
in property <length> popup-min-width: 0px;
|
||||
in property <length> text-size: 12px;
|
||||
in property <int> text-weight: 400;
|
||||
in property <color> text-color: #30343b;
|
||||
in property <color> normal-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;
|
||||
out property <bool> has-focus: trigger-focus.has-focus || popup.is-open;
|
||||
out property <bool> has-hover: root.enabled && trigger-touch.has-hover && !popup.is-open;
|
||||
callback selected(int);
|
||||
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
|
||||
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
|
||||
@@ -25,12 +36,16 @@ export component GroupFilter inherits Rectangle {
|
||||
}
|
||||
|
||||
width: 155px; height: 28px;
|
||||
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
|
||||
border-width: 1px;
|
||||
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color
|
||||
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color;
|
||||
background: root.embedded ? (root.enabled && trigger-touch.has-hover ? ControlStyle.hover : transparent)
|
||||
: !root.enabled ? ControlStyle.disabled
|
||||
: trigger-touch.pressed || popup.is-open ? ControlStyle.pressed
|
||||
: 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;
|
||||
animate background, border-color { duration: 100ms; easing: ease-out; }
|
||||
animate background, outline-color { duration: 100ms; easing: ease-out; }
|
||||
forward-focus: trigger-focus;
|
||||
accessible-role: combobox;
|
||||
accessible-enabled: root.enabled;
|
||||
@@ -52,16 +67,18 @@ export component GroupFilter inherits Rectangle {
|
||||
root.reveal-selection();
|
||||
root.selected(root.current-index);
|
||||
}
|
||||
function open-menu() {
|
||||
public function open-menu() {
|
||||
if root.enabled && root.model.length > 0 {
|
||||
popup-open-timer.running = false;
|
||||
root.popup-animation-enabled = false;
|
||||
root.popup-progress = 0;
|
||||
root.reveal-selection();
|
||||
trigger-focus.focus();
|
||||
popup.show();
|
||||
popup-open-timer.running = true;
|
||||
}
|
||||
}
|
||||
public function close-menu() { popup.close(); }
|
||||
// Defer until the popup has its initial frame; animate its contents only,
|
||||
// keeping the native popup bounds and scrolling geometry stable.
|
||||
popup-open-timer := Timer {
|
||||
@@ -87,14 +104,19 @@ export component GroupFilter inherits Rectangle {
|
||||
return reject;
|
||||
}
|
||||
Text {
|
||||
x: 10px; width: parent.width - 36px; height: parent.height;
|
||||
x: root.embedded ? 8px : 10px;
|
||||
width: parent.width - (root.show-indicator
|
||||
? (root.embedded ? 26px : 36px) : (root.embedded ? 16px : 20px));
|
||||
height: parent.height;
|
||||
text: root.model[root.current-index];
|
||||
color: root.enabled ? #30343b : #92979f;
|
||||
font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||
color: root.enabled ? root.text-color : #92979f;
|
||||
font-size: root.text-size; font-weight: root.text-weight;
|
||||
overflow: elide; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
Text {
|
||||
x: parent.width - 25px; width: 16px; height: parent.height;
|
||||
if root.show-indicator: Text {
|
||||
x: parent.width - (root.embedded ? 16px : 25px);
|
||||
width: root.embedded ? 10px : 16px; height: parent.height;
|
||||
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900; font-size: 10px; color: #858c96;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
@@ -107,22 +129,24 @@ export component GroupFilter inherits Rectangle {
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
trigger-touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
clicked => { trigger-focus.focus(); root.open-menu(); }
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.open-menu(); }
|
||||
}
|
||||
}
|
||||
popup := PopupWindow {
|
||||
x: 0px; y: root.height + 4px;
|
||||
width: root.width; height: root.visible-height + 8px;
|
||||
width: max(root.width, root.popup-min-width); height: root.visible-height + 8px;
|
||||
close-policy: close-on-click-outside;
|
||||
forward-focus: popup-focus;
|
||||
popup-focus := FocusScope {
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
y: (1 - root.popup-progress) * -4px;
|
||||
width: parent.width; height: parent.height;
|
||||
opacity: root.popup-progress;
|
||||
background: white; border-width: 1px;
|
||||
border-color: root.normal-border-color; border-radius: UiRadius.menu;
|
||||
background: white;
|
||||
clip: true;
|
||||
outline-color: root.normal-outline-color; border-radius: UiRadius.menu;
|
||||
ScrollView {
|
||||
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
||||
viewport-width: self.width;
|
||||
@@ -130,27 +154,30 @@ export component GroupFilter inherits Rectangle {
|
||||
viewport-y <=> root.scroll-y;
|
||||
Rectangle {
|
||||
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;
|
||||
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 {
|
||||
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
|
||||
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; }
|
||||
accessible-role: list-item;
|
||||
accessible-label: choice;
|
||||
Text {
|
||||
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;
|
||||
accessible-role: none;
|
||||
}
|
||||
if index == root.current-index: Text {
|
||||
x: parent.width - 24px; width: 16px; height: parent.height;
|
||||
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;
|
||||
accessible-role: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="31" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 139 B |
@@ -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
@@ -1,6 +1,7 @@
|
||||
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||
import { UiRadius } from "ui_radius.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 { WorkbenchStyle } from "workbench_controls.slint";
|
||||
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;
|
||||
}
|
||||
}
|
||||
message-content := Rectangle {
|
||||
message-content := FlatSurface {
|
||||
x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
|
||||
width: parent.width - 48px; height: parent.height - self.y - 24px;
|
||||
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 {
|
||||
x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
|
||||
background: WorkbenchStyle.field-surface;
|
||||
@@ -112,19 +113,19 @@ export component MessagePanel inherits SettingsDialog {
|
||||
announcement-entries := VerticalLayout {
|
||||
width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
|
||||
alignment: start;
|
||||
for item in root.items: Rectangle {
|
||||
for item in root.items: FlatSurface {
|
||||
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.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: 1px;
|
||||
border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border;
|
||||
outline-color: root.selected-id == item.id ? UiSurface.focus : UiSurface.edge;
|
||||
Text {
|
||||
x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
|
||||
text: item.title; overflow: elide; font-size: 13px;
|
||||
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;
|
||||
}
|
||||
if !item.read: Rectangle {
|
||||
@@ -154,10 +155,10 @@ export component MessagePanel inherits SettingsDialog {
|
||||
color: WorkbenchStyle.muted; font-size: 13px;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
content-divider := Rectangle {
|
||||
content-divider := SurfaceSeparator {
|
||||
x: announcement-list.x + announcement-list.width + 8px;
|
||||
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 != "";
|
||||
accessible-role: none;
|
||||
}
|
||||
@@ -199,13 +200,14 @@ export component MessagePanel inherits SettingsDialog {
|
||||
x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
|
||||
icon: FontAwesomeIcons.trash-can; icon-size: 12px;
|
||||
icon-color: WorkbenchStyle.muted; danger: true;
|
||||
normal-background: ControlStyle.surface;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.delete-message + " " + root.selected-title;
|
||||
accessible-action-default => { 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 {
|
||||
vertical-stretch: 0;
|
||||
text: root.selected-body;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { FlatSurface, UiSurface } from "ui_surface.slint";
|
||||
import { LineEdit } from "std-widgets.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ControlStyle } from "common.slint";
|
||||
|
||||
// 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 {
|
||||
in-out property <string> text;
|
||||
in property <string> placeholder-text;
|
||||
in property <InputType> input-type: InputType.text;
|
||||
in property <bool> enabled: true;
|
||||
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;
|
||||
callback accepted();
|
||||
callback edited();
|
||||
@@ -20,8 +22,8 @@ export component RoundedLineEdit inherits Rectangle {
|
||||
forward-focus: editor;
|
||||
|
||||
editor := LineEdit {
|
||||
x: -1px; y: -1px;
|
||||
width: parent.width + 2px; height: parent.height + 2px;
|
||||
x: -2px; y: -2px;
|
||||
width: parent.width + 4px; height: parent.height + 4px;
|
||||
text <=> root.text;
|
||||
placeholder-text: root.placeholder-text;
|
||||
input-type: root.input-type;
|
||||
@@ -30,8 +32,15 @@ export component RoundedLineEdit inherits Rectangle {
|
||||
accepted => { root.accepted(); }
|
||||
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
|
||||
// standard editor's corners before drawing our rounded border on top.
|
||||
// standard editor's corners before drawing the focus outline on top.
|
||||
Rectangle {
|
||||
x: -root.border-radius; y: -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-color: root.surrounding-background;
|
||||
}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
width: parent.width; height: parent.height;
|
||||
border-radius: root.border-radius;
|
||||
border-width: editor.has-focus ? 2px : 1px;
|
||||
border-color: editor.has-focus ? #3564e3 : root.normal-border-color;
|
||||
clip: true;
|
||||
outline-color: editor.has-focus ? UiSurface.focus : root.normal-outline-color;
|
||||
outline-focused: editor.has-focus;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FlatSurface, UiSurface } from "ui_surface.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint";
|
||||
import { FontAwesomeIcons, FloatingBarStyle, ButtonStyle } from "common.slint";
|
||||
|
||||
export struct ControllerEntry {
|
||||
remote-id: string,
|
||||
@@ -19,7 +20,7 @@ export global ServerStrings {
|
||||
in property <string> cancel: "取消";
|
||||
}
|
||||
|
||||
component SessionButton inherits Rectangle {
|
||||
component SessionButton inherits FlatSurface {
|
||||
in property <string> text;
|
||||
in property <bool> danger: false;
|
||||
in property <bool> enabled: true;
|
||||
@@ -29,10 +30,13 @@ component SessionButton inherits Rectangle {
|
||||
width: label.preferred-width + 20px;
|
||||
height: 28px;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: 1px;
|
||||
border-color: root.danger ? #d92d20 : #c9ced7;
|
||||
outline-enabled: !root.danger;
|
||||
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)
|
||||
: (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;
|
||||
accessible-role: button;
|
||||
accessible-label: root.text;
|
||||
@@ -72,9 +76,9 @@ component SessionButton inherits Rectangle {
|
||||
// Restore hover feedback only after a fresh pointer move or press.
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
x: 0px; y: 0px; width: parent.width; height: parent.height;
|
||||
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-top-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; }
|
||||
return reject;
|
||||
}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
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;
|
||||
Text {
|
||||
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);
|
||||
height: parent.height; border-radius: UiRadius.control;
|
||||
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;
|
||||
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; }
|
||||
}
|
||||
name := Text {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.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";
|
||||
|
||||
// Shared visual treatment for the global settings pages.
|
||||
export component SettingsButton inherits Rectangle {
|
||||
export component SettingsButton inherits FlatSurface {
|
||||
in property <string> text;
|
||||
in property <image> icon;
|
||||
in property <bool> primary: false;
|
||||
@@ -14,13 +15,17 @@ export component SettingsButton inherits Rectangle {
|
||||
private property <length> content-padding: root.has-icon ? 8px : 12px;
|
||||
callback clicked();
|
||||
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)
|
||||
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white;
|
||||
border-width: root.quiet || root.primary ? 0px : 1px;
|
||||
border-color: !root.enabled ? WorkbenchStyle.border
|
||||
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border;
|
||||
: touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover
|
||||
: root.quiet ? transparent : ButtonStyle.surface;
|
||||
outline-enabled: !root.quiet && !root.primary;
|
||||
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-enabled: root.enabled;
|
||||
accessible-action-default => { if root.enabled { root.clicked(); } }
|
||||
Rectangle {
|
||||
x: root.align-left ? root.content-padding : (parent.width - self.width) / 2;
|
||||
@@ -59,10 +64,7 @@ export component SettingsDialog inherits DialogSurface {
|
||||
callback action();
|
||||
callback close();
|
||||
background: #f8fafc;
|
||||
border-color: WorkbenchStyle.separator;
|
||||
drop-shadow-color: #14294424;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-offset-y: 8px;
|
||||
outline-color: UiSurface.edge;
|
||||
TouchArea { }
|
||||
HorizontalLayout {
|
||||
x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
|
||||
@@ -97,6 +99,7 @@ export component SettingsDialog inherits DialogSurface {
|
||||
width: 28px; height: 28px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: 14px;
|
||||
icon-color: WorkbenchStyle.muted;
|
||||
normal-background: ControlStyle.surface;
|
||||
hover-background: WorkbenchStyle.hover-surface;
|
||||
pressed-background: WorkbenchStyle.pressed-surface;
|
||||
accessible-role: button; accessible-label: root.close-label;
|
||||
@@ -109,9 +112,9 @@ export component SettingsDialog inherits DialogSurface {
|
||||
|
||||
export component SettingsSelect inherits CompactComboBox {
|
||||
subtle: true;
|
||||
show-arrow: false;
|
||||
normal-border-color: WorkbenchStyle.control-border;
|
||||
hover-border-color: WorkbenchStyle.hover-border;
|
||||
show-arrow: true;
|
||||
normal-outline-color: UiSurface.edge;
|
||||
hover-outline-color: UiSurface.hover;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@@ -123,9 +126,9 @@ export component SettingsGroup inherits Rectangle {
|
||||
x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title;
|
||||
font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center;
|
||||
}
|
||||
Rectangle {
|
||||
FlatSurface {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -141,8 +144,8 @@ export component SettingsRow inherits Rectangle {
|
||||
text: root.label; color: root.enabled ? #34445a : #9ba5b3;
|
||||
font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||
}
|
||||
if root.divider: Rectangle {
|
||||
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec;
|
||||
if root.divider: SurfaceSeparator {
|
||||
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; outline-color: UiSurface.separator;
|
||||
}
|
||||
@children
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,11 @@ inline int ApplyMainWindowStrings(
|
||||
localization::detail::ClampLanguageIndex(language_index);
|
||||
window->global<ui::GalleryStrings>().set_language(language);
|
||||
auto& strings = window->global<ui::UiStrings>();
|
||||
strings.set_window_minimize(Text(localization::window_minimize[language]));
|
||||
strings.set_window_close(Text(localization::window_close[language]));
|
||||
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>();
|
||||
messages.set_messages(Text(localization::messages[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_cancel(Text(localization::cancel[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(
|
||||
Text(localization::input_connection_alias[language]));
|
||||
strings.set_delete_connection(
|
||||
@@ -93,9 +98,6 @@ inline int ApplyMainWindowStrings(
|
||||
strings.set_capture_method_auto(
|
||||
Text(localization::screen_capture_method_auto[language]));
|
||||
strings.set_codec(Text(localization::video_encode_format[language]));
|
||||
strings.set_hardware_codec(
|
||||
Text(localization::enable_hardware_video_codec[language]));
|
||||
strings.set_turn_relay(Text(localization::enable_turn[language]));
|
||||
strings.set_force_relay(Text(localization::force_relay[language]));
|
||||
strings.set_self_hosted(
|
||||
Text(localization::self_hosted_server_config[language]));
|
||||
@@ -104,6 +106,7 @@ inline int ApplyMainWindowStrings(
|
||||
strings.set_save_remote_previews(Text(localization::save_remote_previews[language]));
|
||||
strings.set_unknown_platform(Text(localization::unknown_platform[language]));
|
||||
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language]));
|
||||
strings.set_lock_on_disconnect(Text(localization::lock_on_disconnect[language]));
|
||||
strings.set_file_save_path(
|
||||
Text(localization::file_transfer_save_path[language]));
|
||||
strings.set_default_desktop(Text(localization::default_desktop[language]));
|
||||
@@ -194,6 +197,10 @@ inline int ApplyStreamWindowStrings(
|
||||
const int language =
|
||||
localization::detail::ClampLanguageIndex(language_index);
|
||||
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(Text(localization::remote_update[language]));
|
||||
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
|
||||
@@ -230,6 +237,11 @@ inline int ApplyStreamWindowStrings(
|
||||
strings.set_fullscreen(Text(localization::fullscreen[language]));
|
||||
strings.set_exit_fullscreen(Text(localization::exit_fullscreen[language]));
|
||||
strings.set_disconnect(Text(localization::disconnect[language]));
|
||||
strings.set_confirm_disconnect(
|
||||
Text(localization::stream_confirm_disconnect[language]));
|
||||
strings.set_disconnect_description(
|
||||
Text(localization::stream_disconnect_description[language]));
|
||||
strings.set_cancel(Text(localization::cancel[language]));
|
||||
strings.set_file_transfer(
|
||||
Text(localization::file_transfer_progress[language]));
|
||||
strings.set_expand_control(Text(localization::expand_control_bar[language]));
|
||||
@@ -243,6 +255,8 @@ inline int ApplyStreamWindowStrings(
|
||||
strings.set_stats_connection_latency(Text(localization::connection_latency[language]));
|
||||
strings.set_stats_connection_mode(
|
||||
Text(localization::connection_mode[language]));
|
||||
strings.set_stats_encoder(Text(localization::remote_video_encoder[language]));
|
||||
strings.set_stats_decoder(Text(localization::local_video_decoder[language]));
|
||||
strings.set_stats_encryption(
|
||||
Text(localization::transport_encryption[language]));
|
||||
strings.set_stats_encryption_enabled(
|
||||
|
||||
@@ -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 { ControlStyle, ButtonStyle } from "common.slint";
|
||||
// Colors and spacing for the connection workbench; shapes are shared app-wide.
|
||||
export global WorkbenchStyle {
|
||||
out property <color> canvas: #f2f5f9;
|
||||
out property <color> canvas: #edf2f8;
|
||||
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> hover-surface: #eef3fb;
|
||||
out property <color> pressed-surface: #e4ebf6;
|
||||
out property <color> hover-surface: ControlStyle.hover;
|
||||
out property <color> pressed-surface: ControlStyle.pressed;
|
||||
out property <color> separator: #e1e7ef;
|
||||
out property <color> text: #1c2940;
|
||||
out property <color> muted: #69788e;
|
||||
@@ -16,19 +15,21 @@ export global WorkbenchStyle {
|
||||
out property <color> success: #18835a;
|
||||
}
|
||||
|
||||
export component WorkbenchButton inherits Rectangle {
|
||||
export component WorkbenchButton inherits FlatSurface {
|
||||
in property <string> text;
|
||||
in property <length> text-size: 13px;
|
||||
in property <color> text-color: root.primary ? white : WorkbenchStyle.text;
|
||||
in property <color> normal-border-color: WorkbenchStyle.control-border;
|
||||
in property <color> text-color: root.destructive || root.primary ? white : WorkbenchStyle.text;
|
||||
in property <color> normal-outline-color: ButtonStyle.border;
|
||||
in property <color> normal-background: ButtonStyle.surface;
|
||||
in property <string> icon;
|
||||
in property <string> tooltip;
|
||||
in property <bool> tooltip-above: false;
|
||||
in property <bool> tooltip-suppressed: false;
|
||||
in property <color> icon-color: root.primary
|
||||
in property <color> icon-color: root.destructive ? white : root.primary
|
||||
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
|
||||
: WorkbenchStyle.muted;
|
||||
: ControlStyle.icon;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> destructive: false;
|
||||
in property <bool> soft: false;
|
||||
in property <bool> quiet: false;
|
||||
in property <bool> enabled: true;
|
||||
@@ -44,20 +45,23 @@ export component WorkbenchButton inherits Rectangle {
|
||||
width: 112px;
|
||||
height: 36px;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: root.show-focus ? 2px : root.soft ? 0px : 1px;
|
||||
border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent)
|
||||
: root.primary ? WorkbenchStyle.accent
|
||||
: touch.has-hover ? WorkbenchStyle.hover-border
|
||||
: root.quiet ? transparent : root.normal-border-color;
|
||||
background: root.soft
|
||||
outline-enabled: root.show-focus || (!root.quiet && !root.soft && !root.primary && !root.destructive);
|
||||
outline-focused: root.show-focus;
|
||||
outline-color: !root.enabled ? ButtonStyle.disabled-border
|
||||
: root.show-focus ? WorkbenchStyle.accent
|
||||
: touch.pressed ? ButtonStyle.pressed-border
|
||||
: touch.has-hover ? ButtonStyle.hover-border : root.normal-outline-color;
|
||||
background: root.destructive
|
||||
? (touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20)
|
||||
: root.soft
|
||||
? (root.primary
|
||||
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #edf2ff)
|
||||
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.field-surface))
|
||||
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #dce7ff)
|
||||
: (touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface))
|
||||
: root.primary
|
||||
? (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;
|
||||
animate background, border-color { duration: 120ms; }
|
||||
animate background, outline-color { duration: 120ms; }
|
||||
forward-focus: button-focus;
|
||||
accessible-role: button;
|
||||
accessible-label: root.tooltip != "" ? root.tooltip : root.text;
|
||||
@@ -87,6 +91,11 @@ export component WorkbenchButton inherits Rectangle {
|
||||
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 {
|
||||
enabled: root.enabled;
|
||||
focus-gained(reason) => {
|
||||
@@ -145,7 +154,7 @@ export component WorkbenchButton inherits Rectangle {
|
||||
}
|
||||
|
||||
// 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> icon;
|
||||
in property <bool> error: false;
|
||||
@@ -154,12 +163,8 @@ export component WorkbenchNotice inherits Rectangle {
|
||||
width: min(root.width-limit, max(280px, message.preferred-width + 64px));
|
||||
height: 64px;
|
||||
background: root.error ? #fff1f0 : #e9f6ef;
|
||||
border-width: 1px;
|
||||
border-color: root.error ? #f0c4bf : #b9dfc9;
|
||||
outline-color: root.error ? UiSurface.error : UiSurface.edge;
|
||||
border-radius: UiRadius.surface;
|
||||
drop-shadow-color: #14294424;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-offset-y: 6px;
|
||||
accessible-role: AccessibleRole.text;
|
||||
accessible-label: root.text;
|
||||
Text {
|
||||
@@ -187,9 +192,8 @@ export component WorkbenchNotice inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
export component WorkbenchCard inherits Rectangle {
|
||||
export component WorkbenchCard inherits FlatSurface {
|
||||
background: WorkbenchStyle.surface;
|
||||
border-width: 1px;
|
||||
border-color: WorkbenchStyle.border;
|
||||
outline-color: UiSurface.edge;
|
||||
border-radius: UiRadius.surface;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#include "lan_auth_limiter.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <exception>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk::lan {
|
||||
DeviceAuthLimiter::DeviceAuthLimiter(Now now, size_t source_failure_limit,
|
||||
size_t target_failure_limit)
|
||||
: now_(std::move(now)),
|
||||
source_failure_limit_(source_failure_limit),
|
||||
target_failure_limit_(target_failure_limit) {
|
||||
if (source_failure_limit == 0 || target_failure_limit == 0)
|
||||
throw std::invalid_argument(
|
||||
"Authentication failure limits must be positive");
|
||||
}
|
||||
|
||||
void DeviceAuthLimiter::Prune(Buckets& buckets, Clock::time_point now) {
|
||||
for (auto it = buckets.begin(); it != buckets.end();) {
|
||||
if (it->second.expires <= now && it->second.pending == 0)
|
||||
it = buckets.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
DeviceAuthLimiter::Result DeviceAuthLimiter::Verify(
|
||||
const std::string& source, const std::string& target,
|
||||
const std::function<bool()>& verify) {
|
||||
// Reserve both budgets atomically, then release the lock for slow hashing.
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
const auto now = now_();
|
||||
if (now >= next_cleanup_) {
|
||||
Prune(sources_, now);
|
||||
Prune(targets_, now);
|
||||
next_cleanup_ = now + std::chrono::minutes(1);
|
||||
}
|
||||
// Missing source metadata must not become a per-connection bypass.
|
||||
const std::string source_key = source.empty() ? "unknown" : source;
|
||||
auto retry = [now](Buckets& buckets, const std::string& key, size_t limit) {
|
||||
const auto it = buckets.find(key);
|
||||
if (it == buckets.end()) return 0;
|
||||
if (it->second.expires <= now) {
|
||||
it->second.count = 0;
|
||||
it->second.expires = now + kWindow;
|
||||
}
|
||||
if (it->second.count < limit)
|
||||
return it->second.count + it->second.pending >= limit ? 1 : 0;
|
||||
return static_cast<int>(
|
||||
std::chrono::ceil<std::chrono::seconds>(it->second.expires - now)
|
||||
.count());
|
||||
};
|
||||
const int source_retry = retry(sources_, source_key, source_failure_limit_);
|
||||
const int target_retry = retry(targets_, target, target_failure_limit_);
|
||||
const int retry_after = std::max(source_retry, target_retry);
|
||||
if (retry_after > 0) return {false, retry_after};
|
||||
|
||||
// Do not evict live failures when flooded with new IDs/IPs. Deny new keys
|
||||
// until expiry frees capacity, bounding memory without resetting lockouts.
|
||||
if ((!sources_.count(source_key) && sources_.size() >= kMaxEntries) ||
|
||||
(!targets_.count(target) && targets_.size() >= kMaxEntries)) {
|
||||
return {false, static_cast<int>(kWindow.count())};
|
||||
}
|
||||
for (auto entry : {std::make_pair(&sources_, source_key),
|
||||
std::make_pair(&targets_, target)}) {
|
||||
auto& bucket =
|
||||
entry.first->try_emplace(entry.second, Failures{0, 0, now + kWindow})
|
||||
.first->second;
|
||||
++bucket.pending;
|
||||
}
|
||||
lock.unlock();
|
||||
bool authenticated = false;
|
||||
std::exception_ptr error;
|
||||
try {
|
||||
authenticated = verify();
|
||||
} catch (...) {
|
||||
error = std::current_exception();
|
||||
}
|
||||
lock.lock();
|
||||
const auto finished = now_();
|
||||
auto finish = [&](Buckets& buckets, const std::string& key, size_t limit) {
|
||||
auto& bucket = buckets.at(key);
|
||||
--bucket.pending;
|
||||
if (bucket.expires <= finished) {
|
||||
bucket.count = 0;
|
||||
bucket.expires = finished + kWindow;
|
||||
}
|
||||
// The reservation already owns this bucket. Start a full cooldown at the
|
||||
// threshold; blocked requests do not extend it.
|
||||
if (!authenticated && ++bucket.count == limit)
|
||||
bucket.expires = finished + kWindow;
|
||||
if (bucket.count == 0 && bucket.pending == 0) buckets.erase(key);
|
||||
};
|
||||
finish(sources_, source_key, source_failure_limit_);
|
||||
finish(targets_, target, target_failure_limit_);
|
||||
lock.unlock();
|
||||
if (error) std::rethrow_exception(error);
|
||||
return {authenticated, 0};
|
||||
}
|
||||
|
||||
int CredentialWorkLimiter::Admit(const std::string& source) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
const auto now = now_();
|
||||
constexpr auto window = std::chrono::minutes(1);
|
||||
if (now >= next_cleanup_) {
|
||||
for (auto it = sources_.begin(); it != sources_.end();) {
|
||||
if (it->second.expires <= now)
|
||||
it = sources_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
next_cleanup_ = now + window;
|
||||
}
|
||||
const auto key = source.empty() ? "unknown" : source;
|
||||
if (!sources_.count(key) && sources_.size() >= DeviceAuthLimiter::kMaxEntries)
|
||||
return 60;
|
||||
auto& local =
|
||||
sources_.try_emplace(key, Window{0, now + window}).first->second;
|
||||
if (local.expires <= now) local = {0, now + window};
|
||||
auto retry = [now](const Window& value, size_t limit) {
|
||||
return value.count < limit
|
||||
? 0
|
||||
: static_cast<int>(std::chrono::ceil<std::chrono::seconds>(
|
||||
value.expires - now)
|
||||
.count());
|
||||
};
|
||||
const int delay = retry(local, source_limit_);
|
||||
if (delay > 0) return delay;
|
||||
++local.count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::lan
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-24
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _LAN_AUTH_LIMITER_H_
|
||||
#define _LAN_AUTH_LIMITER_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk::lan {
|
||||
// Keep password verification and retry policy aligned with crossdesk-server.
|
||||
// Failure budgets keyed by transport peer IP and credential target, never a
|
||||
// connection handle. Use separate instances for independent auth domains.
|
||||
class DeviceAuthLimiter {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Now = std::function<Clock::time_point()>;
|
||||
static constexpr size_t kSourceFailures = 20;
|
||||
static constexpr size_t kTargetFailures = 5;
|
||||
static constexpr size_t kMaxEntries = 16384; // Per dimension.
|
||||
static constexpr std::chrono::seconds kWindow{900};
|
||||
|
||||
struct Result {
|
||||
bool authenticated = false;
|
||||
int retry_after = 0; // Positive only when verification was not attempted.
|
||||
};
|
||||
|
||||
explicit DeviceAuthLimiter(Now now = Clock::now,
|
||||
size_t source_failure_limit = kSourceFailures,
|
||||
size_t target_failure_limit = kTargetFailures);
|
||||
Result Verify(const std::string& source, const std::string& target,
|
||||
const std::function<bool()>& verify);
|
||||
|
||||
private:
|
||||
struct Failures {
|
||||
size_t count = 0;
|
||||
size_t pending = 0;
|
||||
Clock::time_point expires;
|
||||
};
|
||||
using Buckets = std::unordered_map<std::string, Failures>;
|
||||
void Prune(Buckets& buckets, Clock::time_point now);
|
||||
|
||||
Now now_;
|
||||
const size_t source_failure_limit_, target_failure_limit_;
|
||||
std::mutex mutex_;
|
||||
Buckets sources_, targets_;
|
||||
Clock::time_point next_cleanup_{};
|
||||
};
|
||||
|
||||
// Admission before expensive work, including successful authentication,
|
||||
// registration and password changes. Separate from credential failure budgets.
|
||||
class CredentialWorkLimiter {
|
||||
public:
|
||||
explicit CredentialWorkLimiter(
|
||||
DeviceAuthLimiter::Now now = DeviceAuthLimiter::Clock::now,
|
||||
size_t source_limit = 60)
|
||||
: now_(std::move(now)), source_limit_(source_limit) {
|
||||
if (!source_limit_)
|
||||
throw std::invalid_argument("Credential source limit must be positive");
|
||||
}
|
||||
int Admit(const std::string& source);
|
||||
|
||||
private:
|
||||
struct Window {
|
||||
size_t count = 0;
|
||||
DeviceAuthLimiter::Clock::time_point expires{};
|
||||
};
|
||||
DeviceAuthLimiter::Now now_;
|
||||
std::mutex mutex_;
|
||||
const size_t source_limit_;
|
||||
DeviceAuthLimiter::Clock::time_point next_cleanup_{};
|
||||
std::unordered_map<std::string, Window> sources_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk::lan
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,656 @@
|
||||
#include "lan_identity.h"
|
||||
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/pem.h>
|
||||
#include <openssl/rand.h>
|
||||
#include <openssl/x509v3.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string_view>
|
||||
|
||||
#include "lan_password.h"
|
||||
#ifdef _WIN32
|
||||
// Winsock must precede windows.h; the networking/ACL headers need its types.
|
||||
// clang-format off
|
||||
#include <winsock2.h>
|
||||
#include <windows.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <Aclapi.h>
|
||||
#include <iphlpapi.h>
|
||||
#include <netioapi.h>
|
||||
#include <sddl.h>
|
||||
// clang-format on
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <net/if.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#ifdef __APPLE__
|
||||
#include <net/if_dl.h>
|
||||
#include <net/if_types.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace crossdesk::lan {
|
||||
namespace {
|
||||
void Require(bool ok, const char* error) {
|
||||
if (!ok) throw std::runtime_error(error);
|
||||
}
|
||||
std::string Hex(const unsigned char* data, size_t size) {
|
||||
static constexpr char digits[] = "0123456789abcdef";
|
||||
std::string result(size * 2, '0');
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
result[2 * i] = digits[data[i] >> 4];
|
||||
result[2 * i + 1] = digits[data[i] & 15];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
bool IsHex(const std::string& text, size_t size) {
|
||||
return text.size() == size &&
|
||||
std::all_of(text.begin(), text.end(), [](char c) {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||
});
|
||||
}
|
||||
void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
|
||||
const auto temporary = path.parent_path() / (".write-" + RandomHex(16));
|
||||
#ifdef _WIN32
|
||||
HANDLE token = nullptr;
|
||||
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
|
||||
"Cannot open user token");
|
||||
DWORD size = 0;
|
||||
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
|
||||
std::vector<unsigned char> data(size);
|
||||
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
|
||||
CloseHandle(token);
|
||||
Require(ok, "Cannot read user token");
|
||||
LPWSTR sid = nullptr;
|
||||
Require(ConvertSidToStringSidW(
|
||||
reinterpret_cast<TOKEN_USER*>(data.data())->User.Sid, &sid),
|
||||
"Cannot read user SID");
|
||||
std::wstring acl = L"D:P(A;;FA;;;" + std::wstring(sid) + L")";
|
||||
LocalFree(sid);
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
|
||||
"Cannot protect LAN file");
|
||||
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
|
||||
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &security,
|
||||
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
LocalFree(descriptor);
|
||||
Require(file != INVALID_HANDLE_VALUE, "Cannot create LAN file");
|
||||
DWORD written = 0;
|
||||
ok = WriteFile(file, text.data(), static_cast<DWORD>(text.size()), &written,
|
||||
nullptr) &&
|
||||
written == text.size() && FlushFileBuffers(file);
|
||||
CloseHandle(file);
|
||||
if (!ok || !MoveFileExW(temporary.c_str(), path.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
|
||||
DeleteFileW(temporary.c_str());
|
||||
throw std::runtime_error("Cannot persist LAN file");
|
||||
}
|
||||
#else
|
||||
int fd =
|
||||
open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
|
||||
Require(fd >= 0, "Cannot create private LAN file");
|
||||
size_t offset = 0;
|
||||
while (offset < text.size()) {
|
||||
const auto count = write(fd, text.data() + offset, text.size() - offset);
|
||||
if (count <= 0) break;
|
||||
offset += static_cast<size_t>(count);
|
||||
}
|
||||
bool ok = offset == text.size() && fsync(fd) == 0;
|
||||
close(fd);
|
||||
if (!ok || rename(temporary.c_str(), path.c_str()) != 0) {
|
||||
unlink(temporary.c_str());
|
||||
throw std::runtime_error("Cannot persist LAN file");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#ifdef _WIN32
|
||||
std::vector<unsigned char> CurrentUser() {
|
||||
HANDLE token = nullptr;
|
||||
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
|
||||
"Cannot open user token");
|
||||
DWORD size = 0;
|
||||
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
|
||||
std::vector<unsigned char> data(size);
|
||||
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
|
||||
CloseHandle(token);
|
||||
Require(ok, "Cannot read user token");
|
||||
return data;
|
||||
}
|
||||
void CheckPrivateWindowsPath(const std::filesystem::path& path) {
|
||||
const auto attributes = GetFileAttributesW(path.c_str());
|
||||
Require(attributes != INVALID_FILE_ATTRIBUTES &&
|
||||
!(attributes & FILE_ATTRIBUTE_REPARSE_POINT),
|
||||
"LAN path cannot be a reparse point");
|
||||
auto user = CurrentUser();
|
||||
auto sid = reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid;
|
||||
PSID owner = nullptr;
|
||||
PACL acl = nullptr;
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
Require(GetNamedSecurityInfoW(
|
||||
const_cast<wchar_t*>(path.c_str()), SE_FILE_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
|
||||
nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS,
|
||||
"Cannot read LAN file permissions");
|
||||
bool ok = owner && EqualSid(owner, sid) && acl && acl->AceCount > 0;
|
||||
for (DWORD i = 0; ok && i < acl->AceCount; ++i) {
|
||||
void* raw = nullptr;
|
||||
if (!GetAce(acl, i, &raw)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
const auto* ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
|
||||
ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
|
||||
EqualSid(const_cast<DWORD*>(&ace->SidStart), sid);
|
||||
}
|
||||
LocalFree(descriptor);
|
||||
Require(ok, "LAN files must be accessible only by the current user");
|
||||
}
|
||||
void CreatePrivateDirectory(const std::filesystem::path& path) {
|
||||
if (!std::filesystem::exists(path)) {
|
||||
auto user = CurrentUser();
|
||||
LPWSTR sid = nullptr;
|
||||
Require(ConvertSidToStringSidW(
|
||||
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
|
||||
"Cannot read user SID");
|
||||
const auto acl = L"D:P(A;OICI;FA;;;" + std::wstring(sid) + L")";
|
||||
LocalFree(sid);
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
|
||||
"Cannot protect LAN directory");
|
||||
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
|
||||
bool ok = CreateDirectoryW(path.c_str(), &security);
|
||||
LocalFree(descriptor);
|
||||
Require(ok, "Cannot create LAN directory");
|
||||
}
|
||||
CheckPrivateWindowsPath(path);
|
||||
}
|
||||
#endif
|
||||
std::string Read(const std::filesystem::path& path) {
|
||||
#ifdef _WIN32
|
||||
CheckPrivateWindowsPath(path);
|
||||
#else
|
||||
struct stat status{};
|
||||
Require(lstat(path.c_str(), &status) == 0 && S_ISREG(status.st_mode) &&
|
||||
status.st_uid == getuid() && (status.st_mode & 0077) == 0,
|
||||
"LAN file must be owned by this user with mode 0600");
|
||||
#endif
|
||||
Require(std::filesystem::file_size(path) <= 64 * 1024, "LAN file too large");
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
Require(input.good(), "Cannot read LAN file");
|
||||
return {std::istreambuf_iterator<char>(input), {}};
|
||||
}
|
||||
std::string BioText(BIO* bio) {
|
||||
char* data = nullptr;
|
||||
long size = BIO_get_mem_data(bio, &data);
|
||||
Require(size > 0, "Cannot encode LAN identity");
|
||||
return {data, static_cast<size_t>(size)};
|
||||
}
|
||||
struct Secret {
|
||||
std::string value;
|
||||
~Secret() { OPENSSL_cleanse(value.data(), value.size()); }
|
||||
};
|
||||
std::string Unhex(const std::string& value, size_t bytes) {
|
||||
Require(IsHex(value, bytes * 2), "Invalid saved LAN password");
|
||||
std::string result(bytes, '\0');
|
||||
auto digit = [](char c) { return c <= '9' ? c - '0' : c - 'a' + 10; };
|
||||
for (size_t i = 0; i < bytes; ++i)
|
||||
result[i] =
|
||||
static_cast<char>((digit(value[2 * i]) << 4) | digit(value[2 * i + 1]));
|
||||
return result;
|
||||
}
|
||||
std::string PasswordKey(const std::filesystem::path& directory, bool create) {
|
||||
const auto path = directory / "password-key";
|
||||
if (create && !std::filesystem::exists(path)) {
|
||||
Secret key{std::string(32, '\0')};
|
||||
Require(
|
||||
RAND_bytes(reinterpret_cast<unsigned char*>(key.value.data()), 32) == 1,
|
||||
"Cannot generate LAN password storage key");
|
||||
PrivateWrite(path, key.value);
|
||||
}
|
||||
auto key = Read(path);
|
||||
Require(key.size() == 32, "Invalid LAN password storage key");
|
||||
return key;
|
||||
}
|
||||
// The independent local key and credential files use owner-only permissions.
|
||||
// Encryption avoids plaintext in identity.json; it does not protect against
|
||||
// an attacker who can read both this user's key and credential files.
|
||||
nlohmann::json EncryptPassword(const std::string& password,
|
||||
const std::string& key,
|
||||
const std::string& context) {
|
||||
const auto nonce = Unhex(RandomHex(12), 12);
|
||||
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> cipher(
|
||||
EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free);
|
||||
std::array<unsigned char, 32> data{}, tag{};
|
||||
int size = 0, tail = 0;
|
||||
Require(
|
||||
cipher &&
|
||||
EVP_EncryptInit_ex(
|
||||
cipher.get(), EVP_aes_256_gcm(), nullptr,
|
||||
reinterpret_cast<const unsigned char*>(key.data()),
|
||||
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
|
||||
EVP_EncryptUpdate(
|
||||
cipher.get(), nullptr, &size,
|
||||
reinterpret_cast<const unsigned char*>(context.data()),
|
||||
static_cast<int>(context.size())) == 1 &&
|
||||
EVP_EncryptUpdate(
|
||||
cipher.get(), data.data(), &size,
|
||||
reinterpret_cast<const unsigned char*>(password.data()),
|
||||
static_cast<int>(password.size())) == 1 &&
|
||||
EVP_EncryptFinal_ex(cipher.get(), data.data() + size, &tail) == 1 &&
|
||||
EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_GET_TAG, 16,
|
||||
tag.data()) == 1,
|
||||
"Cannot save LAN password");
|
||||
return {{"version", 1},
|
||||
{"algorithm", "aes-256-gcm"},
|
||||
{"nonce", Hex(reinterpret_cast<const unsigned char*>(nonce.data()),
|
||||
nonce.size())},
|
||||
{"data", Hex(data.data(), size + tail)},
|
||||
{"tag", Hex(tag.data(), 16)}};
|
||||
}
|
||||
std::string DecryptPassword(const nlohmann::json& record,
|
||||
const std::string& key,
|
||||
const std::string& context) {
|
||||
Require(record.at("version") == 1 && record.at("algorithm") == "aes-256-gcm",
|
||||
"Invalid saved LAN password");
|
||||
const auto nonce = Unhex(record.at("nonce"), 12);
|
||||
const auto data = Unhex(record.at("data"), 6);
|
||||
auto tag = Unhex(record.at("tag"), 16);
|
||||
Secret plain{std::string(32, '\0')};
|
||||
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> cipher(
|
||||
EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free);
|
||||
int size = 0, tail = 0;
|
||||
Require(cipher &&
|
||||
EVP_DecryptInit_ex(
|
||||
cipher.get(), EVP_aes_256_gcm(), nullptr,
|
||||
reinterpret_cast<const unsigned char*>(key.data()),
|
||||
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
|
||||
EVP_DecryptUpdate(
|
||||
cipher.get(), nullptr, &size,
|
||||
reinterpret_cast<const unsigned char*>(context.data()),
|
||||
static_cast<int>(context.size())) == 1 &&
|
||||
EVP_DecryptUpdate(
|
||||
cipher.get(),
|
||||
reinterpret_cast<unsigned char*>(plain.value.data()), &size,
|
||||
reinterpret_cast<const unsigned char*>(data.data()),
|
||||
static_cast<int>(data.size())) == 1 &&
|
||||
EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_SET_TAG, 16,
|
||||
tag.data()) == 1 &&
|
||||
EVP_DecryptFinal_ex(
|
||||
cipher.get(),
|
||||
reinterpret_cast<unsigned char*>(plain.value.data()) + size,
|
||||
&tail) == 1 &&
|
||||
size + tail == 6,
|
||||
"Cannot read saved LAN password");
|
||||
auto password = plain.value.substr(0, 6);
|
||||
Require(ValidPassword(password), "Invalid saved LAN password");
|
||||
return password;
|
||||
}
|
||||
void CreateCertificate(const std::filesystem::path& key_path,
|
||||
const std::filesystem::path& cert_path) {
|
||||
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
|
||||
EVP_PKEY_Q_keygen(nullptr, nullptr, "EC", "prime256v1"), EVP_PKEY_free);
|
||||
std::unique_ptr<X509, decltype(&X509_free)> cert(X509_new(), X509_free);
|
||||
Require(key && cert, "Cannot generate LAN identity");
|
||||
X509_set_version(cert.get(), 2);
|
||||
ASN1_INTEGER_set(X509_get_serialNumber(cert.get()), 1);
|
||||
X509_gmtime_adj(X509_getm_notBefore(cert.get()), -300);
|
||||
X509_gmtime_adj(X509_getm_notAfter(cert.get()), 10L * 365 * 86400);
|
||||
X509_set_pubkey(cert.get(), key.get());
|
||||
auto* name = X509_get_subject_name(cert.get());
|
||||
X509_NAME_add_entry_by_txt(
|
||||
name, "CN", MBSTRING_ASC,
|
||||
reinterpret_cast<const unsigned char*>("CrossDesk LAN"), -1, -1, 0);
|
||||
X509_set_issuer_name(cert.get(), name);
|
||||
for (const auto& extension :
|
||||
{std::pair<int, const char*>{NID_basic_constraints, "critical,CA:FALSE"},
|
||||
{NID_key_usage, "critical,digitalSignature"},
|
||||
{NID_ext_key_usage, "serverAuth"}}) {
|
||||
X509_EXTENSION* ext = X509V3_EXT_conf_nid(
|
||||
nullptr, nullptr, extension.first, const_cast<char*>(extension.second));
|
||||
Require(ext != nullptr, "Cannot create certificate extension");
|
||||
const int result = X509_add_ext(cert.get(), ext, -1);
|
||||
X509_EXTENSION_free(ext);
|
||||
Require(result == 1, "Cannot install certificate extension");
|
||||
}
|
||||
Require(X509_sign(cert.get(), key.get(), EVP_sha256()) > 0,
|
||||
"Cannot sign LAN certificate");
|
||||
std::unique_ptr<BIO, decltype(&BIO_free)> bio(BIO_new(BIO_s_mem()), BIO_free);
|
||||
Require(PEM_write_bio_PrivateKey(bio.get(), key.get(), nullptr, nullptr, 0,
|
||||
nullptr, nullptr) == 1,
|
||||
"Cannot encode private key");
|
||||
PrivateWrite(key_path, BioText(bio.get()));
|
||||
(void)BIO_reset(bio.get());
|
||||
Require(PEM_write_bio_X509(bio.get(), cert.get()) == 1,
|
||||
"Cannot encode certificate");
|
||||
PrivateWrite(cert_path, BioText(bio.get()));
|
||||
}
|
||||
} // namespace
|
||||
unsigned ParsePort(const std::string& value) {
|
||||
unsigned port = 0;
|
||||
auto [end, error] =
|
||||
std::from_chars(value.data(), value.data() + value.size(), port);
|
||||
Require(error == std::errc{} && end == value.data() + value.size() &&
|
||||
port > 0 && port <= 65535,
|
||||
"Invalid LAN port");
|
||||
return port;
|
||||
}
|
||||
bool IsIpv4(const std::string& value) {
|
||||
unsigned octets[4]{};
|
||||
size_t offset = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
size_t end = value.find('.', offset);
|
||||
if (i == 3) end = value.size();
|
||||
if (end == std::string::npos || end <= offset || end - offset > 3 ||
|
||||
(end - offset > 1 && value[offset] == '0'))
|
||||
return false;
|
||||
for (size_t j = offset; j < end; ++j) {
|
||||
if (value[j] < '0' || value[j] > '9') return false;
|
||||
octets[i] = octets[i] * 10 + value[j] - '0';
|
||||
}
|
||||
if (octets[i] > 255) return false;
|
||||
offset = end + 1;
|
||||
}
|
||||
return offset == value.size() + 1 && octets[0] > 0 && octets[0] != 127 &&
|
||||
octets[0] < 224;
|
||||
}
|
||||
std::pair<std::string, unsigned> ParseEndpoint(const std::string& value,
|
||||
unsigned default_port) {
|
||||
std::string address = value;
|
||||
const auto colon = address.find(':');
|
||||
unsigned port = default_port;
|
||||
if (colon != std::string::npos) {
|
||||
port = ParsePort(address.substr(colon + 1));
|
||||
address.resize(colon);
|
||||
}
|
||||
Require(IsIpv4(address) && port > 0 && port <= 65535,
|
||||
"Enter a numeric IPv4 address or IPv4:port");
|
||||
return {address, port};
|
||||
}
|
||||
std::vector<Interface> PhysicalInterfaces() {
|
||||
std::vector<Interface> result;
|
||||
#ifdef _WIN32
|
||||
ULONG bytes = 16384;
|
||||
std::vector<unsigned char> buffer(bytes);
|
||||
auto* adapters = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buffer.data());
|
||||
ULONG code =
|
||||
GetAdaptersAddresses(AF_INET,
|
||||
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST |
|
||||
GAA_FLAG_SKIP_DNS_SERVER,
|
||||
nullptr, adapters, &bytes);
|
||||
if (code == ERROR_BUFFER_OVERFLOW) {
|
||||
buffer.resize(bytes);
|
||||
adapters = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buffer.data());
|
||||
code = GetAdaptersAddresses(AF_INET, 0, nullptr, adapters, &bytes);
|
||||
}
|
||||
if (code != NO_ERROR) return result;
|
||||
for (auto* a = adapters; a; a = a->Next) {
|
||||
if (a->OperStatus != IfOperStatusUp ||
|
||||
(a->IfType != IF_TYPE_ETHERNET_CSMACD &&
|
||||
a->IfType != IF_TYPE_IEEE80211) ||
|
||||
a->PhysicalAddressLength == 0)
|
||||
continue;
|
||||
// HardwareInterface rejects software adapters that imitate Ethernet.
|
||||
MIB_IF_ROW2 row{};
|
||||
row.InterfaceLuid = a->Luid;
|
||||
if (GetIfEntry2(&row) != NO_ERROR ||
|
||||
!row.InterfaceAndOperStatusFlags.HardwareInterface)
|
||||
continue;
|
||||
for (auto* u = a->FirstUnicastAddress; u; u = u->Next) {
|
||||
char address[INET_ADDRSTRLEN]{};
|
||||
inet_ntop(
|
||||
AF_INET,
|
||||
&reinterpret_cast<sockaddr_in*>(u->Address.lpSockaddr)->sin_addr,
|
||||
address, sizeof(address));
|
||||
if (IsIpv4(address)) result.push_back({address});
|
||||
}
|
||||
}
|
||||
#else
|
||||
ifaddrs* list = nullptr;
|
||||
if (getifaddrs(&list) != 0) return result;
|
||||
for (auto* a = list; a; a = a->ifa_next) {
|
||||
if (!a->ifa_addr || a->ifa_addr->sa_family != AF_INET ||
|
||||
!(a->ifa_flags & IFF_UP) ||
|
||||
(a->ifa_flags & (IFF_LOOPBACK | IFF_POINTOPOINT)))
|
||||
continue;
|
||||
bool physical = false;
|
||||
#ifdef __APPLE__
|
||||
for (auto* link = list; link; link = link->ifa_next)
|
||||
if (link->ifa_addr && link->ifa_addr->sa_family == AF_LINK &&
|
||||
std::string(link->ifa_name) == a->ifa_name) {
|
||||
const auto* dl = reinterpret_cast<sockaddr_dl*>(link->ifa_addr);
|
||||
physical = dl->sdl_type == IFT_ETHER && dl->sdl_alen == 6 &&
|
||||
std::string(a->ifa_name).rfind("en", 0) == 0;
|
||||
}
|
||||
#else
|
||||
physical = std::filesystem::exists(std::filesystem::path("/sys/class/net") /
|
||||
a->ifa_name / "device");
|
||||
#endif
|
||||
if (!physical) continue;
|
||||
char address[INET_ADDRSTRLEN]{};
|
||||
inet_ntop(AF_INET, &reinterpret_cast<sockaddr_in*>(a->ifa_addr)->sin_addr,
|
||||
address, sizeof(address));
|
||||
if (IsIpv4(address)) result.push_back({address});
|
||||
}
|
||||
freeifaddrs(list);
|
||||
#endif
|
||||
return result;
|
||||
}
|
||||
std::string RandomHex(size_t bytes) {
|
||||
std::vector<unsigned char> data(bytes);
|
||||
Require(RAND_bytes(data.data(), static_cast<int>(bytes)) == 1,
|
||||
"Random generator failed");
|
||||
return Hex(data.data(), bytes);
|
||||
}
|
||||
bool ValidPassword(const std::string& password) {
|
||||
return password.size() == 6 &&
|
||||
password.find_first_not_of(
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy"
|
||||
"z") == std::string::npos;
|
||||
}
|
||||
bool VerifyLanCertificate(X509* cert) {
|
||||
if (!cert || !X509_get0_pubkey(cert)) return false;
|
||||
return X509_cmp_current_time(X509_get0_notBefore(cert)) < 0 &&
|
||||
X509_cmp_current_time(X509_get0_notAfter(cert)) > 0 &&
|
||||
X509_verify(cert, X509_get0_pubkey(cert)) == 1 &&
|
||||
X509_check_purpose(cert, X509_PURPOSE_SSL_SERVER, 0) == 1 &&
|
||||
(X509_get_key_usage(cert) & KU_DIGITAL_SIGNATURE) != 0;
|
||||
}
|
||||
Identity::Identity(std::filesystem::path directory)
|
||||
: directory_(std::move(directory)) {}
|
||||
void Identity::Load() {
|
||||
DevicePassword::CheckSupport();
|
||||
std::filesystem::create_directories(directory_.parent_path());
|
||||
#ifdef _WIN32
|
||||
CreatePrivateDirectory(directory_);
|
||||
#else
|
||||
std::filesystem::create_directories(directory_);
|
||||
struct stat directory_status{};
|
||||
Require(lstat(directory_.c_str(), &directory_status) == 0 &&
|
||||
S_ISDIR(directory_status.st_mode) &&
|
||||
directory_status.st_uid == getuid(),
|
||||
"Invalid LAN directory owner or type");
|
||||
Require(chmod(directory_.c_str(), 0700) == 0, "Cannot protect LAN directory");
|
||||
#endif
|
||||
const auto store = directory_ / "identity.json";
|
||||
if (!std::filesystem::exists(store)) {
|
||||
Require(!std::filesystem::exists(private_key()) &&
|
||||
!std::filesystem::exists(certificate()),
|
||||
"Incomplete LAN identity; manual recovery required");
|
||||
CreateCertificate(private_key(), certificate());
|
||||
state_ = {
|
||||
{"version", 1}, {"device_id", RandomHex(16)}, {"verifier", nullptr}};
|
||||
Save(state_);
|
||||
} else
|
||||
state_ = nlohmann::json::parse(Read(store));
|
||||
Require(state_.at("version") == 1 &&
|
||||
IsHex(state_.at("device_id").get<std::string>(), 32),
|
||||
"Invalid LAN identity store");
|
||||
// Legacy trust records no longer affect connections. Drop them from memory
|
||||
// and from the next ordinary settings/credential save, without requiring a
|
||||
// migration write before an outgoing connection can authenticate.
|
||||
state_.erase("trust");
|
||||
// Migrate the single endpoint in memory; the next ordinary save persists it.
|
||||
// Ports belong to exact IP addresses, including addresses currently offline.
|
||||
auto& settings = state_["settings"];
|
||||
if (settings.is_null()) settings = nlohmann::json::object();
|
||||
Require(settings.is_object(), "Invalid LAN settings");
|
||||
const auto address = settings.value("address", std::string{});
|
||||
Require(address.empty() || IsIpv4(address), "Invalid LAN listener address");
|
||||
if (!settings.contains("ports")) {
|
||||
settings["ports"] = nlohmann::json::object();
|
||||
if (!address.empty())
|
||||
settings["ports"][address] = settings.contains("port")
|
||||
? settings["port"]
|
||||
: nlohmann::json(52888u);
|
||||
}
|
||||
Require(settings["ports"].is_object(), "Invalid LAN port settings");
|
||||
for (const auto& [ip, port] : settings["ports"].items())
|
||||
Require(IsIpv4(ip) && port.is_number_integer() && port > 0 && port <= 65535,
|
||||
"Invalid LAN port settings");
|
||||
settings["address"] = address;
|
||||
settings["version"] = 2;
|
||||
settings.erase("enabled");
|
||||
settings.erase("interface");
|
||||
settings.erase("port");
|
||||
id_ = state_.at("device_id");
|
||||
auto pem = Read(certificate());
|
||||
std::unique_ptr<BIO, decltype(&BIO_free)> bio(
|
||||
BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())), BIO_free);
|
||||
std::unique_ptr<X509, decltype(&X509_free)> cert(
|
||||
PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), X509_free);
|
||||
Require(VerifyLanCertificate(cert.get()),
|
||||
"LAN certificate invalid or expired");
|
||||
auto secret = Read(private_key());
|
||||
std::unique_ptr<BIO, decltype(&BIO_free)> key_bio(
|
||||
BIO_new_mem_buf(secret.data(), static_cast<int>(secret.size())),
|
||||
BIO_free);
|
||||
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
|
||||
PEM_read_bio_PrivateKey(key_bio.get(), nullptr, nullptr, nullptr),
|
||||
EVP_PKEY_free);
|
||||
Require(key && X509_check_private_key(cert.get(), key.get()) == 1,
|
||||
"LAN private key does not match certificate");
|
||||
OPENSSL_cleanse(secret.data(), secret.size());
|
||||
if (!state_["verifier"].is_null()) {
|
||||
const auto& v = state_["verifier"];
|
||||
if (v.at("version") == 1) {
|
||||
Require(v.at("algorithm") == "scrypt-32768-8-1" &&
|
||||
IsHex(v.at("salt"), 32) && IsHex(v.at("hash"), 64),
|
||||
"Invalid LAN password verifier");
|
||||
} else if (v.at("version") == 2) {
|
||||
// Legacy PAKE records are read only to recover the locally encrypted
|
||||
// password for migration. They are never used by the network protocol.
|
||||
Require(v.at("algorithm") == "spake2p-p256-scrypt-32768-8-1" &&
|
||||
IsHex(v.at("salt"), 32) && IsHex(v.at("w0"), 64) &&
|
||||
IsHex(v.at("L"), 130),
|
||||
"Invalid LAN password verifier");
|
||||
} else {
|
||||
Require(CurrentVerifier(v), "Invalid LAN password verifier");
|
||||
}
|
||||
}
|
||||
// Lifetime lockouts are obsolete; retain only expiring in-memory cooldowns.
|
||||
state_.erase("password_attempts");
|
||||
}
|
||||
void Identity::Save(const nlohmann::json& state) {
|
||||
PrivateWrite(directory_ / "identity.json", state.dump(2));
|
||||
}
|
||||
std::pair<std::string, nlohmann::json> Identity::GeneratePassword() {
|
||||
constexpr std::string_view alphabet =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
constexpr auto unbiased_limit = 256 - 256 % alphabet.size();
|
||||
std::string password;
|
||||
// Match cloud password generation: unbiased ASCII alphanumeric characters,
|
||||
// regenerating all-numeric results. Existing numeric passwords remain valid.
|
||||
do {
|
||||
password.clear();
|
||||
while (password.size() < 6) {
|
||||
unsigned char byte = 0;
|
||||
Require(RAND_bytes(&byte, 1) == 1, "Cannot generate LAN password");
|
||||
if (byte < unbiased_limit) password += alphabet[byte % alphabet.size()];
|
||||
}
|
||||
} while (password.find_first_not_of("0123456789") == std::string::npos);
|
||||
return {password, RegisterPassword(password)};
|
||||
}
|
||||
void Identity::SetPassword(const std::string& password,
|
||||
const nlohmann::json& verifier) {
|
||||
Require(ValidPassword(password),
|
||||
"LAN password must contain exactly 6 ASCII letters or digits");
|
||||
Require(CurrentVerifier(verifier), "Invalid LAN password verifier");
|
||||
Secret key{PasswordKey(directory_, !state_.contains("password"))};
|
||||
auto next = state_;
|
||||
next["verifier"] = verifier;
|
||||
next["password"] = EncryptPassword(
|
||||
password, key.value,
|
||||
"crossdesk.lan.password.v1:" + id_ + ":" + verifier.dump());
|
||||
Save(next);
|
||||
state_ = std::move(next);
|
||||
}
|
||||
std::string Identity::ReadPassword() const {
|
||||
// Verifier-only legacy records require a local password entry to migrate.
|
||||
if (!state_.contains("password")) return {};
|
||||
Secret key{PasswordKey(directory_, false)};
|
||||
return DecryptPassword(
|
||||
state_.at("password"), key.value,
|
||||
"crossdesk.lan.password.v1:" + id_ + ":" + verifier().dump());
|
||||
}
|
||||
nlohmann::json Identity::RegisterPassword(const std::string& password) {
|
||||
Require(ValidPassword(password),
|
||||
"LAN password must contain exactly 6 ASCII letters or digits");
|
||||
const auto record = DevicePassword::Hash(password);
|
||||
Require(record.has_value(), "Cannot derive LAN password verifier");
|
||||
return {{"version", 3}, {"salt", record->salt}, {"hash", record->hash}};
|
||||
}
|
||||
bool Identity::CurrentVerifier(const nlohmann::json& verifier) {
|
||||
return verifier.is_object() && verifier.value("version", 0) == 3 &&
|
||||
verifier.contains("salt") && verifier["salt"].is_string() &&
|
||||
verifier.contains("hash") && verifier["hash"].is_string() &&
|
||||
DevicePassword::IsCurrent({verifier["salt"], verifier["hash"]});
|
||||
}
|
||||
bool Identity::VerifyPassword(const nlohmann::json& verifier,
|
||||
const std::string& password) {
|
||||
if (!ValidPassword(password) || !CurrentVerifier(verifier)) return false;
|
||||
return DevicePassword::Verify(
|
||||
DevicePasswordRecord{verifier["salt"], verifier["hash"]}, password);
|
||||
}
|
||||
|
||||
void Identity::SaveSettings(const std::string& address, unsigned port) {
|
||||
Require(IsIpv4(address) && port > 0 && port <= 65535,
|
||||
"Invalid LAN listener address");
|
||||
auto next = state_;
|
||||
const auto interfaces = PhysicalInterfaces();
|
||||
const auto selected = std::find_if(
|
||||
interfaces.begin(), interfaces.end(),
|
||||
[&](const auto& i) { return i.address == address; });
|
||||
Require(selected != interfaces.end(), "Selected LAN interface is unavailable");
|
||||
next["settings"]["ports"][address] = port;
|
||||
Save(next);
|
||||
state_ = std::move(next);
|
||||
}
|
||||
unsigned Identity::PortFor(const std::string& address) const {
|
||||
return state_.at("settings").at("ports").value(address, 52888u);
|
||||
}
|
||||
bool Identity::HasPortFor(const std::string& address) const {
|
||||
return state_.at("settings").at("ports").contains(address);
|
||||
}
|
||||
void Identity::SelectAddress(const std::string& address) {
|
||||
const auto interfaces = PhysicalInterfaces();
|
||||
Require(std::any_of(interfaces.begin(), interfaces.end(),
|
||||
[&](const auto& i) { return i.address == address; }),
|
||||
"Selected LAN interface is unavailable");
|
||||
if (state_["settings"].value("address", "") == address) return;
|
||||
auto next = state_;
|
||||
next["settings"]["address"] = address;
|
||||
Save(next);
|
||||
state_ = std::move(next);
|
||||
}
|
||||
} // namespace crossdesk::lan
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-24
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _LAN_IDENTITY_H_
|
||||
#define _LAN_IDENTITY_H_
|
||||
|
||||
#include <openssl/x509.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::lan {
|
||||
struct Interface {
|
||||
std::string address;
|
||||
};
|
||||
struct Target {
|
||||
std::string address;
|
||||
// Learned from the current connection, never pinned.
|
||||
std::string device_id;
|
||||
unsigned port = 52888;
|
||||
};
|
||||
unsigned ParsePort(const std::string& value);
|
||||
std::pair<std::string, unsigned> ParseEndpoint(const std::string& value,
|
||||
unsigned default_port);
|
||||
bool IsIpv4(const std::string& value);
|
||||
std::vector<Interface> PhysicalInterfaces();
|
||||
std::string RandomHex(size_t bytes);
|
||||
bool ValidPassword(const std::string& password);
|
||||
bool VerifyLanCertificate(X509* cert);
|
||||
|
||||
// Files are separate from cloud settings and thumbnail credentials. A corrupt
|
||||
// store is an error; it must never be replaced with a newly generated identity.
|
||||
class Identity {
|
||||
public:
|
||||
explicit Identity(std::filesystem::path directory);
|
||||
void Load();
|
||||
static std::pair<std::string, nlohmann::json> GeneratePassword();
|
||||
void SetPassword(const std::string& password, const nlohmann::json& verifier);
|
||||
std::string ReadPassword() const;
|
||||
static nlohmann::json RegisterPassword(const std::string& password);
|
||||
static bool CurrentVerifier(const nlohmann::json& verifier);
|
||||
static bool VerifyPassword(const nlohmann::json& verifier,
|
||||
const std::string& password);
|
||||
void SaveSettings(const std::string& address, unsigned port);
|
||||
// An unconfigured address uses the default port; offline addresses keep
|
||||
// their saved port. Selection only controls the address shown in the UI.
|
||||
unsigned PortFor(const std::string& address) const;
|
||||
bool HasPortFor(const std::string& address) const;
|
||||
void SelectAddress(const std::string& address);
|
||||
const std::string& id() const { return id_; }
|
||||
std::filesystem::path certificate() const {
|
||||
return directory_ / "certificate.pem";
|
||||
}
|
||||
std::filesystem::path private_key() const {
|
||||
return directory_ / "private-key.pem";
|
||||
}
|
||||
const nlohmann::json& verifier() const { return state_.at("verifier"); }
|
||||
const nlohmann::json& settings() const { return state_.at("settings"); }
|
||||
|
||||
private:
|
||||
void Save(const nlohmann::json& state);
|
||||
std::filesystem::path directory_;
|
||||
std::string id_;
|
||||
nlohmann::json state_;
|
||||
};
|
||||
} // namespace crossdesk::lan
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,122 @@
|
||||
#include "lan_password.h"
|
||||
|
||||
#include <openssl/core_names.h>
|
||||
#include <openssl/crypto.h>
|
||||
#include <openssl/kdf.h>
|
||||
#include <openssl/params.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace crossdesk::lan {
|
||||
namespace {
|
||||
// An application format, with a hex salt in password_salt and hex digest here.
|
||||
// Only known work factors are accepted, bounding CPU/memory for corrupt data.
|
||||
constexpr char kPrefix[] = "argon2id$v=19$m=19456,t=2,p=1$";
|
||||
constexpr size_t kSaltBytes = 16, kDigestBytes = 32;
|
||||
|
||||
bool ValidInput(const std::string& password) {
|
||||
return password.size() <= 256 && password.find('\0') == std::string::npos;
|
||||
}
|
||||
|
||||
std::string Hex(const unsigned char* data, size_t size) {
|
||||
constexpr char digits[] = "0123456789abcdef";
|
||||
std::string result;
|
||||
result.reserve(size * 2);
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
result += digits[data[i] >> 4];
|
||||
result += digits[data[i] & 15];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Unhex(const std::string& text, unsigned char* output, size_t size) {
|
||||
if (text.size() != size * 2) return false;
|
||||
auto digit = [](char c) -> int {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
};
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
const int hi = digit(text[i * 2]), lo = digit(text[i * 2 + 1]);
|
||||
if (hi < 0 || lo < 0) return false;
|
||||
output[i] = static_cast<unsigned char>((hi << 4) | lo);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
using Kdf = std::unique_ptr<EVP_KDF, decltype(&EVP_KDF_free)>;
|
||||
using Context = std::unique_ptr<EVP_KDF_CTX, decltype(&EVP_KDF_CTX_free)>;
|
||||
|
||||
std::optional<DevicePasswordRecord> Derive(
|
||||
const std::string& password,
|
||||
const std::array<unsigned char, kSaltBytes>& salt) {
|
||||
Kdf kdf(EVP_KDF_fetch(nullptr, "ARGON2ID", nullptr), EVP_KDF_free);
|
||||
if (!kdf) return std::nullopt;
|
||||
Context context(EVP_KDF_CTX_new(kdf.get()), EVP_KDF_CTX_free);
|
||||
if (!context) return std::nullopt;
|
||||
uint32_t memory = 19456, iterations = 2, lanes = 1, threads = 1,
|
||||
version = 0x13;
|
||||
OSSL_PARAM params[] = {
|
||||
OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_PASSWORD,
|
||||
const_cast<char*>(password.data()),
|
||||
password.size()),
|
||||
OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT,
|
||||
const_cast<unsigned char*>(salt.data()),
|
||||
salt.size()),
|
||||
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_MEMCOST, &memory),
|
||||
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ITER, &iterations),
|
||||
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_LANES, &lanes),
|
||||
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_THREADS, &threads),
|
||||
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_VERSION, &version),
|
||||
OSSL_PARAM_construct_end()};
|
||||
std::array<unsigned char, kDigestBytes> digest{};
|
||||
if (EVP_KDF_derive(context.get(), digest.data(), digest.size(), params) != 1)
|
||||
return std::nullopt;
|
||||
DevicePasswordRecord result{Hex(salt.data(), salt.size()),
|
||||
kPrefix + Hex(digest.data(), digest.size())};
|
||||
OPENSSL_cleanse(digest.data(), digest.size());
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace DevicePassword {
|
||||
void CheckSupport() {
|
||||
// Exercise the provider and parameters, not just algorithm discovery.
|
||||
if (!Hash("startup-check"))
|
||||
throw std::runtime_error(
|
||||
"Argon2id or the secure random source is unavailable");
|
||||
}
|
||||
|
||||
std::optional<DevicePasswordRecord> Hash(const std::string& password) {
|
||||
if (!ValidInput(password)) return std::nullopt;
|
||||
std::array<unsigned char, kSaltBytes> salt{};
|
||||
if (RAND_bytes(salt.data(), salt.size()) != 1) return std::nullopt;
|
||||
return Derive(password, salt);
|
||||
}
|
||||
|
||||
bool IsCurrent(const DevicePasswordRecord& record) {
|
||||
std::array<unsigned char, kSaltBytes> salt{};
|
||||
std::array<unsigned char, kDigestBytes> digest{};
|
||||
return record.hash.rfind(kPrefix, 0) == 0 &&
|
||||
Unhex(record.salt, salt.data(), salt.size()) &&
|
||||
Unhex(record.hash.substr(sizeof(kPrefix) - 1), digest.data(),
|
||||
digest.size());
|
||||
}
|
||||
|
||||
bool Verify(const DevicePasswordRecord& record, const std::string& password) {
|
||||
if (!ValidInput(password) || !IsCurrent(record)) return false;
|
||||
std::array<unsigned char, kSaltBytes> salt{};
|
||||
Unhex(record.salt, salt.data(), salt.size());
|
||||
const auto computed = Derive(password, salt);
|
||||
return computed && computed->hash.size() == record.hash.size() &&
|
||||
CRYPTO_memcmp(computed->hash.data(), record.hash.data(),
|
||||
computed->hash.size()) == 0;
|
||||
}
|
||||
} // namespace DevicePassword
|
||||
|
||||
} // namespace crossdesk::lan
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-24
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _LAN_PASSWORD_H_
|
||||
#define _LAN_PASSWORD_H_
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::lan {
|
||||
// Keep the password record format aligned with crossdesk-server.
|
||||
// Stored in identity.json. The hash identifies the algorithm,
|
||||
// version and work factors; unrecognized formats never fall back to SHA-256.
|
||||
struct DevicePasswordRecord {
|
||||
std::string salt;
|
||||
std::string hash;
|
||||
};
|
||||
|
||||
namespace DevicePassword {
|
||||
void CheckSupport();
|
||||
std::optional<DevicePasswordRecord> Hash(const std::string& password);
|
||||
// LAN accepts only current Argon2id records. Older LAN records are migrated
|
||||
// locally from the encrypted password before connections are accepted.
|
||||
bool Verify(const DevicePasswordRecord& record, const std::string& password);
|
||||
bool IsCurrent(const DevicePasswordRecord& record);
|
||||
} // namespace DevicePassword
|
||||
|
||||
} // namespace crossdesk::lan
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,806 @@
|
||||
#include "lan_session_manager.h"
|
||||
|
||||
#include <openssl/crypto.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <thread>
|
||||
|
||||
namespace crossdesk::lan {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Json = nlohmann::json;
|
||||
template <class T>
|
||||
bool Ready(std::future<T>& future) {
|
||||
return future.valid() &&
|
||||
future.wait_for(std::chrono::seconds(0)) == std::future_status::ready;
|
||||
}
|
||||
void Check(bool condition) {
|
||||
if (!condition) throw std::runtime_error("Invalid LAN protocol message");
|
||||
}
|
||||
bool Id(const Json& value) {
|
||||
if (!value.is_string()) return false;
|
||||
const auto& s = value.get_ref<const std::string&>();
|
||||
return s.size() == 32 && std::all_of(s.begin(), s.end(), [](char c) {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||
});
|
||||
}
|
||||
Json Capabilities() {
|
||||
return Json::array({"host-only-ipv4", "dtls-srtp-all", "password-auth-v1"});
|
||||
}
|
||||
void Capable(const Json& j) {
|
||||
const auto& caps = j.at("capabilities");
|
||||
Check(caps.is_array() && caps.size() <= 16);
|
||||
for (const auto& required : Capabilities())
|
||||
Check(std::find(caps.begin(), caps.end(), required) != caps.end());
|
||||
}
|
||||
void Wipe(std::string& text) {
|
||||
if (!text.empty()) OPENSSL_cleanse(text.data(), text.size());
|
||||
text.clear();
|
||||
}
|
||||
const char* FailureReason(const Session& s, const char* fallback) {
|
||||
switch (s.failure_code.load()) {
|
||||
case MiniRtcDirectInvalidSignal:
|
||||
return "InvalidSignal";
|
||||
case MiniRtcDirectSignalSendFailed:
|
||||
return "SignalSendFailed";
|
||||
case MiniRtcDirectDtlsHandshakeFailed:
|
||||
return "DtlsHandshakeFailed";
|
||||
case MiniRtcDirectRequiredCryptoUnsupported:
|
||||
return "RequiredCryptoUnsupported";
|
||||
case MiniRtcDirectSrtpInstallFailed:
|
||||
return "SrtpInstallFailed";
|
||||
case MiniRtcDirectNetworkPolicyFailed:
|
||||
return "NetworkPolicyFailed";
|
||||
case MiniRtcDirectFingerprintMissing:
|
||||
return "FingerprintMissing";
|
||||
case MiniRtcDirectFingerprintMismatch:
|
||||
return "FingerprintMismatch";
|
||||
case MiniRtcDirectSrtpKeyExportFailed:
|
||||
return "SrtpKeyExportFailed";
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
int Session::SendSignal(const char* data, size_t size, void* context) {
|
||||
auto* s = static_cast<Session*>(context);
|
||||
if (!s || s->closing || !data || !size || size > 256 * 1024) return -1;
|
||||
std::lock_guard lock(s->signal_mutex);
|
||||
if (s->closing || s->signal_count >= 256 ||
|
||||
s->signal_bytes + size > 1024 * 1024)
|
||||
return -1;
|
||||
++s->signal_count;
|
||||
s->signal_bytes += size;
|
||||
s->signals.emplace_back(data, size);
|
||||
return 0;
|
||||
}
|
||||
void Session::Error(MiniRtcDirectError error, void* context) {
|
||||
if (auto* s = static_cast<Session*>(context)) {
|
||||
int empty = -1;
|
||||
s->failure_code.compare_exchange_strong(empty, error);
|
||||
s->failed = true;
|
||||
}
|
||||
}
|
||||
void Session::Status(ConnectionStatus status) {
|
||||
if (closing) return;
|
||||
if (status == ConnectionStatus::Connected)
|
||||
ready = true;
|
||||
else if (status == ConnectionStatus::Closed ||
|
||||
status == ConnectionStatus::Failed ||
|
||||
status == ConnectionStatus::Disconnected)
|
||||
failed = true;
|
||||
}
|
||||
SessionManager::SessionManager(std::filesystem::path path, Hooks hooks)
|
||||
: identity_(std::move(path)), hooks_(std::move(hooks)) {
|
||||
identity_.Load();
|
||||
access_password_ = identity_.ReadPassword();
|
||||
new_password_ = access_password_;
|
||||
if (!Identity::CurrentVerifier(identity_.verifier())) {
|
||||
if (!access_password_.empty()) {
|
||||
// Migrate the saved password, retaining its value, identity and settings.
|
||||
SetPassword(access_password_);
|
||||
} else if (identity_.verifier().is_null()) {
|
||||
// Generate the initial password before automatically accepting connections.
|
||||
ResetPassword();
|
||||
} else {
|
||||
hooks_.error("LAN password is not ready");
|
||||
}
|
||||
}
|
||||
}
|
||||
SessionManager::~SessionManager() {
|
||||
Stop();
|
||||
while (!Quiescent()) {
|
||||
Tick();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
}
|
||||
transport_.Shutdown();
|
||||
Wipe(new_password_);
|
||||
Wipe(access_password_);
|
||||
}
|
||||
void SessionManager::Configure(const std::string& address, unsigned port) {
|
||||
if (stopping_) return;
|
||||
if (!port || port > 65535) throw std::runtime_error("Invalid LAN port");
|
||||
if (!Identity::CurrentVerifier(identity_.verifier()))
|
||||
throw std::runtime_error("LAN password is not ready");
|
||||
ReplaceListener(address, port, true);
|
||||
}
|
||||
void SessionManager::ReplaceListener(const std::string& address, unsigned port,
|
||||
bool save, bool allow_fallback) {
|
||||
const auto previous = listeners_.find(address);
|
||||
if (previous != listeners_.end() && previous->second.port == port) return;
|
||||
const auto next = [&] {
|
||||
try {
|
||||
return transport_.Listen(identity_, address, port);
|
||||
} catch (const std::exception& e) {
|
||||
const std::string error = e.what();
|
||||
if (!allow_fallback ||
|
||||
(error != "LanPortInUse" && error != "LanPortPermissionDenied" &&
|
||||
error != "LanPortCheckFailed"))
|
||||
throw;
|
||||
// Keep the OS-assigned socket bound through persistence/publication;
|
||||
// probing then releasing a free port would introduce an allocation race.
|
||||
return transport_.Listen(identity_, address, 0);
|
||||
}
|
||||
}();
|
||||
try {
|
||||
if (save) identity_.SaveSettings(address, next.port);
|
||||
} catch (...) {
|
||||
transport_.StopAccept(next.id);
|
||||
throw;
|
||||
}
|
||||
const auto old = previous == listeners_.end() ? 0 : previous->second.id;
|
||||
listeners_[address] = {next.id, next.port};
|
||||
SetListenerError(address, next.port, "");
|
||||
if (old) RetireListener(old);
|
||||
}
|
||||
void SessionManager::RetireListener(uint64_t id) {
|
||||
transport_.StopAccept(id);
|
||||
// Only cancel unfinished authentication on this retired endpoint. Other
|
||||
// addresses and sessions that already authenticated keep their channels.
|
||||
for (auto& [_, s] : sessions_)
|
||||
if (s->incoming && s->channel->listener == id &&
|
||||
s->phase < Session::AwaitOffer)
|
||||
Close(s, "ListenerChanged");
|
||||
}
|
||||
void SessionManager::ResetPassword() {
|
||||
if (stopping_ || reset_job_.valid()) return;
|
||||
reset_job_ =
|
||||
std::async(std::launch::async, [current = access_password_]() mutable {
|
||||
try {
|
||||
for (;;) {
|
||||
auto result = Identity::GeneratePassword();
|
||||
if (result.first != current) {
|
||||
Wipe(current);
|
||||
return result;
|
||||
}
|
||||
Wipe(result.first);
|
||||
}
|
||||
} catch (...) {
|
||||
Wipe(current);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
void SessionManager::SetPassword(std::string password) {
|
||||
if (!ValidPassword(password)) {
|
||||
Wipe(password);
|
||||
throw std::runtime_error(
|
||||
"LAN password must contain exactly 6 ASCII letters or digits");
|
||||
}
|
||||
if (stopping_ || reset_job_.valid() ||
|
||||
(password == access_password_ &&
|
||||
Identity::CurrentVerifier(identity_.verifier()))) {
|
||||
Wipe(password);
|
||||
return;
|
||||
}
|
||||
reset_job_ = std::async(
|
||||
std::launch::async, [password = std::move(password)]() mutable {
|
||||
try {
|
||||
auto record = Identity::RegisterPassword(password);
|
||||
return std::make_pair(std::move(password), std::move(record));
|
||||
} catch (...) {
|
||||
Wipe(password);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
void SessionManager::SetListenerError(const std::string& address, unsigned port,
|
||||
const std::string& error) {
|
||||
if (error.empty()) {
|
||||
listener_errors_.erase(address);
|
||||
listener_warnings_.erase(address);
|
||||
return;
|
||||
}
|
||||
const auto previous = listener_errors_.find(address);
|
||||
if (previous != listener_errors_.end() && previous->second.port == port &&
|
||||
previous->second.error == error)
|
||||
return;
|
||||
ListenerIssue issue{address, port, error};
|
||||
listener_errors_[address] = issue;
|
||||
listener_warnings_[address] = std::move(issue);
|
||||
}
|
||||
std::string SessionManager::ListenerError(const std::string& address) const {
|
||||
const auto it = listener_errors_.find(address);
|
||||
return it == listener_errors_.end() ? "" : it->second.error;
|
||||
}
|
||||
std::optional<SessionManager::ListenerIssue> SessionManager::TakeListenerWarning() {
|
||||
if (listener_warnings_.empty()) return std::nullopt;
|
||||
auto it = listener_warnings_.begin();
|
||||
auto issue = std::move(it->second);
|
||||
listener_warnings_.erase(it);
|
||||
return issue;
|
||||
}
|
||||
void SessionManager::RefreshListeners() {
|
||||
if (stopping_ || !Identity::CurrentVerifier(identity_.verifier())) return;
|
||||
const auto available = [&](const auto& address) {
|
||||
return std::any_of(interfaces_.begin(), interfaces_.end(),
|
||||
[&](const auto& i) { return i.address == address; });
|
||||
};
|
||||
for (auto it = listeners_.begin(); it != listeners_.end();) {
|
||||
if (available(it->first)) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
RetireListener(it->second.id);
|
||||
it = listeners_.erase(it);
|
||||
}
|
||||
for (auto it = listener_errors_.begin(); it != listener_errors_.end();) {
|
||||
if (available(it->first)) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
listener_warnings_.erase(it->first);
|
||||
it = listener_errors_.erase(it);
|
||||
}
|
||||
for (const auto& interface : interfaces_) {
|
||||
const auto port = identity_.PortFor(interface.address);
|
||||
const bool initialize = !identity_.HasPortFor(interface.address);
|
||||
try {
|
||||
// Persist the first choice even when the default works. Later conflicts
|
||||
// must not silently change an endpoint users may already have shared.
|
||||
ReplaceListener(interface.address, port, initialize, initialize);
|
||||
} catch (const std::exception& e) {
|
||||
SetListenerError(interface.address, port, e.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
std::string SessionManager::TakeNewPassword() {
|
||||
return std::exchange(new_password_, {});
|
||||
}
|
||||
SessionManager::SessionPtr SessionManager::Connect(const std::string& address,
|
||||
unsigned port,
|
||||
std::string password) {
|
||||
if (stopping_ || sessions_.size() >= 24)
|
||||
throw std::runtime_error("LAN connection limit reached");
|
||||
if (!ValidPassword(password))
|
||||
throw std::runtime_error(
|
||||
"LAN password must contain exactly 6 ASCII letters or digits");
|
||||
if (RetryAfter(address, port) > 0) {
|
||||
Wipe(password);
|
||||
throw std::runtime_error("AuthenticationRateLimited");
|
||||
}
|
||||
auto s = std::make_shared<Session>();
|
||||
const auto [ip, endpoint_port] = ParseEndpoint(address, port);
|
||||
s->target = Target{ip, {}, endpoint_port};
|
||||
s->channel = transport_.Connect(s->target);
|
||||
s->password = std::move(password);
|
||||
s->epoch = epoch_;
|
||||
s->key = "lan/" + RandomHex(16);
|
||||
s->deadline = Clock::now() + std::chrono::seconds(8);
|
||||
sessions_[s->channel->id] = s;
|
||||
return s;
|
||||
}
|
||||
bool SessionManager::Send(const SessionPtr& s, Json j, bool sequenced) {
|
||||
j["version"] = 2;
|
||||
if (sequenced) {
|
||||
j["session_id"] = s->session_id;
|
||||
j["seq"] = s->send_seq + 1;
|
||||
}
|
||||
if (s->closing || !transport_.Send(s->channel, j.dump())) {
|
||||
Close(s, "SignalSendFailed");
|
||||
return false;
|
||||
}
|
||||
if (sequenced) ++s->send_seq;
|
||||
return true;
|
||||
}
|
||||
int SessionManager::RetryAfter(const std::string& address,
|
||||
unsigned port) const {
|
||||
const auto [ip, endpoint_port] = ParseEndpoint(address, port);
|
||||
const auto it = retry_at_.find(ip + ":" + std::to_string(endpoint_port));
|
||||
const auto now = Clock::now();
|
||||
return it == retry_at_.end() || it->second <= now
|
||||
? 0
|
||||
: static_cast<int>(
|
||||
std::chrono::ceil<std::chrono::seconds>(it->second - now)
|
||||
.count());
|
||||
}
|
||||
void SessionManager::Fail(const SessionPtr& s, const std::string& error,
|
||||
int retry_after) {
|
||||
Json reply = {{"type", "error"}, {"error", error}};
|
||||
// Use the same failure codes and retry_after semantics as cloud Join.
|
||||
if (error == "InvalidCredentials")
|
||||
reply["error_code"] = "AUTHENTICATION_FAILED";
|
||||
if (error == "AuthenticationRateLimited")
|
||||
reply["error_code"] = "AUTHENTICATION_THROTTLED";
|
||||
if (error == "Busy") reply["error_code"] = "CREDENTIAL_SERVICE_BUSY";
|
||||
if (retry_after > 0) reply["retry_after"] = retry_after;
|
||||
if (s->session_id.empty() ||
|
||||
(s->incoming && s->phase < Session::AwaitOffer)) {
|
||||
reply["request_id"] = s->auth_request;
|
||||
Send(s, std::move(reply));
|
||||
} else {
|
||||
Send(s, std::move(reply), true);
|
||||
}
|
||||
Close(s, error);
|
||||
}
|
||||
void SessionManager::Prepare(const SessionPtr& s) {
|
||||
size_t admitted = 0;
|
||||
for (auto& [_, other] : sessions_)
|
||||
if (!other->closing && (other->phase == Session::Preparing ||
|
||||
other->phase >= Session::AwaitOffer))
|
||||
++admitted;
|
||||
if (admitted >= 8 || prepare_jobs_.size() >= 2) {
|
||||
Fail(s, "Busy");
|
||||
return;
|
||||
}
|
||||
s->phase = Session::Preparing;
|
||||
if (s->incoming) {
|
||||
s->session_id = RandomHex(16);
|
||||
s->local_id = identity_.id();
|
||||
s->remote_id = RandomHex(16);
|
||||
}
|
||||
auto work = hooks_.prepare(s);
|
||||
prepare_jobs_.push_back(
|
||||
{s, std::async(std::launch::async, [s, work = std::move(work)] {
|
||||
if (s->closing) return Prepared{};
|
||||
Prepared prepared = work();
|
||||
if (!prepared.peer || s->closing) return prepared;
|
||||
MiniRtcDirectOptions options{};
|
||||
options.struct_size = sizeof(options);
|
||||
options.version = 1;
|
||||
options.local_id = s->local_id.c_str();
|
||||
options.remote_id = s->remote_id.c_str();
|
||||
options.session_id = s->session_id.c_str();
|
||||
options.local_address = s->channel->local_address.c_str();
|
||||
options.remote_address = s->channel->remote_address.c_str();
|
||||
options.offerer = !s->incoming;
|
||||
options.send_signal = Session::SendSignal;
|
||||
options.on_error = Session::Error;
|
||||
options.context = s.get();
|
||||
if (InitDirectPeer(prepared.peer, &options) != 0 ||
|
||||
(!s->closing &&
|
||||
StartDirectSession(prepared.peer, !s->incoming) != 0))
|
||||
s->failed = true;
|
||||
return prepared;
|
||||
})});
|
||||
}
|
||||
void SessionManager::Message(const SessionPtr& s, const std::string& text) {
|
||||
auto j = Json::parse(text);
|
||||
Check(j.is_object() && j.at("version").is_number_integer() &&
|
||||
j.at("version") == 2 && j.at("type").is_string() &&
|
||||
!j.contains("user_id") && !j.contains("remote_user_id"));
|
||||
const auto type = j.at("type").get<std::string>();
|
||||
if (type == "error" && (s->session_id.empty() ||
|
||||
(s->incoming && s->phase < Session::AwaitOffer))) {
|
||||
Check(!j.contains("session_id") && !j.contains("seq") &&
|
||||
j.at("request_id") == s->auth_request);
|
||||
const auto error = j.at("error").get<std::string>();
|
||||
Check(error.size() <= 80);
|
||||
if (!s->incoming &&
|
||||
(error == "AuthenticationRateLimited" || error == "Busy") &&
|
||||
j.contains("retry_after")) {
|
||||
Check(j["retry_after"].is_number_integer() && j["retry_after"] > 0 &&
|
||||
j["retry_after"] <= 900);
|
||||
const auto now = Clock::now();
|
||||
for (auto it = retry_at_.begin(); it != retry_at_.end();)
|
||||
if (it->second <= now)
|
||||
it = retry_at_.erase(it);
|
||||
else
|
||||
++it;
|
||||
if (retry_at_.size() < 64)
|
||||
retry_at_[s->target.address + ":" + std::to_string(s->target.port)] =
|
||||
now + std::chrono::seconds(j["retry_after"].get<int>());
|
||||
}
|
||||
Close(s, error);
|
||||
return;
|
||||
}
|
||||
if (s->phase == Session::Preparing && !s->incoming) {
|
||||
Check(j.at("session_id") == s->session_id &&
|
||||
j.at("seq").is_number_unsigned() &&
|
||||
j.at("seq").get<uint64_t>() ==
|
||||
s->receive_seq + s->pending_receive.size() + 1 &&
|
||||
!j.contains("request_id"));
|
||||
// Identity is already bound by auth_result. Process cancellation without
|
||||
// waiting for Peer preparation, before the following WSS close can erase
|
||||
// its reason; earlier queued SDP/candidates never grant authorization.
|
||||
if (type == "bye" || type == "error") {
|
||||
const auto reason = type == "bye" ? std::string("RemoteClosed")
|
||||
: j.at("error").get<std::string>();
|
||||
Check(!reason.empty() && reason.size() <= 80);
|
||||
Close(s, reason);
|
||||
return;
|
||||
}
|
||||
Check(type == "answer" || type == "candidate");
|
||||
Check(s->pending_receive.size() < 128);
|
||||
s->pending_receive.push_back(text);
|
||||
return;
|
||||
}
|
||||
if (s->phase == Session::AwaitAuth) {
|
||||
Check(type == "auth" && !j.contains("session_id") && !j.contains("seq") &&
|
||||
Id(j.at("request_id")) && j.at("password").is_string() &&
|
||||
!j.contains("share") && !j.contains("proof"));
|
||||
Capable(j);
|
||||
s->auth_request = j.at("request_id");
|
||||
auto& submitted = j["password"].get_ref<std::string&>();
|
||||
if (!ValidPassword(submitted)) {
|
||||
Wipe(submitted);
|
||||
Fail(s, "InvalidCredentials");
|
||||
return;
|
||||
}
|
||||
if (auth_jobs_.size() >= 2) {
|
||||
Wipe(submitted);
|
||||
Fail(s, "Busy", 1);
|
||||
return;
|
||||
}
|
||||
const auto delay =
|
||||
credential_work_limiter_.Admit(s->channel->remote_address);
|
||||
if (delay > 0) {
|
||||
Wipe(submitted);
|
||||
Fail(s, "AuthenticationRateLimited", delay);
|
||||
return;
|
||||
}
|
||||
s->phase = Session::Authenticating;
|
||||
auth_jobs_.push_back(
|
||||
{s,
|
||||
std::async(std::launch::async,
|
||||
[this, password = std::exchange(submitted, {}),
|
||||
record = identity_.verifier(),
|
||||
source = s->channel->remote_address,
|
||||
target = identity_.id()]() mutable {
|
||||
try {
|
||||
auto result = auth_limiter_.Verify(source, target, [&] {
|
||||
return Identity::VerifyPassword(record, password);
|
||||
});
|
||||
Wipe(password);
|
||||
return result;
|
||||
} catch (...) {
|
||||
Wipe(password);
|
||||
throw;
|
||||
}
|
||||
})});
|
||||
return;
|
||||
}
|
||||
if (s->phase == Session::AwaitAuthResult) {
|
||||
Check(type == "auth_result" && j.at("request_id") == s->auth_request &&
|
||||
!j.contains("seq"));
|
||||
if (!j.value("success", false)) {
|
||||
Close(s, "InvalidCredentials");
|
||||
return;
|
||||
}
|
||||
Check(Id(j.at("session_id")) && Id(j.at("host_peer_id")) &&
|
||||
Id(j.at("controller_peer_id")) &&
|
||||
j.at("controller_peer_id") != j.at("host_peer_id"));
|
||||
s->target.device_id = j.at("host_peer_id");
|
||||
s->session_id = j.at("session_id");
|
||||
s->local_id = j.at("controller_peer_id");
|
||||
s->remote_id = j.at("host_peer_id");
|
||||
Prepare(s);
|
||||
return;
|
||||
}
|
||||
Check(s->peer && j.at("session_id") == s->session_id &&
|
||||
j.at("seq").is_number_unsigned() &&
|
||||
j.at("seq").get<uint64_t>() == s->receive_seq + 1 &&
|
||||
!j.contains("request_id"));
|
||||
++s->receive_seq;
|
||||
if (type == "bye" || type == "error") {
|
||||
const auto reason = type == "bye" ? std::string("RemoteClosed")
|
||||
: j.at("error").get<std::string>();
|
||||
Check(!reason.empty() && reason.size() <= 80);
|
||||
Close(s, reason);
|
||||
return;
|
||||
}
|
||||
if (type == "offer") {
|
||||
Check(s->incoming && s->phase == Session::AwaitOffer);
|
||||
s->phase = Session::Negotiating;
|
||||
s->deadline = Clock::now() + std::chrono::seconds(15);
|
||||
} else if (type == "answer") {
|
||||
Check(!s->incoming && s->phase == Session::AwaitAnswer);
|
||||
s->phase = Session::Negotiating;
|
||||
} else
|
||||
Check(type == "candidate" &&
|
||||
(s->phase == Session::AwaitAnswer ||
|
||||
s->phase == Session::Negotiating || s->phase == Session::Connected));
|
||||
if (ReceiveDirectSignal(s->peer, text.data(), text.size()) != 0)
|
||||
Fail(s, "InvalidSignal");
|
||||
}
|
||||
void SessionManager::Dispose(const SessionPtr& s) {
|
||||
if (!s->peer) return;
|
||||
auto* peer = std::exchange(s->peer, nullptr);
|
||||
auto owner = s->callback_owner;
|
||||
auto drain = s->drain_callbacks;
|
||||
cleanup_jobs_.push_back(
|
||||
std::async(std::launch::async, [s, peer, owner, drain]() mutable {
|
||||
if (drain) drain();
|
||||
CloseDirectSession(peer);
|
||||
DestroyPeer(&peer);
|
||||
}));
|
||||
}
|
||||
void SessionManager::Close(const SessionPtr& s, const std::string& error) {
|
||||
if (!s || s->closing.exchange(true)) return;
|
||||
s->authorized = false;
|
||||
if (error.empty() && !s->session_id.empty() &&
|
||||
s->phase >= Session::AwaitOffer)
|
||||
transport_.Send(s->channel, Json{{"version", 2},
|
||||
{"type", "bye"},
|
||||
{"session_id", s->session_id},
|
||||
{"seq", ++s->send_seq}}
|
||||
.dump());
|
||||
s->phase = Session::Closing;
|
||||
s->error = error;
|
||||
Wipe(s->password);
|
||||
transport_.Close(s->channel);
|
||||
hooks_.closed(s);
|
||||
Dispose(s);
|
||||
if (!error.empty() && !s->incoming && !stopping_) hooks_.error(error);
|
||||
}
|
||||
void SessionManager::Stop() {
|
||||
if (stopping_) return;
|
||||
stopping_ = true;
|
||||
for (const auto& [_, listener] : listeners_) transport_.StopAccept(listener.id);
|
||||
listeners_.clear();
|
||||
listener_errors_.clear();
|
||||
listener_warnings_.clear();
|
||||
for (auto& [_, s] : sessions_) Close(s);
|
||||
}
|
||||
bool SessionManager::Quiescent() const {
|
||||
return (!stopping_ || network_drained_) && sessions_.empty() &&
|
||||
auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() &&
|
||||
!reset_job_.valid();
|
||||
}
|
||||
std::string SessionManager::PendingWork() const {
|
||||
return "sessions=" + std::to_string(sessions_.size()) +
|
||||
", auth=" + std::to_string(auth_jobs_.size()) +
|
||||
", preparing=" + std::to_string(prepare_jobs_.size()) +
|
||||
", cleanup=" + std::to_string(cleanup_jobs_.size()) +
|
||||
", reset=" + std::to_string(reset_job_.valid()) +
|
||||
", network_drained=" + std::to_string(network_drained_);
|
||||
}
|
||||
void SessionManager::Tick() {
|
||||
if (Ready(reset_job_)) {
|
||||
try {
|
||||
auto [password, verifier] = reset_job_.get();
|
||||
if (!stopping_) {
|
||||
const bool changed = verifier != identity_.verifier();
|
||||
try {
|
||||
identity_.SetPassword(password, verifier);
|
||||
} catch (...) {
|
||||
Wipe(password);
|
||||
throw;
|
||||
}
|
||||
if (changed) ++epoch_;
|
||||
Wipe(new_password_);
|
||||
Wipe(access_password_);
|
||||
access_password_ = password;
|
||||
new_password_ = std::move(password);
|
||||
for (auto& [_, s] : sessions_)
|
||||
if (s->incoming && !s->authorized && s->epoch != epoch_)
|
||||
Fail(s, "CredentialsChanged");
|
||||
interface_check_ = {}; // Start listening as soon as credentials are ready.
|
||||
} else
|
||||
Wipe(password);
|
||||
} catch (const std::exception& e) {
|
||||
hooks_.error(e.what());
|
||||
}
|
||||
}
|
||||
for (auto it = auth_jobs_.begin(); it != auth_jobs_.end();) {
|
||||
if (!Ready(it->result)) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
auto s = it->session;
|
||||
DeviceAuthLimiter::Result result;
|
||||
bool failed = false;
|
||||
try {
|
||||
result = it->result.get();
|
||||
} catch (...) {
|
||||
failed = true;
|
||||
}
|
||||
it = auth_jobs_.erase(it);
|
||||
if (s->closing || stopping_ || s->epoch != epoch_) continue;
|
||||
if (failed) {
|
||||
Fail(s, "Busy", 1);
|
||||
} else if (result.retry_after > 0) {
|
||||
Fail(s, "AuthenticationRateLimited", result.retry_after);
|
||||
} else if (!result.authenticated) {
|
||||
Fail(s, "InvalidCredentials");
|
||||
} else {
|
||||
try {
|
||||
Prepare(s);
|
||||
} catch (...) {
|
||||
Fail(s, "PeerPreparationFailed");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (auto it = prepare_jobs_.begin(); it != prepare_jobs_.end();) {
|
||||
if (!Ready(it->result)) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
auto s = it->session;
|
||||
try {
|
||||
auto p = it->result.get();
|
||||
s->peer = p.peer;
|
||||
s->callback_owner = std::move(p.callback_owner);
|
||||
s->drain_callbacks = std::move(p.drain_callbacks);
|
||||
} catch (...) {
|
||||
s->failed = true;
|
||||
}
|
||||
it = prepare_jobs_.erase(it);
|
||||
if (s->closing || stopping_ || (s->incoming && s->epoch != epoch_)) {
|
||||
Dispose(s);
|
||||
continue;
|
||||
}
|
||||
if (!s->peer || s->failed) {
|
||||
Fail(s, FailureReason(*s, "PeerPreparationFailed"));
|
||||
continue;
|
||||
}
|
||||
hooks_.attached(s);
|
||||
transport_.Authenticated(s->channel);
|
||||
if (s->incoming) {
|
||||
s->phase = Session::AwaitOffer;
|
||||
s->deadline = Clock::now() + std::chrono::seconds(5);
|
||||
Send(s, {{"type", "auth_result"},
|
||||
{"request_id", s->auth_request},
|
||||
{"success", true},
|
||||
{"session_id", s->session_id},
|
||||
{"host_peer_id", s->local_id},
|
||||
{"controller_peer_id", s->remote_id}});
|
||||
} else {
|
||||
s->phase = Session::AwaitAnswer;
|
||||
s->deadline = Clock::now() + std::chrono::seconds(15);
|
||||
while (!s->pending_receive.empty() && !s->closing) {
|
||||
auto text = std::move(s->pending_receive.front());
|
||||
s->pending_receive.pop_front();
|
||||
try {
|
||||
Message(s, text);
|
||||
} catch (...) {
|
||||
Fail(s, "InvalidSignal");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (auto& event : transport_.Poll()) {
|
||||
auto it = sessions_.find(event.channel->id);
|
||||
if (it == sessions_.end()) {
|
||||
const auto listener = listeners_.find(event.channel->local_address);
|
||||
if (event.kind != Transport::Event::Open || !event.channel->incoming ||
|
||||
stopping_ || listener == listeners_.end() ||
|
||||
event.channel->listener != listener->second.id) {
|
||||
transport_.Close(event.channel);
|
||||
continue;
|
||||
}
|
||||
auto s = std::make_shared<Session>();
|
||||
s->channel = event.channel;
|
||||
s->incoming = true;
|
||||
s->key = "lan/" + RandomHex(16);
|
||||
s->epoch = epoch_;
|
||||
sessions_[event.channel->id] = s;
|
||||
it = sessions_.find(event.channel->id);
|
||||
}
|
||||
auto s = it->second;
|
||||
if (s->closing) continue;
|
||||
if (event.kind == Transport::Event::Open) {
|
||||
s->deadline = Clock::now() + std::chrono::seconds(10);
|
||||
s->phase = s->incoming ? Session::AwaitAuth : Session::AwaitAuthResult;
|
||||
if (!s->incoming) {
|
||||
s->auth_request = RandomHex(16);
|
||||
Json auth = {{"type", "auth"},
|
||||
{"request_id", s->auth_request},
|
||||
{"password", std::exchange(s->password, {})},
|
||||
{"capabilities", Capabilities()}};
|
||||
Send(s, auth);
|
||||
Wipe(auth["password"].get_ref<std::string&>());
|
||||
}
|
||||
} else if (event.kind == Transport::Event::Closed)
|
||||
Close(s, event.payload.empty() ? "ConnectionClosed" : event.payload);
|
||||
else
|
||||
try {
|
||||
Message(s, event.payload);
|
||||
} catch (...) {
|
||||
Fail(s, "InvalidSignal");
|
||||
}
|
||||
Wipe(event.payload);
|
||||
}
|
||||
const auto now = Clock::now();
|
||||
for (auto& [_, s] : sessions_) {
|
||||
if (s->closing) continue;
|
||||
if (s->failed) {
|
||||
Fail(s, FailureReason(*s, "SecureTransportFailed"));
|
||||
continue;
|
||||
}
|
||||
if (s->phase != Session::Connected && now >= s->deadline) {
|
||||
Fail(s, s->phase == Session::Negotiating ? "IceConnectionTimeout"
|
||||
: "Timeout");
|
||||
continue;
|
||||
}
|
||||
std::deque<std::string> signals;
|
||||
{
|
||||
std::lock_guard lock(s->signal_mutex);
|
||||
signals.swap(s->signals);
|
||||
}
|
||||
for (const auto& raw : signals) try {
|
||||
auto j = Json::parse(raw);
|
||||
Check(j.at("transmission_id") == s->session_id &&
|
||||
j.at("user_id") == s->local_id &&
|
||||
j.at("remote_user_id") == s->remote_id);
|
||||
const auto type = j.at("type").get<std::string>();
|
||||
Json out;
|
||||
if (type == "offer" || type == "answer")
|
||||
out = {{"type", type}, {"sdp", j.at("sdp")}};
|
||||
else {
|
||||
Check(type == "new_candidate");
|
||||
out = {{"type", "candidate"}, {"ufrag", j.at("ufrag")}};
|
||||
if (j.at("sdp") == "a=end-of-candidates\r\n")
|
||||
out["end_of_candidates"] = true;
|
||||
else
|
||||
out["candidate"] = j.at("sdp");
|
||||
}
|
||||
if (!Send(s, std::move(out), true)) break;
|
||||
} catch (...) {
|
||||
Fail(s, "InvalidLocalSignal");
|
||||
break;
|
||||
}
|
||||
if (s->ready.exchange(false) && !s->closing && !s->failed) {
|
||||
if (s->phase != Session::Negotiating || stopping_ ||
|
||||
(s->incoming && s->epoch != epoch_)) {
|
||||
Fail(s, "CredentialsChanged");
|
||||
continue;
|
||||
}
|
||||
s->phase = Session::Connected;
|
||||
s->authorized = true;
|
||||
hooks_.connected(s);
|
||||
}
|
||||
}
|
||||
if (!stopping_ && now - interface_check_ >= std::chrono::seconds(2)) {
|
||||
interface_check_ = now;
|
||||
interfaces_ = PhysicalInterfaces();
|
||||
RefreshListeners();
|
||||
for (auto& [_, s] : sessions_)
|
||||
if (s->phase != Session::Connecting &&
|
||||
!s->channel->local_address.empty() &&
|
||||
std::none_of(interfaces_.begin(), interfaces_.end(),
|
||||
[&](const auto& i) {
|
||||
return i.address == s->channel->local_address;
|
||||
}))
|
||||
Close(s, "InterfaceRemoved");
|
||||
}
|
||||
for (auto it = cleanup_jobs_.begin(); it != cleanup_jobs_.end();)
|
||||
if (Ready(*it)) {
|
||||
try {
|
||||
it->get();
|
||||
} catch (...) {
|
||||
}
|
||||
it = cleanup_jobs_.erase(it);
|
||||
} else
|
||||
++it;
|
||||
for (auto it = sessions_.begin(); it != sessions_.end();) {
|
||||
const auto& s = it->second;
|
||||
const bool pending =
|
||||
std::any_of(auth_jobs_.begin(), auth_jobs_.end(),
|
||||
[&](const auto& j) { return j.session == s; }) ||
|
||||
std::any_of(prepare_jobs_.begin(), prepare_jobs_.end(),
|
||||
[&](const auto& j) { return j.session == s; });
|
||||
if (s->closing && !pending) {
|
||||
s->phase = Session::Closed;
|
||||
it = sessions_.erase(it);
|
||||
} else
|
||||
++it;
|
||||
}
|
||||
if (stopping_ && !network_drained_ && sessions_.empty() &&
|
||||
auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() &&
|
||||
!reset_job_.valid()) {
|
||||
transport_.Shutdown();
|
||||
network_drained_ = true;
|
||||
}
|
||||
}
|
||||
} // namespace crossdesk::lan
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-24
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _LAN_SESSION_MANAGER_H_
|
||||
#define _LAN_SESSION_MANAGER_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
#include "lan_auth_limiter.h"
|
||||
#include "lan_transport.h"
|
||||
#include "minirtc.h"
|
||||
|
||||
namespace crossdesk::lan {
|
||||
struct Session {
|
||||
enum Phase {
|
||||
Connecting,
|
||||
AwaitAuth,
|
||||
Authenticating,
|
||||
Preparing,
|
||||
AwaitAuthResult,
|
||||
AwaitOffer,
|
||||
AwaitAnswer,
|
||||
Negotiating,
|
||||
Connected,
|
||||
Closing,
|
||||
Closed
|
||||
} phase = Connecting;
|
||||
Transport::ChannelPtr channel;
|
||||
Target target;
|
||||
std::string key, session_id, local_id, remote_id, auth_request;
|
||||
std::string password, error;
|
||||
bool incoming = false;
|
||||
uint64_t epoch = 0, receive_seq = 0, send_seq = 0;
|
||||
std::chrono::steady_clock::time_point deadline;
|
||||
PeerPtr* peer = nullptr; // Only published/cleared by the manager.
|
||||
std::atomic<bool> closing{false}, authorized{false}, ready{false},
|
||||
failed{false};
|
||||
std::atomic<int> failure_code{-1};
|
||||
std::mutex signal_mutex;
|
||||
size_t signal_count = 0, signal_bytes = 0;
|
||||
std::deque<std::string> signals, pending_receive;
|
||||
std::shared_ptr<void> callback_owner;
|
||||
std::function<void()> drain_callbacks;
|
||||
static int SendSignal(const char* data, size_t size, void* context);
|
||||
static void Error(MiniRtcDirectError error, void* context);
|
||||
void Status(ConnectionStatus status);
|
||||
};
|
||||
|
||||
// All public methods run on the GUI manager thread. Only Session's bounded
|
||||
// callback entry points are callable by RTC workers.
|
||||
class SessionManager {
|
||||
public:
|
||||
using SessionPtr = std::shared_ptr<Session>;
|
||||
struct Prepared {
|
||||
PeerPtr* peer = nullptr;
|
||||
std::shared_ptr<void> callback_owner;
|
||||
std::function<void()> drain_callbacks;
|
||||
};
|
||||
struct Hooks {
|
||||
// Called on the manager to take device snapshots; returned work runs off
|
||||
// UI.
|
||||
std::function<std::function<Prepared()>(const SessionPtr&)> prepare;
|
||||
std::function<void(const SessionPtr&)> attached, connected, closed;
|
||||
std::function<void(const std::string&)> error;
|
||||
};
|
||||
SessionManager(std::filesystem::path directory, Hooks hooks);
|
||||
~SessionManager();
|
||||
Identity& identity() { return identity_; }
|
||||
// Refreshed by Tick; UI and listener maintenance share the same snapshot.
|
||||
const std::vector<Interface>& interfaces() const { return interfaces_; }
|
||||
void Configure(const std::string& address, unsigned port);
|
||||
bool password_pending() const { return reset_job_.valid(); }
|
||||
void ResetPassword();
|
||||
void SetPassword(std::string password);
|
||||
bool listening() const { return !listeners_.empty(); }
|
||||
struct ListenerIssue {
|
||||
std::string address;
|
||||
unsigned port;
|
||||
std::string error;
|
||||
};
|
||||
std::string ListenerError(const std::string& address) const;
|
||||
std::optional<ListenerIssue> TakeListenerWarning();
|
||||
std::string TakeNewPassword();
|
||||
int RetryAfter(const std::string& address, unsigned port) const;
|
||||
SessionPtr Connect(const std::string& address, unsigned port,
|
||||
std::string password);
|
||||
void Close(const SessionPtr& session, const std::string& error = {});
|
||||
void Tick();
|
||||
void Stop();
|
||||
bool Quiescent() const;
|
||||
std::string PendingWork() const;
|
||||
|
||||
private:
|
||||
void Prepare(const SessionPtr& session);
|
||||
void Message(const SessionPtr& session, const std::string& text);
|
||||
bool Send(const SessionPtr& session, nlohmann::json message,
|
||||
bool sequenced = false);
|
||||
void Dispose(const SessionPtr& session);
|
||||
void Fail(const SessionPtr& session, const std::string& error,
|
||||
int retry_after = 0);
|
||||
void RefreshListeners();
|
||||
void ReplaceListener(const std::string& address, unsigned port, bool save,
|
||||
bool allow_fallback = false);
|
||||
void RetireListener(uint64_t id);
|
||||
void SetListenerError(const std::string& address, unsigned port,
|
||||
const std::string& error);
|
||||
Identity identity_;
|
||||
Hooks hooks_;
|
||||
Transport transport_;
|
||||
std::vector<Interface> interfaces_;
|
||||
std::map<uint64_t, SessionPtr> sessions_;
|
||||
struct AuthJob {
|
||||
SessionPtr session;
|
||||
std::future<DeviceAuthLimiter::Result> result;
|
||||
};
|
||||
struct PrepareJob {
|
||||
SessionPtr session;
|
||||
std::future<Prepared> result;
|
||||
};
|
||||
std::vector<AuthJob> auth_jobs_;
|
||||
std::vector<PrepareJob> prepare_jobs_;
|
||||
std::vector<std::future<void>> cleanup_jobs_;
|
||||
DeviceAuthLimiter auth_limiter_;
|
||||
CredentialWorkLimiter credential_work_limiter_;
|
||||
std::map<std::string, std::chrono::steady_clock::time_point> retry_at_;
|
||||
struct Listener {
|
||||
uint64_t id;
|
||||
unsigned port;
|
||||
};
|
||||
std::map<std::string, Listener> listeners_;
|
||||
std::map<std::string, ListenerIssue> listener_errors_, listener_warnings_;
|
||||
uint64_t epoch_ = 1;
|
||||
bool stopping_ = false, network_drained_ = false;
|
||||
std::chrono::steady_clock::time_point interface_check_{};
|
||||
std::future<std::pair<std::string, nlohmann::json>> reset_job_;
|
||||
std::string new_password_, access_password_;
|
||||
};
|
||||
} // namespace crossdesk::lan
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,550 @@
|
||||
#include "lan_transport.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
#include <websocketpp/client.hpp>
|
||||
#include <websocketpp/config/asio.hpp>
|
||||
#include <websocketpp/config/asio_client.hpp>
|
||||
#include <websocketpp/server.hpp>
|
||||
|
||||
namespace crossdesk::lan {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Server = websocketpp::server<websocketpp::config::asio_tls>;
|
||||
using Client = websocketpp::client<websocketpp::config::asio_tls_client>;
|
||||
using Hdl = websocketpp::connection_hdl;
|
||||
using Error = websocketpp::lib::error_code;
|
||||
using Context = websocketpp::lib::asio::ssl::context;
|
||||
constexpr const char* kPath = "/crossdesk/lan/v2";
|
||||
constexpr const char* kProtocol = "crossdesk.lan.v2";
|
||||
void ValidateListener(const std::string& address, unsigned port) {
|
||||
if (port > 65535) throw std::runtime_error("Invalid LAN port");
|
||||
const auto interfaces = PhysicalInterfaces();
|
||||
if (!IsIpv4(address) ||
|
||||
std::none_of(interfaces.begin(), interfaces.end(),
|
||||
[&](const auto& i) { return i.address == address; }))
|
||||
throw std::runtime_error("Selected LAN interface is unavailable");
|
||||
}
|
||||
Error ConfigureAcceptor(asio::ip::tcp::acceptor& acceptor, bool automatic_port) {
|
||||
Error error;
|
||||
#ifdef _WIN32
|
||||
(void)automatic_port;
|
||||
acceptor.set_option(asio::socket_base::reuse_address(false), error);
|
||||
if (error) return error;
|
||||
const BOOL exclusive = TRUE;
|
||||
if (::setsockopt(acceptor.native_handle(), SOL_SOCKET, SO_EXCLUSIVEADDRUSE,
|
||||
reinterpret_cast<const char*>(&exclusive),
|
||||
sizeof(exclusive)) == SOCKET_ERROR)
|
||||
return Error(WSAGetLastError(), asio::error::get_system_category());
|
||||
#else
|
||||
// Fixed ports may rebind after TIME_WAIT. OS-assigned ports need no reuse;
|
||||
// exclusive allocation also avoids wildcard listener overlap on BSD.
|
||||
acceptor.set_option(asio::socket_base::reuse_address(!automatic_port), error);
|
||||
#endif
|
||||
return error;
|
||||
}
|
||||
void CheckListenError(const Error& error) {
|
||||
if (!error) return;
|
||||
if (error == asio::error::address_in_use)
|
||||
throw std::runtime_error("LanPortInUse");
|
||||
if (error == asio::error::access_denied)
|
||||
throw std::runtime_error("LanPortPermissionDenied");
|
||||
throw std::runtime_error("Cannot listen on LAN port: " + error.message());
|
||||
}
|
||||
void CheckExistingListener(const std::string& address, unsigned port) {
|
||||
#ifdef __APPLE__
|
||||
// BSD permits a specific-address listener alongside a wildcard listener with
|
||||
// SO_REUSEADDR. Detect that live listener without treating TIME_WAIT or our
|
||||
// established sessions as occupied ports. No application data is sent.
|
||||
asio::io_context io;
|
||||
asio::ip::tcp::socket socket(io);
|
||||
asio::steady_timer deadline(io, std::chrono::milliseconds(200));
|
||||
Error result;
|
||||
bool timed_out = false;
|
||||
socket.async_connect(
|
||||
{asio::ip::make_address_v4(address), static_cast<unsigned short>(port)},
|
||||
[&](const Error& error) {
|
||||
result = error;
|
||||
deadline.cancel();
|
||||
});
|
||||
deadline.async_wait([&](const Error& error) {
|
||||
if (error) return;
|
||||
timed_out = true;
|
||||
Error ignored;
|
||||
socket.close(ignored);
|
||||
});
|
||||
io.run();
|
||||
if (timed_out) throw std::runtime_error("LanPortCheckFailed");
|
||||
if (!result) throw std::runtime_error("LanPortInUse");
|
||||
if (result != asio::error::connection_refused) CheckListenError(result);
|
||||
#else
|
||||
(void)address;
|
||||
(void)port;
|
||||
#endif
|
||||
}
|
||||
std::shared_ptr<Context> TlsContext() {
|
||||
auto context = std::make_shared<Context>(Context::tls);
|
||||
if (SSL_CTX_set_min_proto_version(context->native_handle(), TLS1_3_VERSION) !=
|
||||
1 ||
|
||||
SSL_CTX_set_max_early_data(context->native_handle(), 0) != 1)
|
||||
throw std::runtime_error("TLS 1.3 is required for LAN connections");
|
||||
SSL_CTX_set_options(context->native_handle(), SSL_OP_NO_COMPRESSION);
|
||||
SSL_CTX_set_session_cache_mode(context->native_handle(), SSL_SESS_CACHE_OFF);
|
||||
return context;
|
||||
}
|
||||
} // namespace
|
||||
struct Transport::Channel::State {
|
||||
std::atomic<bool> closing{false};
|
||||
std::mutex budget_mutex;
|
||||
size_t sent_count = 0, sent_bytes = 0, received_count = 0, received_bytes = 0;
|
||||
bool authenticated = false, opened = false, terminal = false;
|
||||
Clock::time_point created = Clock::now(), last_ping = Clock::now(),
|
||||
ping_sent{}, ping_window = Clock::now();
|
||||
unsigned inbound_pings = 0;
|
||||
std::string pending_ping, error;
|
||||
std::function<void(const std::string&)> send, ping;
|
||||
std::function<void()> close, graceful_close;
|
||||
};
|
||||
struct Transport::Impl {
|
||||
asio::io_context io;
|
||||
asio::executor_work_guard<asio::io_context::executor_type> work{
|
||||
io.get_executor()};
|
||||
asio::steady_timer timer{io};
|
||||
std::thread thread;
|
||||
std::atomic<bool> stopping{false};
|
||||
std::atomic<uint64_t> next_id{1};
|
||||
std::map<uint64_t, std::shared_ptr<Server>> servers;
|
||||
std::map<uint64_t, std::shared_ptr<Client>> clients;
|
||||
std::map<Hdl, ChannelPtr, std::owner_less<Hdl>> channels;
|
||||
size_t preauth = 0;
|
||||
std::set<uint64_t> retired_listeners;
|
||||
std::mutex event_mutex;
|
||||
std::vector<Event> events;
|
||||
Impl() {
|
||||
Tick();
|
||||
thread = std::thread([this] { io.run(); });
|
||||
}
|
||||
bool AdmissionBlocked() {
|
||||
std::lock_guard lock(event_mutex);
|
||||
// Keep closed-but-not-yet-consumed channel events in the admission budget.
|
||||
return events.size() >= 64;
|
||||
}
|
||||
void Emit(Event::Kind kind, const ChannelPtr& c, std::string payload = {}) {
|
||||
std::lock_guard lock(event_mutex);
|
||||
events.push_back({kind, c, std::move(payload)});
|
||||
}
|
||||
void Finish(const ChannelPtr& c) {
|
||||
if (c->state->terminal) return;
|
||||
c->state->closing = true;
|
||||
c->state->terminal = true;
|
||||
if (c->incoming && !c->state->authenticated) --preauth;
|
||||
Emit(Event::Closed, c, c->state->error);
|
||||
}
|
||||
void Abort(const ChannelPtr& c) {
|
||||
if (c->state->terminal) return;
|
||||
c->state->closing = true;
|
||||
if (c->state->close) c->state->close();
|
||||
}
|
||||
void Tick() {
|
||||
timer.expires_after(std::chrono::seconds(1));
|
||||
timer.async_wait([this](const Error& error) {
|
||||
if (error || stopping) return;
|
||||
const auto now = Clock::now();
|
||||
std::vector<ChannelPtr> expired;
|
||||
for (const auto& [_, c] : channels) {
|
||||
auto& s = *c->state;
|
||||
if (s.terminal || s.closing) continue;
|
||||
if ((!s.opened && now - s.created >= std::chrono::seconds(8)) ||
|
||||
(!s.pending_ping.empty() &&
|
||||
now - s.ping_sent >= std::chrono::seconds(15))) {
|
||||
s.error = s.opened ? "HeartbeatTimeout" : "ConnectionTimeout";
|
||||
expired.push_back(c);
|
||||
continue;
|
||||
}
|
||||
if (s.opened && s.pending_ping.empty() &&
|
||||
now - s.last_ping >= std::chrono::seconds(5)) {
|
||||
s.pending_ping = RandomHex(8);
|
||||
s.last_ping = s.ping_sent = now;
|
||||
s.ping(s.pending_ping);
|
||||
}
|
||||
}
|
||||
for (auto& c : expired) Abort(c);
|
||||
for (auto it = retired_listeners.begin();
|
||||
it != retired_listeners.end();) {
|
||||
const auto id = *it;
|
||||
if (std::none_of(channels.begin(), channels.end(),
|
||||
[id](const auto& item) {
|
||||
return item.second->listener == id;
|
||||
})) {
|
||||
servers.erase(id);
|
||||
it = retired_listeners.erase(it);
|
||||
} else
|
||||
++it;
|
||||
}
|
||||
Tick();
|
||||
});
|
||||
}
|
||||
template <class Endpoint>
|
||||
void Attach(Endpoint* endpoint, Hdl hdl, const ChannelPtr& c) {
|
||||
channels[hdl] = c;
|
||||
c->state->send = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)](
|
||||
const std::string& text) {
|
||||
Error e;
|
||||
endpoint->send(hdl, text, websocketpp::frame::opcode::text, e);
|
||||
if (e)
|
||||
if (auto c = cweak.lock()) Abort(c);
|
||||
};
|
||||
c->state->ping = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)](
|
||||
const std::string& nonce) {
|
||||
Error e;
|
||||
endpoint->ping(hdl, nonce, e);
|
||||
if (e)
|
||||
if (auto c = cweak.lock()) Abort(c);
|
||||
};
|
||||
c->state->graceful_close = [this, endpoint, hdl,
|
||||
cweak = std::weak_ptr<Channel>(c)] {
|
||||
Error error;
|
||||
endpoint->close(hdl, websocketpp::close::status::going_away, "", error);
|
||||
if (error)
|
||||
if (auto c = cweak.lock()) Abort(c);
|
||||
};
|
||||
c->state->close = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)] {
|
||||
Error e;
|
||||
auto connection = endpoint->get_con_from_hdl(hdl, e);
|
||||
if (!e && connection) {
|
||||
connection->get_raw_socket().cancel(e);
|
||||
connection->get_raw_socket().shutdown(
|
||||
asio::ip::tcp::socket::shutdown_both, e);
|
||||
connection->get_raw_socket().close(e);
|
||||
}
|
||||
if (auto c = cweak.lock()) Finish(c);
|
||||
};
|
||||
}
|
||||
template <class Endpoint>
|
||||
void Configure(Endpoint* endpoint) {
|
||||
endpoint->clear_access_channels(websocketpp::log::alevel::all);
|
||||
endpoint->clear_error_channels(websocketpp::log::elevel::all);
|
||||
endpoint->init_asio(&io);
|
||||
endpoint->set_max_message_size(256 * 1024);
|
||||
endpoint->set_max_http_body_size(0);
|
||||
endpoint->set_open_handshake_timeout(8000);
|
||||
endpoint->set_close_handshake_timeout(1000);
|
||||
endpoint->set_open_handler([this, endpoint](Hdl hdl) {
|
||||
auto it = channels.find(hdl);
|
||||
if (it == channels.end()) return;
|
||||
auto c = it->second;
|
||||
if (c->state->closing) {
|
||||
Abort(c);
|
||||
return;
|
||||
}
|
||||
Error e;
|
||||
auto connection = endpoint->get_con_from_hdl(hdl, e);
|
||||
if (e) {
|
||||
Abort(c);
|
||||
return;
|
||||
}
|
||||
const auto local = connection->get_raw_socket().local_endpoint(e);
|
||||
if (e) {
|
||||
Abort(c);
|
||||
return;
|
||||
}
|
||||
const auto remote = connection->get_raw_socket().remote_endpoint(e);
|
||||
if (e) {
|
||||
Abort(c);
|
||||
return;
|
||||
}
|
||||
if (!local.address().is_v4() || !remote.address().is_v4()) {
|
||||
Abort(c);
|
||||
return;
|
||||
}
|
||||
c->local_address = local.address().to_string();
|
||||
c->remote_address = remote.address().to_string();
|
||||
const auto interfaces = PhysicalInterfaces();
|
||||
if (!IsIpv4(c->remote_address) ||
|
||||
std::none_of(
|
||||
interfaces.begin(), interfaces.end(),
|
||||
[&](const auto& i) { return i.address == c->local_address; })) {
|
||||
c->state->error = "UnsupportedNetworkPath";
|
||||
Abort(c);
|
||||
return;
|
||||
}
|
||||
const auto protocol =
|
||||
c->incoming
|
||||
? connection->get_subprotocol()
|
||||
: connection->get_response_header("Sec-WebSocket-Protocol");
|
||||
if (protocol != kProtocol) {
|
||||
c->state->error = "IncompatibleProtocol";
|
||||
Abort(c);
|
||||
return;
|
||||
}
|
||||
c->state->opened = true;
|
||||
Emit(Event::Open, c);
|
||||
});
|
||||
auto closed = [this, endpoint](Hdl hdl) {
|
||||
auto it = channels.find(hdl);
|
||||
if (it != channels.end()) {
|
||||
auto& state = *it->second->state;
|
||||
if (state.error.empty() && !state.opened && !state.closing) {
|
||||
Error error;
|
||||
auto connection = endpoint->get_con_from_hdl(hdl, error);
|
||||
state.error =
|
||||
!error && connection
|
||||
? "ConnectionFailed: " + connection->get_ec().message()
|
||||
: "ConnectionFailed";
|
||||
}
|
||||
const auto id = it->second->id;
|
||||
const bool incoming = it->second->incoming;
|
||||
Finish(it->second);
|
||||
channels.erase(it);
|
||||
if (!incoming) asio::post(io, [this, id] { clients.erase(id); });
|
||||
}
|
||||
};
|
||||
endpoint->set_close_handler(closed);
|
||||
endpoint->set_fail_handler(closed);
|
||||
endpoint->set_message_handler(
|
||||
[this](Hdl hdl, typename Endpoint::message_ptr message) {
|
||||
auto it = channels.find(hdl);
|
||||
if (it == channels.end()) return;
|
||||
auto c = it->second;
|
||||
auto& s = *c->state;
|
||||
if (s.closing) return;
|
||||
const auto& text = message->get_payload();
|
||||
if (message->get_opcode() != websocketpp::frame::opcode::text ||
|
||||
text.empty() || text.size() > 256 * 1024 ||
|
||||
++s.received_count > 256 ||
|
||||
(s.received_bytes += text.size()) > 1024 * 1024) {
|
||||
Abort(c);
|
||||
return;
|
||||
}
|
||||
Emit(Event::Message, c, text);
|
||||
});
|
||||
endpoint->set_ping_handler([this](Hdl hdl, std::string nonce) {
|
||||
auto it = channels.find(hdl);
|
||||
if (it == channels.end()) return false;
|
||||
auto c = it->second;
|
||||
auto& s = *c->state;
|
||||
auto now = Clock::now();
|
||||
if (now - s.ping_window >= std::chrono::seconds(5)) {
|
||||
s.ping_window = now;
|
||||
s.inbound_pings = 0;
|
||||
}
|
||||
if (s.closing || nonce.size() != 16 || ++s.inbound_pings > 2) {
|
||||
Abort(c);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
endpoint->set_pong_handler([this](Hdl hdl, std::string nonce) {
|
||||
auto it = channels.find(hdl);
|
||||
if (it == channels.end()) return;
|
||||
auto& s = *it->second->state;
|
||||
if (!s.pending_ping.empty() && nonce == s.pending_ping)
|
||||
s.pending_ping.clear();
|
||||
});
|
||||
}
|
||||
};
|
||||
Transport::Transport() : impl_(std::make_unique<Impl>()) {}
|
||||
Transport::~Transport() { Shutdown(); }
|
||||
Transport::Listener Transport::Listen(const Identity& identity,
|
||||
const std::string& address,
|
||||
unsigned port) {
|
||||
ValidateListener(address, port);
|
||||
auto context = TlsContext();
|
||||
context->use_certificate_chain_file(identity.certificate().string());
|
||||
context->use_private_key_file(identity.private_key().string(), Context::pem);
|
||||
auto promise = std::make_shared<std::promise<Listener>>();
|
||||
auto future = promise->get_future();
|
||||
asio::post(impl_->io, [this, address, port, context, promise] {
|
||||
try {
|
||||
if (port) CheckExistingListener(address, port);
|
||||
auto server = std::make_shared<Server>();
|
||||
impl_->Configure(server.get());
|
||||
const uint64_t id = impl_->next_id++;
|
||||
server->set_tls_init_handler([context](Hdl) { return context; });
|
||||
server->set_tcp_pre_init_handler(
|
||||
[this, endpoint = server.get(), id](Hdl hdl) {
|
||||
Error error;
|
||||
auto connection = endpoint->get_con_from_hdl(hdl, error);
|
||||
if (error) return;
|
||||
if (impl_->stopping || impl_->retired_listeners.count(id) ||
|
||||
impl_->preauth >= 16 || impl_->AdmissionBlocked() ||
|
||||
impl_->channels.size() >= 24) {
|
||||
connection->get_raw_socket().close(error);
|
||||
return;
|
||||
}
|
||||
auto c = std::make_shared<Channel>();
|
||||
c->id = impl_->next_id++;
|
||||
c->listener = id;
|
||||
c->incoming = true;
|
||||
c->state = std::make_shared<Channel::State>();
|
||||
++impl_->preauth;
|
||||
impl_->Attach(endpoint, hdl, c);
|
||||
});
|
||||
server->set_validate_handler([endpoint = server.get()](Hdl hdl) {
|
||||
auto c = endpoint->get_con_from_hdl(hdl);
|
||||
if (c->get_resource() != kPath || !c->get_request_body().empty() ||
|
||||
!c->get_request_header("Transfer-Encoding").empty() ||
|
||||
(!c->get_request_header("Content-Length").empty() &&
|
||||
c->get_request_header("Content-Length") != "0"))
|
||||
return false;
|
||||
const auto& protocols = c->get_requested_subprotocols();
|
||||
if (protocols.size() != 1 || protocols[0] != kProtocol) return false;
|
||||
c->select_subprotocol(kProtocol);
|
||||
return true;
|
||||
});
|
||||
auto bound_acceptor = std::make_shared<Server::acceptor_ptr>();
|
||||
server->set_tcp_pre_bind_handler(
|
||||
[port, bound_acceptor](Server::acceptor_ptr acceptor) {
|
||||
*bound_acceptor = acceptor;
|
||||
return ConfigureAcceptor(*acceptor, port == 0);
|
||||
});
|
||||
Error listen_error;
|
||||
server->listen(
|
||||
asio::ip::tcp::endpoint(asio::ip::make_address_v4(address),
|
||||
static_cast<unsigned short>(port)),
|
||||
listen_error);
|
||||
CheckListenError(listen_error);
|
||||
const auto actual_port = server->get_local_endpoint(listen_error).port();
|
||||
CheckListenError(listen_error);
|
||||
#ifndef _WIN32
|
||||
if (!port) {
|
||||
// Allocation is already exclusive. Enable TIME_WAIT reuse before any
|
||||
// accepted socket inherits options so the saved port can restart too.
|
||||
(*bound_acceptor)->set_option(asio::socket_base::reuse_address(true),
|
||||
listen_error);
|
||||
CheckListenError(listen_error);
|
||||
}
|
||||
#endif
|
||||
server->set_tcp_pre_bind_handler({});
|
||||
server->start_accept();
|
||||
impl_->servers[id] = server;
|
||||
promise->set_value({id, actual_port});
|
||||
} catch (...) {
|
||||
promise->set_exception(std::current_exception());
|
||||
}
|
||||
});
|
||||
return future.get();
|
||||
}
|
||||
void Transport::StopAccept(uint64_t listener) {
|
||||
asio::post(impl_->io, [this, listener] {
|
||||
auto it = impl_->servers.find(listener);
|
||||
if (it != impl_->servers.end()) {
|
||||
Error e;
|
||||
it->second->stop_listening(e);
|
||||
impl_->retired_listeners.insert(listener);
|
||||
for (auto& [_, c] : impl_->channels)
|
||||
if (c->listener == listener && !c->state->authenticated) {
|
||||
c->state->error = "ListenerChanged";
|
||||
impl_->Abort(c);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Transport::ChannelPtr Transport::Connect(const Target& target) {
|
||||
if (!IsIpv4(target.address) || target.port == 0 || target.port > 65535)
|
||||
throw std::runtime_error("Invalid LAN endpoint");
|
||||
auto c = std::make_shared<Channel>();
|
||||
c->id = impl_->next_id++;
|
||||
c->state = std::make_shared<Channel::State>();
|
||||
asio::post(impl_->io, [this, c, target] {
|
||||
try {
|
||||
if (impl_->stopping || impl_->channels.size() >= 24 ||
|
||||
impl_->AdmissionBlocked()) {
|
||||
impl_->Finish(c);
|
||||
return;
|
||||
}
|
||||
auto client = std::make_shared<Client>();
|
||||
impl_->Configure(client.get());
|
||||
auto context = TlsContext();
|
||||
context->set_verify_mode(asio::ssl::verify_peer);
|
||||
context->set_verify_callback(
|
||||
[c](bool, asio::ssl::verify_context& verification) {
|
||||
X509_STORE_CTX* store = verification.native_handle();
|
||||
auto* certificate = X509_STORE_CTX_get_current_cert(store);
|
||||
// Self-signed LAN certificates establish the encrypted channel.
|
||||
// Password verification authorizes access at the receiving host;
|
||||
// it does not authenticate this certificate's device identity.
|
||||
const bool valid = X509_STORE_CTX_get_error_depth(store) == 0 &&
|
||||
VerifyLanCertificate(certificate);
|
||||
if (!valid) c->state->error = "InvalidCertificate";
|
||||
return valid;
|
||||
});
|
||||
client->set_tls_init_handler([context](Hdl) { return context; });
|
||||
Error error;
|
||||
auto connection = client->get_connection(
|
||||
"wss://" + target.address + ":" + std::to_string(target.port) + kPath,
|
||||
error);
|
||||
if (error) {
|
||||
impl_->Finish(c);
|
||||
return;
|
||||
}
|
||||
connection->add_subprotocol(kProtocol);
|
||||
impl_->Attach(client.get(), connection->get_handle(), c);
|
||||
impl_->clients[c->id] = client;
|
||||
client->connect(connection);
|
||||
} catch (...) {
|
||||
impl_->Abort(c);
|
||||
impl_->Finish(c);
|
||||
}
|
||||
});
|
||||
return c;
|
||||
}
|
||||
bool Transport::Send(const ChannelPtr& c, std::string message) {
|
||||
if (!c || impl_->stopping || c->state->closing || message.empty() ||
|
||||
message.size() > 256 * 1024)
|
||||
return false;
|
||||
{
|
||||
std::lock_guard lock(c->state->budget_mutex);
|
||||
if (c->state->sent_count >= 256 ||
|
||||
c->state->sent_bytes + message.size() > 1024 * 1024)
|
||||
return false;
|
||||
++c->state->sent_count;
|
||||
c->state->sent_bytes += message.size();
|
||||
}
|
||||
asio::post(impl_->io, [c, message = std::move(message)] {
|
||||
if (!c->state->terminal && c->state->send) c->state->send(message);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
void Transport::Authenticated(const ChannelPtr& c) {
|
||||
asio::post(impl_->io, [this, c] {
|
||||
if (c->incoming && !c->state->terminal && !c->state->authenticated) {
|
||||
c->state->authenticated = true;
|
||||
--impl_->preauth;
|
||||
}
|
||||
});
|
||||
}
|
||||
void Transport::Close(const ChannelPtr& c) {
|
||||
if (!c) return;
|
||||
c->state->closing = true;
|
||||
asio::post(impl_->io, [this, c] {
|
||||
if (c->state->opened && !c->state->terminal && c->state->graceful_close)
|
||||
c->state->graceful_close();
|
||||
else
|
||||
impl_->Abort(c);
|
||||
});
|
||||
}
|
||||
std::vector<Transport::Event> Transport::Poll() {
|
||||
std::lock_guard lock(impl_->event_mutex);
|
||||
std::vector<Event> result;
|
||||
result.swap(impl_->events);
|
||||
return result;
|
||||
}
|
||||
void Transport::Shutdown() {
|
||||
if (!impl_ || impl_->stopping.exchange(true)) return;
|
||||
asio::post(impl_->io, [this] {
|
||||
Error e;
|
||||
impl_->timer.cancel();
|
||||
for (auto& [_, server] : impl_->servers) server->stop_listening(e);
|
||||
for (auto& [_, c] : impl_->channels) impl_->Abort(c);
|
||||
impl_->work.reset();
|
||||
});
|
||||
if (impl_->thread.joinable()) impl_->thread.join();
|
||||
}
|
||||
} // namespace crossdesk::lan
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-24
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _LAN_TRANSPORT_H_
|
||||
#define _LAN_TRANSPORT_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "lan_identity.h"
|
||||
|
||||
namespace crossdesk::lan {
|
||||
// One network thread; callbacks enqueue bounded value events for the UI-owned
|
||||
// session manager. No network callback calls MiniRTC or destroys a Peer.
|
||||
class Transport {
|
||||
public:
|
||||
struct Channel;
|
||||
using ChannelPtr = std::shared_ptr<Channel>;
|
||||
struct Event {
|
||||
enum Kind { Open, Message, Closed } kind;
|
||||
ChannelPtr channel;
|
||||
std::string payload;
|
||||
};
|
||||
Transport();
|
||||
~Transport();
|
||||
Transport(const Transport&) = delete;
|
||||
Transport& operator=(const Transport&) = delete;
|
||||
struct Listener {
|
||||
uint64_t id;
|
||||
unsigned port;
|
||||
};
|
||||
// Port zero lets the OS allocate and hold a free port on this address.
|
||||
Listener Listen(const Identity& identity, const std::string& address,
|
||||
unsigned port);
|
||||
void StopAccept(uint64_t listener);
|
||||
ChannelPtr Connect(const Target& target);
|
||||
bool Send(const ChannelPtr& channel, std::string message);
|
||||
void Authenticated(const ChannelPtr& channel);
|
||||
void Close(const ChannelPtr& channel);
|
||||
std::vector<Event> Poll();
|
||||
void Shutdown();
|
||||
struct Channel {
|
||||
uint64_t id = 0, listener = 0;
|
||||
bool incoming = false;
|
||||
// Published by Open, immutable thereafter.
|
||||
std::string local_address, remote_address;
|
||||
|
||||
private:
|
||||
friend class Transport;
|
||||
struct State;
|
||||
std::shared_ptr<State> state;
|
||||
};
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
} // namespace crossdesk::lan
|
||||
|
||||
#endif
|
||||
@@ -9,12 +9,8 @@
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
void ConfigureLinuxCudaGraphics(bool hardware_acceleration) {
|
||||
void ConfigureLinuxCudaGraphics() {
|
||||
#if USE_CUDA && (defined(__x86_64__) || defined(__amd64__))
|
||||
if (!hardware_acceleration) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect explicit GPU/driver selection, including a request to use Mesa
|
||||
// or software rendering. Native frames can still use the CPU fallback.
|
||||
for (const char* name : {"__NV_PRIME_RENDER_OFFLOAD",
|
||||
@@ -45,8 +41,6 @@ void ConfigureLinuxCudaGraphics(bool hardware_acceleration) {
|
||||
return;
|
||||
}
|
||||
LOG_INFO("Selected NVIDIA PRIME rendering for CUDA/OpenGL video interop");
|
||||
#else
|
||||
(void)hardware_acceleration;
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace crossdesk {
|
||||
|
||||
// Call before SDL or Slint initializes EGL/GLX. Hybrid laptops otherwise
|
||||
// create the GUI context on the integrated GPU while NVDEC uses NVIDIA.
|
||||
void ConfigureLinuxCudaGraphics(bool hardware_acceleration);
|
||||
void ConfigureLinuxCudaGraphics();
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
|
||||
@@ -35,12 +35,12 @@ std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config) {
|
||||
return {
|
||||
{"language", "console_setting_language", languages[language], "zh-CN / en-US / ru-RU"},
|
||||
{"codec", "console_setting_codec", config.GetVideoEncodeFormat() == ConfigCenter::VIDEO_ENCODE_FORMAT::AV1 ? "av1" : "h264", "h264 / av1"},
|
||||
{"hardware", "console_setting_hardware", OnOff(config.IsHardwareVideoCodec()), "on / off"},
|
||||
{"turn", "console_setting_turn", turn_modes[turn], "off / auto / udp / tcp"},
|
||||
{"self_hosted", "console_setting_self_hosted", OnOff(config.IsSelfHosted()), "on / off"},
|
||||
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
|
||||
{"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"},
|
||||
{"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"},
|
||||
{"lock_on_disconnect", "lock_on_disconnect", OnOff(config.IsLockOnDisconnect()), "on / off"},
|
||||
{"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"},
|
||||
{"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"},
|
||||
{"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "on / off"},
|
||||
@@ -54,7 +54,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
||||
result.message = "console_settings_readonly";
|
||||
return result;
|
||||
}
|
||||
const bool network = key == "codec" || key == "hardware" || key == "turn" ||
|
||||
const bool network = key == "codec" || key == "turn" ||
|
||||
key == "self_hosted" || key == "server_host" || key == "server_port";
|
||||
if (network && connection_busy) {
|
||||
result.message = "console_settings_busy";
|
||||
@@ -111,13 +111,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
||||
if (!boolean) return result;
|
||||
const bool enabled = *boolean;
|
||||
canonical = OnOff(enabled);
|
||||
if (key == "hardware") {
|
||||
if (enabled && !ConfigCenter::IsHardwareVideoCodecAvailable()) {
|
||||
result.message = "console_settings_hardware_unavailable";
|
||||
return result;
|
||||
}
|
||||
save = [&config, enabled] { return config.SetHardwareVideoCodec(enabled); };
|
||||
} else if (key == "self_hosted") {
|
||||
if (key == "self_hosted") {
|
||||
if (enabled && (config.GetSignalServerHost().empty() || config.GetSignalServerPort() <= 0 || config.GetSignalServerPort() > 65535)) {
|
||||
result.message = "console_settings_server_required";
|
||||
return result;
|
||||
@@ -127,6 +121,8 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
||||
save = [&config, enabled] { return config.SetPrivacyScreen(enabled); };
|
||||
effect = SettingEffect::next_connection;
|
||||
message = "console_settings_next_connection";
|
||||
} else if (key == "lock_on_disconnect") {
|
||||
save = [&config, enabled] { return config.SetLockOnDisconnect(enabled); };
|
||||
} else if (key == "autostart") {
|
||||
save = [&config, enabled] { return config.SetAutostart(enabled); };
|
||||
effect = SettingEffect::next_login;
|
||||
@@ -138,7 +134,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
||||
} else return result;
|
||||
}
|
||||
for (const auto& setting : GetConsoleSettings(config)) {
|
||||
if (key != "autostart" && key != "hardware" && setting.key == key && setting.value == canonical) {
|
||||
if (key != "autostart" && setting.key == key && setting.value == canonical) {
|
||||
return {true, SettingEffect::none, "console_settings_unchanged"};
|
||||
}
|
||||
}
|
||||
@@ -148,7 +144,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
||||
if (previous_server != GetSignalServerSettings(config)) {
|
||||
effect = SettingEffect::reconnect;
|
||||
message = "console_settings_reconnect";
|
||||
} else if (key == "codec" || key == "hardware" || key == "turn") {
|
||||
} else if (key == "codec" || key == "turn") {
|
||||
effect = SettingEffect::next_connection;
|
||||
message = "console_settings_next_connection";
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ void PlatformMouseController::UpdateDisplayInfoList(
|
||||
}
|
||||
|
||||
int PlatformMouseController::Destroy() {
|
||||
ReleasePressedButtons();
|
||||
CleanupWaylandPortal();
|
||||
|
||||
if (display_) {
|
||||
@@ -77,6 +78,28 @@ int PlatformMouseController::Destroy() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
unsigned PlatformMouseController::ReleasePressedButtons(unsigned mask) {
|
||||
mask &= 7;
|
||||
const unsigned pressed = pressed_buttons_ & mask;
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
const unsigned bit = 1u << i;
|
||||
if (!(pressed & bit)) continue;
|
||||
bool released = false;
|
||||
if (use_wayland_portal_) {
|
||||
RemoteAction up{};
|
||||
up.type = ControlType::mouse;
|
||||
up.m.flag = static_cast<MouseFlag>(2 + i * 2);
|
||||
released = SendWaylandMouseCommand(up, 0) == 0;
|
||||
} else if (display_) {
|
||||
const unsigned buttons[] = {1, 3, 2};
|
||||
released = XTestFakeButtonEvent(display_, buttons[i], False, CurrentTime) != 0;
|
||||
XFlush(display_);
|
||||
}
|
||||
if (released) pressed_buttons_ &= ~bit;
|
||||
}
|
||||
return mask & ~pressed_buttons_;
|
||||
}
|
||||
|
||||
int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
int display_index) {
|
||||
if (remote_action.type != ControlType::mouse) {
|
||||
@@ -84,7 +107,16 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
}
|
||||
|
||||
if (use_wayland_portal_) {
|
||||
return SendWaylandMouseCommand(remote_action, display_index);
|
||||
const int ret = SendWaylandMouseCommand(remote_action, display_index);
|
||||
if (ret == 0) {
|
||||
const int flag = static_cast<int>(remote_action.m.flag);
|
||||
if (flag >= 1 && flag <= 6) {
|
||||
const unsigned bit = 1u << ((flag - 1) / 2);
|
||||
if (flag & 1) pressed_buttons_ |= bit;
|
||||
else pressed_buttons_ &= ~bit;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (!display_) {
|
||||
@@ -112,27 +144,27 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
break;
|
||||
}
|
||||
case MouseFlag::left_down:
|
||||
XTestFakeButtonEvent(display_, 1, True, CurrentTime);
|
||||
if (!XTestFakeButtonEvent(display_, 1, True, CurrentTime)) return -1;
|
||||
XFlush(display_);
|
||||
break;
|
||||
case MouseFlag::left_up:
|
||||
XTestFakeButtonEvent(display_, 1, False, CurrentTime);
|
||||
if (!XTestFakeButtonEvent(display_, 1, False, CurrentTime)) return -1;
|
||||
XFlush(display_);
|
||||
break;
|
||||
case MouseFlag::right_down:
|
||||
XTestFakeButtonEvent(display_, 3, True, CurrentTime);
|
||||
if (!XTestFakeButtonEvent(display_, 3, True, CurrentTime)) return -1;
|
||||
XFlush(display_);
|
||||
break;
|
||||
case MouseFlag::right_up:
|
||||
XTestFakeButtonEvent(display_, 3, False, CurrentTime);
|
||||
if (!XTestFakeButtonEvent(display_, 3, False, CurrentTime)) return -1;
|
||||
XFlush(display_);
|
||||
break;
|
||||
case MouseFlag::middle_down:
|
||||
XTestFakeButtonEvent(display_, 2, True, CurrentTime);
|
||||
if (!XTestFakeButtonEvent(display_, 2, True, CurrentTime)) return -1;
|
||||
XFlush(display_);
|
||||
break;
|
||||
case MouseFlag::middle_up:
|
||||
XTestFakeButtonEvent(display_, 2, False, CurrentTime);
|
||||
if (!XTestFakeButtonEvent(display_, 2, False, CurrentTime)) return -1;
|
||||
XFlush(display_);
|
||||
break;
|
||||
case MouseFlag::wheel_vertical: {
|
||||
@@ -157,6 +189,12 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
break;
|
||||
}
|
||||
|
||||
const int flag = static_cast<int>(remote_action.m.flag);
|
||||
if (flag >= 1 && flag <= 6) {
|
||||
const unsigned bit = 1u << ((flag - 1) / 2);
|
||||
if (flag & 1) pressed_buttons_ |= bit;
|
||||
else pressed_buttons_ &= ~bit;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ class PlatformMouseController final : public MouseController {
|
||||
virtual int Init(std::vector<DisplayInfo> display_info_list);
|
||||
virtual int Destroy();
|
||||
virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
|
||||
unsigned ReleasePressedButtons(unsigned mask = 7) override;
|
||||
void UpdateDisplayInfoList(const std::vector<DisplayInfo>& display_info_list);
|
||||
|
||||
private:
|
||||
@@ -58,6 +59,7 @@ class PlatformMouseController final : public MouseController {
|
||||
int screen_width_ = 0;
|
||||
int screen_height_ = 0;
|
||||
bool use_wayland_portal_ = false;
|
||||
unsigned pressed_buttons_ = 0;
|
||||
|
||||
DBusConnection* dbus_connection_ = nullptr;
|
||||
std::string wayland_session_handle_;
|
||||
|
||||
@@ -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
|
||||
@@ -46,7 +46,37 @@ int PlatformMouseController::Init(std::vector<DisplayInfo> display_info_list) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int PlatformMouseController::Destroy() { return 0; }
|
||||
int PlatformMouseController::Destroy() {
|
||||
return ReleasePressedButtons() == 7 ? 0 : -1;
|
||||
}
|
||||
|
||||
unsigned PlatformMouseController::ReleasePressedButtons(unsigned mask) {
|
||||
mask &= 7;
|
||||
const unsigned pressed = pressed_buttons_ & mask;
|
||||
if (!pressed) return mask;
|
||||
const auto cursor = CGEventCreate(nullptr);
|
||||
if (!cursor) return mask & ~pressed;
|
||||
const auto point = CGEventGetLocation(cursor);
|
||||
CFRelease(cursor);
|
||||
const CGEventType types[] = {kCGEventLeftMouseUp, kCGEventRightMouseUp,
|
||||
kCGEventOtherMouseUp};
|
||||
const CGMouseButton buttons[] = {kCGMouseButtonLeft, kCGMouseButtonRight,
|
||||
kCGMouseButtonCenter};
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
const unsigned bit = 1u << i;
|
||||
if (!(pressed & bit)) continue;
|
||||
const auto event = CGEventCreateMouseEvent(nullptr, types[i], point, buttons[i]);
|
||||
if (!event) continue;
|
||||
CGEventSetIntegerValueField(event, kCGEventSourceUserData, kCrossDeskInjectedEvent);
|
||||
CGEventPost(kCGHIDEventTap, event);
|
||||
CFRelease(event);
|
||||
pressed_buttons_ &= ~bit;
|
||||
if (i == 0) { left_dragging_ = false; left_click_tracker_ = {}; }
|
||||
if (i == 1) { right_dragging_ = false; right_click_tracker_ = {}; }
|
||||
if (i == 2) middle_click_tracker_ = {};
|
||||
}
|
||||
return mask & ~pressed_buttons_;
|
||||
}
|
||||
|
||||
void PlatformMouseController::UpdateDisplayInfoList(
|
||||
const std::vector<DisplayInfo>& display_info_list) {
|
||||
@@ -135,7 +165,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
switch (remote_action.m.flag) {
|
||||
case MouseFlag::left_down:
|
||||
mouse_type = kCGEventLeftMouseDown;
|
||||
left_dragging_ = true;
|
||||
click_state = BeginClick(left_click_tracker_, tracked_mouse_pos_x,
|
||||
tracked_mouse_pos_y);
|
||||
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
|
||||
@@ -144,7 +173,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
break;
|
||||
case MouseFlag::left_up:
|
||||
mouse_type = kCGEventLeftMouseUp;
|
||||
left_dragging_ = false;
|
||||
click_state = EndClick(left_click_tracker_, tracked_mouse_pos_x,
|
||||
tracked_mouse_pos_y);
|
||||
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
|
||||
@@ -153,7 +181,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
break;
|
||||
case MouseFlag::right_down:
|
||||
mouse_type = kCGEventRightMouseDown;
|
||||
right_dragging_ = true;
|
||||
click_state = BeginClick(right_click_tracker_, tracked_mouse_pos_x,
|
||||
tracked_mouse_pos_y);
|
||||
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
|
||||
@@ -162,7 +189,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
break;
|
||||
case MouseFlag::right_up:
|
||||
mouse_type = kCGEventRightMouseUp;
|
||||
right_dragging_ = false;
|
||||
click_state = EndClick(right_click_tracker_, tracked_mouse_pos_x,
|
||||
tracked_mouse_pos_y);
|
||||
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
|
||||
@@ -215,6 +241,16 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
kCrossDeskInjectedEvent);
|
||||
CGEventPost(kCGHIDEventTap, mouse_event);
|
||||
CFRelease(mouse_event);
|
||||
const int flag = static_cast<int>(remote_action.m.flag);
|
||||
if (flag >= 1 && flag <= 6) {
|
||||
const unsigned bit = 1u << ((flag - 1) / 2);
|
||||
if (flag & 1) pressed_buttons_ |= bit;
|
||||
else pressed_buttons_ &= ~bit;
|
||||
left_dragging_ = (pressed_buttons_ & 1) != 0;
|
||||
right_dragging_ = (pressed_buttons_ & 2) != 0;
|
||||
}
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -26,6 +26,7 @@ class PlatformMouseController final : public MouseController {
|
||||
virtual int Init(std::vector<DisplayInfo> display_info_list);
|
||||
virtual int Destroy();
|
||||
virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
|
||||
unsigned ReleasePressedButtons(unsigned mask = 7) override;
|
||||
void UpdateDisplayInfoList(
|
||||
const std::vector<DisplayInfo>& display_info_list);
|
||||
|
||||
@@ -45,6 +46,7 @@ class PlatformMouseController final : public MouseController {
|
||||
std::vector<DisplayInfo> display_info_list_;
|
||||
bool left_dragging_ = false;
|
||||
bool right_dragging_ = false;
|
||||
unsigned pressed_buttons_ = 0;
|
||||
ClickTracker left_click_tracker_;
|
||||
ClickTracker right_click_tracker_;
|
||||
ClickTracker middle_click_tracker_;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include "screen_lock.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
bool LockScreen() {
|
||||
// macOS has no public immediate-lock API. Resolve login.framework at
|
||||
// runtime so an OS without this private entry point fails gracefully.
|
||||
static void* framework = dlopen(
|
||||
"/System/Library/PrivateFrameworks/login.framework/login",
|
||||
RTLD_LAZY | RTLD_LOCAL);
|
||||
using LockScreenFunction = int (*)();
|
||||
static const auto lock = framework
|
||||
? reinterpret_cast<LockScreenFunction>(
|
||||
dlsym(framework, "SACLockScreenImmediate"))
|
||||
: nullptr;
|
||||
if (!lock) {
|
||||
LOG_WARN("macOS screen lock API is unavailable");
|
||||
return false;
|
||||
}
|
||||
const int result = lock();
|
||||
if (result != 0) LOG_WARN("macOS screen lock request failed: {}", result);
|
||||
return result == 0;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -45,6 +45,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
|
||||
if (CreateConnectionPeer() != 0) CloseConnectionPeer();
|
||||
}
|
||||
HandleConnectionStatusChange();
|
||||
HandleScreenLock();
|
||||
HandleWindowsServiceIntegration();
|
||||
devices_.UpdateInteractions();
|
||||
ShareLocalCursorState();
|
||||
@@ -64,12 +65,14 @@ int GuiApplication::RunUnattended(void* stop_event) {
|
||||
}
|
||||
|
||||
// Quiesce network callbacks before destroying the controllers they use.
|
||||
remote_input_.Stop();
|
||||
CloseAllRemoteSessions();
|
||||
WaitForSessionCleanup();
|
||||
keyboard_.ReleaseAllRemotePressedKeys("unattended_host_exit");
|
||||
devices_.DestroyDevices();
|
||||
devices_.DestroyFactories();
|
||||
privacy_.Shutdown();
|
||||
HandleScreenLock();
|
||||
WriteUnattendedStatus(settings_.ActiveIdentity(), false, session);
|
||||
SDL_Quit();
|
||||
return 0;
|
||||
|
||||
@@ -197,8 +197,9 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
|
||||
optimistic.available = local_service_available_;
|
||||
optimistic.interactive_stage = local_interactive_stage_;
|
||||
const auto message = BuildWindowsServiceStatusAction(optimistic).to_json();
|
||||
SendReliableDataFrame(peer_, message.data(), message.size(),
|
||||
control_data_label_.c_str());
|
||||
for (const auto& id : ConnectedControllers())
|
||||
SendControllerData(id, message.data(), message.size(),
|
||||
control_data_label_.c_str());
|
||||
}
|
||||
result.reset();
|
||||
}
|
||||
@@ -293,10 +294,11 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
|
||||
RemoteAction remote_action =
|
||||
BuildWindowsServiceStatusAction(broadcast_status);
|
||||
std::string msg = remote_action.to_json();
|
||||
int ret = SendReliableDataFrame(peer_, msg.data(), msg.size(),
|
||||
control_data_label_.c_str());
|
||||
if (ret != 0) {
|
||||
LOG_WARN("Broadcast Windows service status failed, ret={}", ret);
|
||||
for (const auto& id : ConnectedControllers()) {
|
||||
const int ret = SendControllerData(id, msg.data(), msg.size(),
|
||||
control_data_label_.c_str());
|
||||
if (ret != 0)
|
||||
LOG_WARN("Windows service status send failed, peer={}, ret={}", id, ret);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#include "shutdown_session_guard.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
constexpr wchar_t kShutdownWindowClass[] = L"CrossDesk.ShutdownSessionGuard";
|
||||
}
|
||||
|
||||
ShutdownSessionGuard::ShutdownSessionGuard(
|
||||
std::shared_ptr<std::atomic<bool>> connected)
|
||||
: connected_(std::move(connected)) {}
|
||||
|
||||
ShutdownSessionGuard::~ShutdownSessionGuard() {
|
||||
ClearReason();
|
||||
if (window_) DestroyWindow(window_);
|
||||
}
|
||||
|
||||
bool ShutdownSessionGuard::ReportError(const char* operation) {
|
||||
const DWORD error = GetLastError();
|
||||
const ULONGLONG now = GetTickCount64();
|
||||
if (last_error_tick_ == 0 || now - last_error_tick_ >= 5000) {
|
||||
LOG_WARN("Windows shutdown session guard {} failed, error={}", operation, error);
|
||||
last_error_tick_ = now;
|
||||
}
|
||||
SetLastError(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ShutdownSessionGuard::Start() {
|
||||
if (window_) return true;
|
||||
WNDCLASSW klass{};
|
||||
klass.lpfnWndProc = WindowProc;
|
||||
klass.hInstance = GetModuleHandleW(nullptr);
|
||||
klass.lpszClassName = kShutdownWindowClass;
|
||||
if (!RegisterClassW(&klass) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS)
|
||||
return ReportError("register window");
|
||||
// A top-level window receives session-end broadcasts even when the Slint
|
||||
// windows are hidden in the tray. HWND_MESSAGE does not receive them.
|
||||
window_ = CreateWindowExW(WS_EX_TOOLWINDOW, kShutdownWindowClass, L"CrossDesk",
|
||||
WS_OVERLAPPED, 0, 0, 0, 0, nullptr, nullptr, klass.hInstance, this);
|
||||
if (!window_) return ReportError("create window");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ShutdownSessionGuard::ClearReason() {
|
||||
if (!reason_registered_) return true;
|
||||
if (window_ && !ShutdownBlockReasonDestroy(window_))
|
||||
return ReportError("clear reason");
|
||||
reason_registered_ = false;
|
||||
LOG_INFO("Windows shutdown session guard released");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ShutdownSessionGuard::SyncReason() {
|
||||
if (shutdown_allowed_ || !connected_ ||
|
||||
!connected_->load(std::memory_order_relaxed)) {
|
||||
return ClearReason();
|
||||
}
|
||||
if (reason_registered_) return true;
|
||||
if (!ShutdownBlockReasonCreate(window_, reason_.c_str()))
|
||||
return ReportError("register reason");
|
||||
reason_registered_ = true;
|
||||
LOG_INFO("Windows shutdown session guard armed for connected controller");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ShutdownSessionGuard::Update(std::wstring_view reason) {
|
||||
if (!Start()) return false;
|
||||
if (std::wstring_view(reason_) != reason) {
|
||||
// Keep the old registration and text on failure so the next tick retries
|
||||
// removal before registering the newly selected language.
|
||||
if (!ClearReason()) return false;
|
||||
reason_.assign(reason);
|
||||
}
|
||||
return SyncReason();
|
||||
}
|
||||
|
||||
LRESULT CALLBACK ShutdownSessionGuard::WindowProc(
|
||||
HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
if (message == WM_NCCREATE) {
|
||||
const auto* create = reinterpret_cast<CREATESTRUCTW*>(lparam);
|
||||
auto* guard = static_cast<ShutdownSessionGuard*>(create->lpCreateParams);
|
||||
guard->window_ = window;
|
||||
SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(guard));
|
||||
}
|
||||
auto* guard = reinterpret_cast<ShutdownSessionGuard*>(
|
||||
GetWindowLongPtrW(window, GWLP_USERDATA));
|
||||
if (guard && message == WM_QUERYENDSESSION) {
|
||||
const auto flags = static_cast<DWORD>(lparam);
|
||||
// "Shut down anyway", logoff and Restart Manager must remain available.
|
||||
if ((flags & (ENDSESSION_CRITICAL | ENDSESSION_LOGOFF | ENDSESSION_CLOSEAPP)) != 0)
|
||||
guard->shutdown_allowed_ = true;
|
||||
guard->SyncReason();
|
||||
const bool retain = !guard->shutdown_allowed_ && guard->reason_registered_ &&
|
||||
guard->connected_ && guard->connected_->load(std::memory_order_relaxed);
|
||||
LOG_INFO("Windows shutdown query: flags=0x{:x}, retain_remote_session={}", flags, retain);
|
||||
return retain ? FALSE : TRUE;
|
||||
}
|
||||
if (guard && message == WM_ENDSESSION) {
|
||||
LOG_INFO("Windows session end: confirmed={}, flags=0x{:x}",
|
||||
wparam != FALSE, static_cast<DWORD>(lparam));
|
||||
guard->shutdown_allowed_ = wparam != FALSE;
|
||||
guard->SyncReason();
|
||||
// A cancelled shutdown must leave transport, capture and input running.
|
||||
return 0;
|
||||
}
|
||||
if (guard && message == WM_NCDESTROY) {
|
||||
guard->ClearReason();
|
||||
// Destruction removes the native registration even if explicit removal
|
||||
// failed. A later window must not inherit the destroyed window's state.
|
||||
guard->reason_registered_ = false;
|
||||
guard->window_ = nullptr;
|
||||
SetWindowLongPtrW(window, GWLP_USERDATA, 0);
|
||||
}
|
||||
return DefWindowProcW(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-22
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SHUTDOWN_SESSION_GUARD_H_
|
||||
#define _SHUTDOWN_SESSION_GUARD_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Owned and pumped by the UI thread. The transport may revoke the shared
|
||||
// connection flag from another thread, including between UI ticks.
|
||||
class ShutdownSessionGuard {
|
||||
public:
|
||||
explicit ShutdownSessionGuard(std::shared_ptr<std::atomic<bool>> connected);
|
||||
~ShutdownSessionGuard();
|
||||
ShutdownSessionGuard(const ShutdownSessionGuard&) = delete;
|
||||
ShutdownSessionGuard& operator=(const ShutdownSessionGuard&) = delete;
|
||||
|
||||
bool Update(std::wstring_view reason);
|
||||
|
||||
private:
|
||||
bool Start();
|
||||
bool SyncReason();
|
||||
bool ClearReason();
|
||||
bool ReportError(const char* operation);
|
||||
static LRESULT CALLBACK WindowProc(HWND window, UINT message,
|
||||
WPARAM wparam, LPARAM lparam);
|
||||
|
||||
std::shared_ptr<std::atomic<bool>> connected_;
|
||||
HWND window_ = nullptr;
|
||||
std::wstring reason_;
|
||||
bool reason_registered_ = false;
|
||||
bool shutdown_allowed_ = false;
|
||||
ULONGLONG last_error_tick_ = 0;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -11,6 +11,8 @@ namespace crossdesk {
|
||||
|
||||
int RunSlintRendererProbe() {
|
||||
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX);
|
||||
// Renderer probing should not inhibit automatic display sleep either.
|
||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||
if (!SDL_Init(SDL_INIT_VIDEO)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -40,11 +40,14 @@ void PlatformMouseController::UpdateDisplayInfoList(
|
||||
display_info_list_.size());
|
||||
}
|
||||
|
||||
int PlatformMouseController::Destroy() { return ReleasePressedButtons(); }
|
||||
int PlatformMouseController::Destroy() {
|
||||
return ReleasePressedButtons() == 7 ? 0 : -1;
|
||||
}
|
||||
|
||||
int PlatformMouseController::ReleasePressedButtons() {
|
||||
const unsigned pressed = pressed_buttons_.load();
|
||||
if (!pressed) return 0;
|
||||
unsigned PlatformMouseController::ReleasePressedButtons(unsigned mask) {
|
||||
mask &= 7;
|
||||
const unsigned pressed = pressed_buttons_.load() & mask;
|
||||
if (!pressed) return mask;
|
||||
INPUT input{};
|
||||
input.type = INPUT_MOUSE;
|
||||
input.mi.dwExtraInfo = kInjectedMouseInputMarker;
|
||||
@@ -53,10 +56,10 @@ int PlatformMouseController::ReleasePressedButtons() {
|
||||
if (pressed & 4) input.mi.dwFlags |= MOUSEEVENTF_MIDDLEUP;
|
||||
if (SendInputOnUserDesktop(input) != 1) {
|
||||
LOG_WARN("Release remote mouse buttons failed, error={}", GetLastError());
|
||||
return -1;
|
||||
return mask & ~pressed;
|
||||
}
|
||||
pressed_buttons_.fetch_and(~pressed);
|
||||
return 0;
|
||||
return mask;
|
||||
}
|
||||
|
||||
int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user