mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-27 08:12:18 +08:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b15925f857 | ||
|
|
1e25ec15e1 | ||
|
|
9f7411e5c0 | ||
|
|
8fd7ca1e8d | ||
|
|
c3ea4eb8fc | ||
|
|
38d0ce7728 | ||
|
|
778c3575cd | ||
|
|
dad8384666 | ||
|
|
9496bc8f1c | ||
|
|
e551e04e8f | ||
|
|
e3b3f78b06 | ||
|
|
5db213d126 | ||
|
|
bdd04603c7 | ||
|
|
f3d8689b55 | ||
|
|
0fb4b031c7 | ||
|
|
7fc21f01bc | ||
|
|
86bae6e1f4 | ||
|
|
c03c8a63d4 | ||
|
|
ec9ac6a7cd | ||
|
|
2a0b14035d | ||
|
|
652378afa8 | ||
|
|
0cbdbb0c96 | ||
|
|
63bc5648c0 | ||
|
|
859aaa29d4 | ||
|
|
cc48eb651f | ||
|
|
ae6f81e83a | ||
|
|
2445f79f92 | ||
|
|
bd7f9505d9 |
@@ -7,124 +7,32 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
请先查看 [常见问题 / FAQ](https://github.com/kunkundi/crossdesk/blob/deskhub/docs/FAQ.md) 并搜索 [已有问题 / Existing issues](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
|
只需描述问题,其余选填。中文或英文均可。
|
||||||
Please check the FAQ and existing issues first. You can write in Chinese or English.
|
Only a problem description is required. Chinese or English is welcome.
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem
|
id: problem
|
||||||
attributes:
|
attributes:
|
||||||
label: 问题与预期 / Problem and expected behavior
|
label: 问题描述 / Problem description
|
||||||
description: 描述实际发生的情况、错误提示,以及期望的行为。Describe what happened, any error messages, and what you expected.
|
description: 遇到了什么问题?如方便,也请说明触发操作或错误提示。What went wrong? If possible, include what you were doing or any error message.
|
||||||
placeholder: |
|
|
||||||
实际结果 / Actual behavior:
|
|
||||||
预期结果 / Expected behavior:
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: input
|
||||||
id: reproduction
|
id: environment
|
||||||
attributes:
|
attributes:
|
||||||
label: 复现步骤 / Steps to reproduce
|
label: 使用环境(选填) / Environment (optional)
|
||||||
description: 说明操作步骤和出现频率;偶发问题可描述发生前的操作。Include the steps and frequency. For intermittent issues, describe what you were doing.
|
description: 操作系统和 CrossDesk 版本;连接问题可填写两端信息。OS and CrossDesk version; include both devices for connection issues.
|
||||||
placeholder: |
|
placeholder: Windows 11 → macOS 15; CrossDesk x.y.z
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
出现频率 / Frequency:
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: clients
|
|
||||||
attributes:
|
|
||||||
label: 涉及客户端 / Affected clients
|
|
||||||
description: 可多选;Web 与原生移动端请分别选择。Select all that apply; distinguish Web from native mobile apps.
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- 桌面客户端(Windows / macOS / Linux) / Desktop client
|
|
||||||
- iOS / iPadOS(原生 / Native)
|
|
||||||
- Android(原生 / Native)
|
|
||||||
- 鸿蒙 PC / HarmonyOS PC
|
|
||||||
- 鸿蒙手机或平板 / HarmonyOS mobile
|
|
||||||
- Web 客户端 / Web client
|
|
||||||
- 服务端 / Server
|
|
||||||
- 其他 / Other
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: controller
|
|
||||||
attributes:
|
|
||||||
label: 控制端或本机环境 / Controller or local environment
|
|
||||||
description: |
|
|
||||||
控制端是发起远程操作的设备。请填写完整系统和客户端版本;Web 用户另填浏览器及其版本。自行构建请附提交号。
|
|
||||||
仅涉及安装、启动或服务端时,在此填写发生问题的本机环境。
|
|
||||||
The controller initiates remote access. Include full OS and client versions; Web users should also include the browser and its version. For source builds, include the commit.
|
|
||||||
For installation, startup, or server-only issues, describe the affected local environment here.
|
|
||||||
placeholder: |
|
|
||||||
操作系统及版本 / OS and version:
|
|
||||||
CPU 架构(x64 / arm64 等) / CPU architecture:
|
|
||||||
CrossDesk 版本或提交号 / CrossDesk version or commit:
|
|
||||||
浏览器及版本(仅 Web) / Browser and version (Web only):
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: host
|
|
||||||
attributes:
|
|
||||||
label: 被控端环境 / Remote host environment
|
|
||||||
description: |
|
|
||||||
被控端是接受远程操作的电脑。连接或会话问题请补充这一端的信息;不涉及远程设备时可留空。
|
|
||||||
The host is the computer being controlled. Include its details for connection or session issues; leave empty if no remote device is involved.
|
|
||||||
placeholder: |
|
|
||||||
操作系统及版本 / OS and version:
|
|
||||||
CPU 架构(x64 / arm64 等) / CPU architecture:
|
|
||||||
CrossDesk 版本或提交号 / CrossDesk version or commit:
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: service
|
|
||||||
attributes:
|
|
||||||
label: 服务部署方式 / Server deployment
|
|
||||||
options:
|
|
||||||
- 公共服务 / Public service
|
|
||||||
- 自托管 / Self-hosted
|
|
||||||
- 不确定 / Unknown
|
|
||||||
- 不适用 / Not applicable
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: connection
|
|
||||||
attributes:
|
|
||||||
label: 连接方式 / Connection type
|
|
||||||
description: 可在会话的网络状态中查看;无法确认时选择“不确定”。Check the session's network status, or choose Unknown.
|
|
||||||
options:
|
|
||||||
- P2P 直连 / Direct P2P
|
|
||||||
- TURN 中继 / TURN relay
|
|
||||||
- 尚未建立连接 / Connection not established
|
|
||||||
- 不确定 / Unknown
|
|
||||||
- 不适用 / Not applicable
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: 补充环境 / Additional environment
|
|
||||||
description: |
|
|
||||||
按问题补充相关信息,例如普通会话或桌面墙、自托管服务端版本、局域网或公网、VPN;画面问题可补充显卡、编码格式、硬件编解码设置和显示器情况。
|
|
||||||
Include relevant details such as a normal session or DeskHub, self-hosted server version, LAN or Internet, and VPN use. For video issues, add GPU, codec, hardware codec settings, and display configuration.
|
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: attachments
|
id: attachments
|
||||||
attributes:
|
attributes:
|
||||||
label: 日志与截图 / Logs and screenshots
|
label: 截图或日志(选填) / Screenshots or logs (optional)
|
||||||
description: |
|
description: |
|
||||||
可粘贴相关日志,或拖入日志文件、截图和录屏。上传前请隐去连接密码等敏感信息。
|
可直接拖入截图、录屏或日志文件,请隐去密码等敏感信息。
|
||||||
Paste relevant logs or attach log files, screenshots, and recordings. Redact connection passwords and other private information before uploading.
|
Drag in screenshots, recordings, or logs. Hide passwords and other sensitive information.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -7,66 +7,21 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
提交前请搜索 [已有建议 / Existing requests](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
|
只需描述你的建议,其余选填。中文或英文均可。
|
||||||
Please search existing requests before submitting. You can write in Chinese or English.
|
Only a description of your idea is required. Chinese or English is welcome.
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: use-case
|
|
||||||
attributes:
|
|
||||||
label: 使用场景与问题 / Use case and problem
|
|
||||||
description: 你希望完成什么?目前遇到什么限制?What are you trying to do, and what limitation do you encounter?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: proposal
|
id: proposal
|
||||||
attributes:
|
attributes:
|
||||||
label: 功能建议与预期效果 / Proposal and expected outcome
|
label: 你的建议 / Your idea
|
||||||
description: 描述希望新增或改进的行为,以及理想的使用方式。Describe the behavior you would like to add or improve and how you expect to use it.
|
description: 希望新增或改进什么?如方便,也请说明使用场景。What would you like to add or improve? If possible, describe when you would use it.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: scope
|
|
||||||
attributes:
|
|
||||||
label: 适用范围 / Scope
|
|
||||||
description: 可多选;通用建议可选择“跨平台或通用”。Select all that apply, or choose Cross-platform or general.
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- 跨平台或通用 / Cross-platform or general
|
|
||||||
- 桌面客户端(Windows / macOS / Linux) / Desktop client
|
|
||||||
- iOS / iPadOS
|
|
||||||
- Android
|
|
||||||
- 鸿蒙 PC / HarmonyOS PC
|
|
||||||
- 鸿蒙手机或平板 / HarmonyOS mobile
|
|
||||||
- Web 客户端 / Web client
|
|
||||||
- 桌面墙 / DeskHub
|
|
||||||
- 自托管服务 / Self-hosted services
|
|
||||||
- 文档或安装体验 / Documentation or installation
|
|
||||||
- 其他 / Other
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: impact
|
|
||||||
attributes:
|
|
||||||
label: 影响范围与使用频率 / Impact and frequency
|
|
||||||
description: 哪些用户或场景会受益?你会多频繁地使用?Who would benefit, in which situations, and how often would you use this?
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: 替代方案 / Alternatives
|
|
||||||
description: 目前如何处理这个需求?是否考虑过其他实现方式?How do you handle this today, and what other approaches have you considered?
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: references
|
id: references
|
||||||
attributes:
|
attributes:
|
||||||
label: 参考资料与补充说明 / References and additional context
|
label: 补充说明(选填) / Additional context (optional)
|
||||||
description: 可提供参考链接、截图、示意图或其他要求。Include links, screenshots, mockups, or any additional requirements.
|
description: 可附截图、参考链接或其他说明。Add screenshots, links, or any other details.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ on:
|
|||||||
- "apps/android/**"
|
- "apps/android/**"
|
||||||
- "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"
|
- "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"
|
||||||
- "apps/ios/licenses/**"
|
- "apps/ios/licenses/**"
|
||||||
- "apps/ios/scripts/generate_third_party_licenses.py"
|
|
||||||
- "deps/submodules/minirtc"
|
- "deps/submodules/minirtc"
|
||||||
- "libs/wire/**"
|
- "libs/wire/**"
|
||||||
|
|
||||||
@@ -155,9 +154,6 @@ jobs:
|
|||||||
cache-provider: basic
|
cache-provider: basic
|
||||||
cache-read-only: ${{ github.event_name == 'pull_request' }}
|
cache-read-only: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
- name: Verify shared license catalog
|
|
||||||
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
|
|
||||||
|
|
||||||
- name: Resolve Android version
|
- name: Resolve Android version
|
||||||
shell: python
|
shell: python
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -169,9 +169,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
|
||||||
- name: Verify iOS license catalog
|
|
||||||
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
|
|
||||||
|
|
||||||
- name: Install xmake
|
- name: Install xmake
|
||||||
uses: xmake-io/github-action-setup-xmake@v1
|
uses: xmake-io/github-action-setup-xmake@v1
|
||||||
with:
|
with:
|
||||||
@@ -181,7 +178,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
xmake --version | tee "${RUNNER_TEMP}/xmake-version.txt"
|
xmake --version | tee "${RUNNER_TEMP}/xmake-version.txt"
|
||||||
python3 -B apps/ios/scripts/generate_third_party_licenses.py \
|
python3 -B apps/ios/scripts/verify_native_build.py \
|
||||||
--xmake-version "${RUNNER_TEMP}/xmake-version.txt"
|
--xmake-version "${RUNNER_TEMP}/xmake-version.txt"
|
||||||
|
|
||||||
- name: Pin xmake package repository
|
- name: Pin xmake package repository
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Android artifact version, for example v1.6.0-20261002."
|
description: "Android artifact version, for example v1.7.3-20261026 or v1.7.3-1-20261026."
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
secrets:
|
secrets:
|
||||||
@@ -19,7 +19,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
source_tag:
|
source_tag:
|
||||||
description: "Existing Android release to sign without rebuilding, for example android-v1.6.0-20261002."
|
description: "Existing Android release to sign without rebuilding, for example android-v1.7.3-1-20261026."
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -61,11 +61,11 @@ jobs:
|
|||||||
if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" and not version:
|
if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" and not version:
|
||||||
tag = os.environ["SOURCE_TAG"]
|
tag = os.environ["SOURCE_TAG"]
|
||||||
if not tag.startswith("android-v"):
|
if not tag.startswith("android-v"):
|
||||||
raise ValueError("Use an existing android-v<app-version>-YYYYMMDD release")
|
raise ValueError("Use an existing android-v<app-version>[-<patch>]-YYYYMMDD release")
|
||||||
version = tag.removeprefix("android-")
|
version = tag.removeprefix("android-")
|
||||||
match = re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+-([0-9]{8})", version)
|
match = re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9]+)?-([0-9]{8})", version)
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError("Invalid Android artifact version")
|
raise ValueError("Invalid Android artifact version; expected v<app-version>[-<patch>]-YYYYMMDD")
|
||||||
datetime.datetime.strptime(match[1], "%Y%m%d")
|
datetime.datetime.strptime(match[1], "%Y%m%d")
|
||||||
with open(os.environ["GITHUB_OUTPUT"], "a") as stream:
|
with open(os.environ["GITHUB_OUTPUT"], "a") as stream:
|
||||||
stream.write(f"version={version}\n")
|
stream.write(f"version={version}\n")
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
CrossDesk
|
||||||
|
SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
|
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk 标志:图标在左,双色文字在右" />
|
||||||
<h1>CrossDesk</h1>
|
|
||||||
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
|
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
|
||||||
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
|
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,13 +21,14 @@
|
|||||||
|
|
||||||
CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
|
CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
|
||||||
|
|
||||||
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
|
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [IP 直连](#lan-direct) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
|
||||||
|
|
||||||
<a id="features"></a>
|
<a id="features"></a>
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
|
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
|
||||||
|
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
|
||||||
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
|
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
|
||||||
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
|
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
|
||||||
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
|
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
|
||||||
@@ -61,22 +61,42 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
|
|||||||
|
|
||||||
## 快速连接
|
## 快速连接
|
||||||
|
|
||||||
1. **准备被控电脑。** 安装并运行 CrossDesk,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
|
### 通过设备 ID 连接
|
||||||
|
|
||||||
|
1. **准备被控电脑。** 安装并运行 CrossDesk,切换至 ID 连接模式,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
|
||||||
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
|
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
|
||||||
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
|
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
|
||||||
|
|
||||||
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
|
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
|
||||||
|
|
||||||
|
<a id="lan-direct"></a>
|
||||||
|
|
||||||
|
### 通过局域网 IP 直连
|
||||||
|
|
||||||
|
1. **切换模式。** 两台电脑均运行支持 IP 直连的桌面客户端,点击左上角模式按钮切换到 **IP 直连模式**。若按钮未显示,在 **设置 → 常规 → 网络连接** 中勾选 **启用 IP 直连** 并保存。
|
||||||
|
2. **填写地址。** 在控制端输入被控电脑显示的 `IP:端口`,例如 `192.168.1.20:52888`,点击“连接”。只输入 IP 时使用默认端口 **52888**;请以对方界面显示的实际端口为准。
|
||||||
|
3. **输入密码。** 输入被控电脑在 IP 直连模式下显示的 **6 位英文字母或数字密码**,可勾选“记住密码”。切换模式时,“近期连接”同步切换为对应模式的设备记录。
|
||||||
|
|
||||||
|
两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改各 IP 的端口和密码;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。
|
||||||
|
|
||||||
<a id="preview"></a>
|
<a id="preview"></a>
|
||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
|
|
||||||
### 桌面客户端
|
### 桌面客户端
|
||||||
|
|
||||||
本机凭据、远程连接和近期设备集中在主窗口,左上角进入桌面墙。
|
本机凭据、远程连接和近期设备集中在主窗口,左上角提供连接模式切换和桌面墙入口。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk 主窗口:本机凭据、远程连接入口和近期设备;数据为演示" />
|
<img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk ID 连接模式:模式切换、本机 ID、远程连接入口和近期设备;数据为演示" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### IP 直连模式
|
||||||
|
|
||||||
|
切换本机 IP、修改端口与密码,通过对方地址连接;下方展示独立的局域网近期设备。
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/desktop-lan-zh.png" width="800" alt="CrossDesk IP 直连模式:本机 IP 和端口编辑入口、六位密码、远端地址输入和局域网近期设备;数据为演示" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a id="deskhub"></a>
|
<a id="deskhub"></a>
|
||||||
@@ -96,7 +116,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
|
|||||||
|
|
||||||
## 会话操作与设置
|
## 会话操作与设置
|
||||||
|
|
||||||
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置编码、中继、自托管与桌面墙画面预算。
|
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。
|
||||||
|
|
||||||
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
|
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
|
||||||
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
|
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
|
||||||
@@ -118,7 +138,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
|
|||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
原生控制端支持触控与外接键鼠、多屏切换、文件收发、文本剪贴板和会话画面设置。H.264 / AV1 优先使用硬件解码,不支持或运行失败时回退到软件解码;切换应用时优先通过画中画保持会话,画中画不可用时尝试前台服务保活。当前不提供手机被控或隐私屏功能。
|
原生控制端支持触控与外接键鼠、多屏切换、文件收发、文本剪贴板和会话画面设置。H.264 / AV1 优先使用硬件解码,不支持或运行失败时回退到软件解码;切换应用时优先通过画中画保持会话,画中画不可用时尝试前台服务保活。当前不提供手机被控功能。悬浮菜单中的“隐私屏”按钮可直接开启或关闭电脑本地遮罩,按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠。
|
||||||
|
|
||||||
[使用说明](docs/USER_GUIDE.md#android) · [源码构建](apps/android/README.md)
|
[使用说明](docs/USER_GUIDE.md#android) · [源码构建](apps/android/README.md)
|
||||||
|
|
||||||
@@ -151,9 +171,12 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
|
|||||||
|
|
||||||
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
|
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
|
||||||
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
|
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
|
||||||
|
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
|
||||||
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
|
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
|
||||||
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
|
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
|
||||||
|
|
||||||
|
桌面端的 `CROSSDESK_UPDATE_PUBLIC_KEY` 是可选编译参数,用于验证应用内升级清单的 Ed25519 签名。不配置仍可正常编译、运行和检查版本,但更新只能手动下载;需要应用内自动安装更新时,须配置与发布私钥匹配的公钥。配置方式见 [更新公钥](docs/BUILD.md#update-public-key),密钥生成与发布流程见 [桌面端应用内升级](docs/UPDATER.md)。
|
||||||
|
|
||||||
<a id="sponsor"></a>
|
<a id="sponsor"></a>
|
||||||
|
|
||||||
## 支持 CrossDesk
|
## 支持 CrossDesk
|
||||||
|
|||||||
+31
-8
@@ -1,6 +1,5 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
|
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk logo with the icon on the left and a two-tone wordmark on the right" />
|
||||||
<h1>CrossDesk</h1>
|
|
||||||
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
|
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
|
||||||
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
|
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,13 +21,14 @@
|
|||||||
|
|
||||||
CrossDesk connects your computers from desktop, browser, and mobile clients, with **DeskHub** for viewing and controlling multiple devices together. It is built on [MiniRTC](https://github.com/kunkundi/minirtc) and supports self-hosting.
|
CrossDesk connects your computers from desktop, browser, and mobile clients, with **DeskHub** for viewing and controlling multiple devices together. It is built on [MiniRTC](https://github.com/kunkundi/minirtc) and supports self-hosting.
|
||||||
|
|
||||||
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
|
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Direct IP](#lan-direct) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
|
||||||
|
|
||||||
<a id="features"></a>
|
<a id="features"></a>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
|
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
|
||||||
|
- **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode.
|
||||||
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution.
|
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution.
|
||||||
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
|
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
|
||||||
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
|
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
|
||||||
@@ -61,22 +61,42 @@ Requirements follow the current source and build configuration. Check the releas
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the server-connected status, then find its ID and password under **Local Desktop**.
|
### Connect by device ID
|
||||||
|
|
||||||
|
1. **Prepare the host.** Install and run CrossDesk on the computer to control, select ID mode, and wait for the server-connected status, then find its ID and password under **Local Desktop**.
|
||||||
2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect.
|
2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect.
|
||||||
3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
|
3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
|
||||||
|
|
||||||
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems.
|
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems.
|
||||||
|
|
||||||
|
<a id="lan-direct"></a>
|
||||||
|
|
||||||
|
### Connect directly over LAN
|
||||||
|
|
||||||
|
1. **Switch modes.** Run a desktop client with direct IP support on both computers. Click the top-left mode button to select **Direct IP mode**. If the button is hidden, select **Enable direct IP** under **Settings → General → Network** and save.
|
||||||
|
2. **Enter the address.** On the controller, enter the host's displayed `IP:port`, for example `192.168.1.20:52888`, and click **Connect**. Omitting the port uses **52888**; use the actual port shown on the host.
|
||||||
|
3. **Enter the password.** Use the host's **six-character ASCII letter/digit password** shown in Direct IP mode. You can select **Remember password**. Switching modes also switches the recent device list.
|
||||||
|
|
||||||
|
Both computers must be reachable over the LAN. Internet access may be available or absent; direct sessions do not depend on a cloud signaling service. The main window lets you select the local IP, edit its port, and change the password. Disabling **Enable direct IP** stops listeners, disconnects direct IP sessions, and hides the mode button. See the [direct IP guide](docs/USER_GUIDE_EN.md#lan-direct).
|
||||||
|
|
||||||
<a id="preview"></a>
|
<a id="preview"></a>
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||
|
|
||||||
### Desktop client
|
### Desktop client
|
||||||
|
|
||||||
Local credentials, remote connections, and recent devices share the main window. Open DeskHub from the top-left corner.
|
Local credentials, remote connections, and recent devices share the main window. The top-left controls switch connection modes and open DeskHub.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/desktop-main-en.png" width="800" alt="CrossDesk main window with local credentials, remote connection controls, and recent devices; demonstration data" />
|
<img src="docs/images/desktop-main-en.png" width="800" alt="CrossDesk ID mode with the mode switch, local ID, remote connection controls, and recent devices; demonstration data" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Direct IP mode
|
||||||
|
|
||||||
|
Select the local IP, edit its port and password, and connect using the other desktop's address. Recent LAN devices have their own list below.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/images/desktop-lan-en.png" width="800" alt="CrossDesk Direct IP mode with local IP and port editing, a six-character password, remote address input, and recent LAN devices; demonstration data" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a id="deskhub"></a>
|
<a id="deskhub"></a>
|
||||||
@@ -96,7 +116,7 @@ Group control sends input to all connected devices; closing DeskHub disconnects
|
|||||||
|
|
||||||
## Session controls and settings
|
## Session controls and settings
|
||||||
|
|
||||||
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure codecs, relay, self-hosting, and the DeskHub video budget.
|
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure direct IP availability, codecs, relay, self-hosting, and the DeskHub video budget.
|
||||||
|
|
||||||
- [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens.
|
- [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens.
|
||||||
- [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
|
- [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
|
||||||
@@ -118,7 +138,7 @@ The native controller supports touch and a virtual mouse, multiple monitors, rem
|
|||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
The native controller supports touch and external keyboards/mice, monitor switching, file transfers, text clipboard, and video settings. H.264 / AV1 use hardware decoding when available, with software fallback when unsupported or when decoding fails. Active sessions use Picture in Picture when switching apps, with a foreground service as a fallback when Picture in Picture is unavailable. Phone-as-host and privacy screens are not currently supported.
|
The native controller supports touch and external keyboards/mice, monitor switching, file transfers, text clipboard, and video settings. H.264 / AV1 use hardware decoding when available, with software fallback when unsupported or when decoding fails. Active sessions use Picture in Picture when switching apps, with a foreground service as a fallback when Picture in Picture is unavailable. Phone-as-host is not currently supported. The floating menu’s **Privacy screen** button toggles the computer’s local cover directly, labels the host-confirmed on/off state without changing colors, and shows inline feedback only for errors or unavailable status. Enabling privacy also blocks local keyboard/mouse input, matching the desktop controller.
|
||||||
|
|
||||||
[User guide](docs/USER_GUIDE_EN.md#android) · [Build from source](apps/android/README.md) (Chinese)
|
[User guide](docs/USER_GUIDE_EN.md#android) · [Build from source](apps/android/README.md) (Chinese)
|
||||||
|
|
||||||
@@ -151,9 +171,12 @@ Host your own signaling and TURN services, then enable them under **Settings →
|
|||||||
|
|
||||||
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
|
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
|
||||||
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
|
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
|
||||||
|
- [Connection architecture](docs/connection-architecture.md) (Chinese): discovery, authentication, negotiation, data paths, and lifecycle for ID and direct IP modes.
|
||||||
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
|
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
|
||||||
- [Report an issue or suggestion](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
|
- [Report an issue or suggestion](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
|
||||||
|
|
||||||
|
The optional desktop build parameter `CROSSDESK_UPDATE_PUBLIC_KEY` verifies Ed25519 signatures on in-app update manifests. Without it, the app still builds, runs, and checks for new versions, but updates require manual download. In-app update installation requires a public key matching the release signing private key. See [Update public key](docs/BUILD_EN.md#update-public-key) for configuration and the [desktop updater guide](docs/UPDATER.md) (Chinese) for key generation and publishing.
|
||||||
|
|
||||||
<a id="sponsor"></a>
|
<a id="sponsor"></a>
|
||||||
|
|
||||||
## Support CrossDesk
|
## Support CrossDesk
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
|
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
|
||||||
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
|
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
|
||||||
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
|
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
|
||||||
|
- 悬浮菜单提供“隐私屏”直接开关,无需进入详情页;按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示。开启时与桌面端一致屏蔽电脑本地键鼠;声明控制能力后可使用电脑的连接时自动开启设置。状态超过 5 秒未更新时停止显示成功,指令 15 秒未确认时提示超时;后台保活与画中画恢复沿用同一会话状态。
|
||||||
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
|
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
|
||||||
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。
|
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。
|
||||||
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
|
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
- 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。
|
- 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。
|
||||||
- “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。
|
- “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。
|
||||||
|
|
||||||
当前不包含安卓被控端、隐私屏或 iOS 全部附加功能。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
|
当前不包含安卓被控端或 iOS 全部附加功能。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
|
||||||
|
|
||||||
## 本机更新提示
|
## 本机更新提示
|
||||||
|
|
||||||
@@ -152,7 +153,9 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
|
|||||||
|
|
||||||
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
|
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
|
||||||
|
|
||||||
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。Windows 通过当前已解锁用户桌面的 Unicode 键盘事件输入文本,无需切换电脑输入法或覆盖剪贴板;锁屏和 UAC 安全桌面不接受此文本通道。Windows 被控端也必须更新到包含文本输入支持的版本,仅更新 Android 不会启用该能力。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
|
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
|
||||||
|
|
||||||
|
Windows 文本输入使用 Unicode 键盘事件,无需切换电脑输入法或覆盖剪贴板。本次开发版将完整 UTF-8 文本通过 Windows 服务转发到锁屏、登录与 UAC 安全桌面,保留数字、大小写字母、符号、空格及中文等字符,不将密码转换成数字或 ASCII 按键。该通道需要同时更新 Windows 客户端、CrossDesk Service 和会话辅助程序;仅更新 Android,或仅确认旧服务正在运行,均不会启用修复。旧版可能已声明 `supports_text_input`,但只支持普通桌面文本输入,因此“电脑键盘可输入、搜狗等系统输入法不可输入”仍可能出现。锁屏实际输入效果需在更新后的 Windows 环境验证。
|
||||||
|
|
||||||
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
|
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
|
||||||
|
|
||||||
@@ -246,4 +249,4 @@ AV1 硬件路径尚无对应实机验证;Retroid 上使用 dav1d 软件解码
|
|||||||
|
|
||||||
设置 → 关于使用与 iOS 相同的分组及入口:版本、软件许可、开源组件、源码与构建说明。软件许可中可分别阅读 GNU GPL 第 3 版及开源软件权利说明;开源组件页单独展示 OpenFEC 告知,其余组件列出名称和许可证,详情包含版本、源码、适用的构建配方和每一份许可文档。内容按 Android 构建生成,不显示未使用的 iOS 依赖,Android 签名说明替换 Apple 签名说明。
|
设置 → 关于使用与 iOS 相同的分组及入口:版本、软件许可、开源组件、源码与构建说明。软件许可中可分别阅读 GNU GPL 第 3 版及开源软件权利说明;开源组件页单独展示 OpenFEC 告知,其余组件列出名称和许可证,详情包含版本、源码、适用的构建配方和每一份许可文档。内容按 Android 构建生成,不显示未使用的 iOS 依赖,Android 签名说明替换 Apple 签名说明。
|
||||||
|
|
||||||
APK 保留完整 `THIRD_PARTY_NOTICES.txt`,并生成轻量的 `ThirdPartyLicenses.json` 索引、72 份独立许可文档及 `SourceMetadata.json`。打开文档时才读取正文,长文按有限长度分块显示,支持文本选择、复制与网页链接;打包验证核对每份正文的 SHA-256。源码页面显示标签或提交版本及未发布修改提示,源码链接指向基础提交。当前 Android 构建说明、构建脚本、MiniRTC、OpenSSL 及 SVT-AV1 配方随 APK 离线提供,避免开发版链接指向基础提交中尚不存在的 Android 文件。
|
APK 保留完整 `THIRD_PARTY_NOTICES.txt`,并生成轻量的 `ThirdPartyLicenses.json` 索引、72 份独立许可文档及 `SourceMetadata.json`。打开文档时才读取正文,长文按有限长度分块显示,支持文本选择、复制与网页链接。构建继续生成并打包许可资料,CI 和 APK 验证不再检查许可证内容、哈希或组件清单。源码页面显示标签或提交版本及未发布修改提示,源码链接指向基础提交。当前 Android 构建说明、构建脚本、MiniRTC、OpenSSL 及 SVT-AV1 配方随 APK 离线提供,避免开发版链接指向基础提交中尚不存在的 Android 文件。
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ final class BackgroundSession implements NativeSession.Listener {
|
|||||||
if (view != null) view.fileTransfer(name, progress, sending, received);
|
if (view != null) view.fileTransfer(name, progress, sending, received);
|
||||||
}
|
}
|
||||||
@Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); }
|
@Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); }
|
||||||
|
@Override public void privacyChanged() { if (view != null) view.privacyChanged(); }
|
||||||
@Override public void presence(JSONObject message) { if (view != null) view.presence(message); }
|
@Override public void presence(JSONObject message) { if (view != null) view.presence(message); }
|
||||||
@Override public void announcement(JSONObject message) { if (view != null) view.announcement(message); }
|
@Override public void announcement(JSONObject message) { if (view != null) view.announcement(message); }
|
||||||
@Override public void announcementFailed(String id) { if (view != null) view.announcementFailed(id); }
|
@Override public void announcementFailed(String id) { if (view != null) view.announcementFailed(id); }
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
remote.setOnFocusChangeListener((view,focused)->{GradientDrawable shape=ui.background(BACKGROUND,12);shape.setStroke(dp(focused?2:1),focused?BLUE:LINE);remote.setBackground(shape);});
|
remote.setOnFocusChangeListener((view,focused)->{GradientDrawable shape=ui.background(BACKGROUND,12);shape.setStroke(dp(focused?2:1),focused?BLUE:LINE);remote.setBackground(shape);});
|
||||||
remote.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)});remote.setText(formatId(preferences.getString("lastRemote","")));
|
remote.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)});remote.setText(formatId(preferences.getString("lastRemote","")));
|
||||||
entry.addView(remote,new LinearLayout.LayoutParams(0,-1,1));
|
entry.addView(remote,new LinearLayout.LayoutParams(0,-1,1));
|
||||||
TextView connect=ui.primary("连接 →",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
|
TextView connect=ui.primary("连接",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
|
||||||
Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run();
|
Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run();
|
||||||
remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}});
|
remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}});
|
||||||
LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p);
|
LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p);
|
||||||
@@ -295,7 +295,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
View eye=ui.iconButton("eye","显示密码",()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18);
|
View eye=ui.iconButton("eye","显示密码",()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18);
|
||||||
LinearLayout save=row(),saveText=column();saveText.addView(text("保存密码",15,true));ui.gap(saveText,3);TextView security=text("密码将加密保存在本机 Android Keystore 中",11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription("保存密码");save.addView(remember);sheet.addView(save);
|
LinearLayout save=row(),saveText=column();saveText.addView(text("保存密码",15,true));ui.gap(saveText,3);TextView security=text("密码将加密保存在本机 Android Keystore 中",11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription("保存密码");save.addView(remember);sheet.addView(save);
|
||||||
remember.setChecked(rememberDefault);
|
remember.setChecked(rememberDefault);
|
||||||
ui.gap(sheet,18);TextView connect=ui.primary("连接 →",()->{
|
ui.gap(sheet,18);TextView connect=ui.primary("连接",()->{
|
||||||
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
|
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
|
||||||
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
|
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
|
||||||
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
|
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
|
||||||
@@ -331,6 +331,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
controls=new SessionControls(this,ui,new SessionControls.Actions(){
|
controls=new SessionControls(this,ui,new SessionControls.Actions(){
|
||||||
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
|
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
|
||||||
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
|
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
|
||||||
|
public void togglePrivacy(){if(ready&&session!=null)session.togglePrivacy();}
|
||||||
public void key(int code){tapKey(code);}
|
public void key(int code){tapKey(code);}
|
||||||
public boolean textEdit(int count,String value){return sendTextEdit(count,value);}
|
public boolean textEdit(int count,String value){return sendTextEdit(count,value);}
|
||||||
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
|
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
|
||||||
@@ -339,6 +340,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
|
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
|
||||||
});controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
});controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput));
|
controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput));
|
||||||
|
privacyChanged();
|
||||||
video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();};
|
video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();};
|
||||||
controls.requestApplyInsets();
|
controls.requestApplyInsets();
|
||||||
video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
|
video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
|
||||||
@@ -655,6 +657,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
|
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@Override public void privacyChanged(){if(controls!=null&&session!=null)controls.privacyScreen(session.privacyScreen);}
|
||||||
@Override public void videoSize(int width,int height){videoWidth=width;videoHeight=height;if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);pictureInPicture.videoSize(width,height);updatePictureInPicture();}
|
@Override public void videoSize(int width,int height){videoWidth=width;videoHeight=height;if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);pictureInPicture.videoSize(width,height);updatePictureInPicture();}
|
||||||
@Override public void clipboard(String value){remoteClipboard=value;}
|
@Override public void clipboard(String value){remoteClipboard=value;}
|
||||||
@Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();}
|
@Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();}
|
||||||
@@ -782,7 +785,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
super.onPictureInPictureUiStateChanged(state);
|
super.onPictureInPictureUiStateChanged(state);
|
||||||
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
|
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
|
||||||
}
|
}
|
||||||
@Override protected void onResume(){super.onResume();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
|
@Override protected void onResume(){super.onResume();privacyChanged();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
|
||||||
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
||||||
@Override protected void onPause(){remoteOrientation.pause();releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
|
@Override protected void onPause(){remoteOrientation.pause();releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
|
||||||
@Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();}
|
@Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ final class NativeSession implements AutoCloseable {
|
|||||||
void statistics(RemoteNetworkStatistics.Snapshot value);
|
void statistics(RemoteNetworkStatistics.Snapshot value);
|
||||||
default void fileTransfer(String name,double progress,boolean sending,File received) { }
|
default void fileTransfer(String name,double progress,boolean sending,File received) { }
|
||||||
default void controlFeedback(String message) { }
|
default void controlFeedback(String message) { }
|
||||||
|
default void privacyChanged() { }
|
||||||
default void signaling(int state, String deviceId) { }
|
default void signaling(int state, String deviceId) { }
|
||||||
default void presence(JSONObject message) { }
|
default void presence(JSONObject message) { }
|
||||||
default void announcement(JSONObject message) { }
|
default void announcement(JSONObject message) { }
|
||||||
@@ -49,6 +50,7 @@ final class NativeSession implements AutoCloseable {
|
|||||||
private long handle;
|
private long handle;
|
||||||
private boolean signalReady, joining, started;
|
private boolean signalReady, joining, started;
|
||||||
private volatile boolean connected, supportsTextInput;
|
private volatile boolean connected, supportsTextInput;
|
||||||
|
final RemotePrivacyScreen privacyScreen = new RemotePrivacyScreen();
|
||||||
private int signalState;
|
private int signalState;
|
||||||
private volatile long signalGeneration;
|
private volatile long signalGeneration;
|
||||||
private boolean presenceRequested;
|
private boolean presenceRequested;
|
||||||
@@ -63,6 +65,8 @@ final class NativeSession implements AutoCloseable {
|
|||||||
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
|
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
|
||||||
private final Runnable statisticsTick=new Runnable(){public void run(){
|
private final Runnable statisticsTick=new Runnable(){public void run(){
|
||||||
if(closed)return;
|
if(closed)return;
|
||||||
|
privacyScreen.tick(android.os.SystemClock.elapsedRealtime());
|
||||||
|
listener.privacyChanged();
|
||||||
listener.statistics(networkStatistics.snapshot(RemoteNetworkStatistics.now()));
|
listener.statistics(networkStatistics.snapshot(RemoteNetworkStatistics.now()));
|
||||||
if(!closed)main.postDelayed(this,1000);
|
if(!closed)main.postDelayed(this,1000);
|
||||||
}};
|
}};
|
||||||
@@ -193,7 +197,13 @@ final class NativeSession implements AutoCloseable {
|
|||||||
} else if (type == 4) {
|
} else if (type == 4) {
|
||||||
JSONObject info=event.optJSONObject("host_info");
|
JSONObject info=event.optJSONObject("host_info");
|
||||||
if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false);
|
if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false);
|
||||||
ui(() -> listener.data(event));
|
ui(() -> {
|
||||||
|
if(event.optInt("type",-1)==10){
|
||||||
|
privacyScreen.receive(event,android.os.SystemClock.elapsedRealtime());
|
||||||
|
listener.privacyChanged();
|
||||||
|
}
|
||||||
|
listener.data(event);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else if (type == 5) {
|
else if (type == 5) {
|
||||||
int width = event.getInt("width"), height = event.getInt("height");
|
int width = event.getInt("width"), height = event.getInt("height");
|
||||||
@@ -266,6 +276,16 @@ final class NativeSession implements AutoCloseable {
|
|||||||
if (type == 2 && player != null) player.setEnabled(value != 0);
|
if (type == 2 && player != null) player.setEnabled(value != 0);
|
||||||
execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} });
|
execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} });
|
||||||
}
|
}
|
||||||
|
void togglePrivacy() {
|
||||||
|
if(closed||!connected)return;
|
||||||
|
boolean enable=!privacyScreen.active();
|
||||||
|
long request=privacyScreen.begin(android.os.SystemClock.elapsedRealtime());
|
||||||
|
if(request<0)return;
|
||||||
|
listener.privacyChanged();
|
||||||
|
execute(()->{if(connected&&!nPrivacy(handle,enable?1:2,true))ui(()->{
|
||||||
|
privacyScreen.failed(request);listener.privacyChanged();
|
||||||
|
});});
|
||||||
|
}
|
||||||
void clipboard(String value) {
|
void clipboard(String value) {
|
||||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||||
if (bytes.length == 0 || bytes.length > 128 * 1024) return;
|
if (bytes.length == 0 || bytes.length > 128 * 1024) return;
|
||||||
@@ -319,6 +339,7 @@ final class NativeSession implements AutoCloseable {
|
|||||||
private static native boolean nPresenceRequest(long handle,byte[] data);
|
private static native boolean nPresenceRequest(long handle,byte[] data);
|
||||||
private static native void nClipboard(long handle, byte[] data);
|
private static native void nClipboard(long handle, byte[] data);
|
||||||
private static native boolean nSecureAttention(long handle);
|
private static native boolean nSecureAttention(long handle);
|
||||||
|
private static native boolean nPrivacy(long handle,int flag,boolean blockLocalInput);
|
||||||
private static native void nFileDirectory(long handle,String directory);
|
private static native void nFileDirectory(long handle,String directory);
|
||||||
private static native boolean nSendFile(long handle,String path,byte[] name);
|
private static native boolean nSendFile(long handle,String path,byte[] name);
|
||||||
private static native boolean nPumpFiles(long handle);
|
private static native boolean nPumpFiles(long handle);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package cn.crossdesk.mobile;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
/** Host-confirmed privacy state; all access belongs to the main thread. */
|
||||||
|
final class RemotePrivacyScreen {
|
||||||
|
boolean received, supported, inputBlockSupported, overlay, inputBlocked, remotePaused, pending;
|
||||||
|
int state;
|
||||||
|
long revision, requestRevision, requestId, receivedAt, requestedAt;
|
||||||
|
String reason = "", failure = "";
|
||||||
|
|
||||||
|
void receive(JSONObject message, long now) {
|
||||||
|
try {
|
||||||
|
if (message.getInt("type") != 10) return;
|
||||||
|
JSONObject value = message.getJSONObject("privacy_status");
|
||||||
|
int next = value.getInt("state");
|
||||||
|
long rev = value.getLong("revision");
|
||||||
|
if (next < 0 || next > 5 || rev < 0 || rev > 0xffffffffL ||
|
||||||
|
received && (int)(rev - revision) < 0) return;
|
||||||
|
boolean support = value.getBoolean("supported"), cover = value.getBoolean("overlay_active");
|
||||||
|
boolean paused = value.getBoolean("remote_paused");
|
||||||
|
if (next == 3 && (!support || !cover || paused)) return;
|
||||||
|
boolean blockSupport = value.getBoolean("input_block_supported"), blocked = value.getBoolean("input_blocked");
|
||||||
|
String detail = value.getString("reason");
|
||||||
|
state = next; revision = rev; supported = support; overlay = cover; remotePaused = paused;
|
||||||
|
inputBlockSupported = blockSupport; inputBlocked = blocked; reason = detail;
|
||||||
|
received = true; receivedAt = now; failure = "";
|
||||||
|
if (pending && (int)(revision - requestRevision) > 0 && state != 2 && state != 4) pending = false;
|
||||||
|
} catch (org.json.JSONException ignored) { }
|
||||||
|
}
|
||||||
|
boolean active() { return overlay || remotePaused || state == 2 || state == 3 || state == 4; }
|
||||||
|
boolean fresh(long now) { return received && now - receivedAt <= 5000; }
|
||||||
|
boolean enabled(long now) { return fresh(now) && state == 3 && overlay && !remotePaused; }
|
||||||
|
String buttonTitle(long now) {
|
||||||
|
if (enabled(now)) return "隐私屏已开启";
|
||||||
|
if (fresh(now) && supported && state == 1 && !active()) return "隐私屏已关闭";
|
||||||
|
return "隐私屏";
|
||||||
|
}
|
||||||
|
String feedback(long now) {
|
||||||
|
if (failure.isEmpty() && (pending || fresh(now) && (state == 2 || state == 4))) return "";
|
||||||
|
if (failure.isEmpty() && !pending && fresh(now) && supported &&
|
||||||
|
(enabled(now) || state == 1 && !active())) return "";
|
||||||
|
String detail = detail(now);
|
||||||
|
return status(now) + (detail.isEmpty() ? "" : ":" + detail);
|
||||||
|
}
|
||||||
|
boolean canToggle(long now) {
|
||||||
|
return !pending && fresh(now) && state != 4 && (active() || supported && state == 1);
|
||||||
|
}
|
||||||
|
long begin(long now) {
|
||||||
|
tick(now);
|
||||||
|
if (!canToggle(now)) return -1;
|
||||||
|
pending = true; requestedAt = now; requestRevision = revision; failure = "";
|
||||||
|
return ++requestId;
|
||||||
|
}
|
||||||
|
void failed(long id) {
|
||||||
|
if (pending && requestId == id) { pending = false; failure = "指令发送失败,请重试"; }
|
||||||
|
}
|
||||||
|
void tick(long now) {
|
||||||
|
if (pending && now - requestedAt >= 15000) {
|
||||||
|
pending = false; failure = "请求超时,尚未确认电脑的隐私屏状态";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String status(long now) {
|
||||||
|
if (!failure.isEmpty()) return failure;
|
||||||
|
if (!received) return "等待电脑返回隐私屏状态;旧版电脑端可能不支持";
|
||||||
|
if (!fresh(now)) return "隐私屏状态已过期,等待电脑确认";
|
||||||
|
if (state == 2) return "正在开启隐私屏…";
|
||||||
|
if (state == 4) return "正在关闭隐私屏…";
|
||||||
|
if (pending) return "指令已发送,等待电脑确认…";
|
||||||
|
if (state == 5) return "隐私屏异常";
|
||||||
|
if (!supported || state == 0) return "隐私屏暂不可用";
|
||||||
|
if (state == 3 && overlay && !remotePaused)
|
||||||
|
return "隐私屏已开启";
|
||||||
|
return state == 1 && !active() ? "隐私屏已关闭" : "隐私屏状态待确认";
|
||||||
|
}
|
||||||
|
String detail(long now) {
|
||||||
|
return fresh(now) && (state == 0 || state == 5 || !supported) ? reason : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ final class SessionControls extends FrameLayout {
|
|||||||
default boolean textEdit(int deletions,String value){return false;}
|
default boolean textEdit(int deletions,String value){return false;}
|
||||||
void videoSettings(int field,int value);
|
void videoSettings(int field,int value);
|
||||||
void sendFile(); void saveFile(); void secureAttention();
|
void sendFile(); void saveFile(); void secureAttention();
|
||||||
|
default void togglePrivacy() { }
|
||||||
}
|
}
|
||||||
private final MobileUi ui;
|
private final MobileUi ui;
|
||||||
private final Actions actions;
|
private final Actions actions;
|
||||||
@@ -36,6 +37,9 @@ final class SessionControls extends FrameLayout {
|
|||||||
keyboardInsets(height,height>ui.dp(80),0);
|
keyboardInsets(height,height>ui.dp(80),0);
|
||||||
}
|
}
|
||||||
private TextView networkLabel,videoFeedback;
|
private TextView networkLabel,videoFeedback;
|
||||||
|
private RemotePrivacyScreen privacy=new RemotePrivacyScreen();
|
||||||
|
private View privacyButton;
|
||||||
|
private TextView privacyFeedback,privacyCaption;
|
||||||
private TextView transferLabel,saveFileButton;
|
private TextView transferLabel,saveFileButton;
|
||||||
private String transferStatus="";
|
private String transferStatus="";
|
||||||
private boolean hasReceivedFile;
|
private boolean hasReceivedFile;
|
||||||
@@ -100,12 +104,13 @@ final class SessionControls extends FrameLayout {
|
|||||||
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
|
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
|
||||||
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
|
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
|
||||||
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
|
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
|
||||||
|
void privacyScreen(RemotePrivacyScreen value){privacy=value;renderPrivacy();}
|
||||||
void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();}
|
void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();}
|
||||||
private void renderFileStatus(){
|
private void renderFileStatus(){
|
||||||
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
|
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
|
||||||
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
|
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
|
||||||
}
|
}
|
||||||
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();}
|
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;privacyFeedback=privacyCaption=null;privacyButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();}
|
||||||
private void openMenu(String page){
|
private void openMenu(String page){
|
||||||
cancelMouseInput();
|
cancelMouseInput();
|
||||||
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
|
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
|
||||||
@@ -121,13 +126,15 @@ final class SessionControls extends FrameLayout {
|
|||||||
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
|
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
|
||||||
}
|
}
|
||||||
if(page.equals("controls")){
|
if(page.equals("controls")){
|
||||||
String[] labels={"键盘",mouseVisible?"收起鼠标":"虚拟鼠标",relative?"相对鼠标":"绝对鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
|
String[] labels={"键盘",mouseVisible?"收起鼠标":"虚拟鼠标",relative?"相对鼠标":"绝对鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板","隐私屏"};
|
||||||
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard"};
|
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard","lock"};
|
||||||
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},this::toggleMouse,actions::mouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
|
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},this::toggleMouse,actions::mouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard,actions::togglePrivacy};
|
||||||
menuColumns=controlColumns();
|
menuColumns=controlColumns();
|
||||||
for(int row=0;row<(labels.length+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int index=row*menuColumns+col;if(index>=labels.length){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
|
for(int row=0;row<(labels.length+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int index=row*menuColumns+col;if(index>=labels.length){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
|
||||||
|
if(index==10){privacyButton=control;privacyCaption=caption;}
|
||||||
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
|
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
|
||||||
}body.addView(line);if((row+1)*menuColumns<labels.length)ui.gap(body,7);}
|
}body.addView(line);if((row+1)*menuColumns<labels.length)ui.gap(body,7);}
|
||||||
|
privacyFeedback=ui.text("",11,false);privacyFeedback.setTextColor(SECONDARY);privacyFeedback.setPadding(0,ui.dp(7),0,0);privacyFeedback.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(privacyFeedback);renderPrivacy();
|
||||||
ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel);
|
ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel);
|
||||||
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
|
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
|
||||||
}else if(page.equals("network")){
|
}else if(page.equals("network")){
|
||||||
@@ -141,6 +148,16 @@ final class SessionControls extends FrameLayout {
|
|||||||
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
|
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
|
||||||
positionMouse();
|
positionMouse();
|
||||||
}
|
}
|
||||||
|
private void renderPrivacy(){
|
||||||
|
if(privacyButton==null)return;
|
||||||
|
long now=android.os.SystemClock.elapsedRealtime();privacy.tick(now);
|
||||||
|
boolean enabled=privacy.enabled(now);String action=privacy.active()?"关闭隐私屏":"开启隐私屏";
|
||||||
|
privacyCaption.setText(privacy.buttonTitle(now));
|
||||||
|
privacyButton.setSelected(enabled);
|
||||||
|
privacyButton.setContentDescription(action+","+privacy.status(now));
|
||||||
|
privacyButton.setEnabled(privacy.canToggle(now));privacyButton.setAlpha(privacyButton.isEnabled()?1f:.45f);
|
||||||
|
String feedback=privacy.feedback(now);privacyFeedback.setText(feedback);privacyFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);
|
||||||
|
}
|
||||||
private final class SettingRow {
|
private final class SettingRow {
|
||||||
final int field;final int[] values;final SegmentedControl group;
|
final int field;final int[] values;final SegmentedControl group;
|
||||||
SettingRow(LinearLayout body,int field,String title,String[] labels,int[] values){
|
SettingRow(LinearLayout body,int field,String title,String[] labels,int[] values){
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include "../../../libs/controller/include/mobile_controller_protocol.h"
|
#include "../../../libs/controller/include/mobile_controller_protocol.h"
|
||||||
namespace crossdesk::android_controller {
|
namespace crossdesk::android_controller {
|
||||||
using mobile_controller::SecureAttentionMessage;
|
using mobile_controller::SecureAttentionMessage;
|
||||||
|
using mobile_controller::PrivacyMessage;
|
||||||
using mobile_controller::VideoSettingsMessage;
|
using mobile_controller::VideoSettingsMessage;
|
||||||
using mobile_controller::NetworkReport;
|
using mobile_controller::NetworkReport;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
|
|||||||
}
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
|
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
|
||||||
auto* s = From(handle); if (!s || !s->controller) return;
|
auto* s = From(handle); if (!s || !s->controller) return;
|
||||||
auto action = MakeHostInformation("CrossDesk Android", {}, false, "1.6.0", HostPlatform::Android);
|
auto action = MakeHostInformation("CrossDesk Android", {}, true, "1.6.0", HostPlatform::Android);
|
||||||
s->Send(kControlStream, action.to_json()); FreeRemoteAction(action);
|
s->Send(kControlStream, action.to_json()); FreeRemoteAction(action);
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)(
|
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)(
|
||||||
@@ -429,6 +429,14 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jcla
|
|||||||
const auto data = android_controller::SecureAttentionMessage();
|
const auto data = android_controller::SecureAttentionMessage();
|
||||||
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
||||||
}
|
}
|
||||||
|
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
|
||||||
|
JNIEnv*, jclass, jlong handle, jint flag, jboolean block_input) {
|
||||||
|
auto* s = From(handle);
|
||||||
|
if (!s || !s->controller || s->stopping) return false;
|
||||||
|
const auto data = android_controller::PrivacyMessage(flag, block_input);
|
||||||
|
return !data.empty() &&
|
||||||
|
SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
|
||||||
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) {
|
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) {
|
||||||
auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory));
|
auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Package existing reviewed notices for the Android runtime dependency set."""
|
"""Package existing reviewed notices for the Android runtime dependency set."""
|
||||||
import json
|
import json
|
||||||
import hashlib
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -70,8 +69,7 @@ def write_pages(output, catalog, additional, revision, minirtc):
|
|||||||
target = output / asset
|
target = output / asset
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_text(document['text'], encoding='utf-8')
|
target.write_text(document['text'], encoding='utf-8')
|
||||||
documents.append({'title': document['title'], 'asset': asset,
|
documents.append({'title': document['title'], 'asset': asset})
|
||||||
'sha256': hashlib.sha256(document['text'].encode('utf-8')).hexdigest()})
|
|
||||||
component['documents'] = documents
|
component['documents'] = documents
|
||||||
(output / 'ThirdPartyLicenses.json').write_text(json.dumps(
|
(output / 'ThirdPartyLicenses.json').write_text(json.dumps(
|
||||||
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"""Check the shipped native ABI, ELF load alignment and APK zip alignment."""
|
"""Check the shipped native ABI, ELF load alignment and APK zip alignment."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import struct
|
import struct
|
||||||
@@ -40,33 +39,15 @@ def verify(path):
|
|||||||
name, extra = struct.unpack('<HH', raw.read(4))
|
name, extra = struct.unpack('<HH', raw.read(4))
|
||||||
if (library.header_offset + 30 + name + extra) % 16384:
|
if (library.header_offset + 30 + name + extra) % 16384:
|
||||||
raise ValueError('APK native library entry is not 16 KB aligned')
|
raise ValueError('APK native library entry is not 16 KB aligned')
|
||||||
if 'assets/THIRD_PARTY_NOTICES.txt' not in archive.namelist():
|
|
||||||
raise ValueError('Missing third-party notices')
|
|
||||||
if archive.read('assets/PRIVACY.md') != (Path(__file__).resolve().parents[3] / 'PRIVACY.md').read_bytes():
|
if archive.read('assets/PRIVACY.md') != (Path(__file__).resolve().parents[3] / 'PRIVACY.md').read_bytes():
|
||||||
raise ValueError('Missing or outdated bundled privacy policy')
|
raise ValueError('Missing or outdated bundled privacy policy')
|
||||||
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.json'))
|
|
||||||
source = json.loads(archive.read('assets/SourceMetadata.json'))
|
source = json.loads(archive.read('assets/SourceMetadata.json'))
|
||||||
components = catalog['components']
|
|
||||||
ids = [item['id'] for item in components]
|
|
||||||
if catalog['schemaVersion'] != 1 or len(ids) != len(set(ids)) or not {
|
|
||||||
'crossdesk', 'minirtc', 'openfec', 'openh264', 'libiconv', 'android-ndk', 'svt-av1'}.issubset(ids):
|
|
||||||
raise ValueError('Incomplete About catalog')
|
|
||||||
for component in components:
|
|
||||||
if not component['documents']:
|
|
||||||
raise ValueError('Missing component documents')
|
|
||||||
for document in component['documents']:
|
|
||||||
text = archive.read('assets/'+document['asset'])
|
|
||||||
if not text or hashlib.sha256(text).hexdigest() != document['sha256']:
|
|
||||||
raise ValueError('Incomplete license document: '+document['asset'])
|
|
||||||
for document in component.get('buildDocuments', []):
|
|
||||||
if not archive.read('assets/'+document['asset']):
|
|
||||||
raise ValueError('Missing build document')
|
|
||||||
if source['schemaVersion'] != 1 or len(source['revision']) != 40:
|
if source['schemaVersion'] != 1 or len(source['revision']) != 40:
|
||||||
raise ValueError('Missing source revision')
|
raise ValueError('Missing source revision')
|
||||||
for document in source['buildDocuments']:
|
for document in source['buildDocuments']:
|
||||||
if not archive.read('assets/'+document['asset']):
|
if not archive.read('assets/'+document['asset']):
|
||||||
raise ValueError('Missing source build instructions')
|
raise ValueError('Missing source build instructions')
|
||||||
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, privacy policy, notices, About catalog and documents')
|
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, privacy policy and source metadata')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-25
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SCREEN_LOCK_H_
|
||||||
|
#define _SCREEN_LOCK_H_
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
// Called on the application thread. True means the OS accepted the request,
|
||||||
|
// not that the asynchronous transition to the lock screen has finished.
|
||||||
|
bool LockScreen();
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -68,6 +68,13 @@ int ConfigCenter::Load() {
|
|||||||
language_ = static_cast<LANGUAGE>(language_value);
|
language_ = static_cast<LANGUAGE>(language_value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enable_ip_direct_ = ini_->GetBoolValue(section_, "enable_ip_direct", true);
|
||||||
|
connection_mode_ =
|
||||||
|
enable_ip_direct_ &&
|
||||||
|
std::strcmp(ini_->GetValue(section_, "connection_mode", "id"), "ip") == 0
|
||||||
|
? CONNECTION_MODE::DIRECT_IP
|
||||||
|
: CONNECTION_MODE::DEVICE_ID;
|
||||||
|
|
||||||
const long screen_capture_method_value = ini_->GetLongValue(
|
const long screen_capture_method_value = ini_->GetLongValue(
|
||||||
section_, "screen_capture_method",
|
section_, "screen_capture_method",
|
||||||
static_cast<long>(ScreenCaptureMethod::Auto));
|
static_cast<long>(ScreenCaptureMethod::Auto));
|
||||||
@@ -130,6 +137,8 @@ int ConfigCenter::Load() {
|
|||||||
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
|
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||||
enable_privacy_screen_.store(
|
enable_privacy_screen_.store(
|
||||||
ini_->GetBoolValue(section_, "enable_privacy_screen", true));
|
ini_->GetBoolValue(section_, "enable_privacy_screen", true));
|
||||||
|
lock_on_disconnect_.store(
|
||||||
|
ini_->GetBoolValue(section_, "lock_on_disconnect", true));
|
||||||
save_remote_previews_.store(
|
save_remote_previews_.store(
|
||||||
ini_->GetBoolValue(section_, "save_remote_previews", true));
|
ini_->GetBoolValue(section_, "save_remote_previews", true));
|
||||||
portable_service_prompt_suppressed_ =
|
portable_service_prompt_suppressed_ =
|
||||||
@@ -201,6 +210,9 @@ int ConfigCenter::StoreValues(
|
|||||||
|
|
||||||
int ConfigCenter::Save() {
|
int ConfigCenter::Save() {
|
||||||
ini_->SetLongValue(section_, "language", static_cast<long>(language_));
|
ini_->SetLongValue(section_, "language", static_cast<long>(language_));
|
||||||
|
ini_->SetValue(section_, "connection_mode",
|
||||||
|
connection_mode_ == CONNECTION_MODE::DIRECT_IP ? "ip" : "id");
|
||||||
|
ini_->SetBoolValue(section_, "enable_ip_direct", enable_ip_direct_);
|
||||||
ini_->SetLongValue(section_, "screen_capture_method",
|
ini_->SetLongValue(section_, "screen_capture_method",
|
||||||
static_cast<long>(screen_capture_method_));
|
static_cast<long>(screen_capture_method_));
|
||||||
ini_->SetLongValue(section_, "video_encode_format",
|
ini_->SetLongValue(section_, "video_encode_format",
|
||||||
@@ -222,6 +234,7 @@ int ConfigCenter::Save() {
|
|||||||
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
|
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||||
ini_->SetBoolValue(section_, "enable_privacy_screen",
|
ini_->SetBoolValue(section_, "enable_privacy_screen",
|
||||||
enable_privacy_screen_.load());
|
enable_privacy_screen_.load());
|
||||||
|
ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
|
||||||
ini_->SetBoolValue(section_, "save_remote_previews",
|
ini_->SetBoolValue(section_, "save_remote_previews",
|
||||||
save_remote_previews_.load());
|
save_remote_previews_.load());
|
||||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||||
@@ -249,6 +262,34 @@ int ConfigCenter::SetLanguage(LANGUAGE language) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int ConfigCenter::SetConnectionMode(CONNECTION_MODE mode) {
|
||||||
|
if (mode != CONNECTION_MODE::DEVICE_ID && mode != CONNECTION_MODE::DIRECT_IP) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (mode == CONNECTION_MODE::DIRECT_IP && !enable_ip_direct_) return -1;
|
||||||
|
if (mode == connection_mode_) return 0;
|
||||||
|
if (StoreValues({{"connection_mode",
|
||||||
|
mode == CONNECTION_MODE::DIRECT_IP ? "ip" : "id"}}) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
connection_mode_ = mode;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ConfigCenter::SetIpDirectEnabled(bool enabled) {
|
||||||
|
if (enabled == enable_ip_direct_) return 0;
|
||||||
|
const auto mode = enabled ? connection_mode_ : CONNECTION_MODE::DEVICE_ID;
|
||||||
|
// Persist availability and the fallback mode together, so a failed save
|
||||||
|
// leaves both the current runtime and the next launch unchanged.
|
||||||
|
if (StoreValues({{"enable_ip_direct", enabled ? "true" : "false"},
|
||||||
|
{"connection_mode", mode == CONNECTION_MODE::DIRECT_IP
|
||||||
|
? "ip" : "id"}}) != 0)
|
||||||
|
return -1;
|
||||||
|
enable_ip_direct_ = enabled;
|
||||||
|
connection_mode_ = mode;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
|
int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
|
||||||
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
|
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -278,10 +319,7 @@ int ConfigCenter::SetTurnMode(TURN_MODE mode) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int ConfigCenter::SetTurn(bool enable_turn, bool force_relay) {
|
int ConfigCenter::SetForceRelay(bool force_relay) {
|
||||||
if (!enable_turn) {
|
|
||||||
return SetTurnMode(TURN_MODE::DISABLED);
|
|
||||||
}
|
|
||||||
if (!force_relay) {
|
if (!force_relay) {
|
||||||
return SetTurnMode(TURN_MODE::AUTO_UDP_TCP);
|
return SetTurnMode(TURN_MODE::AUTO_UDP_TCP);
|
||||||
}
|
}
|
||||||
@@ -371,6 +409,17 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int ConfigCenter::SetLockOnDisconnect(bool enabled) {
|
||||||
|
if (StoreValues({{"lock_on_disconnect", enabled ? "true" : "false"}}) != 0)
|
||||||
|
return -1;
|
||||||
|
lock_on_disconnect_.store(enabled);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigCenter::IsLockOnDisconnect() const {
|
||||||
|
return lock_on_disconnect_.load();
|
||||||
|
}
|
||||||
|
|
||||||
int ConfigCenter::SetSaveRemotePreviews(bool enabled) {
|
int ConfigCenter::SetSaveRemotePreviews(bool enabled) {
|
||||||
if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) {
|
if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -409,6 +458,12 @@ int ConfigCenter::SetSponsorshipPromptDismissedVersion(
|
|||||||
|
|
||||||
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; }
|
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; }
|
||||||
|
|
||||||
|
ConfigCenter::CONNECTION_MODE ConfigCenter::GetConnectionMode() const {
|
||||||
|
return connection_mode_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigCenter::IsIpDirectEnabled() const { return enable_ip_direct_; }
|
||||||
|
|
||||||
ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
|
ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
|
||||||
return screen_capture_method_;
|
return screen_capture_method_;
|
||||||
}
|
}
|
||||||
@@ -421,10 +476,6 @@ ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
|
|||||||
return turn_mode_;
|
return turn_mode_;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConfigCenter::IsEnableTurn() const {
|
|
||||||
return turn_mode_ != TURN_MODE::DISABLED;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ConfigCenter::IsForceRelay() const {
|
bool ConfigCenter::IsForceRelay() const {
|
||||||
return turn_mode_ == TURN_MODE::FORCE_UDP || turn_mode_ == TURN_MODE::FORCE_TCP;
|
return turn_mode_ == TURN_MODE::FORCE_UDP || turn_mode_ == TURN_MODE::FORCE_TCP;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace crossdesk {
|
|||||||
class ConfigCenter {
|
class ConfigCenter {
|
||||||
public:
|
public:
|
||||||
enum class LANGUAGE { CHINESE = 0, ENGLISH = 1, RUSSIAN = 2 };
|
enum class LANGUAGE { CHINESE = 0, ENGLISH = 1, RUSSIAN = 2 };
|
||||||
|
enum class CONNECTION_MODE { DEVICE_ID = 0, DIRECT_IP = 1 };
|
||||||
enum class VIDEO_ENCODE_FORMAT { H264 = 0, AV1 = 1 };
|
enum class VIDEO_ENCODE_FORMAT { H264 = 0, AV1 = 1 };
|
||||||
enum class TURN_MODE {
|
enum class TURN_MODE {
|
||||||
DISABLED = 0,
|
DISABLED = 0,
|
||||||
@@ -35,10 +36,12 @@ class ConfigCenter {
|
|||||||
|
|
||||||
// write config
|
// write config
|
||||||
int SetLanguage(LANGUAGE language);
|
int SetLanguage(LANGUAGE language);
|
||||||
|
int SetConnectionMode(CONNECTION_MODE mode);
|
||||||
|
int SetIpDirectEnabled(bool enabled);
|
||||||
int SetScreenCaptureMethod(ScreenCaptureMethod method);
|
int SetScreenCaptureMethod(ScreenCaptureMethod method);
|
||||||
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
|
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
|
||||||
int SetTurnMode(TURN_MODE turn_mode);
|
int SetTurnMode(TURN_MODE turn_mode);
|
||||||
int SetTurn(bool enable_turn, bool force_relay);
|
int SetForceRelay(bool force_relay);
|
||||||
int SetServerHost(const std::string& signal_server_host);
|
int SetServerHost(const std::string& signal_server_host);
|
||||||
int SetServerPort(int signal_server_port);
|
int SetServerPort(int signal_server_port);
|
||||||
int SetServerEndpoint(const std::string& address, int port);
|
int SetServerEndpoint(const std::string& address, int port);
|
||||||
@@ -46,6 +49,7 @@ class ConfigCenter {
|
|||||||
int SetAutostart(bool enable_autostart);
|
int SetAutostart(bool enable_autostart);
|
||||||
int SetDaemon(bool enable_daemon);
|
int SetDaemon(bool enable_daemon);
|
||||||
int SetPrivacyScreen(bool enable_privacy_screen);
|
int SetPrivacyScreen(bool enable_privacy_screen);
|
||||||
|
int SetLockOnDisconnect(bool enabled);
|
||||||
int SetSaveRemotePreviews(bool enabled);
|
int SetSaveRemotePreviews(bool enabled);
|
||||||
int SetPortableServicePromptSuppressed(bool suppressed);
|
int SetPortableServicePromptSuppressed(bool suppressed);
|
||||||
int SetSponsorshipPromptDismissedVersion(const std::string& version);
|
int SetSponsorshipPromptDismissedVersion(const std::string& version);
|
||||||
@@ -54,10 +58,11 @@ class ConfigCenter {
|
|||||||
// read config
|
// read config
|
||||||
|
|
||||||
LANGUAGE GetLanguage() const;
|
LANGUAGE GetLanguage() const;
|
||||||
|
CONNECTION_MODE GetConnectionMode() const;
|
||||||
|
bool IsIpDirectEnabled() const;
|
||||||
ScreenCaptureMethod GetScreenCaptureMethod() const;
|
ScreenCaptureMethod GetScreenCaptureMethod() const;
|
||||||
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
|
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
|
||||||
TURN_MODE GetTurnMode() const;
|
TURN_MODE GetTurnMode() const;
|
||||||
bool IsEnableTurn() const;
|
|
||||||
bool IsForceRelay() const;
|
bool IsForceRelay() const;
|
||||||
bool IsEnableSrtp() const;
|
bool IsEnableSrtp() const;
|
||||||
std::string GetSignalServerHost() const;
|
std::string GetSignalServerHost() const;
|
||||||
@@ -68,6 +73,7 @@ class ConfigCenter {
|
|||||||
bool IsEnableAutostart() const;
|
bool IsEnableAutostart() const;
|
||||||
bool IsEnableDaemon() const;
|
bool IsEnableDaemon() const;
|
||||||
bool IsEnablePrivacyScreen() const;
|
bool IsEnablePrivacyScreen() const;
|
||||||
|
bool IsLockOnDisconnect() const;
|
||||||
bool IsSaveRemotePreviews() const;
|
bool IsSaveRemotePreviews() const;
|
||||||
bool IsPortableServicePromptSuppressed() const;
|
bool IsPortableServicePromptSuppressed() const;
|
||||||
std::string GetSponsorshipPromptDismissedVersion() const;
|
std::string GetSponsorshipPromptDismissedVersion() const;
|
||||||
@@ -86,6 +92,8 @@ class ConfigCenter {
|
|||||||
const char* section_ = "Settings";
|
const char* section_ = "Settings";
|
||||||
|
|
||||||
LANGUAGE language_ = LANGUAGE::CHINESE;
|
LANGUAGE language_ = LANGUAGE::CHINESE;
|
||||||
|
CONNECTION_MODE connection_mode_ = CONNECTION_MODE::DEVICE_ID;
|
||||||
|
bool enable_ip_direct_ = true;
|
||||||
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
|
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
|
||||||
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
|
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
|
||||||
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
|
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
|
||||||
@@ -97,6 +105,7 @@ class ConfigCenter {
|
|||||||
bool enable_autostart_ = false;
|
bool enable_autostart_ = false;
|
||||||
bool enable_daemon_ = false;
|
bool enable_daemon_ = false;
|
||||||
std::atomic<bool> enable_privacy_screen_{true};
|
std::atomic<bool> enable_privacy_screen_{true};
|
||||||
|
std::atomic<bool> lock_on_disconnect_{true};
|
||||||
std::atomic<bool> save_remote_previews_{true};
|
std::atomic<bool> save_remote_previews_{true};
|
||||||
bool portable_service_prompt_suppressed_ = false;
|
bool portable_service_prompt_suppressed_ = false;
|
||||||
std::string sponsorship_prompt_dismissed_version_;
|
std::string sponsorship_prompt_dismissed_version_;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
#include "features/gallery/gallery_window_state.h"
|
#include "features/gallery/gallery_window_state.h"
|
||||||
#include "features/gallery/gallery_workspace.h"
|
#include "features/gallery/gallery_workspace.h"
|
||||||
#include "features/gallery/preview_bandwidth.h"
|
#include "features/gallery/preview_bandwidth.h"
|
||||||
|
#include "features/gallery/preview_resolution.h"
|
||||||
#include "filesystem_utf8.h"
|
#include "filesystem_utf8.h"
|
||||||
#include "localization.h"
|
#include "localization.h"
|
||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
@@ -1034,6 +1035,9 @@ struct GuiApplication::SlintUi {
|
|||||||
bool portable_service_dialog_initialized = false;
|
bool portable_service_dialog_initialized = false;
|
||||||
bool sponsorship_prompt_pending = false;
|
bool sponsorship_prompt_pending = false;
|
||||||
std::string recent_model_signature;
|
std::string recent_model_signature;
|
||||||
|
gallery::PreviewResolutionDebouncer recent_preview_resolution;
|
||||||
|
gallery::PreviewResolution recent_preview_size;
|
||||||
|
std::unordered_map<std::string, slint::Image> recent_preview_images;
|
||||||
std::optional<std::string> recent_group_filter;
|
std::optional<std::string> recent_group_filter;
|
||||||
std::vector<std::string> recent_filter_groups;
|
std::vector<std::string> recent_filter_groups;
|
||||||
slint::Timer timer;
|
slint::Timer timer;
|
||||||
@@ -1162,6 +1166,7 @@ int GuiApplication::Run() {
|
|||||||
updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath());
|
updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath());
|
||||||
#endif
|
#endif
|
||||||
InitializeUi();
|
InitializeUi();
|
||||||
|
InitializeLan();
|
||||||
#ifndef __OHOS__
|
#ifndef __OHOS__
|
||||||
ui_->activation_receiver = std::move(activation_receiver);
|
ui_->activation_receiver = std::move(activation_receiver);
|
||||||
#endif
|
#endif
|
||||||
@@ -1296,6 +1301,10 @@ void GuiApplication::InitializeUi() {
|
|||||||
slint::set_xdg_app_id("crossdesk");
|
slint::set_xdg_app_id("crossdesk");
|
||||||
#endif
|
#endif
|
||||||
ui_ = std::make_unique<SlintUi>();
|
ui_ = std::make_unique<SlintUi>();
|
||||||
|
for (const auto& window : {ui_->main, ui_->wall})
|
||||||
|
window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled());
|
||||||
|
ui_->main->set_lan_mode(config_center_->GetConnectionMode() ==
|
||||||
|
ConfigCenter::CONNECTION_MODE::DIRECT_IP);
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
ui_->shutdown_guard =
|
ui_->shutdown_guard =
|
||||||
std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_);
|
std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_);
|
||||||
@@ -1436,7 +1445,6 @@ void GuiApplication::InitializeSystemTray() {
|
|||||||
};
|
};
|
||||||
auto exit_app = [this] {
|
auto exit_app = [this] {
|
||||||
exit_ = true;
|
exit_ = true;
|
||||||
slint::quit_event_loop();
|
|
||||||
};
|
};
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
ui_->tray = std::make_unique<WinTray>(
|
ui_->tray = std::make_unique<WinTray>(
|
||||||
@@ -1484,7 +1492,8 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
|||||||
auto& main = ui_->Window(gallery_window);
|
auto& main = ui_->Window(gallery_window);
|
||||||
main->set_language_index(language_button_value_);
|
main->set_language_index(language_button_value_);
|
||||||
main->set_codec_index(video_encode_format_button_value_);
|
main->set_codec_index(video_encode_format_button_value_);
|
||||||
main->set_turn_enabled(enable_turn_);
|
main->set_ip_direct_enabled_draft(config_center_->IsIpDirectEnabled());
|
||||||
|
main->set_settings_save_error("");
|
||||||
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
||||||
main->set_self_hosted_enabled(enable_self_hosted_);
|
main->set_self_hosted_enabled(enable_self_hosted_);
|
||||||
main->set_autostart_enabled(enable_autostart_);
|
main->set_autostart_enabled(enable_autostart_);
|
||||||
@@ -1495,6 +1504,7 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
|||||||
main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
|
main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
|
||||||
main->set_privacy_on_connect_enabled(
|
main->set_privacy_on_connect_enabled(
|
||||||
config_center_->IsEnablePrivacyScreen());
|
config_center_->IsEnablePrivacyScreen());
|
||||||
|
main->set_lock_on_disconnect_enabled(config_center_->IsLockOnDisconnect());
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
main->set_capture_method_visible(true);
|
main->set_capture_method_visible(true);
|
||||||
main->set_capture_method_index(
|
main->set_capture_method_index(
|
||||||
@@ -1502,8 +1512,10 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
|||||||
#endif
|
#endif
|
||||||
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||||
main->set_privacy_setting_available(true);
|
main->set_privacy_setting_available(true);
|
||||||
|
main->set_lock_on_disconnect_available(true);
|
||||||
#else
|
#else
|
||||||
main->set_privacy_setting_available(false);
|
main->set_privacy_setting_available(false);
|
||||||
|
main->set_lock_on_disconnect_available(false);
|
||||||
#endif
|
#endif
|
||||||
#if defined(__OHOS__)
|
#if defined(__OHOS__)
|
||||||
// The sandbox default is not the system desktop. Show the actual receive
|
// The sandbox default is not the system desktop. Show the actual receive
|
||||||
@@ -1544,6 +1556,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
|||||||
SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta);
|
SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta);
|
||||||
});
|
});
|
||||||
main->on_open_gallery([this] { ShowGalleryWindow(); });
|
main->on_open_gallery([this] { ShowGalleryWindow(); });
|
||||||
|
main->on_connection_mode_changed([this, main](bool lan_mode) {
|
||||||
|
const auto mode = lan_mode ? ConfigCenter::CONNECTION_MODE::DIRECT_IP
|
||||||
|
: ConfigCenter::CONNECTION_MODE::DEVICE_ID;
|
||||||
|
if (config_center_->SetConnectionMode(mode) != 0) {
|
||||||
|
LOG_WARN("Could not save connection mode preference");
|
||||||
|
main->set_offline_warning(UiText(
|
||||||
|
localization_language_index_ == 0
|
||||||
|
? "无法保存连接模式,下次启动可能不会恢复当前模式。"
|
||||||
|
: localization_language_index_ == 2
|
||||||
|
? "Не удалось сохранить режим подключения. При следующем запуске он может не восстановиться."
|
||||||
|
: "Could not save the connection mode. It may not be restored on the next launch."));
|
||||||
|
}
|
||||||
|
});
|
||||||
main->on_toggle_gallery_fullscreen(
|
main->on_toggle_gallery_fullscreen(
|
||||||
[this] { ToggleGalleryFullscreen(); });
|
[this] { ToggleGalleryFullscreen(); });
|
||||||
main->on_gallery_action(
|
main->on_gallery_action(
|
||||||
@@ -1678,8 +1703,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
|||||||
show_password_ = !show_password_;
|
show_password_ = !show_password_;
|
||||||
main->set_password_visible(show_password_);
|
main->set_password_visible(show_password_);
|
||||||
});
|
});
|
||||||
main->on_reset_password([this](slint::SharedString value) {
|
main->on_reset_password([this, main](slint::SharedString value) {
|
||||||
return RequestPasswordChange(std::string(value));
|
const std::string password(value);
|
||||||
|
if (!main->get_lan_mode()) return RequestPasswordChange(password);
|
||||||
|
if (!lan::ValidPassword(password)) return false;
|
||||||
|
if (!LanAvailable()) return true; // The initialization error is shown on the main view.
|
||||||
|
try {
|
||||||
|
lan_error_.clear();
|
||||||
|
lan_->SetPassword(password);
|
||||||
|
main->set_lan_password_pending(lan_->password_pending());
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
lan_error_ = error.what();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
main->on_refresh_password([this] {
|
main->on_refresh_password([this] {
|
||||||
const auto password = GenerateRandomPassword(password_saved_);
|
const auto password = GenerateRandomPassword(password_saved_);
|
||||||
@@ -1691,6 +1727,106 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
|||||||
}
|
}
|
||||||
RequestPasswordChange(*password);
|
RequestPasswordChange(*password);
|
||||||
});
|
});
|
||||||
|
main->on_lan_select_address([this, main](slint::SharedString address) {
|
||||||
|
if (!LanAvailable()) {
|
||||||
|
main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
lan_->identity().SelectAddress(std::string(address));
|
||||||
|
const auto port =
|
||||||
|
UiText(std::to_string(lan_->identity().PortFor(std::string(address))));
|
||||||
|
main->set_lan_listener_ip(address);
|
||||||
|
main->set_lan_listener_port(port);
|
||||||
|
main->set_lan_local_port(port);
|
||||||
|
main->set_lan_port_dirty(false);
|
||||||
|
main->set_lan_endpoint_error("");
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
main->set_lan_endpoint_error(UiText(error.what()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
main->on_lan_configure([this, main](slint::SharedString address,
|
||||||
|
slint::SharedString port) {
|
||||||
|
if (!LanAvailable()) {
|
||||||
|
main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const auto port_number = lan::ParsePort(std::string(port));
|
||||||
|
lan_->Configure(std::string(address), port_number);
|
||||||
|
const auto normalized_port = UiText(std::to_string(port_number));
|
||||||
|
main->set_lan_listener_ip(address);
|
||||||
|
main->set_lan_listener_port(normalized_port);
|
||||||
|
main->set_lan_local_port(normalized_port);
|
||||||
|
main->set_lan_port_dirty(false);
|
||||||
|
lan_error_.clear();
|
||||||
|
main->set_lan_endpoint_error("");
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
main->set_lan_endpoint_error(UiText(error.what()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
main->on_lan_reset_password([this] {
|
||||||
|
if (LanAvailable()) {
|
||||||
|
lan_error_.clear();
|
||||||
|
lan_->ResetPassword();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
main->on_lan_copy_text([this](slint::SharedString value) {
|
||||||
|
if (!SDL_SetClipboardText(std::string(value).c_str())) {
|
||||||
|
lan_error_ = "Cannot copy text";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
main->on_lan_prepare_connect([this, main](slint::SharedString endpoint) {
|
||||||
|
try {
|
||||||
|
const auto [address, port] = lan::ParseEndpoint(Trim(std::string(endpoint)), 52888);
|
||||||
|
if (!LanAvailable()) return UiText(lan_error_.empty() ? "Busy" : lan_error_);
|
||||||
|
main->set_lan_remote_address(UiText(address));
|
||||||
|
main->set_lan_remote_port(UiText(std::to_string(port)));
|
||||||
|
main->set_lan_retry_after(lan_->RetryAfter(address, port));
|
||||||
|
// History is newest first. Match both address and port, and only use
|
||||||
|
// the latest matching record when an endpoint has been reused.
|
||||||
|
for (const auto& [_, recent] : recent_connections_) {
|
||||||
|
if (recent.lan.device_id.empty() || recent.lan.address != address ||
|
||||||
|
recent.lan.port != port)
|
||||||
|
continue;
|
||||||
|
if (recent.remember_password && lan::ValidPassword(recent.password)) {
|
||||||
|
main->set_lan_access_password(UiText(recent.password));
|
||||||
|
main->set_lan_remember_password(true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lan_error_.clear();
|
||||||
|
main->set_lan_message("");
|
||||||
|
return slint::SharedString{};
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
return UiText(error.what());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
main->on_lan_cancel_connect([this, main] {
|
||||||
|
if (lan_ && lan_connecting_) lan_->Close(lan_connecting_);
|
||||||
|
lan_connecting_.reset();
|
||||||
|
main->set_lan_connecting(false);
|
||||||
|
lan_error_.clear();
|
||||||
|
main->set_lan_message("");
|
||||||
|
});
|
||||||
|
main->on_lan_connect([this, main](slint::SharedString address,
|
||||||
|
slint::SharedString port,
|
||||||
|
slint::SharedString password, bool remember) {
|
||||||
|
try {
|
||||||
|
ConnectLan(std::string(address), lan::ParsePort(std::string(port)),
|
||||||
|
std::string(password), remember);
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
lan_error_ = error.what();
|
||||||
|
}
|
||||||
|
main->set_lan_connecting(lan_connecting_ != nullptr);
|
||||||
|
main->set_lan_message(UiText(lan_error_));
|
||||||
|
});
|
||||||
main->on_connect_requested(
|
main->on_connect_requested(
|
||||||
[this](slint::SharedString id) { ConnectFromUi(std::string(id)); });
|
[this](slint::SharedString id) { ConnectFromUi(std::string(id)); });
|
||||||
main->on_format_remote_id([](slint::SharedString value) {
|
main->on_format_remote_id([](slint::SharedString value) {
|
||||||
@@ -1703,7 +1839,7 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
|||||||
if (auto props = FindRemoteSession(remote_id);
|
if (auto props = FindRemoteSession(remote_id);
|
||||||
props && props->connection_status_ == ConnectionStatus::Connected &&
|
props && props->connection_status_ == ConnectionStatus::Connected &&
|
||||||
!props->closing_) {
|
!props->closing_) {
|
||||||
CloseStreamTab(remote_id);
|
CloseStreamTab(props->remote_id_);
|
||||||
SyncMainWindow();
|
SyncMainWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2266,9 +2402,101 @@ void GuiApplication::BindServerCallbacks() {
|
|||||||
|
|
||||||
void GuiApplication::Tick() {
|
void GuiApplication::Tick() {
|
||||||
if (exit_) {
|
if (exit_) {
|
||||||
slint::quit_event_loop();
|
BeginShutdown();
|
||||||
|
if (ShutdownDrained()) slint::quit_event_loop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
TickLan();
|
||||||
|
if (LanAvailable()) {
|
||||||
|
const auto address = lan_->identity().settings().value("address", "");
|
||||||
|
ui_->main->set_lan_password_pending(lan_->password_pending());
|
||||||
|
ui_->main->set_lan_listening(lan_->listening());
|
||||||
|
if (lan_connecting_ && lan_connecting_->authorized) {
|
||||||
|
ui_->main->set_lan_open(false);
|
||||||
|
lan_connecting_.reset();
|
||||||
|
} else if (lan_connecting_ && lan_connecting_->closing) {
|
||||||
|
lan_connecting_.reset();
|
||||||
|
}
|
||||||
|
ui_->main->set_lan_connecting(lan_connecting_ != nullptr);
|
||||||
|
int retry_after = 0;
|
||||||
|
if (ui_->main->get_lan_open()) {
|
||||||
|
try {
|
||||||
|
retry_after = lan_->RetryAfter(
|
||||||
|
std::string(ui_->main->get_lan_remote_address()),
|
||||||
|
lan::ParsePort(std::string(ui_->main->get_lan_remote_port())));
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
// An incomplete address has no cooldown; input validation handles it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui_->main->set_lan_retry_after(retry_after);
|
||||||
|
if (auto password = lan_->TakeNewPassword(); !password.empty())
|
||||||
|
ui_->main->set_lan_new_password(UiText(password));
|
||||||
|
const auto& available_interfaces = lan_->interfaces();
|
||||||
|
const auto previous = ui_->main->get_lan_interfaces();
|
||||||
|
bool changed = !previous || previous->row_count() != available_interfaces.size();
|
||||||
|
for (size_t i = 0; !changed && i < available_interfaces.size(); ++i)
|
||||||
|
changed = std::string_view(previous->row_data(i).value()) !=
|
||||||
|
available_interfaces[i].address;
|
||||||
|
if (changed) {
|
||||||
|
std::vector<slint::SharedString> addresses;
|
||||||
|
addresses.reserve(available_interfaces.size());
|
||||||
|
for (const auto& network_interface : available_interfaces)
|
||||||
|
addresses.push_back(UiText(network_interface.address));
|
||||||
|
ui_->main->set_lan_interfaces(
|
||||||
|
std::make_shared<slint::VectorModel<slint::SharedString>>(std::move(addresses)));
|
||||||
|
}
|
||||||
|
// Keep the current displayed address while it remains available, including
|
||||||
|
// when a previously missing preferred address returns during a port edit.
|
||||||
|
const auto previous_address = std::string(ui_->main->get_lan_listener_ip());
|
||||||
|
auto display_address = previous_address.empty() ? address : previous_address;
|
||||||
|
int interface_index = -1;
|
||||||
|
for (size_t i = 0; i < available_interfaces.size(); ++i)
|
||||||
|
if (available_interfaces[i].address == display_address) {
|
||||||
|
interface_index = static_cast<int>(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (interface_index < 0) {
|
||||||
|
interface_index = available_interfaces.empty() ? -1 : 0;
|
||||||
|
display_address = interface_index == 0
|
||||||
|
? available_interfaces.front().address : "";
|
||||||
|
}
|
||||||
|
const auto port =
|
||||||
|
UiText(std::to_string(lan_->identity().PortFor(display_address)));
|
||||||
|
if (previous_address != display_address) {
|
||||||
|
// A disappearing address must not transfer its unfinished port edit to
|
||||||
|
// another interface. Explicit selection commits the old edit first.
|
||||||
|
if (!previous_address.empty() && ui_->main->get_lan_port_dirty()) {
|
||||||
|
ui_->main->set_lan_access_warning_endpoint(UiText(previous_address));
|
||||||
|
ui_->main->set_lan_access_warning("Selected LAN interface is unavailable");
|
||||||
|
}
|
||||||
|
ui_->main->set_lan_port_dirty(false);
|
||||||
|
ui_->main->set_lan_endpoint_error("");
|
||||||
|
ui_->main->set_lan_local_port(port);
|
||||||
|
}
|
||||||
|
ui_->main->set_lan_listener_ip(UiText(display_address));
|
||||||
|
ui_->main->set_lan_listener_port(port);
|
||||||
|
ui_->main->set_lan_interface_index(interface_index);
|
||||||
|
ui_->main->set_lan_interface_draft_index(interface_index);
|
||||||
|
if (!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty())
|
||||||
|
ui_->main->set_lan_local_port(port);
|
||||||
|
ui_->main->set_lan_listener_error(UiText(
|
||||||
|
display_address.empty() ? "Selected LAN interface is unavailable"
|
||||||
|
: lan_->ListenerError(display_address)));
|
||||||
|
// Show failures one address at a time, without interrupting edits or another
|
||||||
|
// dialog. A working listener must not clear another address's error.
|
||||||
|
if (ui_->main->get_lan_mode() && ui_->main->get_can_show_sponsorship() &&
|
||||||
|
!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty()) {
|
||||||
|
if (auto issue = lan_->TakeListenerWarning()) {
|
||||||
|
ui_->main->set_lan_access_warning_endpoint(
|
||||||
|
UiText(issue->address + ":" + std::to_string(issue->port)));
|
||||||
|
ui_->main->set_lan_access_warning(UiText(issue->error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto lan_message = lan_error_.rfind("ConnectionFailed", 0) == 0
|
||||||
|
? std::string("ConnectionFailed")
|
||||||
|
: lan_error_;
|
||||||
|
ui_->main->set_lan_message(UiText(lan_message));
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
if (ui_->shutdown_guard) {
|
if (ui_->shutdown_guard) {
|
||||||
ui_->shutdown_guard->Update(ui_->shutdown_reason);
|
ui_->shutdown_guard->Update(ui_->shutdown_reason);
|
||||||
@@ -2356,11 +2584,13 @@ void GuiApplication::Tick() {
|
|||||||
HandleRecentConnections();
|
HandleRecentConnections();
|
||||||
if (was_reloading_recent_connections && !reload_recent_connections_) {
|
if (was_reloading_recent_connections && !reload_recent_connections_) {
|
||||||
ui_->recent_model_signature.clear();
|
ui_->recent_model_signature.clear();
|
||||||
|
ui_->recent_preview_images.clear();
|
||||||
}
|
}
|
||||||
HandleConnectionStatusChange();
|
HandleConnectionStatusChange();
|
||||||
HandlePendingPresenceProbe();
|
HandlePendingPresenceProbe();
|
||||||
HandlePresenceProbeTimeout();
|
HandlePresenceProbeTimeout();
|
||||||
HandlePrivacy();
|
HandlePrivacy();
|
||||||
|
HandleScreenLock();
|
||||||
HandleWindowsServiceIntegration();
|
HandleWindowsServiceIntegration();
|
||||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
SyncXWaylandWindowActivation();
|
SyncXWaylandWindowActivation();
|
||||||
@@ -2461,7 +2691,7 @@ void GuiApplication::ShareLocalCursorState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!is_server_mode_ || !peer_ || connected_controllers.empty()) {
|
if (!is_server_mode_ || connected_controllers.empty()) {
|
||||||
cursor_delivery_states_.clear();
|
cursor_delivery_states_.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2587,9 +2817,8 @@ void GuiApplication::ShareLocalCursorState() {
|
|||||||
action.type = ControlType::cursor_state;
|
action.type = ControlType::cursor_state;
|
||||||
action.cs = outgoing;
|
action.cs = outgoing;
|
||||||
const std::string message = action.to_json();
|
const std::string message = action.to_json();
|
||||||
const int result = SendReliableDataFrameToPeer(
|
const int result = SendControllerData(recipient.remote_id, message.c_str(),
|
||||||
peer_, message.c_str(), message.size(), mouse_label_.c_str(),
|
message.size(), mouse_label_.c_str());
|
||||||
recipient.remote_id.c_str(), recipient.remote_id.size());
|
|
||||||
if (result != 0) {
|
if (result != 0) {
|
||||||
LOG_WARN("Send cursor state to [{}] failed, ret={}",
|
LOG_WARN("Send cursor state to [{}] failed, ret={}",
|
||||||
recipient.remote_id, result);
|
recipient.remote_id, result);
|
||||||
@@ -3008,8 +3237,9 @@ void GuiApplication::SyncMainWindow() {
|
|||||||
std::vector<slint::SharedString> recent_groups{
|
std::vector<slint::SharedString> recent_groups{
|
||||||
tr_group("全部设备", "All devices", "Все устройства"),
|
tr_group("全部设备", "All devices", "Все устройства"),
|
||||||
tr_group("未分组", "Ungrouped", "Без группы")};
|
tr_group("未分组", "Ungrouped", "Без группы")};
|
||||||
|
const bool lan_mode = ui_->main->get_lan_mode();
|
||||||
int recent_group_index = 0;
|
int recent_group_index = 0;
|
||||||
if (ui_->recent_group_filter) {
|
if (!lan_mode && ui_->recent_group_filter) {
|
||||||
if (ui_->recent_group_filter->empty()) recent_group_index = 1;
|
if (ui_->recent_group_filter->empty()) recent_group_index = 1;
|
||||||
else {
|
else {
|
||||||
const auto found = std::find(ui_->gallery.groups.begin(),
|
const auto found = std::find(ui_->gallery.groups.begin(),
|
||||||
@@ -3046,12 +3276,26 @@ void GuiApplication::SyncMainWindow() {
|
|||||||
return props && props->connection_status_ == ConnectionStatus::Connected &&
|
return props && props->connection_status_ == ConnectionStatus::Connected &&
|
||||||
!props->closing_;
|
!props->closing_;
|
||||||
};
|
};
|
||||||
|
const auto preview_size = ui_->recent_preview_resolution.Update(
|
||||||
|
gallery::PreviewResolution::Viewport(
|
||||||
|
ui_->main->get_recent_preview_width(),
|
||||||
|
ui_->main->get_recent_preview_height(),
|
||||||
|
ui_->main->window().scale_factor()),
|
||||||
|
gallery::PreviewResolutionDebouncer::Clock::now());
|
||||||
|
const bool save_previews = config_center_->IsSaveRemotePreviews();
|
||||||
|
if (!(preview_size == ui_->recent_preview_size) || !save_previews) {
|
||||||
|
ui_->recent_preview_size = preview_size;
|
||||||
|
ui_->recent_preview_images.clear();
|
||||||
|
}
|
||||||
std::ostringstream signature_builder;
|
std::ostringstream signature_builder;
|
||||||
signature_builder << config_center_->IsSaveRemotePreviews() << '\n'
|
signature_builder << save_previews << '\n'
|
||||||
|
<< preview_size.width << ':' << preview_size.height << '\n'
|
||||||
|
<< lan_mode << '\n'
|
||||||
<< bool(ui_->recent_group_filter) << '\0'
|
<< bool(ui_->recent_group_filter) << '\0'
|
||||||
<< ui_->recent_group_filter.value_or("") << '\n';
|
<< ui_->recent_group_filter.value_or("") << '\n';
|
||||||
for (const auto& [key, connection] : recent_connections_) {
|
for (const auto& [key, connection] : recent_connections_) {
|
||||||
signature_builder << key << '\0' << connection.remote_id << '\0'
|
signature_builder << key << '\0' << connection.remote_id << '\0'
|
||||||
|
<< connection.lan.address << ':' << connection.lan.port << '\0'
|
||||||
<< connection.remote_host_name << '\0'
|
<< connection.remote_host_name << '\0'
|
||||||
<< static_cast<int>(connection.platform) << '\0'
|
<< static_cast<int>(connection.platform) << '\0'
|
||||||
<< settings_.RecentConnectionDisplayName(connection)
|
<< settings_.RecentConnectionDisplayName(connection)
|
||||||
@@ -3072,7 +3316,9 @@ void GuiApplication::SyncMainWindow() {
|
|||||||
std::vector<ui::RecentConnection> model;
|
std::vector<ui::RecentConnection> model;
|
||||||
model.reserve(recent_connections_.size());
|
model.reserve(recent_connections_.size());
|
||||||
for (const auto& [_, connection] : recent_connections_) {
|
for (const auto& [_, connection] : recent_connections_) {
|
||||||
if (!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter))
|
if (lan_mode != !connection.lan.device_id.empty()) continue;
|
||||||
|
if (!lan_mode &&
|
||||||
|
!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter))
|
||||||
continue;
|
continue;
|
||||||
// Slint-generated scalar fields have no defaults. With no image,
|
// Slint-generated scalar fields have no defaults. With no image,
|
||||||
// has_thumbnail must remain false so the remote platform is rendered.
|
// has_thumbnail must remain false so the remote platform is rendered.
|
||||||
@@ -3082,21 +3328,34 @@ void GuiApplication::SyncMainWindow() {
|
|||||||
item.group_names = UiText(group_labels[connection.remote_id]);
|
item.group_names = UiText(group_labels[connection.remote_id]);
|
||||||
item.display_name =
|
item.display_name =
|
||||||
UiText(settings_.RecentConnectionDisplayName(connection));
|
UiText(settings_.RecentConnectionDisplayName(connection));
|
||||||
item.online = device_presence_cache_.IsOnline(connection.remote_id);
|
item.lan = !connection.lan.device_id.empty();
|
||||||
|
item.endpoint = UiText(item.lan ? connection.lan.address + ":" +
|
||||||
|
std::to_string(connection.lan.port)
|
||||||
|
: "");
|
||||||
|
item.online =
|
||||||
|
!item.lan && device_presence_cache_.IsOnline(connection.remote_id);
|
||||||
item.connected = is_connected(connection.remote_id);
|
item.connected = is_connected(connection.remote_id);
|
||||||
item.platform = UiText(HostPlatformName(connection.platform));
|
item.platform = UiText(HostPlatformName(connection.platform));
|
||||||
std::vector<unsigned char> rgba;
|
if (save_previews && !connection.image_path.empty()) {
|
||||||
int image_width = 0;
|
const auto path = connection.image_path.string();
|
||||||
int image_height = 0;
|
auto cached = ui_->recent_preview_images.find(path);
|
||||||
if (config_center_->IsSaveRemotePreviews() &&
|
if (cached == ui_->recent_preview_images.end()) {
|
||||||
!connection.image_path.empty() &&
|
std::vector<unsigned char> rgba;
|
||||||
thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width,
|
int image_width = 0, image_height = 0;
|
||||||
&image_height)) {
|
if (thumbnail_->DecodeImage(connection.image_path, preview_size.width,
|
||||||
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
|
preview_size.height, &rgba, &image_width,
|
||||||
image_height);
|
&image_height)) {
|
||||||
std::memcpy(pixels.begin(), rgba.data(), rgba.size());
|
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
|
||||||
item.thumbnail = slint::Image(std::move(pixels));
|
image_height);
|
||||||
item.has_thumbnail = true;
|
std::memcpy(pixels.begin(), rgba.data(), rgba.size());
|
||||||
|
cached = ui_->recent_preview_images.emplace(
|
||||||
|
path, slint::Image(std::move(pixels))).first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cached != ui_->recent_preview_images.end()) {
|
||||||
|
item.thumbnail = cached->second;
|
||||||
|
item.has_thumbnail = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
model.push_back(std::move(item));
|
model.push_back(std::move(item));
|
||||||
}
|
}
|
||||||
@@ -3163,7 +3422,9 @@ void GuiApplication::SyncConnectionDialog() {
|
|||||||
} else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) {
|
} else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) {
|
||||||
text = localization::connection_not_authenticated[localization_language_index_];
|
text = localization::connection_not_authenticated[localization_language_index_];
|
||||||
} else {
|
} else {
|
||||||
text = ConnectionStatusText(status, localization_language_index_);
|
text = props->lan_session_ && !props->lan_session_->error.empty()
|
||||||
|
? props->lan_session_->error
|
||||||
|
: ConnectionStatusText(status, localization_language_index_);
|
||||||
}
|
}
|
||||||
if (auth.retry_after > 0) {
|
if (auth.retry_after > 0) {
|
||||||
char retry_text[256];
|
char retry_text[256];
|
||||||
@@ -4260,6 +4521,7 @@ bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) {
|
|||||||
bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
||||||
const auto previous_server = GetSignalServerSettings(*config_center_);
|
const auto previous_server = GetSignalServerSettings(*config_center_);
|
||||||
auto& main = ui_->Window(gallery_window);
|
auto& main = ui_->Window(gallery_window);
|
||||||
|
main->set_settings_save_error("");
|
||||||
const bool has_endpoint = main->get_self_hosted_enabled() ||
|
const bool has_endpoint = main->get_self_hosted_enabled() ||
|
||||||
!std::string(main->get_server_host()).empty() ||
|
!std::string(main->get_server_host()).empty() ||
|
||||||
!std::string(main->get_server_port()).empty();
|
!std::string(main->get_server_port()).empty();
|
||||||
@@ -4282,7 +4544,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
|||||||
if (!wall_settings || (wall_changed && !updated_wall.Save(ui_->gallery_path))) {
|
if (!wall_settings || (wall_changed && !updated_wall.Save(ui_->gallery_path))) {
|
||||||
main->set_gallery_settings_error(UiText(
|
main->set_gallery_settings_error(UiText(
|
||||||
localization_language_index_ == 0 ? "保存失败,请重试"
|
localization_language_index_ == 0 ? "保存失败,请重试"
|
||||||
: localization_language_index_ == 2 ? "Не удалось сохранить"
|
: localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
|
||||||
: "Could not save; try again"));
|
: "Could not save; try again"));
|
||||||
main->set_settings_tab(1);
|
main->set_settings_tab(1);
|
||||||
return false;
|
return false;
|
||||||
@@ -4298,10 +4560,29 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
|||||||
main->set_self_host_settings_open(true);
|
main->set_self_host_settings_open(true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (config_center_->SetIpDirectEnabled(main->get_ip_direct_enabled_draft()) != 0) {
|
||||||
|
if (wall_changed && !ui_->gallery.Save(ui_->gallery_path))
|
||||||
|
LOG_ERROR("Failed to restore DeskHub settings after IP direct save failure");
|
||||||
|
main->set_settings_save_error(UiText(
|
||||||
|
localization_language_index_ == 0 ? "无法保存 IP 连接设置,请重试。"
|
||||||
|
: localization_language_index_ == 2 ? "Не удалось сохранить настройки подключения по IP. Повторите попытку."
|
||||||
|
: "Could not save IP connection settings. Try again."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||||
|
if (config_center_->SetLockOnDisconnect(main->get_lock_on_disconnect_enabled()) != 0) {
|
||||||
|
if (wall_changed && !ui_->gallery.Save(ui_->gallery_path))
|
||||||
|
LOG_ERROR("Failed to restore DeskHub settings after lock preference save failure");
|
||||||
|
main->set_settings_save_error(UiText(
|
||||||
|
localization_language_index_ == 0 ? "无法保存断开后锁屏设置,请重试。"
|
||||||
|
: localization_language_index_ == 2 ? "Не удалось сохранить настройку блокировки при отключении. Повторите попытку."
|
||||||
|
: "Could not save lock-on-disconnect settings. Try again."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
language_button_value_ =
|
language_button_value_ =
|
||||||
localization::detail::ClampLanguageIndex(main->get_language_index());
|
localization::detail::ClampLanguageIndex(main->get_language_index());
|
||||||
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
|
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
|
||||||
enable_turn_ = main->get_turn_enabled();
|
|
||||||
enable_self_hosted_ = main->get_self_hosted_enabled();
|
enable_self_hosted_ = main->get_self_hosted_enabled();
|
||||||
#if !defined(__OHOS__)
|
#if !defined(__OHOS__)
|
||||||
enable_autostart_ = main->get_autostart_enabled();
|
enable_autostart_ = main->get_autostart_enabled();
|
||||||
@@ -4317,7 +4598,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
|||||||
config_center_->SetVideoEncodeFormat(
|
config_center_->SetVideoEncodeFormat(
|
||||||
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
|
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
|
||||||
video_encode_format_button_value_));
|
video_encode_format_button_value_));
|
||||||
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
|
config_center_->SetForceRelay(main->get_force_relay_enabled());
|
||||||
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
||||||
#if !defined(__OHOS__)
|
#if !defined(__OHOS__)
|
||||||
config_center_->SetAutostart(enable_autostart_);
|
config_center_->SetAutostart(enable_autostart_);
|
||||||
@@ -4383,7 +4664,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
|||||||
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
|
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
|
||||||
localization_language_ = config_center_->GetLanguage();
|
localization_language_ = config_center_->GetLanguage();
|
||||||
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
|
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
|
||||||
enable_turn_ = config_center_->IsEnableTurn();
|
|
||||||
enable_self_hosted_ = config_center_->IsSelfHosted();
|
enable_self_hosted_ = config_center_->IsSelfHosted();
|
||||||
enable_autostart_ = config_center_->IsEnableAutostart();
|
enable_autostart_ = config_center_->IsEnableAutostart();
|
||||||
enable_daemon_ = config_center_->IsEnableDaemon();
|
enable_daemon_ = config_center_->IsEnableDaemon();
|
||||||
@@ -4397,7 +4677,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
|||||||
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
|
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
|
||||||
language_button_value_last_ = language_button_value_;
|
language_button_value_last_ = language_button_value_;
|
||||||
video_encode_format_button_value_last_ = video_encode_format_button_value_;
|
video_encode_format_button_value_last_ = video_encode_format_button_value_;
|
||||||
enable_turn_last_ = enable_turn_;
|
|
||||||
enable_self_hosted_last_ = enable_self_hosted_;
|
enable_self_hosted_last_ = enable_self_hosted_;
|
||||||
enable_autostart_last_ = enable_autostart_;
|
enable_autostart_last_ = enable_autostart_;
|
||||||
enable_daemon_last_ = enable_daemon_;
|
enable_daemon_last_ = enable_daemon_;
|
||||||
@@ -4407,9 +4686,12 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
|||||||
#endif
|
#endif
|
||||||
if (ui_) {
|
if (ui_) {
|
||||||
ui_->localized_language = -1;
|
ui_->localized_language = -1;
|
||||||
|
for (const auto& window : {ui_->main, ui_->wall})
|
||||||
|
window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled());
|
||||||
if (!ui_->main->get_settings_open()) ResetSettingsUi();
|
if (!ui_->main->get_settings_open()) ResetSettingsUi();
|
||||||
if (!ui_->wall->get_settings_open()) ResetSettingsUi(true);
|
if (!ui_->wall->get_settings_open()) ResetSettingsUi(true);
|
||||||
}
|
}
|
||||||
|
ApplyLanSettings();
|
||||||
if (reconnect && !HasActiveSession()) {
|
if (reconnect && !HasActiveSession()) {
|
||||||
// Tick recreates the peer after asynchronous cleanup, using the committed
|
// Tick recreates the peer after asynchronous cleanup, using the committed
|
||||||
// configuration. Keep the application and its console running throughout.
|
// configuration. Keep the application and its console running throughout.
|
||||||
@@ -4434,6 +4716,26 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
|||||||
|
|
||||||
void GuiApplication::ConnectFromUi(const std::string& input) {
|
void GuiApplication::ConnectFromUi(const std::string& input) {
|
||||||
ui_->gallery_auth_id.clear();
|
ui_->gallery_auth_id.clear();
|
||||||
|
if (input.rfind("lan-", 0) == 0) {
|
||||||
|
if (!LanAvailable()) return;
|
||||||
|
if (auto existing = FindRemoteSession(input);
|
||||||
|
existing && existing->connection_established_) {
|
||||||
|
existing->tab_opened_ = true;
|
||||||
|
focused_remote_id_ = existing->remote_id_;
|
||||||
|
need_to_create_stream_window_ = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const auto& [_, recent] : recent_connections_)
|
||||||
|
if (recent.remote_id == input && !recent.lan.device_id.empty()) {
|
||||||
|
ui_->main->invoke_request_lan_connection(
|
||||||
|
UiText(recent.lan.address +
|
||||||
|
(recent.lan.port == 52888
|
||||||
|
? ""
|
||||||
|
: ":" + std::to_string(recent.lan.port))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const std::string remote_id = CompactPeerId(input);
|
const std::string remote_id = CompactPeerId(input);
|
||||||
if (!gallery::Workspace::ValidId(remote_id)) {
|
if (!gallery::Workspace::ValidId(remote_id)) {
|
||||||
ui_->main->set_connection_input_error(
|
ui_->main->set_connection_input_error(
|
||||||
@@ -5009,8 +5311,8 @@ bool GuiApplication::AddGalleryDevice(const std::string& input,
|
|||||||
password.find_first_not_of(
|
password.find_first_not_of(
|
||||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") !=
|
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") !=
|
||||||
std::string::npos)
|
std::string::npos)
|
||||||
return error("请输入不超过 6 位的字母或数字密码",
|
return error("请输入 1–6 位英文字母或数字密码",
|
||||||
"Use a password of 1–6 letters or digits",
|
"Use 1–6 letters (A–Z, a–z) or digits (0–9)",
|
||||||
"Пароль: 1–6 латинских букв или цифр");
|
"Пароль: 1–6 латинских букв или цифр");
|
||||||
if (!signal_connected_)
|
if (!signal_connected_)
|
||||||
return error("尚未连接服务器,请稍后重试",
|
return error("尚未连接服务器,请稍后重试",
|
||||||
@@ -5028,7 +5330,7 @@ bool GuiApplication::AddGalleryDevice(const std::string& input,
|
|||||||
updated.Add(id, Trim(group));
|
updated.Add(id, Trim(group));
|
||||||
if (!updated.Save(ui_->gallery_path))
|
if (!updated.Save(ui_->gallery_path))
|
||||||
return error("保存失败,请重试", "Could not save; try again",
|
return error("保存失败,请重试", "Could not save; try again",
|
||||||
"Не удалось сохранить");
|
"Не удалось сохранить. Повторите попытку");
|
||||||
ui_->gallery = std::move(updated);
|
ui_->gallery = std::move(updated);
|
||||||
ui_->gallery_hidden_active_ids.erase(id);
|
ui_->gallery_hidden_active_ids.erase(id);
|
||||||
if (auto props = FindRemoteSession(id)) {
|
if (auto props = FindRemoteSession(id)) {
|
||||||
@@ -5071,7 +5373,7 @@ bool GuiApplication::ImportGalleryRecent(const std::string& input_group) {
|
|||||||
if (!updated.Save(ui_->gallery_path)) {
|
if (!updated.Save(ui_->gallery_path)) {
|
||||||
ui_->wall->set_gallery_notice(UiText(
|
ui_->wall->set_gallery_notice(UiText(
|
||||||
localization_language_index_ == 0 ? "保存失败,请重试"
|
localization_language_index_ == 0 ? "保存失败,请重试"
|
||||||
: localization_language_index_ == 2 ? "Не удалось сохранить"
|
: localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
|
||||||
: "Could not save; try again"));
|
: "Could not save; try again"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -5153,7 +5455,7 @@ bool GuiApplication::SaveDeviceGroups(const std::string& new_group,
|
|||||||
if (!updated.SetGroups(id, names) || !updated.Save(ui_->gallery_path)) {
|
if (!updated.SetGroups(id, names) || !updated.Save(ui_->gallery_path)) {
|
||||||
main->set_device_group_error(UiText(
|
main->set_device_group_error(UiText(
|
||||||
localization_language_index_ == 0 ? "保存失败,请重试"
|
localization_language_index_ == 0 ? "保存失败,请重试"
|
||||||
: localization_language_index_ == 2 ? "Не удалось сохранить"
|
: localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
|
||||||
: "Could not save; try again"));
|
: "Could not save; try again"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -5209,6 +5511,7 @@ void GuiApplication::GalleryAction(const std::string& action,
|
|||||||
std::set<std::string> seen;
|
std::set<std::string> seen;
|
||||||
int available = 0;
|
int available = 0;
|
||||||
for (const auto& [_, recent] : recent_connections_) {
|
for (const auto& [_, recent] : recent_connections_) {
|
||||||
|
if (!recent.lan.device_id.empty()) continue;
|
||||||
if (!gallery::Workspace::ValidId(recent.remote_id) ||
|
if (!gallery::Workspace::ValidId(recent.remote_id) ||
|
||||||
!seen.insert(recent.remote_id).second)
|
!seen.insert(recent.remote_id).second)
|
||||||
continue;
|
continue;
|
||||||
@@ -5412,7 +5715,7 @@ void GuiApplication::GalleryAction(const std::string& action,
|
|||||||
SyncGallery();
|
SyncGallery();
|
||||||
};
|
};
|
||||||
if (action == "file") {
|
if (action == "file") {
|
||||||
OpenFileDialog(std::string(tr("选择分发文件", "Select a file", "Выберите файл")),
|
OpenFileDialog(std::string(tr("选择分发文件", "Select a file to send", "Выберите файл для отправки")),
|
||||||
[enqueue](std::string file) {
|
[enqueue](std::string file) {
|
||||||
if (!file.empty()) enqueue(file);
|
if (!file.empty()) enqueue(file);
|
||||||
});
|
});
|
||||||
@@ -5697,8 +6000,9 @@ void GuiApplication::SyncGallery() {
|
|||||||
};
|
};
|
||||||
const auto name = [this](const std::string& id) {
|
const auto name = [this](const std::string& id) {
|
||||||
for (const auto& [_, recent] : recent_connections_)
|
for (const auto& [_, recent] : recent_connections_)
|
||||||
if (recent.remote_id == id)
|
if (recent.lan.device_id.empty())
|
||||||
return settings_.RecentConnectionDisplayName(recent);
|
if (recent.remote_id == id)
|
||||||
|
return settings_.RecentConnectionDisplayName(recent);
|
||||||
Thumbnail::RecentConnection device{};
|
Thumbnail::RecentConnection device{};
|
||||||
device.remote_id = id;
|
device.remote_id = id;
|
||||||
if (auto props = FindRemoteSession(id)) {
|
if (auto props = FindRemoteSession(id)) {
|
||||||
@@ -5716,7 +6020,7 @@ void GuiApplication::SyncGallery() {
|
|||||||
{
|
{
|
||||||
std::shared_lock lock(remote_sessions_mutex_);
|
std::shared_lock lock(remote_sessions_mutex_);
|
||||||
for (const auto& [id, props] : remote_sessions_)
|
for (const auto& [id, props] : remote_sessions_)
|
||||||
if (props && !props->closing_ &&
|
if (props && !props->lan_session_ && !props->closing_ &&
|
||||||
props->connection_status_ == ConnectionStatus::Connected)
|
props->connection_status_ == ConnectionStatus::Connected)
|
||||||
connected_ids.push_back(id);
|
connected_ids.push_back(id);
|
||||||
}
|
}
|
||||||
@@ -5732,9 +6036,9 @@ void GuiApplication::SyncGallery() {
|
|||||||
device.id = UiText(FormatPeerId(id.c_str()));
|
device.id = UiText(FormatPeerId(id.c_str()));
|
||||||
const auto mode = props->traversal_mode_.load();
|
const auto mode = props->traversal_mode_.load();
|
||||||
device.mode = UiText(mode == TraversalMode::P2P
|
device.mode = UiText(mode == TraversalMode::P2P
|
||||||
? tr("直连", "Direct", "Напрямую")
|
? tr("直连", "Direct", "Прямой")
|
||||||
: mode == TraversalMode::UnknownMode
|
: mode == TraversalMode::UnknownMode
|
||||||
? tr("等待统计", "Awaiting stats", "Ждём данные")
|
? tr("等待统计", "Awaiting stats", "Ожидание статистики")
|
||||||
: tr("中继", "Relay", "Ретрансляция"));
|
: tr("中继", "Relay", "Ретрансляция"));
|
||||||
device.inbound = device.outbound = device.loss = "—";
|
device.inbound = device.outbound = device.loss = "—";
|
||||||
device.video_latency = "—";
|
device.video_latency = "—";
|
||||||
@@ -5816,11 +6120,12 @@ void GuiApplication::SyncGallery() {
|
|||||||
bool remember = operation.remember_password;
|
bool remember = operation.remember_password;
|
||||||
if (!operation.password)
|
if (!operation.password)
|
||||||
for (const auto& [_, recent] : recent_connections_)
|
for (const auto& [_, recent] : recent_connections_)
|
||||||
if (recent.remote_id == operation.id) {
|
if (recent.lan.device_id.empty())
|
||||||
password = recent.password;
|
if (recent.remote_id == operation.id) {
|
||||||
remember = recent.remember_password;
|
password = recent.password;
|
||||||
break;
|
remember = recent.remember_password;
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
// Join itself determines availability. Background probes must never
|
// Join itself determines availability. Background probes must never
|
||||||
// overwrite the interactive page's single pending presence probe.
|
// overwrite the interactive page's single pending presence probe.
|
||||||
ConnectTo(operation.id, password.c_str(), remember, true, true);
|
ConnectTo(operation.id, password.c_str(), remember, true, true);
|
||||||
@@ -5970,6 +6275,7 @@ void GuiApplication::SyncGallery() {
|
|||||||
tile.selected = wall.selected.count(id);
|
tile.selected = wall.selected.count(id);
|
||||||
auto props = FindRemoteSession(id);
|
auto props = FindRemoteSession(id);
|
||||||
for (const auto& [_, recent] : recent_connections_) {
|
for (const auto& [_, recent] : recent_connections_) {
|
||||||
|
if (!recent.lan.device_id.empty()) continue;
|
||||||
if (recent.remote_id != id) continue;
|
if (recent.remote_id != id) continue;
|
||||||
tile.host_name = UiText(recent.remote_host_name);
|
tile.host_name = UiText(recent.remote_host_name);
|
||||||
break;
|
break;
|
||||||
@@ -5995,7 +6301,7 @@ void GuiApplication::SyncGallery() {
|
|||||||
});
|
});
|
||||||
// A queued retry takes precedence over errors from the previous session.
|
// A queued retry takes precedence over errors from the previous session.
|
||||||
if (queued)
|
if (queued)
|
||||||
tile.status = UiText(tr("排队中", "Queued", "В очереди"));
|
tile.status = UiText(tr("队列中", "Queued", "В очереди"));
|
||||||
else if (props && props->connection_auth_.Get().password_required)
|
else if (props && props->connection_auth_.Get().password_required)
|
||||||
tile.status = UiText(tr("需要密码", "Password required", "Нужен пароль"));
|
tile.status = UiText(tr("需要密码", "Password required", "Нужен пароль"));
|
||||||
else if (props && !tile.connected)
|
else if (props && !tile.connected)
|
||||||
@@ -6205,6 +6511,9 @@ void GuiApplication::SyncGallery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GuiApplication::Cleanup() {
|
void GuiApplication::Cleanup() {
|
||||||
|
BeginShutdown();
|
||||||
|
while (!ShutdownDrained())
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||||
remote_input_.Stop();
|
remote_input_.Stop();
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
|
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
|
||||||
@@ -6245,6 +6554,7 @@ void GuiApplication::Cleanup() {
|
|||||||
keyboard_.ReleaseAllRemotePressedKeys("application_exit");
|
keyboard_.ReleaseAllRemotePressedKeys("application_exit");
|
||||||
devices_.DestroyDevices();
|
devices_.DestroyDevices();
|
||||||
privacy_.Shutdown();
|
privacy_.Shutdown();
|
||||||
|
HandleScreenLock();
|
||||||
devices_.DestroyFactories();
|
devices_.DestroyFactories();
|
||||||
CloseAllRemoteSessions();
|
CloseAllRemoteSessions();
|
||||||
WaitForSessionCleanup();
|
WaitForSessionCleanup();
|
||||||
|
|||||||
@@ -21,23 +21,30 @@ struct TranslationRow {
|
|||||||
|
|
||||||
// Single source of truth for all UI strings.
|
// Single source of truth for all UI strings.
|
||||||
#define CROSSDESK_LOCALIZATION_ALL(X) \
|
#define CROSSDESK_LOCALIZATION_ALL(X) \
|
||||||
|
X(window_minimize, u8"最小化", "Minimize", u8"Свернуть") \
|
||||||
|
X(window_maximize, u8"最大化", "Maximize", u8"Развернуть") \
|
||||||
|
X(window_restore, u8"还原窗口", "Restore window", u8"Восстановить окно") \
|
||||||
|
X(window_close, u8"关闭窗口", "Close window", u8"Закрыть окно") \
|
||||||
|
X(save, u8"保存", "Save", u8"Сохранить") \
|
||||||
|
X(copyright_notice, u8"© 2026 JUNKUN DI。保留所有权利。", "© 2026 by JUNKUN DI. All rights reserved.", u8"© 2026 JUNKUN DI. Все права защищены.") \
|
||||||
|
X(license_notice, u8"依据 GNU GPL v3 许可发布。", "Licensed under GNU GPL v3.", u8"Распространяется по лицензии GNU GPL v3.") \
|
||||||
X(messages, u8"消息", "Messages", u8"Сообщения") \
|
X(messages, u8"消息", "Messages", u8"Сообщения") \
|
||||||
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
|
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
|
||||||
X(message_read, u8"已读", "Read", u8"Прочитано") \
|
X(message_read, u8"已读", "Read", u8"Прочитано") \
|
||||||
X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \
|
X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \
|
||||||
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
|
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
|
||||||
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \
|
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление для просмотра") \
|
||||||
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
|
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
|
||||||
X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения. Повторите попытку.") \
|
X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения локально. Повторите попытку.") \
|
||||||
X(message_close, u8"关闭", "Close", u8"Закрыть") \
|
X(message_close, u8"关闭", "Close", u8"Закрыть") \
|
||||||
X(message_delete, u8"删除", "Delete", u8"Удалить") \
|
X(message_delete, u8"删除", "Delete", u8"Удалить") \
|
||||||
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
|
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
|
||||||
X(message_delete_confirmation, u8"删除这条公告?", "Delete this announcement?", u8"Удалить это объявление?") \
|
X(message_delete_confirmation, u8"删除这条公告?", "Delete this announcement?", u8"Удалить это объявление?") \
|
||||||
X(message_delete_error, u8"无法删除这条公告,请重试。", "Unable to delete this announcement. Please try again.", u8"Не удалось удалить объявление. Повторите попытку.") \
|
X(message_delete_error, u8"无法删除这条公告,请重试。", "Unable to delete this announcement. Please try again.", u8"Не удалось удалить объявление. Повторите попытку.") \
|
||||||
X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \
|
X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \
|
||||||
X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk") \
|
X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk без графического интерфейса") \
|
||||||
X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \
|
X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \
|
||||||
X(console_help, u8"命令: status 查看ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status, settings, password, random, help, quit", u8"Команды: status, settings, password, random, help, quit") \
|
X(console_help, u8"命令: status 查看 ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status — show ID/password/status; settings — configure; password — change password; random — random password; help — help; quit — exit", u8"Команды: status — ID/пароль/состояние; settings — настройки; password — сменить пароль; random — случайный пароль; help — справка; quit — выход") \
|
||||||
X(console_status, u8"状态", "Status", u8"Состояние") \
|
X(console_status, u8"状态", "Status", u8"Состояние") \
|
||||||
X(console_online, u8"在线", "Online", u8"В сети") \
|
X(console_online, u8"在线", "Online", u8"В сети") \
|
||||||
X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
|
X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
|
||||||
@@ -77,7 +84,7 @@ struct TranslationRow {
|
|||||||
X(console_settings_next_connection, u8"已保存,将用于后续连接。", "Saved; applies to subsequent connections.", u8"Сохранено. Будет применено к следующим подключениям.") \
|
X(console_settings_next_connection, u8"已保存,将用于后续连接。", "Saved; applies to subsequent connections.", u8"Сохранено. Будет применено к следующим подключениям.") \
|
||||||
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \
|
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \
|
||||||
X(console_settings_autostart, u8"已保存,在下次图形桌面登录时生效。", "Saved; applies at the next graphical desktop login.", u8"Сохранено. Будет применено при следующем входе в графический сеанс.") \
|
X(console_settings_autostart, u8"已保存,在下次图形桌面登录时生效。", "Saved; applies at the next graphical desktop login.", u8"Сохранено. Будет применено при следующем входе в графический сеанс.") \
|
||||||
X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль остаётся на переднем плане.") \
|
X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль без графического интерфейса остаётся на переднем плане.") \
|
||||||
X(console_settings_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", u8"Укажите адрес и порт сервера перед включением собственного сервера.") \
|
X(console_settings_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", u8"Укажите адрес и порт сервера перед включением собственного сервера.") \
|
||||||
X(console_settings_path_invalid, u8"请选择已存在且可写的绝对目录路径;default 使用默认目录。", "Use an existing writable absolute directory, or default for the default location.", u8"Укажите существующий доступный для записи абсолютный путь к каталогу или default.") \
|
X(console_settings_path_invalid, u8"请选择已存在且可写的绝对目录路径;default 使用默认目录。", "Use an existing writable absolute directory, or default for the default location.", u8"Укажите существующий доступный для записи абсолютный путь к каталогу или default.") \
|
||||||
X(console_settings_readonly, u8"该选项由系统或控制端管理,不可在此修改。", "This option is managed by the system or controller and is read-only here.", u8"Этот параметр управляется системой или клиентом и здесь недоступен для изменения.") \
|
X(console_settings_readonly, u8"该选项由系统或控制端管理,不可在此修改。", "This option is managed by the system or controller and is read-only here.", u8"Этот параметр управляется системой или клиентом и здесь недоступен для изменения.") \
|
||||||
@@ -91,7 +98,7 @@ struct TranslationRow {
|
|||||||
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
|
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
|
||||||
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
|
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
|
||||||
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \
|
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \
|
||||||
X(save_remote_previews, u8"保存远程画面预览:", "Save remote screen previews:", u8"Сохранять превью экрана:") \
|
X(save_remote_previews, u8"保存远程画面预览:", "Save remote screen previews:", u8"Сохранять превью удалённого экрана:") \
|
||||||
X(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \
|
X(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \
|
||||||
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
|
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
|
||||||
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
|
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
|
||||||
@@ -100,17 +107,17 @@ struct TranslationRow {
|
|||||||
X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
|
X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
|
||||||
X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \
|
X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \
|
||||||
X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \
|
X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \
|
||||||
X(local_id, u8"本机ID", "Local ID", u8"Локальный ID") \
|
X(local_id, u8"本机 ID", "Local ID", u8"Локальный ID") \
|
||||||
X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
|
X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
|
||||||
u8"Скопировано в буфер обмена") \
|
u8"Скопировано в буфер обмена") \
|
||||||
X(password, u8"密码", "Password", u8"Пароль") \
|
X(password, u8"密码", "Password", u8"Пароль") \
|
||||||
X(refresh_password, u8"刷新随机密码", "Refresh random password", \
|
X(refresh_password, u8"刷新随机密码", "Refresh random password", \
|
||||||
u8"Обновить случайный пароль") \
|
u8"Обновить случайный пароль") \
|
||||||
X(max_password_len, u8"最大6个字符", "Max 6 chars", u8"Макс. 6 символов") \
|
X(password_format_invalid, u8"密码必须为 6 位英文字母或数字。", "Use exactly 6 letters (A–Z, a–z) or digits (0–9).", u8"Пароль должен содержать ровно 6 латинских букв или цифр.") \
|
||||||
X(remote_desktop, u8"远程桌面", "Remote Desktop", \
|
X(remote_desktop, u8"远程桌面", "Remote Desktop", \
|
||||||
u8"Удаленный рабочий стол") \
|
u8"Удаленный рабочий стол") \
|
||||||
X(device_name, u8"设备名称", "Device Name", u8"Имя устройства") \
|
X(device_name, u8"设备名称", "Device Name", u8"Имя устройства") \
|
||||||
X(remote_id, u8"对端ID", "Remote ID", u8"Удаленный ID") \
|
X(remote_id, u8"对端 ID", "Remote ID", u8"Удаленный ID") \
|
||||||
X(connect, u8"连接", "Connect", u8"Подключиться") \
|
X(connect, u8"连接", "Connect", u8"Подключиться") \
|
||||||
X(recent_connections, u8"近期连接", "Recent Connections", \
|
X(recent_connections, u8"近期连接", "Recent Connections", \
|
||||||
u8"Недавние подключения") \
|
u8"Недавние подключения") \
|
||||||
@@ -126,11 +133,9 @@ struct TranslationRow {
|
|||||||
u8"Развернуть панель управления") \
|
u8"Развернуть панель управления") \
|
||||||
X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \
|
X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \
|
||||||
u8"Свернуть панель управления") \
|
u8"Свернуть панель управления") \
|
||||||
X(fullscreen, u8"全屏", " Fullscreen", u8"Полный экран") \
|
X(fullscreen, u8"全屏", "Fullscreen", u8"На весь экран") \
|
||||||
X(show_net_traffic_stats, u8"显示网络状态", "Show Net Traffic Stats", \
|
X(show_net_traffic_stats, u8"显示网络状态", "Show network status", u8"Показать состояние сети") \
|
||||||
u8"Показать статистику трафика") \
|
X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide network status", u8"Скрыть состояние сети") \
|
||||||
X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide Net Traffic Stats", \
|
|
||||||
u8"Скрыть статистику трафика") \
|
|
||||||
X(video, u8"视频", "Video", u8"Видео") \
|
X(video, u8"视频", "Video", u8"Видео") \
|
||||||
X(audio, u8"音频", "Audio", u8"Аудио") \
|
X(audio, u8"音频", "Audio", u8"Аудио") \
|
||||||
X(data, u8"数据", "Data", u8"Данные") \
|
X(data, u8"数据", "Data", u8"Данные") \
|
||||||
@@ -142,19 +147,15 @@ struct TranslationRow {
|
|||||||
u8"Выйти из полноэкранного режима") \
|
u8"Выйти из полноэкранного режима") \
|
||||||
X(control_mouse, u8"控制鼠标", "Control Mouse", u8"Управление мышью") \
|
X(control_mouse, u8"控制鼠标", "Control Mouse", u8"Управление мышью") \
|
||||||
X(release_mouse, u8"释放鼠标", "Release Mouse", u8"Освободить мышь") \
|
X(release_mouse, u8"释放鼠标", "Release Mouse", u8"Освободить мышь") \
|
||||||
X(audio_capture, u8"播放声音", "Audio Capture", u8"Воспроизведение звука") \
|
X(audio_capture, u8"播放声音", "Play audio", u8"Воспроизведение звука") \
|
||||||
X(mute, u8" 静音", " Mute", u8"Без звука") \
|
X(mute, u8"静音", "Mute", u8"Без звука") \
|
||||||
X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Сочетания клавиш") \
|
X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Отправить сочетание клавиш") \
|
||||||
X(send_sas, u8"发送SAS", "Send SAS", u8"Отправить SAS") \
|
X(send_sas, u8"发送 SAS", "Send SAS", u8"Отправить SAS") \
|
||||||
X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать") \
|
X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать удалённое устройство") \
|
||||||
X(remote_password_box_visible, u8"远端密码框已出现", \
|
X(remote_password_box_visible, u8"远端密码框已出现", "Remote password box visible", u8"На удалённом устройстве появилось поле пароля") \
|
||||||
"Remote password box visible", u8"Окно ввода пароля видно") \
|
X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送 SAS", "Remote lock screen visible, send SAS", u8"Удалённый экран заблокирован, можно отправить SAS") \
|
||||||
X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送SAS", \
|
X(remote_secure_desktop_active, u8"远端已进入安全桌面", "Remote secure desktop active", u8"На удалённом устройстве активен защищённый рабочий стол") \
|
||||||
"Remote lock screen visible, send SAS", \
|
X(remote_service_unavailable, u8"远端 Windows 服务不可用", \
|
||||||
u8"Видна блокировка, отправьте SAS") \
|
|
||||||
X(remote_secure_desktop_active, u8"远端已进入安全桌面", \
|
|
||||||
"Remote secure desktop active", u8"Активен защищенный рабочий стол") \
|
|
||||||
X(remote_service_unavailable, u8"远端Windows服务不可用", \
|
|
||||||
"Remote Windows service unavailable", \
|
"Remote Windows service unavailable", \
|
||||||
u8"Служба Windows на удаленной стороне недоступна") \
|
u8"Служба Windows на удаленной стороне недоступна") \
|
||||||
X(windows_service_setup_title, u8"安装 CrossDesk Service", \
|
X(windows_service_setup_title, u8"安装 CrossDesk Service", \
|
||||||
@@ -173,8 +174,7 @@ struct TranslationRow {
|
|||||||
u8"нормально; будет недоступно только управление этим компьютером на " \
|
u8"нормально; будет недоступно только управление этим компьютером на " \
|
||||||
u8"экране блокировки.") \
|
u8"экране блокировки.") \
|
||||||
X(install_windows_service, u8"安装", "Install", u8"Установить") \
|
X(install_windows_service, u8"安装", "Install", u8"Установить") \
|
||||||
X(windows_service_settings_label, u8"锁屏控制服务:", \
|
X(windows_service_settings_label, u8"锁屏控制服务:", "Lock-screen control service:", u8"Служба управления при блокировке:") \
|
||||||
"Lock Screen Service:", u8"Служба блокировки экрана:") \
|
|
||||||
X(windows_service_installed, u8"已安装", "Installed", u8"Установлена") \
|
X(windows_service_installed, u8"已安装", "Installed", u8"Установлена") \
|
||||||
X(do_not_remind_again, u8"不再提醒", "Do not remind again", \
|
X(do_not_remind_again, u8"不再提醒", "Do not remind again", \
|
||||||
u8"Больше не напоминать") \
|
u8"Больше не напоминать") \
|
||||||
@@ -210,13 +210,8 @@ struct TranslationRow {
|
|||||||
u8"Способ захвата:") \
|
u8"Способ захвата:") \
|
||||||
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
|
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
|
||||||
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
|
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
|
||||||
X(remote_update_title, u8"建议升级被控端", "Remote update recommended", \
|
X(remote_update_title, u8"建议升级被控端", "Remote update recommended", u8"Рекомендуем обновить удалённое устройство") \
|
||||||
u8"Рекомендуется обновление") \
|
X(remote_update, u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", "Update the remote device to the latest version for improved features and performance.", u8"В новой версии улучшены функции и производительность. Рекомендуем обновить CrossDesk на удалённом устройстве до последней версии.") \
|
||||||
X(remote_update, \
|
|
||||||
u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", \
|
|
||||||
"Update the remote device to the latest version for improved features and performance.", \
|
|
||||||
u8"В новой версии улучшены функции и производительность. " \
|
|
||||||
u8"Рекомендуем обновить CrossDesk на удалённом устройстве.") \
|
|
||||||
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
|
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
|
||||||
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
|
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
|
||||||
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \
|
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \
|
||||||
@@ -233,46 +228,34 @@ struct TranslationRow {
|
|||||||
X(video_quality_high, u8"高", "High", u8"Высокое") \
|
X(video_quality_high, u8"高", "High", u8"Высокое") \
|
||||||
X(video_quality_medium, u8"中", "Medium", u8"Среднее") \
|
X(video_quality_medium, u8"中", "Medium", u8"Среднее") \
|
||||||
X(video_quality_low, u8"低", "Low", u8"Низкое") \
|
X(video_quality_low, u8"低", "Low", u8"Низкое") \
|
||||||
X(video_encode_format, u8"视频编码格式:", \
|
X(video_encode_format, u8"视频编码格式:", "Video codec:", u8"Видеокодек:") \
|
||||||
"Video Encode Format:", u8"Формат кодека видео:") \
|
|
||||||
X(av1, u8"AV1", "AV1", "AV1") \
|
X(av1, u8"AV1", "AV1", "AV1") \
|
||||||
X(h264, u8"H.264", "H.264", "H.264") \
|
X(h264, u8"H.264", "H.264", "H.264") \
|
||||||
X(enable_turn, u8"启用中继服务:", \
|
X(force_relay, u8"强制中继连接:", "Force relay connection:", u8"Только через ретранслятор:") \
|
||||||
"Enable TURN Service:", u8"Включить TURN-сервис:") \
|
X(self_hosted_server_config, u8"自托管配置", "Self-hosted server", u8"Собственный сервер") \
|
||||||
X(force_relay, u8"强制中继连接:", "Force Relay:", u8"Только через реле:") \
|
X(self_hosted_server_settings, u8"自托管设置", "Self-hosted server settings", u8"Настройки своего сервера") \
|
||||||
X(self_hosted_server_config, u8"自托管配置", "Self-Hosted Config", \
|
|
||||||
u8"Конфигурация self-hosted") \
|
|
||||||
X(self_hosted_server_settings, u8"自托管设置", "Self-Hosted Settings", \
|
|
||||||
u8"Настройки self-hosted") \
|
|
||||||
X(self_hosted_server_address, u8"服务器地址:", \
|
X(self_hosted_server_address, u8"服务器地址:", \
|
||||||
"Server Address:", u8"Адрес сервера:") \
|
"Server Address:", u8"Адрес сервера:") \
|
||||||
X(self_hosted_server_port, u8"信令服务端口:", \
|
X(self_hosted_server_port, u8"信令服务端口:", "Signaling port:", u8"Порт сигнализации:") \
|
||||||
"Signal Service Port:", u8"Порт сигнального сервиса:") \
|
|
||||||
X(self_hosted_address_invalid, u8"请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。", "Enter a valid host, IP, or HTTPS/WSS signaling URL. Paths are supported.", u8"Введите имя хоста, IP или HTTPS/WSS-адрес сигнального сервиса. Путь допустим.") \
|
X(self_hosted_address_invalid, u8"请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。", "Enter a valid host, IP, or HTTPS/WSS signaling URL. Paths are supported.", u8"Введите имя хоста, IP или HTTPS/WSS-адрес сигнального сервиса. Путь допустим.") \
|
||||||
X(self_hosted_port_invalid, u8"请输入 1–65535 范围内的信令端口。", "Enter a signaling port between 1 and 65535.", u8"Введите порт сигнального сервиса от 1 до 65535.") \
|
X(self_hosted_port_invalid, u8"请输入 1–65535 范围内的信令端口。", "Enter a signaling port between 1 and 65535.", u8"Введите порт сигнального сервиса от 1 до 65535.") \
|
||||||
X(self_hosted_save_failed, u8"配置保存失败,请检查配置目录是否可写后重试。", "Could not save settings. Check that the configuration directory is writable and retry.", u8"Не удалось сохранить настройки. Проверьте доступ на запись в каталог конфигурации.") \
|
X(self_hosted_save_failed, u8"配置保存失败,请检查配置目录是否可写后重试。", "Could not save settings. Check that the configuration directory is writable and retry.", u8"Не удалось сохранить настройки. Проверьте доступ на запись в каталог конфигурации и повторите попытку.") \
|
||||||
X(ok, u8"确认", "OK", u8"ОК") \
|
X(ok, u8"确认", "OK", u8"ОК") \
|
||||||
X(cancel, u8"取消", "Cancel", u8"Отмена") \
|
X(cancel, u8"取消", "Cancel", u8"Отмена") \
|
||||||
X(new_password, u8"请输入六位密码:", \
|
X(new_password, u8"请输入六位密码:", "Enter a six-character password:", u8"Введите пароль из 6 символов:") \
|
||||||
"Please input a six-char password:", u8"Введите шестизначный пароль:") \
|
|
||||||
X(input_password, u8"请输入密码:", \
|
X(input_password, u8"请输入密码:", \
|
||||||
"Please input password:", u8"Введите пароль:") \
|
"Please input password:", u8"Введите пароль:") \
|
||||||
X(validate_password, u8"验证密码中...", "Validate password ...", \
|
X(validate_password, u8"验证密码中...", "Validating password...", u8"Проверка пароля...") \
|
||||||
u8"Проверка пароля...") \
|
|
||||||
X(reinput_password, u8"请重新输入密码", "Please input password again", \
|
X(reinput_password, u8"请重新输入密码", "Please input password again", \
|
||||||
u8"Повторно введите пароль") \
|
u8"Повторно введите пароль") \
|
||||||
X(remember_password, u8"记住密码", "Remember password", \
|
X(remember_password, u8"记住密码", "Remember password", \
|
||||||
u8"Запомнить пароль") \
|
u8"Запомнить пароль") \
|
||||||
X(signal_connected, u8"已连接服务器", "Connected", u8"Подключено к серверу") \
|
X(signal_connected, u8"已连接服务器", "Connected to server", u8"Подключено к серверу") \
|
||||||
X(signal_disconnected, u8"未连接服务器", "Disconnected", \
|
X(signal_disconnected, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
|
||||||
u8"Нет подключения к серверу") \
|
|
||||||
X(signal_reconnect, u8"重新连接", "Reconnect", u8"Переподключиться") \
|
X(signal_reconnect, u8"重新连接", "Reconnect", u8"Переподключиться") \
|
||||||
X(signal_reconnecting, u8"正在重新连接...", "Reconnecting...", \
|
X(signal_reconnecting, u8"正在重新连接...", "Reconnecting...", \
|
||||||
u8"Переподключение...") \
|
u8"Переподключение...") \
|
||||||
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", \
|
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", "Certificate verification failed. Reinstall the self-hosted root certificate.", u8"Ошибка проверки сертификата. Переустановите корневой сертификат собственного сервера.") \
|
||||||
"Certificate verification failed. Reinstall the self-hosted root " \
|
|
||||||
"certificate.", \
|
|
||||||
u8"Ошибка проверки сертификата. Переустановите корневой сертификат.") \
|
|
||||||
X(p2p_connected, u8"对等连接已建立", "P2P Connected", u8"P2P подключено") \
|
X(p2p_connected, u8"对等连接已建立", "P2P Connected", u8"P2P подключено") \
|
||||||
X(p2p_disconnected, u8"对等连接已断开", "P2P Disconnected", \
|
X(p2p_disconnected, u8"对等连接已断开", "P2P Disconnected", \
|
||||||
u8"P2P отключено") \
|
u8"P2P отключено") \
|
||||||
@@ -283,12 +266,12 @@ struct TranslationRow {
|
|||||||
X(receiving_screen, u8"画面接收中...", "Receiving screen...", \
|
X(receiving_screen, u8"画面接收中...", "Receiving screen...", \
|
||||||
u8"Получение изображения...") \
|
u8"Получение изображения...") \
|
||||||
X(p2p_failed, u8"对等连接失败", "P2P Failed", u8"Сбой P2P") \
|
X(p2p_failed, u8"对等连接失败", "P2P Failed", u8"Сбой P2P") \
|
||||||
X(connection_auth_throttled, u8"验证尝试过于频繁,请稍后重试。", "Too many authentication attempts. Try again later.", u8"Слишком много попыток. Повторите позже.") \
|
X(connection_auth_throttled, u8"验证尝试过于频繁,请稍后重试。", "Too many authentication attempts. Try again later.", u8"Слишком много попыток аутентификации. Повторите позже.") \
|
||||||
X(connection_service_busy, u8"认证服务繁忙,请稍后重试。", "Authentication service busy. Try again later.", u8"Сервис проверки занят. Повторите позже.") \
|
X(connection_service_busy, u8"认证服务繁忙,请稍后重试。", "Authentication service busy. Try again later.", u8"Сервис проверки занят. Повторите позже.") \
|
||||||
X(connection_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", u8"Сеанс входа истёк. Подключитесь заново.") \
|
X(connection_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", u8"Сеанс входа истёк. Подключитесь заново.") \
|
||||||
X(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \
|
X(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \
|
||||||
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \
|
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \
|
||||||
X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \
|
X(no_such_id, u8"无此 ID", "No such ID", u8"ID не найден") \
|
||||||
X(about, u8"关于", "About", u8"О программе") \
|
X(about, u8"关于", "About", u8"О программе") \
|
||||||
X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \
|
X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \
|
||||||
X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
|
X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
|
||||||
@@ -319,26 +302,24 @@ struct TranslationRow {
|
|||||||
"Access Website: ", u8"Официальный сайт: ") \
|
"Access Website: ", u8"Официальный сайт: ") \
|
||||||
X(update, u8"更新", "Update", u8"Обновить") \
|
X(update, u8"更新", "Update", u8"Обновить") \
|
||||||
X(install_update, u8"安装更新", "Install update", u8"Установить обновление") \
|
X(install_update, u8"安装更新", "Install update", u8"Установить обновление") \
|
||||||
X(manual_download, u8"手动下载", "Download", u8"Скачать") \
|
X(manual_download, u8"手动下载", "Download manually", u8"Скачать вручную") \
|
||||||
X(install_update_confirm, u8"下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?", \
|
X(install_update_confirm, u8"下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?", \
|
||||||
"CrossDesk will close after downloading the update. Connections and file transfers will be interrupted. Installation requires system authorization, then CrossDesk will restart. Continue?", \
|
"CrossDesk will close after downloading the update. Connections and file transfers will be interrupted. Installation requires system authorization, then CrossDesk will restart. Continue?", \
|
||||||
u8"После загрузки CrossDesk закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \
|
u8"После загрузки CrossDesk закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \
|
||||||
X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \
|
X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \
|
||||||
X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \
|
X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \
|
||||||
X(connection_alias, u8"修改名称", "Edit Alias", \
|
X(connection_alias, u8"修改名称", "Rename", u8"Переименовать") \
|
||||||
u8"Изменить имя подключения") \
|
|
||||||
X(delete_connection, u8"删除连接", "Delete Connection", \
|
X(delete_connection, u8"删除连接", "Delete Connection", \
|
||||||
u8"Удалить подключение") \
|
u8"Удалить подключение") \
|
||||||
X(connect_to_this_connection, u8"发起连接", "Connect to this connection", \
|
X(connect_to_this_connection, u8"发起连接", "Connect", u8"Подключиться") \
|
||||||
u8"Подключиться") \
|
|
||||||
X(input_connection_alias, u8"请输入连接名称:", \
|
X(input_connection_alias, u8"请输入连接名称:", \
|
||||||
"Please input connection name:", u8"Введите имя подключения:") \
|
"Please input connection name:", u8"Введите имя подключения:") \
|
||||||
X(confirm_delete_connection, u8"确认删除此连接", \
|
X(confirm_delete_connection, u8"确认删除此连接", "Delete this connection?", u8"Удалить это подключение?") \
|
||||||
"Confirm to delete this connection", u8"Удалить это подключение?") \
|
|
||||||
X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \
|
X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \
|
||||||
X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \
|
X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \
|
||||||
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on connect:", \
|
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on incoming connection:", u8"Приватный экран при подключении:") \
|
||||||
u8"Приватность при подключении:") \
|
X(lock_on_disconnect, u8"连接断开后锁屏:", "Lock screen on disconnect:", \
|
||||||
|
u8"Блокировка при отключении:") \
|
||||||
X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \
|
X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \
|
||||||
u8"Вступит в силу после перезапуска") \
|
u8"Вступит в силу после перезапуска") \
|
||||||
X(select_file, u8"选择文件发送", "Select File to Send", \
|
X(select_file, u8"选择文件发送", "Select File to Send", \
|
||||||
@@ -371,19 +352,19 @@ struct TranslationRow {
|
|||||||
"File Transfer Save Path:", u8"Путь сохранения файлов:") \
|
"File Transfer Save Path:", u8"Путь сохранения файлов:") \
|
||||||
X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \
|
X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \
|
||||||
X(resolution, u8"分辨率", "Res", u8"Разрешение") \
|
X(resolution, u8"分辨率", "Res", u8"Разрешение") \
|
||||||
X(video_latency, u8"视频延时", "Video delay", u8"Задержка") \
|
X(video_latency, u8"视频延时", "Video latency", u8"Задержка видео") \
|
||||||
X(connection_latency, u8"连接延时", "Connection RTT", u8"Задержка соединения") \
|
X(connection_latency, u8"连接延时", "Connection latency", u8"Задержка соединения") \
|
||||||
X(connection_mode, u8"连接模式", "Mode", u8"Режим") \
|
X(connection_mode, u8"连接模式", "Connection mode", u8"Режим подключения") \
|
||||||
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
|
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
|
||||||
X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \
|
X(connection_mode_relay, u8"中继", "Relay", u8"Ретрансляция") \
|
||||||
X(transport_encryption, u8"加密传输", "Encryption", u8"Шифрование") \
|
X(transport_encryption, u8"加密传输", "Encryption", u8"Шифрование") \
|
||||||
X(remote_video_encoder, u8"被控端编码器", "Remote encoder", u8"Кодер удалённой стороны") \
|
X(remote_video_encoder, u8"被控端编码器", "Remote encoder", u8"Кодер удалённой стороны") \
|
||||||
X(local_video_decoder, u8"控制端解码器", "Local decoder", u8"Локальный декодер") \
|
X(local_video_decoder, u8"控制端解码器", "Local decoder", u8"Локальный декодер") \
|
||||||
X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \
|
X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \
|
||||||
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
|
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
|
||||||
X(online, u8"在线", "Online", u8"Онлайн") \
|
X(online, u8"在线", "Online", u8"В сети") \
|
||||||
X(offline, u8"离线", "Offline", u8"Офлайн") \
|
X(offline, u8"离线", "Offline", u8"Не в сети") \
|
||||||
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство офлайн") \
|
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство не в сети") \
|
||||||
X(request_permissions, u8"权限请求", "Request Permissions", \
|
X(request_permissions, u8"权限请求", "Request Permissions", \
|
||||||
u8"Запрос разрешений") \
|
u8"Запрос разрешений") \
|
||||||
X(screen_recording_permission, u8"屏幕录制权限", \
|
X(screen_recording_permission, u8"屏幕录制权限", \
|
||||||
|
|||||||
@@ -226,15 +226,11 @@ int ClipboardController::SendToPeers(const std::string &text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (owner_.peer_) {
|
lock.unlock();
|
||||||
const int ret =
|
for (const auto& id : owner_.ConnectedControllers()) {
|
||||||
SendReliableDataFrame(owner_.peer_, text.data(), text.size(),
|
const int ret = owner_.SendControllerData(id, text.data(), text.size(),
|
||||||
owner_.clipboard_label_.c_str());
|
owner_.clipboard_label_.c_str());
|
||||||
if (ret != 0) {
|
if (ret != 0) return ret;
|
||||||
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
|
|
||||||
owner_.remote_id_display_, ret);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -43,18 +43,17 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
|
|||||||
SpeakerCapturerFactory().Create());
|
SpeakerCapturerFactory().Create());
|
||||||
},
|
},
|
||||||
[this](unsigned char *data, size_t size, const char *) {
|
[this](unsigned char *data, size_t size, const char *) {
|
||||||
if (!owner_.peer_) return;
|
|
||||||
MiniRtcAudioFrame frame{};
|
MiniRtcAudioFrame frame{};
|
||||||
frame.data = reinterpret_cast<const char *>(data);
|
frame.data = reinterpret_cast<const char *>(data);
|
||||||
frame.size = size;
|
frame.size = size;
|
||||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
for (const auto& id : owner_.ConnectedControllers())
|
||||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
owner_.WithController(
|
||||||
for (const auto& [id, status] : owner_.connection_status_) {
|
id, [&](PeerPtr* peer, const std::string& wire) {
|
||||||
if (status == ConnectionStatus::Connected)
|
frame.captured_timestamp = GetSystemTimeMicros(peer);
|
||||||
SendAudioFrameToPeer(owner_.peer_, &frame,
|
return SendAudioFrameToPeer(peer, &frame,
|
||||||
owner_.audio_label_.c_str(), id.data(),
|
owner_.audio_label_.c_str(),
|
||||||
id.size());
|
wire.data(), wire.size());
|
||||||
}
|
});
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
|
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
|
||||||
@@ -132,6 +131,10 @@ void SessionDeviceManager::Initialize() {
|
|||||||
DeviceControllerFactory::Device::Keyboard));
|
DeviceControllerFactory::Device::Keyboard));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int SessionDeviceManager::EnsureCaptureReady() {
|
||||||
|
return screen_capturer_ ? 0 : InitializeScreenCapturer();
|
||||||
|
}
|
||||||
|
|
||||||
int SessionDeviceManager::InitializeScreenCapturer() {
|
int SessionDeviceManager::InitializeScreenCapturer() {
|
||||||
#ifdef __APPLE__
|
#ifdef __APPLE__
|
||||||
if (!owner_.EnsureMacScreenRecordingPermission()) {
|
if (!owner_.EnsureMacScreenRecordingPermission()) {
|
||||||
@@ -193,16 +196,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
|||||||
.count()
|
.count()
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
std::vector<std::string> connected_remote_ids;
|
const auto connected_remote_ids = owner_.ConnectedControllers();
|
||||||
{
|
|
||||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
|
||||||
connected_remote_ids.reserve(owner_.connection_status_.size());
|
|
||||||
for (const auto &[remote_id, status] : owner_.connection_status_) {
|
|
||||||
if (status == ConnectionStatus::Connected) {
|
|
||||||
connected_remote_ids.push_back(remote_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture can still deliver frames while ICE is gathering or after
|
// Capture can still deliver frames while ICE is gathering or after
|
||||||
// the final controller disconnects. Do not broadcast those frames to
|
// the final controller disconnects. Do not broadcast those frames to
|
||||||
@@ -232,28 +226,27 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
|||||||
has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs;
|
has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs;
|
||||||
const bool stream_changed = !last_video_frame_stream_id_.empty() &&
|
const bool stream_changed = !last_video_frame_stream_id_.empty() &&
|
||||||
last_video_frame_stream_id_ != stream_id;
|
last_video_frame_stream_id_ != stream_id;
|
||||||
if (resumed_after_gap || stream_changed) {
|
|
||||||
if (RequestVideoKeyFrame(owner_.peer_, stream_id.c_str()) == 0) {
|
|
||||||
LOG_DEBUG("Request video key frame before sending captured frame, "
|
|
||||||
"stream='{}', gap_ms={}, stream_changed={}",
|
|
||||||
stream_id, duration_ms, stream_changed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MiniRtcVideoFrame frame{};
|
MiniRtcVideoFrame frame{};
|
||||||
frame.data = reinterpret_cast<const char *>(data);
|
frame.data = reinterpret_cast<const char *>(data);
|
||||||
frame.size = size;
|
frame.size = size;
|
||||||
frame.width = width;
|
frame.width = width;
|
||||||
frame.height = height;
|
frame.height = height;
|
||||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
|
||||||
frame.native_frame = native_frame;
|
frame.native_frame = native_frame;
|
||||||
const auto send_started = std::chrono::steady_clock::now();
|
const auto send_started = std::chrono::steady_clock::now();
|
||||||
uint64_t send_errors = 0;
|
uint64_t send_errors = 0;
|
||||||
for (const std::string &remote_id : connected_remote_ids) {
|
for (const std::string &remote_id : connected_remote_ids) {
|
||||||
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
|
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
|
||||||
stream_id)) continue;
|
stream_id)) continue;
|
||||||
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
|
if (owner_.WithController(
|
||||||
remote_id.data(), remote_id.size()) != 0)
|
remote_id, [&](PeerPtr* peer, const std::string& wire) {
|
||||||
|
if (resumed_after_gap || stream_changed)
|
||||||
|
RequestVideoKeyFrame(peer, stream_id.c_str());
|
||||||
|
frame.captured_timestamp = GetSystemTimeMicros(peer);
|
||||||
|
return SendVideoFrameToPeer(peer, &frame, stream_id.c_str(),
|
||||||
|
wire.data(), wire.size());
|
||||||
|
}) != 0)
|
||||||
++send_errors;
|
++send_errors;
|
||||||
}
|
}
|
||||||
const auto send_us =
|
const auto send_us =
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public:
|
|||||||
void DestroyFactories();
|
void DestroyFactories();
|
||||||
|
|
||||||
int InitializeScreenCapturer();
|
int InitializeScreenCapturer();
|
||||||
|
int EnsureCaptureReady();
|
||||||
int StartScreenCapturer();
|
int StartScreenCapturer();
|
||||||
int StopScreenCapturer();
|
int StopScreenCapturer();
|
||||||
bool ReleaseRemoteMouseButtons();
|
bool ReleaseRemoteMouseButtons();
|
||||||
|
|||||||
@@ -94,13 +94,19 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
|
|||||||
peer = props ? props->peer_ : owner_.peer_;
|
peer = props ? props->peer_ : owner_.peer_;
|
||||||
events = props ? props->peer_events_ : owner_.peer_events_;
|
events = props ? props->peer_events_ : owner_.peer_events_;
|
||||||
}
|
}
|
||||||
|
if (!props && file.remote_id.rfind("lan/", 0) == 0) {
|
||||||
|
events = owner_.ControllerEvents(file.remote_id);
|
||||||
|
peer = events ? events->DirectPeer() : nullptr;
|
||||||
|
}
|
||||||
auto session = std::make_shared<file_transfer::Session>(
|
auto session = std::make_shared<file_transfer::Session>(
|
||||||
[peer, events, file, props](const char*, const char* data, size_t size) {
|
[peer, events, file, props](const char*, const char* data, size_t size) {
|
||||||
if (!peer || !events || (props && props->closing_)) return -1;
|
if (!peer || !events || (props && props->closing_)) return -1;
|
||||||
auto callback = events->EnterCallback();
|
auto callback = events->EnterCallback();
|
||||||
if (!callback) return -1;
|
if (!callback || !events->DataAllowed()) return -1;
|
||||||
return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(),
|
const auto wire_id = events->WireId(file.remote_id);
|
||||||
file.remote_id.c_str(), file.remote_id.size());
|
return SendReliableDataFrameToPeer(
|
||||||
|
peer, data, size, file.file_label.c_str(),
|
||||||
|
wire_id.data(), wire_id.size());
|
||||||
}, file_transfer::Session::Progress{});
|
}, file_transfer::Session::Progress{});
|
||||||
state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0;
|
state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0;
|
||||||
state.current_file_id_ = id;
|
state.current_file_id_ = id;
|
||||||
@@ -313,6 +319,10 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
|||||||
peer = props ? props->peer_ : owner_.peer_;
|
peer = props ? props->peer_ : owner_.peer_;
|
||||||
events = props ? props->peer_events_ : owner_.peer_events_;
|
events = props ? props->peer_events_ : owner_.peer_events_;
|
||||||
}
|
}
|
||||||
|
if (!props && remote.rfind("lan/", 0) == 0) {
|
||||||
|
events = owner_.ControllerEvents(remote);
|
||||||
|
peer = events ? events->DirectPeer() : nullptr;
|
||||||
|
}
|
||||||
if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
|
if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
|
||||||
std::shared_ptr<FileReceiver> receiver;
|
std::shared_ptr<FileReceiver> receiver;
|
||||||
std::filesystem::path default_directory;
|
std::filesystem::path default_directory;
|
||||||
@@ -333,10 +343,12 @@ void FileTransferManager::Receive(const char* data, size_t size,
|
|||||||
});
|
});
|
||||||
entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) {
|
entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) {
|
||||||
auto callback = events->EnterCallback();
|
auto callback = events->EnterCallback();
|
||||||
if (!callback) return -1;
|
if (!callback || !events->DataAllowed()) return -1;
|
||||||
const auto bytes = EncodeFileTransferAck(ack);
|
const auto bytes = EncodeFileTransferAck(ack);
|
||||||
return SendReliableDataFrameToPeer(peer, bytes.data(), bytes.size(), kFileFeedbackStream,
|
const auto wire_id = events->WireId(remote);
|
||||||
remote.c_str(), remote.size());
|
return SendReliableDataFrameToPeer(
|
||||||
|
peer, bytes.data(), bytes.size(), kFileFeedbackStream,
|
||||||
|
wire_id.data(), wire_id.size());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
receiver = entry.session;
|
receiver = entry.session;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
#include "interactive_desktop.h"
|
#include "interactive_desktop.h"
|
||||||
#include "service_host.h"
|
#include "service_host.h"
|
||||||
#include "unattended_config.h"
|
#include "unattended_config.h"
|
||||||
|
#include "platform/windows/input/windows_input_injector.h"
|
||||||
|
#include "platform/windows/input/windows_text_input.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
@@ -275,11 +277,51 @@ InputOutcome KeyboardController::ApplyRemoteText(const std::string& remote_id,
|
|||||||
std::lock_guard input_lock(remote_input_mutex_);
|
std::lock_guard input_lock(remote_input_mutex_);
|
||||||
if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
|
if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
|
||||||
if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
|
if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
|
||||||
|
#if _WIN32
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
if (!BuildWindowsTextInputs(text, inputs)) return InputOutcome::failed;
|
||||||
|
if (owner_.privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable())
|
||||||
|
owner_.privacy_.SuspendForDesktop();
|
||||||
|
static const bool process_elevated = IsAdministratorProcess();
|
||||||
|
const bool injected = DispatchDesktopInput(
|
||||||
|
[&] {
|
||||||
|
return PreferUserDesktopInput(IsCurrentSessionUserDesktopActive(),
|
||||||
|
owner_.windows_consent_ui_.load(),
|
||||||
|
owner_.local_service_available_.load(),
|
||||||
|
process_elevated);
|
||||||
|
},
|
||||||
|
[&] {
|
||||||
|
const UINT sent = SendWindowsTextInputs(
|
||||||
|
inputs, [](INPUT* batch, UINT count) {
|
||||||
|
return SendInputOnUserDesktop(batch, count);
|
||||||
|
});
|
||||||
|
const DWORD error = GetLastError();
|
||||||
|
const bool blocked =
|
||||||
|
error == ERROR_ACCESS_DENIED || error == ERROR_GEN_FAILURE;
|
||||||
|
// A text commit is a batch: only zero insertion permits a retry.
|
||||||
|
return DesktopInputResult{
|
||||||
|
sent == inputs.size(),
|
||||||
|
sent == 0 && (IsDesktopTransitionInputError(error) || blocked),
|
||||||
|
sent == 0 && blocked};
|
||||||
|
},
|
||||||
|
[&]() -> DesktopInputResult {
|
||||||
|
const auto response = SendCrossDeskSecureDesktopTextInput(text, 1000);
|
||||||
|
const auto json = nlohmann::json::parse(response, nullptr, false);
|
||||||
|
if (json.is_object() && json.value("ok", false)) return {true, false};
|
||||||
|
const std::string error =
|
||||||
|
json.is_object() ? json.value("error", "") : "invalid_response";
|
||||||
|
// No text or raw reply in diagnostics; commits may be passwords.
|
||||||
|
LOG_WARN("Secure desktop text injection failed: error={}", error);
|
||||||
|
return {false, IsDesktopInputSetupPending(error)};
|
||||||
|
});
|
||||||
|
return injected ? InputOutcome::applied : InputOutcome::failed;
|
||||||
|
#else
|
||||||
if (!owner_.devices_.SendTextCommand(text)) {
|
if (!owner_.devices_.SendTextCommand(text)) {
|
||||||
LOG_WARN("Failed to inject committed remote text");
|
LOG_WARN("Failed to inject committed remote text");
|
||||||
return InputOutcome::failed;
|
return InputOutcome::failed;
|
||||||
}
|
}
|
||||||
return InputOutcome::applied;
|
return InputOutcome::applied;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
InputOutcome KeyboardController::ApplyRemoteState(const std::string& remote_id,
|
InputOutcome KeyboardController::ApplyRemoteState(const std::string& remote_id,
|
||||||
|
|||||||
@@ -465,7 +465,6 @@ int SettingsManager::Load() {
|
|||||||
static_cast<int>(owner_.config_center_->GetLanguage()));
|
static_cast<int>(owner_.config_center_->GetLanguage()));
|
||||||
owner_.video_encode_format_button_value_ =
|
owner_.video_encode_format_button_value_ =
|
||||||
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat());
|
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat());
|
||||||
owner_.enable_turn_ = owner_.config_center_->IsEnableTurn();
|
|
||||||
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
|
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
|
||||||
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
|
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
|
||||||
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
|
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
|
||||||
@@ -484,7 +483,6 @@ int SettingsManager::Load() {
|
|||||||
owner_.language_button_value_last_ = owner_.language_button_value_;
|
owner_.language_button_value_last_ = owner_.language_button_value_;
|
||||||
owner_.video_encode_format_button_value_last_ =
|
owner_.video_encode_format_button_value_last_ =
|
||||||
owner_.video_encode_format_button_value_;
|
owner_.video_encode_format_button_value_;
|
||||||
owner_.enable_turn_last_ = owner_.enable_turn_;
|
|
||||||
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
|
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
|
||||||
owner_.enable_autostart_last_ = owner_.enable_autostart_;
|
owner_.enable_autostart_last_ = owner_.enable_autostart_;
|
||||||
|
|
||||||
@@ -744,6 +742,8 @@ std::string SettingsManager::RecentConnectionDisplayName(
|
|||||||
connection.remote_host_name != "unknown") {
|
connection.remote_host_name != "unknown") {
|
||||||
return connection.remote_host_name;
|
return connection.remote_host_name;
|
||||||
}
|
}
|
||||||
|
if (!connection.lan.device_id.empty())
|
||||||
|
return connection.lan.address + ":" + std::to_string(connection.lan.port);
|
||||||
return connection.remote_id;
|
return connection.remote_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
#include "platform/video_renderer.h"
|
#include "platform/video_renderer.h"
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
|
#include "screen_lock.h"
|
||||||
#include "runtime/device_presence_request.h"
|
#include "runtime/device_presence_request.h"
|
||||||
#include "runtime/gui_runtime.h"
|
#include "runtime/gui_runtime.h"
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ constexpr auto kPresenceRetryInterval = std::chrono::seconds(1);
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool GuiRuntime::HasActiveSession() {
|
bool GuiRuntime::HasActiveSession() {
|
||||||
|
if (lan_ && !lan_->Quiescent()) return true;
|
||||||
if (stream_window_inited_) return true;
|
if (stream_window_inited_) return true;
|
||||||
std::shared_lock lock(connection_status_mutex_);
|
std::shared_lock lock(connection_status_mutex_);
|
||||||
return std::any_of(connection_status_.begin(), connection_status_.end(),
|
return std::any_of(connection_status_.begin(), connection_status_.end(),
|
||||||
@@ -188,7 +190,8 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
|
|||||||
|
|
||||||
void GuiRuntime::HandleServerControllerDisconnected(
|
void GuiRuntime::HandleServerControllerDisconnected(
|
||||||
const std::string& remote_id, const char* reason) {
|
const std::string& remote_id, const char* reason) {
|
||||||
transfers_.Cancel(peer_events_.get(), remote_id);
|
if (auto events = ControllerEvents(remote_id))
|
||||||
|
transfers_.Cancel(events.get(), remote_id);
|
||||||
preview_subscriptions_.Remove(remote_id);
|
preview_subscriptions_.Remove(remote_id);
|
||||||
|
|
||||||
bool has_connected_controller = false;
|
bool has_connected_controller = false;
|
||||||
@@ -196,6 +199,8 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
|||||||
{
|
{
|
||||||
std::unique_lock lock(connection_status_mutex_);
|
std::unique_lock lock(connection_status_mutex_);
|
||||||
connection_status_.erase(remote_id);
|
connection_status_.erase(remote_id);
|
||||||
|
disconnect_lock_.Disconnected(remote_id,
|
||||||
|
config_center_->IsLockOnDisconnect());
|
||||||
remote_input_.Disconnect(remote_id);
|
remote_input_.Disconnect(remote_id);
|
||||||
devices_.OnControllerConnectionsChanged();
|
devices_.OnControllerConnectionsChanged();
|
||||||
connection_host_names_.erase(remote_id);
|
connection_host_names_.erase(remote_id);
|
||||||
@@ -243,11 +248,30 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
|||||||
#endif
|
#endif
|
||||||
devices_.StopSpeakerCapturer();
|
devices_.StopSpeakerCapturer();
|
||||||
start_mouse_controller_ = false;
|
start_mouse_controller_ = false;
|
||||||
start_keyboard_capturer_ = false;
|
{
|
||||||
|
std::shared_lock lock(remote_sessions_mutex_);
|
||||||
|
start_keyboard_capturer_ = std::any_of(
|
||||||
|
remote_sessions_.begin(), remote_sessions_.end(), [](const auto& item) {
|
||||||
|
const auto& session = item.second;
|
||||||
|
return session && !session->closing_ &&
|
||||||
|
session->connection_established_ && !session->wall_managed_;
|
||||||
|
});
|
||||||
|
}
|
||||||
remote_client_id_.clear();
|
remote_client_id_.clear();
|
||||||
audio_capture_ = false;
|
audio_capture_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GuiRuntime::HandleScreenLock() {
|
||||||
|
// Serialize request consumption with incoming admission so a reconnect
|
||||||
|
// cancels a pending lock before it can interrupt the new controller.
|
||||||
|
std::unique_lock lock(connection_status_mutex_);
|
||||||
|
if (!disconnect_lock_.TakeRequest(config_center_->IsLockOnDisconnect()))
|
||||||
|
return;
|
||||||
|
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||||
|
if (LockScreen()) LOG_INFO("Requested screen lock after last controller disconnected");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
|
int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
|
||||||
const char* password,
|
const char* password,
|
||||||
bool remember_password) {
|
bool remember_password) {
|
||||||
@@ -294,10 +318,15 @@ int GuiRuntime::ForceSignalReconnect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||||
if (!props || props->closing_.exchange(true)) return;
|
if (!props) return;
|
||||||
|
if (props->lan_session_ && !props->lan_session_->closing) {
|
||||||
|
if (lan_) lan_->Close(props->lan_session_);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props->closing_.exchange(true)) return;
|
||||||
MuteRemoteAudio(props);
|
MuteRemoteAudio(props);
|
||||||
if (props->peer_events_) {
|
if (props->peer_events_) {
|
||||||
props->peer_events_->Deactivate();
|
props->peer_events_->StopAccepting();
|
||||||
transfers_.Cancel(props->peer_events_.get());
|
transfers_.Cancel(props->peer_events_.get());
|
||||||
}
|
}
|
||||||
props->connection_established_ = false;
|
props->connection_established_ = false;
|
||||||
@@ -350,7 +379,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
|||||||
session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask(
|
session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask(
|
||||||
[props, peer, frame_snapshot, video_width, video_height, platform,
|
[props, peer, frame_snapshot, video_width, video_height, platform,
|
||||||
thumbnail = thumbnail_, queued_at]() mutable {
|
thumbnail = thumbnail_, queued_at]() mutable {
|
||||||
if (peer) {
|
if (props->peer_events_) props->peer_events_->Deactivate();
|
||||||
|
if (peer && !props->lan_session_) {
|
||||||
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
|
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
|
||||||
props->remote_id_);
|
props->remote_id_);
|
||||||
LeaveConnection(peer, props->remote_id_.c_str());
|
LeaveConnection(peer, props->remote_id_.c_str());
|
||||||
@@ -359,11 +389,20 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
|||||||
if (thumbnail && props->was_connected_) {
|
if (thumbnail && props->was_connected_) {
|
||||||
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
|
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
|
||||||
? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr;
|
? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr;
|
||||||
|
const auto direct = props->lan_session_;
|
||||||
|
const Thumbnail::LanTarget target =
|
||||||
|
direct ? Thumbnail::LanTarget{direct->target.address,
|
||||||
|
direct->target.device_id,
|
||||||
|
direct->target.port}
|
||||||
|
: Thumbnail::LanTarget{};
|
||||||
|
const auto record_id = direct
|
||||||
|
? Thumbnail::LanRecordId(target.device_id)
|
||||||
|
: props->remote_id_;
|
||||||
if (thumbnail->SaveToThumbnail(
|
if (thumbnail->SaveToThumbnail(
|
||||||
nv12, video_width, video_height, props->remote_id_,
|
nv12, video_width, video_height, record_id,
|
||||||
props->remote_host_name_,
|
props->remote_host_name_,
|
||||||
props->remember_password_ ? props->remote_password_ : "",
|
props->remember_password_ ? props->remote_password_ : "",
|
||||||
platform) != 0) {
|
platform, direct ? &target : nullptr) != 0) {
|
||||||
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
|
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,6 +414,17 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GuiRuntime::CloseServerController(const std::string& remote_id) {
|
void GuiRuntime::CloseServerController(const std::string& remote_id) {
|
||||||
|
if (remote_id.rfind("lan/", 0) == 0) {
|
||||||
|
std::shared_ptr<lan::Session> session;
|
||||||
|
{
|
||||||
|
std::shared_lock lock(remote_sessions_mutex_);
|
||||||
|
if (auto it = lan_controllers_.find(remote_id);
|
||||||
|
it != lan_controllers_.end())
|
||||||
|
session = it->second.session;
|
||||||
|
}
|
||||||
|
if (session && lan_) lan_->Close(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!peer_ || remote_id.empty() || controller_cleanup_tasks_.count(remote_id))
|
if (!peer_ || remote_id.empty() || controller_cleanup_tasks_.count(remote_id))
|
||||||
return;
|
return;
|
||||||
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
|
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
|
||||||
@@ -392,20 +442,17 @@ void GuiRuntime::CloseConnectionPeer() {
|
|||||||
announcements_.Reset();
|
announcements_.Reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
peer_events_->Deactivate();
|
peer_events_->StopAccepting();
|
||||||
transfers_.Cancel(peer_events_.get());
|
transfers_.Cancel(peer_events_.get());
|
||||||
announcements_.Reset();
|
announcements_.Reset();
|
||||||
std::vector<std::string> controllers;
|
std::vector<std::string> controllers;
|
||||||
{
|
{
|
||||||
std::shared_lock lock(connection_status_mutex_);
|
std::shared_lock lock(connection_status_mutex_);
|
||||||
for (const auto& [id, status] : connection_status_) controllers.push_back(id);
|
for (const auto& [id, status] : connection_status_)
|
||||||
|
if (id.rfind("lan/", 0) != 0) controllers.push_back(id);
|
||||||
}
|
}
|
||||||
for (const auto& id : controllers)
|
for (const auto& id : controllers)
|
||||||
HandleServerControllerDisconnected(id, "peer_closed");
|
HandleServerControllerDisconnected(id, "peer_closed");
|
||||||
devices_.StopMouseController();
|
|
||||||
devices_.StopScreenCapturer();
|
|
||||||
devices_.StopSpeakerCapturer();
|
|
||||||
devices_.StopKeyboardCapturer();
|
|
||||||
signal_connected_ = false;
|
signal_connected_ = false;
|
||||||
device_presence_cache_.SetSignalConnected(false);
|
device_presence_cache_.SetSignalConnected(false);
|
||||||
signal_status_ = SignalStatus::SignalClosed;
|
signal_status_ = SignalStatus::SignalClosed;
|
||||||
@@ -418,6 +465,7 @@ void GuiRuntime::CloseConnectionPeer() {
|
|||||||
// these finish before the shared peer is destroyed.
|
// these finish before the shared peer is destroyed.
|
||||||
connection_peer_cleanup_ = session_cleanup_queue_.PostTask(
|
connection_peer_cleanup_ = session_cleanup_queue_.PostTask(
|
||||||
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
|
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
|
||||||
|
handler->Deactivate();
|
||||||
LOG_INFO("[{}] Background connection peer cleanup", id);
|
LOG_INFO("[{}] Background connection peer cleanup", id);
|
||||||
LeaveConnection(peer, id.c_str());
|
LeaveConnection(peer, id.c_str());
|
||||||
DestroyPeer(&peer);
|
DestroyPeer(&peer);
|
||||||
@@ -551,6 +599,12 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
|
|||||||
std::shared_lock lock(remote_sessions_mutex_);
|
std::shared_lock lock(remote_sessions_mutex_);
|
||||||
auto it = remote_sessions_.find(remote_id);
|
auto it = remote_sessions_.find(remote_id);
|
||||||
if (it == remote_sessions_.end()) {
|
if (it == remote_sessions_.end()) {
|
||||||
|
if (remote_id.rfind("lan-", 0) == 0)
|
||||||
|
for (const auto& [_, props] : remote_sessions_)
|
||||||
|
if (props->lan_session_ && !props->closing_ &&
|
||||||
|
Thumbnail::LanRecordId(props->lan_session_->target.device_id) ==
|
||||||
|
remote_id)
|
||||||
|
return props;
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
return it->second;
|
return it->second;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ inline nlohmann::json BuildDevicePresenceRequest(
|
|||||||
const auto append = [&](const std::string& id) {
|
const auto append = [&](const std::string& id) {
|
||||||
// Recent connection keys contain Y/N plus display settings after the ID.
|
// Recent connection keys contain Y/N plus display settings after the ID.
|
||||||
const std::string pure_id = id.substr(0, id.find_first_of("YN"));
|
const std::string pure_id = id.substr(0, id.find_first_of("YN"));
|
||||||
|
if (pure_id.rfind("lan-", 0) == 0 || pure_id.rfind("lan/", 0) == 0) return;
|
||||||
if (!pure_id.empty() &&
|
if (!pure_id.empty() &&
|
||||||
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
|
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
|
||||||
devices.push_back(pure_id);
|
devices.push_back(pure_id);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-25
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _DISCONNECT_LOCK_POLICY_H_
|
||||||
|
#define _DISCONNECT_LOCK_POLICY_H_
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
// Guarded by GuiRuntime's connection_status_mutex_. Only incoming sessions
|
||||||
|
// that reached Connected can request a lock; terminal callbacks may repeat.
|
||||||
|
class DisconnectLockPolicy {
|
||||||
|
public:
|
||||||
|
void Connected(const std::string& remote_id) {
|
||||||
|
controllers_.insert(remote_id);
|
||||||
|
pending_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Disconnected(const std::string& remote_id, bool enabled) {
|
||||||
|
if (controllers_.erase(remote_id) && controllers_.empty() && enabled)
|
||||||
|
pending_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TakeRequest(bool enabled) {
|
||||||
|
return std::exchange(pending_, false) && enabled && controllers_.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::unordered_set<std::string> controllers_;
|
||||||
|
bool pending_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -70,6 +70,7 @@ GuiRuntime::~GuiRuntime() {
|
|||||||
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
|
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
|
||||||
if (windows_service_worker_) windows_service_worker_->Reset();
|
if (windows_service_worker_) windows_service_worker_->Reset();
|
||||||
#endif
|
#endif
|
||||||
|
lan_.reset();
|
||||||
transfers_.Shutdown();
|
transfers_.Shutdown();
|
||||||
WaitForSessionCleanup();
|
WaitForSessionCleanup();
|
||||||
}
|
}
|
||||||
@@ -292,7 +293,7 @@ int GuiRuntime::CreateConnectionPeer() {
|
|||||||
LOG_INFO("Create peer [{}] instance failed", client_id_);
|
LOG_INFO("Create peer [{}] instance failed", client_id_);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (0 == devices_.InitializeScreenCapturer()) {
|
if (0 == devices_.EnsureCaptureReady()) {
|
||||||
const auto &displays = devices_.display_info_list();
|
const auto &displays = devices_.display_info_list();
|
||||||
for (size_t index = 0; index < displays.size(); ++index) {
|
for (size_t index = 0; index < displays.size(); ++index) {
|
||||||
const std::string stream_id = MakeDisplayStreamId(index);
|
const std::string stream_id = MakeDisplayStreamId(index);
|
||||||
@@ -334,10 +335,18 @@ void GuiRuntime::HandleRecentConnections() {
|
|||||||
// Record a successful connection before a preview or host info arrives.
|
// Record a successful connection before a preview or host info arrives.
|
||||||
// The close path still updates its final thumbnail. Remember password
|
// The close path still updates its final thumbnail. Remember password
|
||||||
// controls only credential storage, not whether this device is listed.
|
// controls only credential storage, not whether this device is listed.
|
||||||
|
const auto direct = props->lan_session_;
|
||||||
|
const Thumbnail::LanTarget target =
|
||||||
|
direct ? Thumbnail::LanTarget{direct->target.address,
|
||||||
|
direct->target.device_id,
|
||||||
|
direct->target.port}
|
||||||
|
: Thumbnail::LanTarget{};
|
||||||
|
const auto record_id =
|
||||||
|
direct ? Thumbnail::LanRecordId(target.device_id) : props->remote_id_;
|
||||||
if (thumbnail_->SaveToThumbnail(
|
if (thumbnail_->SaveToThumbnail(
|
||||||
nullptr, 0, 0, props->remote_id_, host_name,
|
nullptr, 0, 0, record_id, host_name,
|
||||||
props->remember_password_ ? props->remote_password_ : "",
|
props->remember_password_ ? props->remote_password_ : "",
|
||||||
platform) == 0)
|
platform, direct ? &target : nullptr) == 0)
|
||||||
reload_recent_connections_ = true;
|
reload_recent_connections_ = true;
|
||||||
else
|
else
|
||||||
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
|
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
|
||||||
@@ -346,9 +355,7 @@ void GuiRuntime::HandleRecentConnections() {
|
|||||||
if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) {
|
if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) {
|
||||||
uint32_t now_time = SDL_GetTicks();
|
uint32_t now_time = SDL_GetTicks();
|
||||||
if (now_time - recent_connection_image_save_time_ >= 50) {
|
if (now_time - recent_connection_image_save_time_ >= 50) {
|
||||||
int ret = thumbnail_->LoadThumbnail(recent_connections_,
|
int ret = thumbnail_->LoadThumbnail(recent_connections_);
|
||||||
&recent_connection_image_width_,
|
|
||||||
&recent_connection_image_height_);
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
LOG_INFO("Load recent connection thumbnails");
|
LOG_INFO("Load recent connection thumbnails");
|
||||||
}
|
}
|
||||||
@@ -356,7 +363,8 @@ void GuiRuntime::HandleRecentConnections() {
|
|||||||
|
|
||||||
recent_connection_ids_.clear();
|
recent_connection_ids_.clear();
|
||||||
for (const auto &conn : recent_connections_) {
|
for (const auto &conn : recent_connections_) {
|
||||||
recent_connection_ids_.push_back(conn.first);
|
if (conn.second.lan.device_id.empty())
|
||||||
|
recent_connection_ids_.push_back(conn.first);
|
||||||
}
|
}
|
||||||
need_to_send_recent_connections_ = true;
|
need_to_send_recent_connections_ = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
#include "features/input/keyboard_controller.h"
|
#include "features/input/keyboard_controller.h"
|
||||||
#include "features/input/remote_input_dispatcher.h"
|
#include "features/input/remote_input_dispatcher.h"
|
||||||
#include "features/settings/settings_manager.h"
|
#include "features/settings/settings_manager.h"
|
||||||
|
#include "network/lan/lan_session_manager.h"
|
||||||
#include "privacy_controller.h"
|
#include "privacy_controller.h"
|
||||||
#include "runtime/gui_state.h"
|
#include "runtime/gui_state.h"
|
||||||
|
#include "runtime/disconnect_lock_policy.h"
|
||||||
#include "runtime/announcement_inbox.h"
|
#include "runtime/announcement_inbox.h"
|
||||||
#include "runtime/peer_event_handler.h"
|
#include "runtime/peer_event_handler.h"
|
||||||
#include "runtime/privacy_session_policy.h"
|
#include "runtime/privacy_session_policy.h"
|
||||||
@@ -50,6 +52,35 @@ class GuiRuntime : protected gui_detail::GuiState {
|
|||||||
static void SdlCaptureAudioIn(void* userdata, uint8_t* stream, int len);
|
static void SdlCaptureAudioIn(void* userdata, uint8_t* stream, int len);
|
||||||
static void SdlCaptureAudioOut(void* userdata, uint8_t* stream, int len);
|
static void SdlCaptureAudioOut(void* userdata, uint8_t* stream, int len);
|
||||||
|
|
||||||
|
void InitializeLan();
|
||||||
|
void ApplyLanSettings();
|
||||||
|
bool LanAvailable() const;
|
||||||
|
void TickLan();
|
||||||
|
void BeginShutdown();
|
||||||
|
bool ShutdownDrained();
|
||||||
|
void RegisterStreams(PeerPtr* peer, size_t display_count);
|
||||||
|
void ConnectLan(const std::string& address, unsigned port,
|
||||||
|
std::string password, bool remember_password = false);
|
||||||
|
int WithController(
|
||||||
|
const std::string& id,
|
||||||
|
const std::function<int(PeerPtr*, const std::string&)>& operation);
|
||||||
|
int SendControllerData(const std::string& id, const char* data, size_t size,
|
||||||
|
const char* label);
|
||||||
|
std::shared_ptr<PeerEventHandler> ControllerEvents(const std::string& id);
|
||||||
|
std::vector<std::string> ConnectedControllers();
|
||||||
|
std::unique_ptr<lan::SessionManager> lan_;
|
||||||
|
std::string lan_error_;
|
||||||
|
std::shared_ptr<lan::Session> lan_connecting_;
|
||||||
|
bool lan_stopping_ = false;
|
||||||
|
bool stopping_ = false;
|
||||||
|
std::chrono::steady_clock::time_point shutdown_report_at_{};
|
||||||
|
struct LanController {
|
||||||
|
std::shared_ptr<lan::Session> session;
|
||||||
|
std::shared_ptr<PeerEventHandler> events;
|
||||||
|
};
|
||||||
|
// Protected by remote_sessions_mutex_; snapshots acquire callback leases
|
||||||
|
// before releasing that lock, so removal cannot race a raw Peer use.
|
||||||
|
std::map<std::string, LanController> lan_controllers_;
|
||||||
int CreateConnectionPeer();
|
int CreateConnectionPeer();
|
||||||
int ConnectTo(const std::string& remote_id, const char* password,
|
int ConnectTo(const std::string& remote_id, const char* password,
|
||||||
bool remember_password, bool bypass_presence_check = false,
|
bool remember_password, bool bypass_presence_check = false,
|
||||||
@@ -72,6 +103,7 @@ class GuiRuntime : protected gui_detail::GuiState {
|
|||||||
const char* reason);
|
const char* reason);
|
||||||
void HandleWindowsServiceIntegration();
|
void HandleWindowsServiceIntegration();
|
||||||
void HandlePrivacy();
|
void HandlePrivacy();
|
||||||
|
void HandleScreenLock();
|
||||||
bool IsAuthorizedController(const std::string& remote_id);
|
bool IsAuthorizedController(const std::string& remote_id);
|
||||||
void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
|
void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
|
||||||
// Caller holds connection_status_mutex_ exclusively. Turns privacy off when
|
// Caller holds connection_status_mutex_ exclusively. Turns privacy off when
|
||||||
@@ -127,6 +159,7 @@ class GuiRuntime : protected gui_detail::GuiState {
|
|||||||
bool unattended_host_ = false;
|
bool unattended_host_ = false;
|
||||||
AnnouncementInbox announcements_;
|
AnnouncementInbox announcements_;
|
||||||
PrivacySessionPolicy privacy_sessions_;
|
PrivacySessionPolicy privacy_sessions_;
|
||||||
|
DisconnectLockPolicy disconnect_lock_;
|
||||||
std::mutex privacy_commands_mutex_;
|
std::mutex privacy_commands_mutex_;
|
||||||
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
|
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
|
||||||
uint32_t last_privacy_revision_ = 0;
|
uint32_t last_privacy_revision_ = 0;
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
#include <display_stream_id.h>
|
||||||
|
|
||||||
|
#include "rd_log.h"
|
||||||
|
#include "runtime/gui_runtime.h"
|
||||||
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
|
#include "platform/linux/headless/headless_console.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
void GuiRuntime::RegisterStreams(PeerPtr* peer, size_t displays) {
|
||||||
|
for (size_t i = 0; i < displays; ++i) {
|
||||||
|
const auto id = MakeDisplayStreamId(i);
|
||||||
|
AddVideoStream(peer, id.c_str());
|
||||||
|
}
|
||||||
|
AddAudioStream(peer, audio_label_.c_str());
|
||||||
|
AddDataStream(peer, data_label_.c_str(), false);
|
||||||
|
for (const auto& label :
|
||||||
|
{mouse_label_, keyboard_label_, control_data_label_, file_label_,
|
||||||
|
file_feedback_label_, clipboard_label_})
|
||||||
|
AddDataStream(peer, label.c_str(), true);
|
||||||
|
}
|
||||||
|
void GuiRuntime::InitializeLan() {
|
||||||
|
if (unattended_host_ || stopping_ || lan_ ||
|
||||||
|
!config_center_->IsIpDirectEnabled()) return;
|
||||||
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
|
if (HeadlessConsole::Instance().active()) return;
|
||||||
|
#endif
|
||||||
|
lan_error_.clear();
|
||||||
|
lan::SessionManager::Hooks hooks;
|
||||||
|
hooks.error = [this](const std::string& error) {
|
||||||
|
if (!lan_stopping_ && !stopping_) lan_error_ = error;
|
||||||
|
};
|
||||||
|
hooks.prepare = [this](const std::shared_ptr<lan::Session>& session) {
|
||||||
|
if (stopping_ || lan_stopping_ || devices_.EnsureCaptureReady() != 0)
|
||||||
|
throw std::runtime_error("Capture initialization failed");
|
||||||
|
const size_t displays = devices_.display_info_list().size();
|
||||||
|
auto events = std::make_shared<PeerEventHandler>(*this);
|
||||||
|
events->BindDirect(session);
|
||||||
|
// Direct initialization must not depend on cloud login/configuration having
|
||||||
|
// successfully populated the managed peer's Params snapshot.
|
||||||
|
Params params{};
|
||||||
|
std::snprintf(params.log_path, sizeof(params.log_path), "%s",
|
||||||
|
dll_log_path_.c_str());
|
||||||
|
params.hardware_acceleration = true;
|
||||||
|
params.native_video_output = true;
|
||||||
|
params.av1_encoding = config_center_->GetVideoEncodeFormat() ==
|
||||||
|
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
|
||||||
|
params.enable_srtp = true;
|
||||||
|
params.turn_mode = TurnMode::TurnDisabled;
|
||||||
|
params.video_content_type = VideoContentType::ScreenContent;
|
||||||
|
params.video_quality = VideoQuality::QualityHigh;
|
||||||
|
params.video_frame_rate = 60;
|
||||||
|
params.video_degradation_preference =
|
||||||
|
VideoDegradationPreference::MaintainResolution;
|
||||||
|
params.on_receive_video_frame = PeerEventHandler::OnReceiveVideoBuffer;
|
||||||
|
params.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
|
||||||
|
params.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
|
||||||
|
params.on_connection_status = PeerEventHandler::OnConnectionStatus;
|
||||||
|
params.on_net_status_report = PeerEventHandler::OnNetStatusReport;
|
||||||
|
params.user_id = session->local_id.c_str();
|
||||||
|
params.user_data = events.get();
|
||||||
|
if (!session->incoming) {
|
||||||
|
auto props = FindRemoteSession(session->key);
|
||||||
|
if (!props) throw std::runtime_error("LAN session cancelled");
|
||||||
|
props->peer_events_ = events;
|
||||||
|
props->local_id_ = session->local_id;
|
||||||
|
}
|
||||||
|
return [this, session, events, params, displays] {
|
||||||
|
lan::SessionManager::Prepared result;
|
||||||
|
result.callback_owner = events;
|
||||||
|
result.drain_callbacks = [events] { events->Deactivate(); };
|
||||||
|
result.peer = CreatePeer(¶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
|
||||||
@@ -84,17 +84,52 @@ void LogSecureDesktopInputBlocked(uint32_t *last_tick, const char *side,
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
bool PeerEventHandler::QueueEarlyData(const char* data, size_t size,
|
||||||
|
const char* label, size_t label_size) {
|
||||||
|
if (!direct_bound_) return false;
|
||||||
|
auto session = direct_.lock();
|
||||||
|
if (!session || session->closing) return true;
|
||||||
|
std::lock_guard lock(early_data_mutex_);
|
||||||
|
if (session->authorized) return false;
|
||||||
|
// Keep bounded reliable metadata until the manager commits Connected. Mouse,
|
||||||
|
// keyboard, clipboard and files never enter this pre-authorization queue.
|
||||||
|
if (std::string_view(label, label_size) != owner_.control_data_label_)
|
||||||
|
return true;
|
||||||
|
if (size > 8192 || early_data_.size() >= 16 ||
|
||||||
|
early_data_bytes_ + size > 65536) {
|
||||||
|
session->failed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
early_data_bytes_ += size;
|
||||||
|
early_data_.emplace_back(data, size);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void PeerEventHandler::FlushEarlyData() {
|
||||||
|
std::vector<std::string> queued;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(early_data_mutex_);
|
||||||
|
queued.swap(early_data_);
|
||||||
|
early_data_bytes_ = 0;
|
||||||
|
}
|
||||||
|
const auto session = direct_.lock();
|
||||||
|
if (!session || !session->authorized || session->closing) return;
|
||||||
|
const auto& label = owner_.control_data_label_;
|
||||||
|
for (const auto& data : queued)
|
||||||
|
OnReceiveDataBuffer(data.data(), data.size(), session->remote_id.data(),
|
||||||
|
session->remote_id.size(), label.data(), label.size(),
|
||||||
|
this);
|
||||||
|
}
|
||||||
|
|
||||||
void PeerEventHandler::OnReceiveDataBuffer(
|
void PeerEventHandler::OnReceiveDataBuffer(
|
||||||
const char *data, size_t size, const char *user_id, size_t user_id_size,
|
const char *data, size_t size, const char *user_id, size_t user_id_size,
|
||||||
const char *src_id, size_t src_id_size, void *user_data) {
|
const char *src_id, size_t src_id_size, void *user_data) {
|
||||||
auto *handler = static_cast<PeerEventHandler *>(user_data);
|
auto *handler = static_cast<PeerEventHandler *>(user_data);
|
||||||
if (!handler) return;
|
if (!handler) return;
|
||||||
auto callback = handler->EnterCallback();
|
auto callback = handler->EnterCallback();
|
||||||
if (!callback) return;
|
if (!callback || handler->QueueEarlyData(data, size, src_id, src_id_size) ||
|
||||||
GuiRuntime *runtime = handler ? &handler->owner_ : nullptr;
|
!handler->DataAllowed())
|
||||||
if (!runtime) {
|
|
||||||
return;
|
return;
|
||||||
}
|
GuiRuntime* runtime = &handler->owner_;
|
||||||
|
|
||||||
std::string source_id = std::string(src_id, src_id_size);
|
std::string source_id = std::string(src_id, src_id_size);
|
||||||
// The service-owned host runs as SYSTEM. Do not expose filesystem or
|
// The service-owned host runs as SYSTEM. Do not expose filesystem or
|
||||||
@@ -105,11 +140,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
source_id == runtime->clipboard_label_))
|
source_id == runtime->clipboard_label_))
|
||||||
return;
|
return;
|
||||||
if (source_id == runtime->file_label_) {
|
if (source_id == runtime->file_label_) {
|
||||||
runtime->transfers_.Receive(data, size, handler, std::string(user_id, user_id_size));
|
runtime->transfers_.Receive(data, size, handler,
|
||||||
|
handler->ConnectionKey(user_id, user_id_size));
|
||||||
return;
|
return;
|
||||||
} else if (source_id == runtime->clipboard_label_) {
|
} else if (source_id == runtime->clipboard_label_) {
|
||||||
if (size > 0) {
|
if (size > 0) {
|
||||||
std::string remote_user_id(user_id, user_id_size);
|
std::string remote_user_id =
|
||||||
|
handler->ConnectionKey(user_id, user_id_size);
|
||||||
auto props =
|
auto props =
|
||||||
runtime->FindRemoteSession(remote_user_id);
|
runtime->FindRemoteSession(remote_user_id);
|
||||||
if (props && !props->clipboard_allowed_.load()) {
|
if (props && !props->clipboard_allowed_.load()) {
|
||||||
@@ -121,11 +158,15 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else if (source_id == runtime->file_feedback_label_) {
|
} else if (source_id == runtime->file_feedback_label_) {
|
||||||
runtime->transfers_.HandleAck(data, size, handler, std::string(user_id, user_id_size));
|
runtime->transfers_.HandleAck(
|
||||||
|
data, size, handler, handler->ConnectionKey(user_id, user_id_size));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string remote_id(user_id, user_id_size);
|
const std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
||||||
|
const std::string wire_id = handler->WireId(remote_id);
|
||||||
|
PeerPtr* bound_peer =
|
||||||
|
handler->IsDirect() ? handler->DirectPeer() : runtime->peer_;
|
||||||
std::string json_str(data, size);
|
std::string json_str(data, size);
|
||||||
const auto preview_json =
|
const auto preview_json =
|
||||||
json_str.find("\"deskhub_preview") != std::string::npos
|
json_str.find("\"deskhub_preview") != std::string::npos
|
||||||
@@ -145,29 +186,31 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
if (!previous || subscription->IsNewerThan(*previous)) {
|
if (!previous || subscription->IsNewerThan(*previous)) {
|
||||||
if (subscription->fps > 0)
|
if (subscription->fps > 0)
|
||||||
accepted = UpdateVideoSettings(
|
accepted = UpdateVideoSettings(
|
||||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
bound_peer, wire_id.data(), wire_id.size(),
|
||||||
static_cast<VideoQuality>(subscription->quality),
|
static_cast<VideoQuality>(subscription->quality),
|
||||||
subscription->frame_rate,
|
subscription->frame_rate,
|
||||||
static_cast<VideoDegradationPreference>(
|
static_cast<VideoDegradationPreference>(
|
||||||
subscription->EncoderPreference())) == 0;
|
subscription->EncoderPreference())) == 0;
|
||||||
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
|
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
|
||||||
subscription->max_bitrate_bps.value_or(0)))
|
subscription->max_bitrate_bps.value_or(0)))
|
||||||
accepted = UpdateVideoBitrateLimit(
|
accepted = UpdateVideoBitrateLimit(
|
||||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
bound_peer, wire_id.data(), wire_id.size(),
|
||||||
subscription->max_bitrate_bps.value_or(0)) == 0;
|
subscription->max_bitrate_bps.value_or(0)) == 0;
|
||||||
// Rebalancing bandwidth alone must not force every device to send a
|
// Rebalancing bandwidth alone must not force every device to send a
|
||||||
// keyframe. The pause API requests its own keyframes on resume.
|
// keyframe. The pause API requests its own keyframes on resume.
|
||||||
if (accepted && (!previous || previous->width != subscription->width ||
|
if (accepted && (!previous || previous->width != subscription->width ||
|
||||||
previous->height != subscription->height))
|
previous->height != subscription->height))
|
||||||
accepted = UpdateVideoResolution(
|
accepted = UpdateVideoResolution(bound_peer, wire_id.data(),
|
||||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
wire_id.size(), subscription->width,
|
||||||
subscription->width, subscription->height) == 0;
|
subscription->height) == 0;
|
||||||
if (accepted)
|
if (accepted)
|
||||||
accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(),
|
accepted =
|
||||||
remote_id.size(), subscription->fps == 0) == 0;
|
SetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(),
|
||||||
|
subscription->fps == 0) == 0;
|
||||||
if (accepted)
|
if (accepted)
|
||||||
accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(),
|
accepted =
|
||||||
remote_id.size(), !subscription->audio) == 0;
|
SetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(),
|
||||||
|
!subscription->audio) == 0;
|
||||||
if (accepted && subscription->audio)
|
if (accepted && subscription->audio)
|
||||||
runtime->devices_.StartSpeakerCapturer();
|
runtime->devices_.StartSpeakerCapturer();
|
||||||
}
|
}
|
||||||
@@ -178,9 +221,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
{"request_id", subscription->request_id},
|
{"request_id", subscription->request_id},
|
||||||
{"accepted",
|
{"accepted",
|
||||||
accepted}}.dump();
|
accepted}}.dump();
|
||||||
SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(),
|
SendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(),
|
||||||
runtime->control_data_label_.c_str(),
|
runtime->control_data_label_.c_str(),
|
||||||
remote_id.data(), remote_id.size());
|
wire_id.data(), wire_id.size());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (preview_json.is_object() &&
|
if (preview_json.is_object() &&
|
||||||
@@ -253,7 +296,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
const auto& settings = remote_action.vs;
|
const auto& settings = remote_action.vs;
|
||||||
remote_action.vs.accepted =
|
remote_action.vs.accepted =
|
||||||
UpdateVideoSettings(
|
UpdateVideoSettings(
|
||||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
bound_peer, wire_id.data(), wire_id.size(),
|
||||||
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
|
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
|
||||||
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
|
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
|
||||||
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
|
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
|
||||||
@@ -262,9 +305,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
settings.frame_rate, settings.preference, remote_action.vs.accepted);
|
settings.frame_rate, settings.preference, remote_action.vs.accepted);
|
||||||
remote_action.type = ControlType::video_settings_status;
|
remote_action.type = ControlType::video_settings_status;
|
||||||
const auto message = remote_action.to_json();
|
const auto message = remote_action.to_json();
|
||||||
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
|
SendReliableDataFrameToPeer(bound_peer, message.data(), message.size(),
|
||||||
runtime->control_data_label_.c_str(),
|
runtime->control_data_label_.c_str(),
|
||||||
remote_id.data(), remote_id.size());
|
wire_id.data(), wire_id.size());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (remote_action.type == ControlType::privacy_status) {
|
if (remote_action.type == ControlType::privacy_status) {
|
||||||
@@ -407,8 +450,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
props->video_settings_request_tick_ = SDL_GetTicks();
|
props->video_settings_request_tick_ = SDL_GetTicks();
|
||||||
settings.vs = props->video_settings_;
|
settings.vs = props->video_settings_;
|
||||||
const auto message = settings.to_json();
|
const auto message = settings.to_json();
|
||||||
SendReliableDataFrame(props->peer_, message.data(), message.size(),
|
SendReliableDataFrame(
|
||||||
props->control_data_label_.c_str());
|
handler->IsDirect() ? handler->DirectPeer() : props->peer_,
|
||||||
|
message.data(), message.size(),
|
||||||
|
props->control_data_label_.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// client mode
|
// client mode
|
||||||
@@ -456,8 +501,8 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
// command arrives after it (e.g. when unmuting an inactive window).
|
// command arrives after it (e.g. when unmuting an inactive window).
|
||||||
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
|
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
|
||||||
const bool paused = subscription ? !subscription->audio : !remote_action.a;
|
const bool paused = subscription ? !subscription->audio : !remote_action.a;
|
||||||
const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(),
|
const int ret = SetAudioSendPaused(bound_peer, wire_id.data(),
|
||||||
remote_id.size(), paused);
|
wire_id.size(), paused);
|
||||||
if (remote_action.a)
|
if (remote_action.a)
|
||||||
runtime->devices_.StartSpeakerCapturer();
|
runtime->devices_.StartSpeakerCapturer();
|
||||||
else if (ret != 0)
|
else if (ret != 0)
|
||||||
|
|||||||
@@ -49,6 +49,22 @@ constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
std::string PeerEventHandler::ConnectionKey(const char* id, size_t size) const {
|
||||||
|
if (auto session = direct_.lock()) return session->key;
|
||||||
|
return direct_bound_ ? std::string{} : std::string(id, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string PeerEventHandler::WireId(const std::string& key) const {
|
||||||
|
if (auto session = direct_.lock()) return session->remote_id;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PeerEventHandler::DataAllowed() const {
|
||||||
|
if (!direct_bound_) return true;
|
||||||
|
auto session = direct_.lock();
|
||||||
|
return session && session->authorized && !session->closing;
|
||||||
|
}
|
||||||
|
|
||||||
void PeerEventHandler::SendClientInfo(PeerPtr* peer,
|
void PeerEventHandler::SendClientInfo(PeerPtr* peer,
|
||||||
const std::string& client_id) {
|
const std::string& client_id) {
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
@@ -74,8 +90,8 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
|||||||
if (!handler) return;
|
if (!handler) return;
|
||||||
auto callback = handler->EnterCallback();
|
auto callback = handler->EnterCallback();
|
||||||
if (!callback) return;
|
if (!callback) return;
|
||||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
GuiRuntime* runtime = &handler->owner_;
|
||||||
if (!runtime || !message || size == 0) {
|
if (!message || size == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
std::string s(message, size);
|
std::string s(message, size);
|
||||||
@@ -179,10 +195,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
|||||||
if (!handler) return;
|
if (!handler) return;
|
||||||
auto callback = handler->EnterCallback();
|
auto callback = handler->EnterCallback();
|
||||||
if (!callback) return;
|
if (!callback) return;
|
||||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
GuiRuntime* runtime = &handler->owner_;
|
||||||
if (!runtime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string client_id(user_id, user_id_size);
|
std::string client_id(user_id, user_id_size);
|
||||||
if (client_id == runtime->client_id_) {
|
if (client_id == runtime->client_id_) {
|
||||||
@@ -251,17 +264,30 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
|
|||||||
}
|
}
|
||||||
|
|
||||||
void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||||
const char* user_id,
|
const char* user_id, size_t size,
|
||||||
const size_t user_id_size,
|
void* context) {
|
||||||
void* user_data) {
|
auto* handler = static_cast<PeerEventHandler*>(context);
|
||||||
|
if (!handler) return;
|
||||||
|
if (handler->direct_bound_) {
|
||||||
|
auto callback = handler->EnterCallback();
|
||||||
|
if (callback)
|
||||||
|
if (auto session = handler->direct_.lock()) session->Status(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DispatchConnectionStatus(status, user_id, size, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
||||||
|
const char* user_id,
|
||||||
|
const size_t user_id_size,
|
||||||
|
void* user_data) {
|
||||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||||
if (!handler) return;
|
if (!handler) return;
|
||||||
auto callback = handler->EnterCallback();
|
auto callback = handler->EnterCallback();
|
||||||
if (!callback) return;
|
if (!callback) return;
|
||||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
GuiRuntime* runtime = &handler->owner_;
|
||||||
if (!runtime) return;
|
|
||||||
|
|
||||||
std::string remote_id(user_id, user_id_size);
|
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
||||||
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
|
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
|
||||||
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
|
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
|
||||||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
|
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
|
||||||
@@ -432,6 +458,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
|||||||
{
|
{
|
||||||
std::unique_lock lock(runtime->connection_status_mutex_);
|
std::unique_lock lock(runtime->connection_status_mutex_);
|
||||||
if (status == ConnectionStatus::Connected) {
|
if (status == ConnectionStatus::Connected) {
|
||||||
|
runtime->disconnect_lock_.Connected(remote_id);
|
||||||
// Local privacy requires explicit desktop capability. Host info may
|
// Local privacy requires explicit desktop capability. Host info may
|
||||||
// arrive before Connected; consume that saved capability here.
|
// arrive before Connected; consume that saved capability here.
|
||||||
const bool automatic_enable = runtime->privacy_sessions_.Connected(
|
const bool automatic_enable = runtime->privacy_sessions_.Connected(
|
||||||
@@ -474,8 +501,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
|||||||
}
|
}
|
||||||
std::string msg = remote_action.to_json();
|
std::string msg = remote_action.to_json();
|
||||||
const int ret =
|
const int ret =
|
||||||
SendReliableDataFrame(runtime->peer_, msg.data(), msg.size(),
|
runtime->SendControllerData(remote_id, msg.data(), msg.size(),
|
||||||
runtime->control_data_label_.c_str());
|
runtime->control_data_label_.c_str());
|
||||||
if (ret != 0) {
|
if (ret != 0) {
|
||||||
LOG_WARN("[{}] host information send failed, ret={}", remote_id,
|
LOG_WARN("[{}] host information send failed, ret={}", remote_id,
|
||||||
ret);
|
ret);
|
||||||
@@ -516,21 +543,12 @@ void PeerEventHandler::OnNetStatusReport(
|
|||||||
if (!handler) return;
|
if (!handler) return;
|
||||||
auto callback = handler->EnterCallback();
|
auto callback = handler->EnterCallback();
|
||||||
if (!callback) return;
|
if (!callback) return;
|
||||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
GuiRuntime* runtime = &handler->owner_;
|
||||||
if (!runtime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strchr(client_id, '@') != nullptr && strchr(user_id, '-') == nullptr) {
|
if (const char* at_pos = handler->IsDirect() ? nullptr : strchr(client_id, '@');
|
||||||
std::string id, password;
|
at_pos && strchr(user_id, '-') == nullptr) {
|
||||||
const char* at_pos = strchr(client_id, '@');
|
const std::string id(client_id, at_pos - client_id);
|
||||||
if (at_pos == nullptr) {
|
const std::string password(at_pos + 1);
|
||||||
id = client_id;
|
|
||||||
password.clear();
|
|
||||||
} else {
|
|
||||||
id.assign(client_id, at_pos - client_id);
|
|
||||||
password = at_pos + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool is_self_hosted = runtime->config_center_->IsSelfHosted();
|
bool is_self_hosted = runtime->config_center_->IsSelfHosted();
|
||||||
|
|
||||||
@@ -578,7 +596,7 @@ void PeerEventHandler::OnNetStatusReport(
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string remote_id(user_id, user_id_size);
|
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
||||||
auto props = runtime->FindRemoteSession(remote_id);
|
auto props = runtime->FindRemoteSession(remote_id);
|
||||||
if (!props) {
|
if (!props) {
|
||||||
bool report_codecs = false;
|
bool report_codecs = false;
|
||||||
@@ -597,9 +615,8 @@ void PeerEventHandler::OnNetStatusReport(
|
|||||||
net_traffic_stats->video_encoder);
|
net_traffic_stats->video_encoder);
|
||||||
const auto message = action.to_json();
|
const auto message = action.to_json();
|
||||||
// Report only to this controller, after releasing the status lock.
|
// Report only to this controller, after releasing the status lock.
|
||||||
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
|
runtime->SendControllerData(remote_id, message.data(), message.size(),
|
||||||
runtime->control_data_label_.c_str(),
|
runtime->control_data_label_.c_str());
|
||||||
remote_id.data(), remote_id.size());
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
#define _PEER_EVENT_HANDLER_H_
|
#define _PEER_EVENT_HANDLER_H_
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "minirtc.h"
|
#include "minirtc.h"
|
||||||
#include "runtime/session_lifecycle.h"
|
#include "runtime/session_lifecycle.h"
|
||||||
@@ -16,12 +19,30 @@
|
|||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
class GuiRuntime;
|
class GuiRuntime;
|
||||||
|
namespace lan {
|
||||||
|
struct Session;
|
||||||
|
}
|
||||||
|
|
||||||
// Adapts MiniRTC C callbacks to the GUI runtime.
|
// Adapts MiniRTC C callbacks to the GUI runtime.
|
||||||
class PeerEventHandler {
|
class PeerEventHandler {
|
||||||
public:
|
public:
|
||||||
explicit PeerEventHandler(GuiRuntime &owner);
|
explicit PeerEventHandler(GuiRuntime &owner);
|
||||||
|
|
||||||
|
void BindDirect(const std::shared_ptr<lan::Session>& session) {
|
||||||
|
direct_ = session;
|
||||||
|
direct_bound_ = true;
|
||||||
|
}
|
||||||
|
std::string ConnectionKey(const char* id, size_t size) const;
|
||||||
|
std::string WireId(const std::string& key) const;
|
||||||
|
bool DataAllowed() const;
|
||||||
|
bool IsDirect() const { return direct_bound_; }
|
||||||
|
void SetDirectPeer(PeerPtr* peer) { direct_peer_ = peer; }
|
||||||
|
PeerPtr* DirectPeer() const { return direct_peer_; }
|
||||||
|
void FlushEarlyData();
|
||||||
|
void ApplyConnectionStatus(ConnectionStatus status, const std::string& id) {
|
||||||
|
DispatchConnectionStatus(status, id.data(), id.size(), this);
|
||||||
|
}
|
||||||
|
void StopAccepting() { callbacks_.StopAccepting(); }
|
||||||
bool IsActive() const { return callbacks_.IsActive(); }
|
bool IsActive() const { return callbacks_.IsActive(); }
|
||||||
auto EnterCallback() { return callbacks_.Enter(); }
|
auto EnterCallback() { return callbacks_.Enter(); }
|
||||||
void Deactivate() { callbacks_.Close(); }
|
void Deactivate() { callbacks_.Close(); }
|
||||||
@@ -51,9 +72,19 @@ public:
|
|||||||
void *user_data);
|
void *user_data);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static void SendClientInfo(PeerPtr *peer, const std::string &client_id);
|
static void DispatchConnectionStatus(ConnectionStatus status, const char* id,
|
||||||
|
size_t size, void* context);
|
||||||
|
static void SendClientInfo(PeerPtr* peer, const std::string& client_id);
|
||||||
|
bool QueueEarlyData(const char* data, size_t size, const char* label,
|
||||||
|
size_t label_size);
|
||||||
|
std::weak_ptr<lan::Session> direct_;
|
||||||
|
bool direct_bound_ = false;
|
||||||
|
PeerPtr* direct_peer_ = nullptr;
|
||||||
|
std::mutex early_data_mutex_;
|
||||||
|
std::vector<std::string> early_data_;
|
||||||
|
size_t early_data_bytes_ = 0;
|
||||||
|
|
||||||
GuiRuntime &owner_;
|
GuiRuntime& owner_;
|
||||||
gui_detail::SessionCallbackGate callbacks_;
|
gui_detail::SessionCallbackGate callbacks_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,20 +38,21 @@ void PeerEventHandler::OnReceiveVideoBuffer(
|
|||||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||||
if (!handler) return;
|
if (!handler) return;
|
||||||
auto callback = handler->EnterCallback();
|
auto callback = handler->EnterCallback();
|
||||||
if (!callback) return;
|
if (!callback || !handler->DataAllowed()) return;
|
||||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
GuiRuntime* runtime = &handler->owner_;
|
||||||
if (!runtime || !video_frame) {
|
if (!video_frame) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string remote_id(user_id, user_id_size);
|
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
|
||||||
auto props = runtime->FindRemoteSession(remote_id);
|
auto props = runtime->FindRemoteSession(remote_id);
|
||||||
if (!props) return;
|
if (!props) return;
|
||||||
|
|
||||||
if (props->connection_established_) {
|
if (props->connection_established_) {
|
||||||
const auto timing_now = VideoLatencyFrame::Clock::now();
|
const auto timing_now = VideoLatencyFrame::Clock::now();
|
||||||
// Pair the clocks before preview conversion so its cost is included.
|
// Pair the clocks before preview conversion so its cost is included.
|
||||||
const auto local_now_us = GetSystemTimeMicros(props->peer_);
|
const auto local_now_us = GetSystemTimeMicros(
|
||||||
|
handler->IsDirect() ? handler->DirectPeer() : props->peer_);
|
||||||
const auto* received_native = GetNativeVideoFrame(video_frame);
|
const auto* received_native = GetNativeVideoFrame(video_frame);
|
||||||
props->received_video_stats_.Record(
|
props->received_video_stats_.Record(
|
||||||
received_native ? received_native->width : video_frame->width,
|
received_native ? received_native->width : video_frame->width,
|
||||||
@@ -274,15 +275,15 @@ void PeerEventHandler::OnReceiveAudioBuffer(
|
|||||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||||
if (!handler) return;
|
if (!handler) return;
|
||||||
auto callback = handler->EnterCallback();
|
auto callback = handler->EnterCallback();
|
||||||
if (!callback) return;
|
if (!callback || !handler->DataAllowed()) return;
|
||||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
GuiRuntime* runtime = &handler->owner_;
|
||||||
if (!runtime || runtime->unattended_host_) {
|
if (runtime->unattended_host_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
|
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
|
||||||
const auto session =
|
const auto session =
|
||||||
runtime->FindRemoteSession(std::string(user_id, user_id_size));
|
runtime->FindRemoteSession(handler->ConnectionKey(user_id, user_id_size));
|
||||||
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
|
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
|
||||||
runtime->audio_buffer_fresh_ = true;
|
runtime->audio_buffer_fresh_ = true;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
|
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
|
||||||
|
if (remote_id.rfind("lan/", 0) == 0) {
|
||||||
|
const auto events = ControllerEvents(remote_id);
|
||||||
|
if (!events || !events->DataAllowed()) return false;
|
||||||
|
}
|
||||||
// Connections appear here only after the existing password/signalling/RTC
|
// Connections appear here only after the existing password/signalling/RTC
|
||||||
// admission path. Outgoing/viewer sessions live in remote_sessions_ instead.
|
// admission path. Outgoing/viewer sessions live in remote_sessions_ instead.
|
||||||
std::shared_lock lock(connection_status_mutex_);
|
std::shared_lock lock(connection_status_mutex_);
|
||||||
@@ -109,9 +113,9 @@ void GuiRuntime::HandlePrivacy() {
|
|||||||
"A connected controller does not support privacy screen");
|
"A connected controller does not support privacy screen");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!peer_ || (last_privacy_status_tick_ != 0 &&
|
if (last_privacy_status_tick_ != 0 &&
|
||||||
status.revision == last_privacy_revision_ &&
|
status.revision == last_privacy_revision_ &&
|
||||||
now - last_privacy_status_tick_ < 1000))
|
now - last_privacy_status_tick_ < 1000)
|
||||||
return;
|
return;
|
||||||
RemoteAction action{};
|
RemoteAction action{};
|
||||||
action.type = ControlType::privacy_status;
|
action.type = ControlType::privacy_status;
|
||||||
@@ -119,9 +123,8 @@ void GuiRuntime::HandlePrivacy() {
|
|||||||
const auto message = action.to_json();
|
const auto message = action.to_json();
|
||||||
bool sent = true;
|
bool sent = true;
|
||||||
for (const auto& id : controllers) {
|
for (const auto& id : controllers) {
|
||||||
const int result = SendReliableDataFrameToPeer(
|
const int result = SendControllerData(id, message.data(), message.size(),
|
||||||
peer_, message.data(), message.size(), control_data_label_.c_str(),
|
control_data_label_.c_str());
|
||||||
id.data(), id.size());
|
|
||||||
if (result != 0) {
|
if (result != 0) {
|
||||||
sent = false;
|
sent = false;
|
||||||
LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result);
|
LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result);
|
||||||
|
|||||||
@@ -31,7 +31,12 @@
|
|||||||
#include "runtime/received_video_stats.h"
|
#include "runtime/received_video_stats.h"
|
||||||
#include "runtime/connection_auth_state.h"
|
#include "runtime/connection_auth_state.h"
|
||||||
|
|
||||||
namespace crossdesk { class PeerEventHandler; }
|
namespace crossdesk {
|
||||||
|
class PeerEventHandler;
|
||||||
|
namespace lan {
|
||||||
|
struct Session;
|
||||||
|
}
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
namespace crossdesk::gui_detail {
|
namespace crossdesk::gui_detail {
|
||||||
|
|
||||||
@@ -105,6 +110,7 @@ struct RemoteSession {
|
|||||||
// Decoder-thread scratch memory, never shared with the UI.
|
// Decoder-thread scratch memory, never shared with the UI.
|
||||||
std::mutex gallery_conversion_mutex_;
|
std::mutex gallery_conversion_mutex_;
|
||||||
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
|
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
|
||||||
|
std::shared_ptr<lan::Session> lan_session_;
|
||||||
Params params_;
|
Params params_;
|
||||||
PeerPtr* peer_ = nullptr;
|
PeerPtr* peer_ = nullptr;
|
||||||
std::shared_ptr<PeerEventHandler> peer_events_;
|
std::shared_ptr<PeerEventHandler> peer_events_;
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ struct RecentConnectionsState {
|
|||||||
recent_connections_;
|
recent_connections_;
|
||||||
std::vector<std::string> recent_connection_ids_;
|
std::vector<std::string> recent_connection_ids_;
|
||||||
std::vector<std::string> gallery_presence_ids_;
|
std::vector<std::string> gallery_presence_ids_;
|
||||||
int recent_connection_image_width_ = 160;
|
|
||||||
int recent_connection_image_height_ = 90;
|
|
||||||
uint32_t recent_connection_image_save_time_ = 0;
|
uint32_t recent_connection_image_save_time_ = 0;
|
||||||
DevicePresenceCache device_presence_cache_;
|
DevicePresenceCache device_presence_cache_;
|
||||||
std::atomic<bool> need_to_send_recent_connections_{true};
|
std::atomic<bool> need_to_send_recent_connections_{true};
|
||||||
@@ -150,13 +148,11 @@ struct UserSettingsState {
|
|||||||
char connection_login_identity_[17] = "";
|
char connection_login_identity_[17] = "";
|
||||||
int language_button_value_ = 0;
|
int language_button_value_ = 0;
|
||||||
int video_encode_format_button_value_ = 0;
|
int video_encode_format_button_value_ = 0;
|
||||||
bool enable_turn_ = true;
|
|
||||||
char signal_server_ip_[256] = "api.crossdesk.cn";
|
char signal_server_ip_[256] = "api.crossdesk.cn";
|
||||||
char signal_server_port_[6] = "9099";
|
char signal_server_port_[6] = "9099";
|
||||||
bool enable_self_hosted_ = false;
|
bool enable_self_hosted_ = false;
|
||||||
int language_button_value_last_ = 0;
|
int language_button_value_last_ = 0;
|
||||||
int video_encode_format_button_value_last_ = 0;
|
int video_encode_format_button_value_last_ = 0;
|
||||||
bool enable_turn_last_ = true;
|
|
||||||
bool enable_self_hosted_last_ = false;
|
bool enable_self_hosted_last_ = false;
|
||||||
bool enable_autostart_ = false;
|
bool enable_autostart_ = false;
|
||||||
bool enable_autostart_last_ = false;
|
bool enable_autostart_last_ = false;
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ void SessionCallbackGate::EndCallback() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SessionCallbackGate::StopAccepting() {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
accepting_callbacks_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
void SessionCallbackGate::Close() {
|
void SessionCallbackGate::Close() {
|
||||||
std::unique_lock lock(mutex_);
|
std::unique_lock lock(mutex_);
|
||||||
accepting_callbacks_ = false;
|
accepting_callbacks_ = false;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class SessionCallbackGate {
|
|||||||
CallbackGuard Enter();
|
CallbackGuard Enter();
|
||||||
|
|
||||||
// Called by the session owner, never from within this session's callbacks.
|
// Called by the session owner, never from within this session's callbacks.
|
||||||
|
void StopAccepting();
|
||||||
void Close();
|
void Close();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { ScrollView, TextEdit } from "std-widgets.slint";
|
import { ScrollView, TextEdit } from "std-widgets.slint";
|
||||||
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
|
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
|
||||||
import { WorkbenchStyle } from "workbench_controls.slint";
|
import { WorkbenchStyle } from "workbench_controls.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { GalleryStrings } from "device_strings.slint";
|
import { GalleryStrings } from "device_strings.slint";
|
||||||
import { FontAwesomeIcons } from "common.slint";
|
import { FontAwesomeIcons, ControlStyle } from "common.slint";
|
||||||
import "../assets/fonts/message_spacing.ttf";
|
import "../assets/fonts/message_spacing.ttf";
|
||||||
|
|
||||||
export enum AboutPage { overview, components, component, document, source }
|
export enum AboutPage { overview, components, component, document, source }
|
||||||
@@ -26,11 +27,11 @@ export global AboutStrings {
|
|||||||
out property <string> back: GalleryStrings.tr("返回", "Back", "Назад");
|
out property <string> back: GalleryStrings.tr("返回", "Back", "Назад");
|
||||||
}
|
}
|
||||||
|
|
||||||
export component AboutNavigationGroup inherits Rectangle {
|
export component AboutNavigationGroup inherits FlatSurface {
|
||||||
height: rows.preferred-height + 2px;
|
height: rows.preferred-height + 2px;
|
||||||
background: WorkbenchStyle.surface;
|
background: WorkbenchStyle.surface;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
border-width: 1px; border-color: WorkbenchStyle.separator;
|
outline-color: UiSurface.edge;
|
||||||
clip: true;
|
clip: true;
|
||||||
rows := VerticalLayout {
|
rows := VerticalLayout {
|
||||||
x: 1px; y: 1px; width: parent.width - 2px;
|
x: 1px; y: 1px; width: parent.width - 2px;
|
||||||
@@ -41,13 +42,13 @@ export component AboutNavigationGroup inherits Rectangle {
|
|||||||
|
|
||||||
export component AboutRowSeparator inherits Rectangle {
|
export component AboutRowSeparator inherits Rectangle {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
Rectangle {
|
SurfaceSeparator {
|
||||||
x: 14px; width: parent.width - 30px; height: 1px;
|
x: 14px; width: parent.width - 30px; height: 1px;
|
||||||
background: WorkbenchStyle.separator;
|
outline-color: UiSurface.separator;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export component AboutNavigationRow inherits Rectangle {
|
export component AboutNavigationRow inherits FlatSurface {
|
||||||
in property <string> title;
|
in property <string> title;
|
||||||
in property <string> subtitle;
|
in property <string> subtitle;
|
||||||
in property <string> icon;
|
in property <string> icon;
|
||||||
@@ -60,7 +61,7 @@ export component AboutNavigationRow inherits Rectangle {
|
|||||||
background: touch.pressed ? WorkbenchStyle.pressed-surface
|
background: touch.pressed ? WorkbenchStyle.pressed-surface
|
||||||
: touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
|
: touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
|
||||||
border-radius: root.grouped ? 0px : UiRadius.control;
|
border-radius: root.grouped ? 0px : UiRadius.control;
|
||||||
border-width: root.grouped ? 0px : 1px; border-color: WorkbenchStyle.separator;
|
outline-enabled: !root.grouped; outline-color: UiSurface.edge;
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "")
|
accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "")
|
||||||
+ (root.detail != "" ? ", " + root.detail : "");
|
+ (root.detail != "" ? ", " + root.detail : "");
|
||||||
@@ -69,10 +70,10 @@ export component AboutNavigationRow inherits Rectangle {
|
|||||||
x: 12px; y: (parent.height - self.height) / 2;
|
x: 12px; y: (parent.height - self.height) / 2;
|
||||||
width: 28px; height: 28px;
|
width: 28px; height: 28px;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
background: WorkbenchStyle.field-surface;
|
background: ControlStyle.surface;
|
||||||
Text {
|
Text {
|
||||||
text: root.icon; font-family: "Font Awesome 6 Free"; font-weight: 900;
|
text: root.icon; font-family: "Font Awesome 6 Free"; font-weight: 900;
|
||||||
font-size: 13px; color: WorkbenchStyle.muted;
|
font-size: 13px; color: ControlStyle.icon;
|
||||||
horizontal-alignment: center; vertical-alignment: center;
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
accessible-role: none;
|
accessible-role: none;
|
||||||
}
|
}
|
||||||
@@ -226,7 +227,7 @@ export component AboutPanel inherits SettingsDialog {
|
|||||||
footer := VerticalLayout {
|
footer := VerticalLayout {
|
||||||
x: 24px; y: parent.height - self.height - 24px;
|
x: 24px; y: parent.height - self.height - 24px;
|
||||||
width: parent.width - 48px; height: self.preferred-height; spacing: 12px;
|
width: parent.width - 48px; height: self.preferred-height; spacing: 12px;
|
||||||
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
|
SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
alignment: end;
|
alignment: end;
|
||||||
SettingsButton { text: AboutStrings.back; clicked => { root.back(); } }
|
SettingsButton { text: AboutStrings.back; clicked => { root.back(); } }
|
||||||
|
|||||||
@@ -1,17 +1,42 @@
|
|||||||
|
import { FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line
|
|
||||||
// colors in one place so the Slint renderer produces the same visual weight.
|
|
||||||
export global ImGuiLineStyle {
|
|
||||||
out property <color> border: #0000004d;
|
|
||||||
out property <color> separator: #6363639e;
|
|
||||||
out property <color> divider: #0000007a;
|
|
||||||
out property <color> control-separator: #b2b2b2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export global FloatingBarStyle {
|
export global FloatingBarStyle {
|
||||||
out property <length> corner-radius: UiRadius.menu;
|
out property <length> corner-radius: UiRadius.menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fields and selection controls share a muted fill.
|
||||||
|
export global ControlStyle {
|
||||||
|
out property <color> surface: #dce6f3;
|
||||||
|
out property <color> hover: #cedef1;
|
||||||
|
out property <color> pressed: #bfd2ec;
|
||||||
|
out property <color> selected: #bcd4f7;
|
||||||
|
out property <color> selected-hover: #adcaf1;
|
||||||
|
out property <color> selected-pressed: #99bce8;
|
||||||
|
out property <color> selected-text: #1d4e9e;
|
||||||
|
out property <color> disabled: #f0f2f5;
|
||||||
|
out property <color> icon: #52657f;
|
||||||
|
out property <color> input-surface: #dee7f2;
|
||||||
|
out property <color> input-focused: #e5ecf5;
|
||||||
|
out property <color> input-placeholder: #566a84;
|
||||||
|
// These tints over white match the field fills for standard LineEdit.
|
||||||
|
out property <color> input-tint: #235eaa26;
|
||||||
|
out property <color> input-focus-tint: #235eaa1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons use a light surface and a crisp edge, distinct from fields.
|
||||||
|
export global ButtonStyle {
|
||||||
|
out property <color> surface: #ffffff;
|
||||||
|
out property <color> hover: #f0f5ff;
|
||||||
|
out property <color> pressed: #dfe9fa;
|
||||||
|
out property <color> border: #b8c7db;
|
||||||
|
out property <color> hover-border: #7e9fd1;
|
||||||
|
out property <color> pressed-border: #6488bf;
|
||||||
|
out property <color> disabled-border: #dce2eb;
|
||||||
|
out property <color> selected: #3564e3;
|
||||||
|
out property <color> selected-hover: #4574f0;
|
||||||
|
out property <color> selected-pressed: #254cc0;
|
||||||
|
}
|
||||||
|
|
||||||
// ImGui loads a 32px font atlas and applies per-window font scales. These
|
// ImGui loads a 32px font atlas and applies per-window font scales. These
|
||||||
// logical sizes reproduce those visual tiers on Slint's HiDPI renderer.
|
// logical sizes reproduce those visual tiers on Slint's HiDPI renderer.
|
||||||
export global ImGuiFontStyle {
|
export global ImGuiFontStyle {
|
||||||
@@ -69,10 +94,10 @@ export component IconButton inherits Rectangle {
|
|||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
in property <bool> danger: false;
|
in property <bool> danger: false;
|
||||||
in property <length> icon-size: 16px;
|
in property <length> icon-size: 16px;
|
||||||
in property <color> icon-color: #24272d;
|
in property <color> icon-color: ControlStyle.icon;
|
||||||
in property <color> normal-background: transparent;
|
in property <color> normal-background: transparent;
|
||||||
in property <color> hover-background: #edf0f5;
|
in property <color> hover-background: ControlStyle.hover;
|
||||||
in property <color> pressed-background: #d9dde5;
|
in property <color> pressed-background: ControlStyle.pressed;
|
||||||
in property <bool> slashed: false;
|
in property <bool> slashed: false;
|
||||||
callback clicked;
|
callback clicked;
|
||||||
|
|
||||||
@@ -138,8 +163,8 @@ export component ImageButton inherits Rectangle {
|
|||||||
|
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
background: touch.pressed ? (danger ? #d92d20 : #d9dde5)
|
background: touch.pressed ? (danger ? #d92d20 : ControlStyle.pressed)
|
||||||
: touch.has-hover ? (danger ? #f04438 : #edf0f5)
|
: touch.has-hover ? (danger ? #f04438 : ControlStyle.hover)
|
||||||
: transparent;
|
: transparent;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
|
|
||||||
@@ -174,14 +199,14 @@ export component ImageButton inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export component CompactComboBox inherits Rectangle {
|
export component CompactComboBox inherits FlatSurface {
|
||||||
in property <[string]> model;
|
in property <[string]> model;
|
||||||
in-out property <int> current-index: 0;
|
in-out property <int> current-index: 0;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
in property <bool> subtle: false;
|
in property <bool> subtle: false;
|
||||||
in property <bool> show-arrow: true;
|
in property <bool> show-arrow: true;
|
||||||
in property <color> normal-border-color: root.subtle ? #dfe6ef : #bfc2c7;
|
in property <color> normal-outline-color: ButtonStyle.border;
|
||||||
in property <color> hover-border-color: root.normal-border-color;
|
in property <color> hover-outline-color: ButtonStyle.hover-border;
|
||||||
property <bool> popup-animation-enabled: false;
|
property <bool> popup-animation-enabled: false;
|
||||||
property <float> popup-progress: 0.0;
|
property <float> popup-progress: 0.0;
|
||||||
callback selected(int);
|
callback selected(int);
|
||||||
@@ -205,43 +230,45 @@ export component CompactComboBox inherits Rectangle {
|
|||||||
|
|
||||||
width: 73px;
|
width: 73px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1;
|
background: !root.enabled ? ControlStyle.disabled
|
||||||
border-width: 1px;
|
: combo-touch.pressed ? ButtonStyle.pressed
|
||||||
border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color;
|
: combo-popup.is-open || combo-touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
|
||||||
|
outline-focused: root.enabled && combo-popup.is-open;
|
||||||
|
outline-color: !root.enabled ? ButtonStyle.disabled-border
|
||||||
|
: combo-popup.is-open ? UiSurface.focus
|
||||||
|
: combo-touch.pressed ? ButtonStyle.pressed-border
|
||||||
|
: combo-touch.has-hover ? root.hover-outline-color : root.normal-outline-color;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
|
animate background, outline-color { duration: 120ms; }
|
||||||
|
accessible-role: combobox;
|
||||||
|
accessible-enabled: root.enabled;
|
||||||
|
accessible-expandable: true;
|
||||||
|
accessible-expanded: combo-popup.is-open;
|
||||||
|
accessible-value: root.model[root.current-index];
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
x: root.subtle ? 10px : 5px;
|
x: root.subtle ? 10px : 8px;
|
||||||
width: parent.width - (root.show-arrow ? (root.subtle ? 36px : 24px) : (root.subtle ? 20px : 10px));
|
width: parent.width - self.x - (root.show-arrow ? 28px : self.x);
|
||||||
|
height: parent.height;
|
||||||
text: root.model[root.current-index];
|
text: root.model[root.current-index];
|
||||||
color: root.enabled ? #202124 : #8b8f95;
|
color: root.enabled ? #202124 : #8b8f95;
|
||||||
font-size: root.subtle ? 12px : ImGuiFontStyle.body;
|
font-size: root.subtle ? 12px : ImGuiFontStyle.body;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
|
accessible-role: none;
|
||||||
}
|
}
|
||||||
if root.show-arrow: Rectangle {
|
if root.show-arrow: Text {
|
||||||
x: parent.width - (root.subtle ? 27px : 21px);
|
x: parent.width - 26px;
|
||||||
width: 20px;
|
width: 18px; height: parent.height;
|
||||||
height: parent.height - 2px;
|
// Switch glyphs so the software renderer also shows the open state.
|
||||||
y: 1px;
|
text: combo-popup.is-open ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down;
|
||||||
background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc;
|
color: !root.enabled ? #98a2b3 : combo-popup.is-open ? UiSurface.focus : ControlStyle.icon;
|
||||||
border-top-right-radius: UiRadius.control - 1px;
|
font-family: "Font Awesome 6 Free";
|
||||||
border-bottom-right-radius: UiRadius.control - 1px;
|
font-weight: 900;
|
||||||
Text {
|
font-size: 10px;
|
||||||
text: FontAwesomeIcons.angle-down;
|
horizontal-alignment: center;
|
||||||
color: root.subtle ? #79879a : #202124;
|
vertical-alignment: center;
|
||||||
font-family: "Font Awesome 6 Free";
|
accessible-role: none;
|
||||||
font-weight: 900;
|
|
||||||
font-size: 10px;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
transform-rotation: combo-popup.is-open ? 180deg : 0deg;
|
|
||||||
animate transform-rotation {
|
|
||||||
duration: 140ms;
|
|
||||||
easing: ease-out;
|
|
||||||
enabled: combo-popup.is-open;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
combo-touch := TouchArea {
|
combo-touch := TouchArea {
|
||||||
enabled: root.enabled; mouse-cursor: pointer;
|
enabled: root.enabled; mouse-cursor: pointer;
|
||||||
@@ -269,12 +296,12 @@ export component CompactComboBox inherits Rectangle {
|
|||||||
|
|
||||||
combo-popup := PopupWindow {
|
combo-popup := PopupWindow {
|
||||||
x: 0px;
|
x: 0px;
|
||||||
y: root.height;
|
y: root.height + 4px;
|
||||||
width: root.width;
|
width: root.width;
|
||||||
height: root.model.length * 23px;
|
height: root.model.length * 23px + 8px;
|
||||||
close-policy: close-on-click-outside;
|
close-policy: close-on-click-outside;
|
||||||
|
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
// Keep the reveal anchored to the popup's top edge while the
|
// Keep the reveal anchored to the popup's top edge while the
|
||||||
// content settles downward into its final position.
|
// content settles downward into its final position.
|
||||||
y: (1.0 - root.popup-progress) * -8px;
|
y: (1.0 - root.popup-progress) * -8px;
|
||||||
@@ -282,18 +309,33 @@ export component CompactComboBox inherits Rectangle {
|
|||||||
height: parent.height * root.popup-progress;
|
height: parent.height * root.popup-progress;
|
||||||
opacity: root.popup-progress;
|
opacity: root.popup-progress;
|
||||||
background: white;
|
background: white;
|
||||||
border-width: 1px;
|
outline-color: root.normal-outline-color;
|
||||||
border-color: root.normal-border-color;
|
|
||||||
border-radius: UiRadius.menu;
|
border-radius: UiRadius.menu;
|
||||||
clip: true;
|
clip: true;
|
||||||
for choice[index] in root.model: Rectangle {
|
for choice[index] in root.model: Rectangle {
|
||||||
y: index * 23px;
|
x: 4px; y: 4px + index * 23px;
|
||||||
width: parent.width;
|
width: parent.width - 8px;
|
||||||
height: 23px;
|
height: 23px;
|
||||||
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
|
border-radius: UiRadius.control;
|
||||||
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
background: index == root.current-index
|
||||||
|
? (option-touch.pressed ? ButtonStyle.pressed : ButtonStyle.hover)
|
||||||
|
: option-touch.pressed ? ButtonStyle.pressed : option-touch.has-hover ? ButtonStyle.hover : white;
|
||||||
|
Text {
|
||||||
|
x: 6px; width: parent.width - 28px; height: parent.height; text: choice;
|
||||||
|
color: index == root.current-index ? ControlStyle.selected-text : #202124;
|
||||||
|
font-weight: index == root.current-index ? 600 : 400;
|
||||||
|
font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
if index == root.current-index: Text {
|
||||||
|
x: parent.width - 20px; width: 14px; height: parent.height;
|
||||||
|
text: FontAwesomeIcons.check; color: UiSurface.focus;
|
||||||
|
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px;
|
||||||
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
|
accessible-role: none;
|
||||||
|
}
|
||||||
option-touch := TouchArea {
|
option-touch := TouchArea {
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
|
mouse-cursor: pointer;
|
||||||
clicked => {
|
clicked => {
|
||||||
popup-open-timer.running = false;
|
popup-open-timer.running = false;
|
||||||
root.popup-animation-enabled = false;
|
root.popup-animation-enabled = false;
|
||||||
@@ -306,12 +348,12 @@ export component CompactComboBox inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
width: parent.width;
|
width: parent.width;
|
||||||
height: parent.height;
|
height: parent.height;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-width: 1px;
|
clip: true;
|
||||||
border-color: root.normal-border-color;
|
outline-color: root.normal-outline-color;
|
||||||
border-radius: UiRadius.menu;
|
border-radius: UiRadius.menu;
|
||||||
z: 2;
|
z: 2;
|
||||||
}
|
}
|
||||||
@@ -319,19 +361,20 @@ export component CompactComboBox inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export component ImGuiCheckBox inherits Rectangle {
|
export component ImGuiCheckBox inherits FlatSurface {
|
||||||
in-out property <bool> checked;
|
in-out property <bool> checked;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
|
|
||||||
width: 21px;
|
width: 21px;
|
||||||
height: 21px;
|
height: 21px;
|
||||||
background: root.enabled ? white : #f1f1f1;
|
background: !root.enabled ? ControlStyle.disabled
|
||||||
border-width: 1px;
|
: root.checked ? (check-touch.pressed ? #254cc0 : check-touch.has-hover ? #4574f0 : #3564e3)
|
||||||
border-color: check-touch.has-hover && root.enabled ? #8cbef2 : #b9bdc2;
|
: check-touch.pressed ? ControlStyle.pressed : check-touch.has-hover ? ControlStyle.hover : ControlStyle.surface;
|
||||||
|
outline-color: check-touch.has-hover && root.enabled ? UiSurface.focus : UiSurface.edge;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
if root.checked: Text {
|
if root.checked: Text {
|
||||||
text: FontAwesomeIcons.check;
|
text: FontAwesomeIcons.check;
|
||||||
color: root.enabled ? #2e86de : #8b8f95;
|
color: root.enabled ? white : #8b8f95;
|
||||||
font-family: "Font Awesome 6 Free";
|
font-family: "Font Awesome 6 Free";
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -368,7 +411,7 @@ export component MiniToggleSwitch inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export component CompactButton inherits Rectangle {
|
export component CompactButton inherits FlatSurface {
|
||||||
in property <string> text;
|
in property <string> text;
|
||||||
in property <length> text-size: ImGuiFontStyle.body;
|
in property <length> text-size: ImGuiFontStyle.body;
|
||||||
in property <bool> primary: false;
|
in property <bool> primary: false;
|
||||||
@@ -378,13 +421,17 @@ export component CompactButton inherits Rectangle {
|
|||||||
|
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: !root.enabled ? (root.quiet ? transparent : #eceef0)
|
background: !root.enabled ? (root.quiet ? transparent : ControlStyle.disabled)
|
||||||
: button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd)
|
: button-touch.pressed ? (root.primary ? #82b8f2 : ButtonStyle.pressed)
|
||||||
: button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2)
|
: button-touch.has-hover ? (root.primary ? #b8d9fb : ButtonStyle.hover)
|
||||||
: root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7;
|
: root.primary ? #a8d0fa : root.quiet ? transparent : ButtonStyle.surface;
|
||||||
border-width: 1px;
|
outline-enabled: !root.quiet;
|
||||||
border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0;
|
outline-color: !root.enabled ? ButtonStyle.disabled-border
|
||||||
|
: root.primary ? UiSurface.focus
|
||||||
|
: button-touch.pressed ? ButtonStyle.pressed-border
|
||||||
|
: button-touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
|
animate background, outline-color { duration: 100ms; }
|
||||||
Text {
|
Text {
|
||||||
text: root.text;
|
text: root.text;
|
||||||
color: root.enabled ? #24272d : #8e9298;
|
color: root.enabled ? #24272d : #8e9298;
|
||||||
@@ -393,27 +440,30 @@ export component CompactButton inherits Rectangle {
|
|||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
}
|
}
|
||||||
button-touch := TouchArea { enabled: root.enabled; clicked => { root.clicked(); } }
|
button-touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export component PrimaryButton inherits Rectangle {
|
export component PrimaryButton inherits FlatSurface {
|
||||||
in property <string> text;
|
in property <string> text;
|
||||||
in property <length> text-size: ImGuiFontStyle.body;
|
in property <length> text-size: 12px;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
callback clicked;
|
callback clicked;
|
||||||
|
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
height: 30px;
|
height: 32px;
|
||||||
background: !root.enabled ? #b8c1d1
|
background: !root.enabled ? #b8c1d1
|
||||||
: touch.pressed ? #1849a9
|
: touch.pressed ? #254cc0
|
||||||
: touch.has-hover ? #2970d6
|
: touch.has-hover ? #4574f0
|
||||||
: #2463c7;
|
: #3564e3;
|
||||||
|
outline-enabled: false;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
|
animate background { duration: 100ms; }
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: root.text;
|
text: root.text;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: root.text-size;
|
font-size: root.text-size;
|
||||||
|
font-weight: 600;
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
}
|
}
|
||||||
@@ -424,32 +474,35 @@ export component PrimaryButton inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export component SecondaryButton inherits Rectangle {
|
export component SecondaryButton inherits FlatSurface {
|
||||||
in property <string> text;
|
in property <string> text;
|
||||||
in property <length> text-size: ImGuiFontStyle.body;
|
in property <length> text-size: 12px;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
callback clicked;
|
callback clicked;
|
||||||
|
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
height: 30px;
|
height: 32px;
|
||||||
background: !root.enabled ? #f1f2f4
|
background: !root.enabled ? ControlStyle.disabled
|
||||||
: touch.pressed ? #dfe3e9
|
: touch.pressed ? ButtonStyle.pressed
|
||||||
: touch.has-hover ? #f3f4f6 : white;
|
: touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
|
||||||
border-width: 1px;
|
outline-color: !root.enabled ? ButtonStyle.disabled-border
|
||||||
border-color: #c9ced7;
|
: touch.pressed ? ButtonStyle.pressed-border
|
||||||
|
: touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
clip: true;
|
animate background, outline-color { duration: 100ms; }
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: root.text;
|
text: root.text;
|
||||||
color: root.enabled ? #24272d : #949aa5;
|
color: root.enabled ? #303f55 : #949aa5;
|
||||||
font-size: root.text-size;
|
font-size: root.text-size;
|
||||||
|
font-weight: 500;
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
touch := TouchArea {
|
touch := TouchArea {
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
|
mouse-cursor: pointer;
|
||||||
clicked => { root.clicked(); }
|
clicked => { root.clicked(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,9 +548,8 @@ export component SectionTitle inherits Text {
|
|||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
export component DialogSurface inherits Rectangle {
|
export component DialogSurface inherits FlatSurface {
|
||||||
background: white;
|
background: white;
|
||||||
border-width: 1px;
|
outline-color: UiSurface.edge;
|
||||||
border-color: ImGuiLineStyle.border;
|
|
||||||
border-radius: UiRadius.surface;
|
border-radius: UiRadius.surface;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
||||||
import { CompactButton, ImGuiCheckBox } from "common.slint";
|
import { CompactButton, ImGuiCheckBox, ControlStyle } from "common.slint";
|
||||||
import { GalleryStrings } from "device_strings.slint";
|
import { GalleryStrings } from "device_strings.slint";
|
||||||
|
|
||||||
export struct DeviceGroupOption {
|
export struct DeviceGroupOption {
|
||||||
@@ -20,13 +21,13 @@ export component DeviceGroupDialog inherits Rectangle {
|
|||||||
callback cancel();
|
callback cancel();
|
||||||
background: #00000040;
|
background: #00000040;
|
||||||
TouchArea {}
|
TouchArea {}
|
||||||
dialog := Rectangle {
|
dialog := FlatSurface {
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
width: min(360px, parent.width - 40px);
|
width: min(360px, parent.width - 40px);
|
||||||
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
|
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
|
||||||
background: white; border-radius: UiRadius.surface;
|
background: white; border-radius: UiRadius.surface;
|
||||||
border-width: 1px; border-color: #b2b2b2;
|
outline-color: UiSurface.edge;
|
||||||
TouchArea {}
|
TouchArea {}
|
||||||
Text {
|
Text {
|
||||||
x: 18px; y: 16px; height: 24px;
|
x: 18px; y: 16px; height: 24px;
|
||||||
@@ -41,10 +42,10 @@ export component DeviceGroupDialog inherits Rectangle {
|
|||||||
x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
|
x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
|
||||||
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
|
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
|
||||||
}
|
}
|
||||||
group-list := Rectangle {
|
group-list := FlatSurface {
|
||||||
x: 18px; y: 95px; width: parent.width - 36px;
|
x: 18px; y: 95px; width: parent.width - 36px;
|
||||||
height: min(5, max(1, root.options.length)) * 32px + 2px;
|
height: min(5, max(1, root.options.length)) * 32px + 2px;
|
||||||
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: UiRadius.surface;
|
background: #fbfcfd; outline-color: UiSurface.edge; border-radius: UiRadius.surface;
|
||||||
clip: true;
|
clip: true;
|
||||||
ScrollView {
|
ScrollView {
|
||||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||||
@@ -52,16 +53,21 @@ export component DeviceGroupDialog inherits Rectangle {
|
|||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
for option[index] in root.options: Rectangle {
|
for option[index] in root.options: Rectangle {
|
||||||
|
private property <bool> selected: option.selected && (option.name != "" || root.new-group == "");
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: row-touch.has-hover ? #eef4fc : transparent;
|
background: self.selected
|
||||||
|
? (row-touch.pressed ? ControlStyle.selected-pressed : row-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
|
||||||
|
: row-touch.pressed ? ControlStyle.pressed : row-touch.has-hover ? ControlStyle.hover : transparent;
|
||||||
ImGuiCheckBox {
|
ImGuiCheckBox {
|
||||||
x: 9px; y: 6px; width: 20px; height: 20px;
|
x: 9px; y: 6px; width: 20px; height: 20px;
|
||||||
checked: option.selected && (option.name != "" || root.new-group == "");
|
checked: parent.selected;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
x: 39px; width: parent.width - 48px; height: parent.height;
|
x: 39px; width: parent.width - 48px; height: parent.height;
|
||||||
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name;
|
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name;
|
||||||
color: #30343b; font-size: 12px; overflow: elide; vertical-alignment: center;
|
color: parent.selected ? ControlStyle.selected-text : #30343b;
|
||||||
|
font-weight: parent.selected ? 600 : 400;
|
||||||
|
font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||||
}
|
}
|
||||||
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
|
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
|
||||||
}
|
}
|
||||||
@@ -75,10 +81,10 @@ export component DeviceGroupDialog inherits Rectangle {
|
|||||||
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
|
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
|
||||||
border-color: white;
|
border-color: white;
|
||||||
}
|
}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
width: parent.width; height: parent.height;
|
width: parent.width; height: parent.height;
|
||||||
border-width: 1px; border-radius: UiRadius.surface;
|
border-radius: UiRadius.surface;
|
||||||
border-color: #e1e5eb;
|
outline-color: UiSurface.edge;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RoundedLineEdit {
|
RoundedLineEdit {
|
||||||
|
|||||||
@@ -3,4 +3,16 @@ export global GalleryStrings {
|
|||||||
public pure function tr(zh: string, en: string, ru: string) -> string {
|
public pure function tr(zh: string, en: string, ru: string) -> string {
|
||||||
return self.language == 0 ? zh : self.language == 2 ? ru : en;
|
return self.language == 0 ? zh : self.language == 2 ? ru : en;
|
||||||
}
|
}
|
||||||
|
public pure function device-count(count: int) -> string {
|
||||||
|
if self.language == 0 { return count + " 台设备"; }
|
||||||
|
if self.language == 2 {
|
||||||
|
let last-two = mod(count, 100);
|
||||||
|
let last = mod(count, 10);
|
||||||
|
if last-two >= 11 && last-two <= 14 { return count + " устройств"; }
|
||||||
|
if last == 1 { return count + " устройство"; }
|
||||||
|
if last >= 2 && last <= 4 { return count + " устройства"; }
|
||||||
|
return count + " устройств";
|
||||||
|
}
|
||||||
|
return count + (count == 1 ? " device" : " devices");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SurfaceSeparator, UiSurface } from "ui_surface.slint";
|
||||||
import { FileTransferEntry } from "stream_window.slint";
|
import { FileTransferEntry } from "stream_window.slint";
|
||||||
import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint";
|
import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint";
|
||||||
import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint";
|
import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint";
|
||||||
@@ -26,7 +27,7 @@ export component FileTransferNotification inherits Window {
|
|||||||
DialogSurface {
|
DialogSurface {
|
||||||
width: parent.width; height: parent.height;
|
width: parent.width; height: parent.height;
|
||||||
background: WorkbenchStyle.surface;
|
background: WorkbenchStyle.surface;
|
||||||
border-color: WorkbenchStyle.separator;
|
outline-color: UiSurface.edge;
|
||||||
Text {
|
Text {
|
||||||
x: 16px; y: 8px;
|
x: 16px; y: 8px;
|
||||||
width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px);
|
width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px);
|
||||||
@@ -50,9 +51,9 @@ export component FileTransferNotification inherits Window {
|
|||||||
pressed-background: WorkbenchStyle.pressed-surface;
|
pressed-background: WorkbenchStyle.pressed-surface;
|
||||||
clicked => { root.dismissed(); }
|
clicked => { root.dismissed(); }
|
||||||
}
|
}
|
||||||
Rectangle {
|
SurfaceSeparator {
|
||||||
x: 16px; y: 40px; width: parent.width - 32px; height: 1px;
|
x: 16px; y: 40px; width: parent.width - 32px; height: 1px;
|
||||||
background: WorkbenchStyle.separator;
|
outline-color: UiSurface.separator;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
x: 16px; y: 47px; width: parent.width - 32px; height: 18px;
|
x: 16px; y: 47px; width: parent.width - 32px; height: 18px;
|
||||||
@@ -96,7 +97,7 @@ export component FileTransferNotification inherits Window {
|
|||||||
text: root.cancel-text;
|
text: root.cancel-text;
|
||||||
text-size: ImGuiFontStyle.base;
|
text-size: ImGuiFontStyle.base;
|
||||||
text-color: WorkbenchStyle.muted;
|
text-color: WorkbenchStyle.muted;
|
||||||
normal-border-color: WorkbenchStyle.border;
|
normal-outline-color: UiSurface.edge;
|
||||||
clicked => { root.cancel-requested(); }
|
clicked => { root.cancel-requested(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { CompactButton, FontAwesomeIcons } from "common.slint";
|
import { CompactButton, FontAwesomeIcons, ControlStyle } from "common.slint";
|
||||||
|
|
||||||
export struct GalleryMenuItem { label: string, enabled: bool }
|
export struct GalleryMenuItem { label: string, enabled: bool }
|
||||||
|
|
||||||
@@ -88,12 +89,11 @@ export component GalleryMenuButton inherits Rectangle {
|
|||||||
color: root.enabled ? #62666d : #8e9298;
|
color: root.enabled ? #62666d : #8e9298;
|
||||||
horizontal-alignment: center; vertical-alignment: center;
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
}
|
}
|
||||||
if root.compact-trigger: Rectangle {
|
if root.compact-trigger: FlatSurface {
|
||||||
background: more-touch.pressed || popup.is-open ? #e5edfc : more-touch.has-hover ? white : #fffffff0;
|
background: more-touch.pressed || popup.is-open ? ControlStyle.selected : more-touch.has-hover ? ControlStyle.hover : white;
|
||||||
border-radius: UiRadius.menu;
|
border-radius: UiRadius.menu;
|
||||||
border-width: 1px;
|
outline-color: trigger-focus.has-focus ? UiSurface.focus : UiSurface.edge;
|
||||||
border-color: trigger-focus.has-focus ? #3564e3 : #ffffff80;
|
animate background, outline-color { duration: 120ms; }
|
||||||
animate background, border-color { duration: 120ms; }
|
|
||||||
for dot in 3: Rectangle {
|
for dot in 3: Rectangle {
|
||||||
x: parent.width / 2 - 6px + dot * 5px;
|
x: parent.width / 2 - 6px + dot * 5px;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
@@ -117,11 +117,12 @@ export component GalleryMenuButton inherits Rectangle {
|
|||||||
forward-focus: popup-focus;
|
forward-focus: popup-focus;
|
||||||
popup-focus := FocusScope {
|
popup-focus := FocusScope {
|
||||||
key-pressed(event) => { return root.handle-key(event); }
|
key-pressed(event) => { return root.handle-key(event); }
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu;
|
background: white; outline-color: UiSurface.edge; border-radius: UiRadius.menu;
|
||||||
if root.destructive-index > 0: Rectangle {
|
clip: true;
|
||||||
|
if root.destructive-index > 0: SurfaceSeparator {
|
||||||
x: 10px; y: root.menu-padding + root.destructive-index * root.row-height + (root.divider-spacing - 1px) / 2;
|
x: 10px; y: root.menu-padding + root.destructive-index * root.row-height + (root.divider-spacing - 1px) / 2;
|
||||||
width: parent.width - 20px; height: 1px; background: #e8edf4;
|
width: parent.width - 20px; height: 1px; outline-color: UiSurface.separator;
|
||||||
}
|
}
|
||||||
for item[index] in root.items: Rectangle {
|
for item[index] in root.items: Rectangle {
|
||||||
private property <bool> destructive: index == root.destructive-index;
|
private property <bool> destructive: index == root.destructive-index;
|
||||||
@@ -132,7 +133,9 @@ export component GalleryMenuButton inherits Rectangle {
|
|||||||
+ (root.destructive-index > 0 && index >= root.destructive-index ? root.divider-spacing : 0px);
|
+ (root.destructive-index > 0 && index >= root.destructive-index ? root.divider-spacing : 0px);
|
||||||
width: parent.width - root.menu-padding * 2; height: root.row-height;
|
width: parent.width - root.menu-padding * 2; height: root.row-height;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
background: item.enabled && index == root.current-index ? (self.destructive ? #fff0f0 : #edf4fc) : transparent;
|
background: item.enabled && index == root.current-index
|
||||||
|
? (self.destructive ? (item-touch.pressed ? #f6babb : #fbe0e0)
|
||||||
|
: item-touch.pressed ? ControlStyle.pressed : ControlStyle.hover) : transparent;
|
||||||
accessible-role: button; accessible-label: item.label; accessible-enabled: item.enabled;
|
accessible-role: button; accessible-label: item.label; accessible-enabled: item.enabled;
|
||||||
accessible-action-default => { root.choose(index); }
|
accessible-action-default => { root.choose(index); }
|
||||||
if self.has-icon: Text {
|
if self.has-icon: Text {
|
||||||
@@ -148,7 +151,7 @@ export component GalleryMenuButton inherits Rectangle {
|
|||||||
text: item.label; color: parent.foreground;
|
text: item.label; color: parent.foreground;
|
||||||
font-size: root.compact-menu ? 11px : 12px; vertical-alignment: center;
|
font-size: root.compact-menu ? 11px : 12px; vertical-alignment: center;
|
||||||
}
|
}
|
||||||
TouchArea {
|
item-touch := TouchArea {
|
||||||
enabled: item.enabled;
|
enabled: item.enabled;
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: pointer;
|
||||||
changed has-hover => { if self.has-hover { root.current-index = index; } }
|
changed has-hover => { if self.has-hover { root.current-index = index; } }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { GalleryStrings } from "device_strings.slint";
|
import { GalleryStrings } from "device_strings.slint";
|
||||||
import { CompactButton } from "common.slint";
|
import { CompactButton } from "common.slint";
|
||||||
@@ -66,7 +67,7 @@ component NetworkRow inherits Rectangle {
|
|||||||
text: !root.device.encryption-known
|
text: !root.device.encryption-known
|
||||||
? GalleryStrings.tr("待确认", "Unknown", "Неизвестно")
|
? GalleryStrings.tr("待确认", "Unknown", "Неизвестно")
|
||||||
: root.device.encryption-enabled
|
: root.device.encryption-enabled
|
||||||
? GalleryStrings.tr("已加密", "Encrypted", "Защищено")
|
? GalleryStrings.tr("已加密", "Encrypted", "Зашифровано")
|
||||||
: GalleryStrings.tr("未加密", "Unencrypted", "Без шифр.");
|
: GalleryStrings.tr("未加密", "Unencrypted", "Без шифр.");
|
||||||
color: !root.device.encryption-known ? #7a8490
|
color: !root.device.encryption-known ? #7a8490
|
||||||
: root.device.encryption-enabled ? #18794e : #8a6428;
|
: root.device.encryption-enabled ? #18794e : #8a6428;
|
||||||
@@ -190,13 +191,13 @@ export component GalleryNetworkDialog inherits Rectangle {
|
|||||||
callback close();
|
callback close();
|
||||||
background: #00000042;
|
background: #00000042;
|
||||||
TouchArea { clicked => { root.close(); } }
|
TouchArea { clicked => { root.close(); } }
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
width: min(760px, parent.width - 32px);
|
width: min(760px, parent.width - 32px);
|
||||||
height: min(120px + root.list-height, parent.height - 32px);
|
height: min(120px + root.list-height, parent.height - 32px);
|
||||||
background: white; border-radius: UiRadius.surface;
|
background: white; border-radius: UiRadius.surface;
|
||||||
border-width: 1px; border-color: #c6cdd5;
|
outline-color: UiSurface.edge;
|
||||||
TouchArea {}
|
TouchArea {}
|
||||||
FocusScope {
|
FocusScope {
|
||||||
init => { self.focus(); }
|
init => { self.focus(); }
|
||||||
@@ -207,7 +208,7 @@ export component GalleryNetworkDialog inherits Rectangle {
|
|||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
x: 20px; y: 17px; width: parent.width - 120px; height: 24px;
|
x: 20px; y: 17px; width: parent.width - 120px; height: 24px;
|
||||||
text: GalleryStrings.tr("设备网络状态", "Device network status", "Сеть устройств");
|
text: GalleryStrings.tr("设备网络状态", "Device network status", "Состояние сети устройств");
|
||||||
font-size: 17px; font-weight: 600; color: #293746;
|
font-size: 17px; font-weight: 600; color: #293746;
|
||||||
}
|
}
|
||||||
CompactButton {
|
CompactButton {
|
||||||
@@ -237,7 +238,7 @@ export component GalleryNetworkDialog inherits Rectangle {
|
|||||||
for device[index] in root.devices: NetworkRow {
|
for device[index] in root.devices: NetworkRow {
|
||||||
y: index * root.row-height; width: list.viewport-width; height: root.row-height;
|
y: index * root.row-height; width: list.viewport-width; height: root.row-height;
|
||||||
device: device; background: mod(index, 2) == 0 ? white : #f8fafc;
|
device: device; background: mod(index, 2) == 0 ? white : #f8fafc;
|
||||||
Rectangle { y: parent.height - 1px; height: 1px; background: #edf0f4; }
|
SurfaceSeparator { y: parent.height - 1px; height: 1px; outline-color: UiSurface.separator; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if root.devices.length == 0: NetworkEmptyState {
|
if root.devices.length == 0: NetworkEmptyState {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export component GallerySettings inherits Rectangle {
|
|||||||
y: 0px; width: parent.width;
|
y: 0px; width: parent.width;
|
||||||
title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3;
|
title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3;
|
||||||
SettingsRow {
|
SettingsRow {
|
||||||
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение");
|
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Макс. разрешение");
|
||||||
label-reserve: self.width - 112px;
|
label-reserve: self.width - 112px;
|
||||||
SettingsSelect {
|
SettingsSelect {
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
@@ -33,7 +33,7 @@ export component GallerySettings inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsRow {
|
SettingsRow {
|
||||||
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео");
|
label: GalleryStrings.tr("视频总带宽", "Total bandwidth", "Общий битрейт");
|
||||||
label-reserve: self.width - 112px; divider: false;
|
label-reserve: self.width - 112px; divider: false;
|
||||||
SettingsSelect {
|
SettingsSelect {
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { GalleryStrings } from "device_strings.slint";
|
import { GalleryStrings } from "device_strings.slint";
|
||||||
import { RemoteCursor } from "remote_cursor.slint";
|
import { RemoteCursor } from "remote_cursor.slint";
|
||||||
@@ -6,7 +7,7 @@ import { GalleryMenuButton } from "gallery_menu_button.slint";
|
|||||||
import { GroupFilter } from "group_filter.slint";
|
import { GroupFilter } from "group_filter.slint";
|
||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
||||||
import { CompactButton, SecondaryButton, IconButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint";
|
import { CompactButton, SecondaryButton, IconButton, FontAwesomeIcons, ImGuiCheckBox, ControlStyle } from "common.slint";
|
||||||
|
|
||||||
component GalleryButton inherits CompactButton {
|
component GalleryButton inherits CompactButton {
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
@@ -289,7 +290,7 @@ export component GalleryView inherits Rectangle {
|
|||||||
selected(index) => { root.action("group", index); }
|
selected(index) => { root.action("group", index); }
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: root.count + GalleryStrings.tr(" 台设备", " devices", " устройств");
|
text: GalleryStrings.device-count(root.count);
|
||||||
min-width: 0px; overflow: elide; font-size: 11px; color: #858c96; vertical-alignment: center;
|
min-width: 0px; overflow: elide; font-size: 11px; color: #858c96; vertical-alignment: center;
|
||||||
}
|
}
|
||||||
Rectangle { horizontal-stretch: 1; min-width: 0px; }
|
Rectangle { horizontal-stretch: 1; min-width: 0px; }
|
||||||
@@ -335,8 +336,8 @@ export component GalleryView inherits Rectangle {
|
|||||||
: GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор");
|
: GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор");
|
||||||
clicked => { root.action("all-select", ""); }
|
clicked => { root.action("all-select", ""); }
|
||||||
}
|
}
|
||||||
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; primary: true; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } }
|
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; primary: true; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action("connect", ""); } }
|
||||||
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
|
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Disconnect", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
|
||||||
batch-menu := GalleryMenuButton {
|
batch-menu := GalleryMenuButton {
|
||||||
horizontal-padding: root.toolbar-button-padding;
|
horizontal-padding: root.toolbar-button-padding;
|
||||||
width: self.preferred-button-width; enabled: root.selected-count > 0;
|
width: self.preferred-button-width; enabled: root.selected-count > 0;
|
||||||
@@ -473,7 +474,7 @@ export component GalleryView inherits Rectangle {
|
|||||||
height: root.card-height;
|
height: root.card-height;
|
||||||
opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1;
|
opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1;
|
||||||
background: !tile.online ? DeviceCardStyle.offline-background
|
background: !tile.online ? DeviceCardStyle.offline-background
|
||||||
: tile.selected ? #eaf3ff : root.batch && tile-touch.has-hover ? #f3f8fe : white;
|
: tile.selected ? ControlStyle.selected : root.batch && tile-touch.has-hover ? ControlStyle.hover : white;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: root.card-inset; y: self.x;
|
x: root.card-inset; y: self.x;
|
||||||
width: parent.width - self.x * 2;
|
width: parent.width - self.x * 2;
|
||||||
@@ -491,12 +492,13 @@ export component GalleryView inherits Rectangle {
|
|||||||
width: parent.width - self.x - (tile-touch.has-hover && !root.batch ? DeviceCardStyle.toolbar-width + 12px : root.batch ? 35px : 9px);
|
width: parent.width - self.x - (tile-touch.has-hover && !root.batch ? DeviceCardStyle.toolbar-width + 12px : root.batch ? 35px : 9px);
|
||||||
height: 24px; text: tile.name; color: tile.online ? #24272d : DeviceCardStyle.offline-text; font-size: 12px; overflow: elide; vertical-alignment: center;
|
height: 24px; text: tile.name; color: tile.online ? #24272d : DeviceCardStyle.offline-text; font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||||
}
|
}
|
||||||
if root.batch && !root.simplified: Rectangle {
|
if root.batch && !root.simplified: FlatSurface {
|
||||||
x: parent.width - 27px; y: parent.height - 27px;
|
x: parent.width - 27px; y: parent.height - 27px;
|
||||||
width: 18px; height: 18px; border-radius: 4px; border-width: 1px;
|
width: 18px; height: 18px; border-radius: 4px;
|
||||||
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white;
|
background: tile.selected ? (tile-touch.pressed ? #254cc0 : tile-touch.has-hover ? #4574f0 : #3564e3)
|
||||||
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8;
|
: tile-touch.pressed ? ControlStyle.pressed : tile-touch.has-hover ? ControlStyle.hover : ControlStyle.surface;
|
||||||
animate background, border-color { duration: 120ms; }
|
outline-color: tile.selected ? UiSurface.focus : tile-touch.has-hover ? UiSurface.hover : UiSurface.edge;
|
||||||
|
animate background, outline-color { duration: 120ms; }
|
||||||
if tile.selected: Text {
|
if tile.selected: Text {
|
||||||
text: FontAwesomeIcons.check; color: white;
|
text: FontAwesomeIcons.check; color: white;
|
||||||
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px;
|
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px;
|
||||||
@@ -531,27 +533,27 @@ export component GalleryView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Draw each table line once, continuously across occupied and empty cells.
|
// Draw each table line once, continuously across occupied and empty cells.
|
||||||
for column in root.columns + 1: Rectangle {
|
for column in root.columns + 1: SurfaceSeparator {
|
||||||
visible: root.count > 0;
|
visible: root.count > 0;
|
||||||
x: min(column * root.card-width, grid.table-width - 1px);
|
x: min(column * root.card-width, grid.table-width - 1px);
|
||||||
y: 0px;
|
y: 0px;
|
||||||
width: 1px; height: grid.table-height;
|
width: 1px; height: grid.table-height;
|
||||||
background: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column)
|
vertical: true; outline-color: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column)
|
||||||
? #76aeea : #c9cdd2;
|
? UiSurface.focus : UiSurface.separator;
|
||||||
}
|
}
|
||||||
for row in root.rows + 1: Rectangle {
|
for row in root.rows + 1: SurfaceSeparator {
|
||||||
visible: root.count > 0;
|
visible: root.count > 0;
|
||||||
x: 0px;
|
x: 0px;
|
||||||
y: min(row * root.card-height, grid.table-height - 1px);
|
y: min(row * root.card-height, grid.table-height - 1px);
|
||||||
width: grid.table-width; height: 1px;
|
width: grid.table-width; height: 1px;
|
||||||
background: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row)
|
outline-color: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row)
|
||||||
? #76aeea : #c9cdd2;
|
? UiSurface.focus : UiSurface.separator;
|
||||||
}
|
}
|
||||||
if root.drag-target >= 0: Rectangle {
|
if root.drag-target >= 0: FlatSurface {
|
||||||
x: mod(root.drag-target, root.columns) * root.card-width + 1px;
|
x: mod(root.drag-target, root.columns) * root.card-width + 1px;
|
||||||
y: floor(root.drag-target / root.columns) * root.card-height + 1px;
|
y: floor(root.drag-target / root.columns) * root.card-height + 1px;
|
||||||
width: root.card-width - 2px; height: root.card-height - 2px;
|
width: root.card-width - 2px; height: root.card-height - 2px;
|
||||||
background: #76aeea26; border-width: 2px; border-color: #4a94de;
|
background: #76aeea26; outline-color: UiSurface.focus;
|
||||||
}
|
}
|
||||||
if root.count == 0: Text {
|
if root.count == 0: Text {
|
||||||
text: root.simplified
|
text: root.simplified
|
||||||
@@ -577,7 +579,7 @@ export component GalleryView inherits Rectangle {
|
|||||||
background: root.controlling ? #e7f1fd : #f0f2f5;
|
background: root.controlling ? #e7f1fd : #f0f2f5;
|
||||||
focus-state := Text {
|
focus-state := Text {
|
||||||
x: 8px; width: parent.width - 16px; height: parent.height;
|
x: 8px; width: parent.width - 16px; height: parent.height;
|
||||||
text: root.group-controlling ? GalleryStrings.tr("群控中", "Group control", "Групповое")
|
text: root.group-controlling ? GalleryStrings.tr("群控中", "Group control", "Групп. управление")
|
||||||
: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
|
: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
|
||||||
: root.focused.connected && root.focused.has-frame && !root.focused.stale
|
: root.focused.connected && root.focused.has-frame && !root.focused.stale
|
||||||
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
|
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
|
||||||
@@ -588,8 +590,8 @@ export component GalleryView inherits Rectangle {
|
|||||||
horizontal-padding: root.toolbar-button-padding;
|
horizontal-padding: root.toolbar-button-padding;
|
||||||
primary: root.group-controlling;
|
primary: root.group-controlling;
|
||||||
enabled: root.group-controlling || (root.focused.connected && root.focused.has-frame && !root.focused.stale);
|
enabled: root.group-controlling || (root.focused.connected && root.focused.has-frame && !root.focused.stale);
|
||||||
text: root.group-controlling ? GalleryStrings.tr("关闭群控", "Stop group", "Откл. группу") : GalleryStrings.tr("开启群控", "Group control", "Групп. ввод");
|
text: root.group-controlling ? GalleryStrings.tr("关闭群控", "Stop group control", "Откл. групп. ввод") : GalleryStrings.tr("开启群控", "Group control", "Групп. ввод");
|
||||||
alternate-text: root.group-controlling ? GalleryStrings.tr("开启群控", "Group control", "Групп. ввод") : GalleryStrings.tr("关闭群控", "Stop group", "Откл. группу");
|
alternate-text: root.group-controlling ? GalleryStrings.tr("开启群控", "Group control", "Групп. ввод") : GalleryStrings.tr("关闭群控", "Stop group control", "Откл. групп. ввод");
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: self.text + GalleryStrings.tr(":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах");
|
accessible-label: self.text + GalleryStrings.tr(":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах");
|
||||||
accessible-action-default => { self.clicked(); }
|
accessible-action-default => { self.clicked(); }
|
||||||
@@ -597,11 +599,11 @@ export component GalleryView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
GalleryButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
|
GalleryButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
|
||||||
}
|
}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px;
|
x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px;
|
||||||
width: parent.width; height: parent.height - self.y;
|
width: parent.width; height: parent.height - self.y;
|
||||||
background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: UiRadius.surface;
|
background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: UiRadius.surface;
|
||||||
border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : root.focused.online ? #d5dbe3 : DeviceCardStyle.offline-border;
|
outline-color: root.controlling ? UiSurface.focus : UiSurface.edge;
|
||||||
video := Rectangle {
|
video := Rectangle {
|
||||||
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
||||||
background: root.focused.online ? #24272d : DeviceCardStyle.offline-preview;
|
background: root.focused.online ? #24272d : DeviceCardStyle.offline-preview;
|
||||||
@@ -668,12 +670,12 @@ export component GalleryView inherits Rectangle {
|
|||||||
// Collapse the focused tile and its gap without changing the page model.
|
// Collapse the focused tile and its gap without changing the page model.
|
||||||
width: tile.id == root.focused.id ? 0px : root.rail-slot-width;
|
width: tile.id == root.focused.id ? 0px : root.rail-slot-width;
|
||||||
height: root.rail-card-height; visible: tile.id != root.focused.id;
|
height: root.rail-card-height; visible: tile.id != root.focused.id;
|
||||||
rail-card := Rectangle {
|
rail-card := FlatSurface {
|
||||||
private property <string> presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети")
|
private property <string> presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети")
|
||||||
: tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
|
: tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
|
||||||
private property <string> details: tile.name + "\n" + tile.host-name + "\nID: " + tile.id + "\n" + tile.group-names + "\n" + self.presence;
|
private property <string> details: tile.name + "\n" + tile.host-name + "\nID: " + tile.id + "\n" + tile.group-names + "\n" + self.presence;
|
||||||
width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: UiRadius.surface;
|
width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: UiRadius.surface;
|
||||||
background: tile.online ? #f5f6f8 : DeviceCardStyle.offline-background; border-width: 1px; border-color: tile.online ? #d5dbe3 : DeviceCardStyle.offline-border;
|
background: tile.online ? #f5f6f8 : DeviceCardStyle.offline-background; outline-color: UiSurface.edge;
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: self.details;
|
accessible-label: self.details;
|
||||||
accessible-action-default => { root.action("focus", tile.id); }
|
accessible-action-default => { root.action("focus", tile.id); }
|
||||||
@@ -714,9 +716,9 @@ export component GalleryView inherits Rectangle {
|
|||||||
visible: root.focus-rail-visible;
|
visible: root.focus-rail-visible;
|
||||||
x: 0px; y: rail.y + rail.height; width: parent.width; height: 12px;
|
x: 0px; y: rail.y + rail.height; width: parent.width; height: 12px;
|
||||||
private property <bool> highlighted: divider-touch.has-hover || root.focus-resizing || divider-focus.has-focus;
|
private property <bool> highlighted: divider-touch.has-hover || root.focus-resizing || divider-focus.has-focus;
|
||||||
Rectangle {
|
SurfaceSeparator {
|
||||||
x: 0px; y: 5px; width: parent.width; height: 1px;
|
x: 0px; y: 5px; width: parent.width; height: 1px;
|
||||||
background: focus-divider.highlighted ? #a6c9ed : #e6e9ed;
|
outline-color: focus-divider.highlighted ? UiSurface.focus : UiSurface.separator;
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: (parent.width - self.width) / 2; y: 4px; width: 36px; height: 3px; border-radius: 1.5px;
|
x: (parent.width - self.width) / 2; y: 4px; width: 36px; height: 3px; border-radius: 1.5px;
|
||||||
@@ -775,7 +777,7 @@ export component GalleryView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
y: parent.height - 44px; height: 44px; background: #fafbfc;
|
y: parent.height - 44px; height: 44px; background: #fafbfc;
|
||||||
Rectangle { y: 0px; width: parent.width; height: 1px; background: #e1e4e8; }
|
SurfaceSeparator { y: 0px; width: parent.width; height: 1px; outline-color: UiSurface.separator; }
|
||||||
}
|
}
|
||||||
// Connection status and pagination remain available in every selection state.
|
// Connection status and pagination remain available in every selection state.
|
||||||
footer := Rectangle {
|
footer := Rectangle {
|
||||||
@@ -792,8 +794,8 @@ export component GalleryView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
network-button := SecondaryButton {
|
network-button := SecondaryButton {
|
||||||
x: connection-count.x + connection-count.width + 12px;
|
x: connection-count.x + connection-count.width + 12px;
|
||||||
min-width: 64px; width: 64px; height: parent.height;
|
width: 64px; height: parent.height;
|
||||||
border-color: #c8cbd0; border-radius: UiRadius.control;
|
outline-color: UiSurface.edge; border-radius: UiRadius.control;
|
||||||
text: GalleryStrings.tr("网络状态", "Network", "Сеть");
|
text: GalleryStrings.tr("网络状态", "Network", "Сеть");
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: self.text;
|
accessible-label: self.text;
|
||||||
@@ -823,6 +825,7 @@ export component GalleryView inherits Rectangle {
|
|||||||
IconButton {
|
IconButton {
|
||||||
width: 24px; height: 24px;
|
width: 24px; height: 24px;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
|
normal-background: ControlStyle.surface;
|
||||||
icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
|
icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
|
||||||
tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
|
tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
|
||||||
clicked => { root.toggle-fullscreen(); }
|
clicked => { root.toggle-fullscreen(); }
|
||||||
@@ -840,11 +843,11 @@ export component GalleryView inherits Rectangle {
|
|||||||
horizontal-alignment: center; vertical-alignment: center;
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if root.dragging: Rectangle {
|
if root.dragging: FlatSurface {
|
||||||
x: clamp(grid.x + root.drag-x + 14px, 8px, parent.width - self.width - 8px);
|
x: clamp(grid.x + root.drag-x + 14px, 8px, parent.width - self.width - 8px);
|
||||||
y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px);
|
y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px);
|
||||||
width: 224px; height: 54px; z: 10;
|
width: 224px; height: max(54px, drag-hint.y + drag-hint.height + 9px); z: 10;
|
||||||
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: UiRadius.surface;
|
background: white; outline-color: UiSurface.focus; border-radius: UiRadius.surface;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: 8px; y: 9px; width: 64px; height: 36px;
|
x: 8px; y: 9px; width: 64px; height: 36px;
|
||||||
background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview;
|
background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview;
|
||||||
@@ -854,28 +857,28 @@ export component GalleryView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text { x: 80px; y: 8px; width: parent.width - 88px; height: 18px; text: root.dragged-tile.name; font-size: 12px; color: #303943; overflow: elide; }
|
Text { x: 80px; y: 8px; width: parent.width - 88px; height: 18px; text: root.dragged-tile.name; font-size: 12px; color: #303943; overflow: elide; }
|
||||||
Text {
|
drag-hint := Text {
|
||||||
x: 80px; y: 29px; width: parent.width - 88px; height: 16px;
|
x: 80px; y: 29px; width: parent.width - 88px; height: max(16px, self.preferred-height);
|
||||||
text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page", "Удерживайте для перелистывания")
|
text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page; release to cancel", "Удерживайте для перелистывания; отпустите для отмены")
|
||||||
: root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена")
|
: root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена")
|
||||||
: GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "На другое устройство");
|
: GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "Перетащите на другое устройство");
|
||||||
font-size: 10px; color: #527ca8; overflow: elide;
|
font-size: 10px; color: #527ca8; wrap: word-wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if root.swap-save-failed: Rectangle {
|
if root.swap-save-failed: FlatSurface {
|
||||||
x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px;
|
x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px;
|
||||||
width: min(420px, parent.width - 32px); height: 36px; z: 10;
|
width: min(420px, parent.width - 32px); height: 36px; z: 10;
|
||||||
background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: UiRadius.surface;
|
background: #fff2f0; outline-color: UiSurface.edge; border-radius: UiRadius.surface;
|
||||||
Text { x: 12px; y: 0px; width: parent.width - 24px; height: parent.height; font-size: 12px; color: #9d4339; vertical-alignment: center;
|
Text { x: 12px; y: 0px; width: parent.width - 24px; height: parent.height; font-size: 12px; color: #9d4339; vertical-alignment: center;
|
||||||
text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide;
|
text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Device details are rendered by MainWindow; resize hints stay local.
|
// Device details are rendered by MainWindow; resize hints stay local.
|
||||||
if root.device-tooltip-visible && (root.device-tooltip-id == "resize-grid" || root.device-tooltip-id == "resize-focus"): Rectangle {
|
if root.device-tooltip-visible && (root.device-tooltip-id == "resize-grid" || root.device-tooltip-id == "resize-focus"): FlatSurface {
|
||||||
x: clamp(root.device-tooltip-x, 8px, parent.width - self.width - 8px);
|
x: clamp(root.device-tooltip-x, 8px, parent.width - self.width - 8px);
|
||||||
y: clamp(root.device-tooltip-y + self.height > footer.y ? root.device-tooltip-y - self.height - 8px : root.device-tooltip-y, 4px, parent.height - self.height - 4px);
|
y: clamp(root.device-tooltip-y + self.height > footer.y ? root.device-tooltip-y - self.height - 8px : root.device-tooltip-y, 4px, parent.height - self.height - 4px);
|
||||||
width: min(320px, parent.width - 16px); height: device-tooltip-label.preferred-height + 16px;
|
width: min(320px, parent.width - 16px); height: device-tooltip-label.preferred-height + 16px;
|
||||||
background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: UiRadius.control;
|
background: #22252bf2; outline-color: UiSurface.edge; border-radius: UiRadius.control;
|
||||||
device-tooltip-label := Text {
|
device-tooltip-label := Text {
|
||||||
x: 8px; y: 8px; width: parent.width - 16px;
|
x: 8px; y: 8px; width: parent.width - 16px;
|
||||||
text: root.device-tooltip-text; color: white; font-size: 11px; wrap: word-wrap;
|
text: root.device-tooltip-text; color: white; font-size: 11px; wrap: word-wrap;
|
||||||
@@ -884,9 +887,9 @@ export component GalleryView inherits Rectangle {
|
|||||||
if root.adding: Rectangle {
|
if root.adding: Rectangle {
|
||||||
background: #00000042;
|
background: #00000042;
|
||||||
TouchArea {}
|
TouchArea {}
|
||||||
add-dialog := Rectangle {
|
add-dialog := FlatSurface {
|
||||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px;
|
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px;
|
||||||
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
|
background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
|
||||||
function submit() {
|
function submit() {
|
||||||
if root.add-device(device-id.text, password.text, group.text, remember.checked) {
|
if root.add-device(device-id.text, password.text, group.text, remember.checked) {
|
||||||
password.text = "";
|
password.text = "";
|
||||||
@@ -919,10 +922,10 @@ export component GalleryView inherits Rectangle {
|
|||||||
TouchArea { clicked => { remember.checked = !remember.checked; } }
|
TouchArea { clicked => { remember.checked = !remember.checked; } }
|
||||||
}
|
}
|
||||||
Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; }
|
Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; }
|
||||||
GalleryButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
|
GalleryButton { x: 136px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
|
||||||
GalleryButton {
|
GalleryButton {
|
||||||
x: 254px; y: 260px; width: 128px; height: 28px; primary: true;
|
x: 218px; y: 260px; width: 164px; height: 28px; primary: true;
|
||||||
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить");
|
text: GalleryStrings.tr("添加并连接", "Add and connect", "Добавить и подключить");
|
||||||
enabled: device-id.text != "" && password.text != "";
|
enabled: device-id.text != "" && password.text != "";
|
||||||
clicked => { add-dialog.submit(); }
|
clicked => { add-dialog.submit(); }
|
||||||
}
|
}
|
||||||
@@ -931,25 +934,27 @@ export component GalleryView inherits Rectangle {
|
|||||||
if root.importing: Rectangle {
|
if root.importing: Rectangle {
|
||||||
background: #00000042;
|
background: #00000042;
|
||||||
TouchArea {}
|
TouchArea {}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px;
|
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px;
|
||||||
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
|
background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
|
||||||
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
|
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从近期连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
|
||||||
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; }
|
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add to DeskHub, then select devices to connect.", "Добавьте в DeskHub, затем выберите устройства для подключения."); color: #62666d; font-size: 11px; }
|
||||||
ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-available > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-available; }
|
ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-available > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-available; }
|
||||||
Text { x: 45px; y: 74px; height: 21px; text: GalleryStrings.tr("全选", "Select all", "Выбрать все"); color: root.recent-available > 0 ? #24272d : #92969e; font-size: 12px; vertical-alignment: center; }
|
Text { x: 45px; y: 74px; height: 21px; text: GalleryStrings.tr("全选", "Select all", "Выбрать все"); color: root.recent-available > 0 ? #24272d : #92969e; font-size: 12px; vertical-alignment: center; }
|
||||||
TouchArea { x: 18px; y: 74px; width: 144px; height: 21px; enabled: root.recent-available > 0; clicked => { root.action("recent-all", ""); } }
|
TouchArea { x: 18px; y: 74px; width: 144px; height: 21px; enabled: root.recent-available > 0; clicked => { root.action("recent-all", ""); } }
|
||||||
Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("待添加 ", "To add ", "К добавлению ") + root.recent-selected + " / " + root.recent-available; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; }
|
Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("待添加 ", "To add ", "К добавлению ") + root.recent-selected + " / " + root.recent-available; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; }
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
x: 18px; y: 102px; width: 404px; height: 160px;
|
x: 18px; y: 102px; width: 404px; height: 160px;
|
||||||
border-width: 1px; border-color: #d8dadd; border-radius: UiRadius.surface; clip: true;
|
outline-color: UiSurface.edge; border-radius: UiRadius.surface; clip: true;
|
||||||
recent-list := ScrollView {
|
recent-list := ScrollView {
|
||||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||||
viewport-width: self.width - 12px;
|
viewport-width: self.width - 12px;
|
||||||
viewport-height: max(self.height, root.recent-devices.length * 44px);
|
viewport-height: max(self.height, root.recent-devices.length * 44px);
|
||||||
for device[index] in root.recent-devices: Rectangle {
|
for device[index] in root.recent-devices: Rectangle {
|
||||||
y: index * 44px; width: recent-list.viewport-width; height: 44px;
|
y: index * 44px; width: recent-list.viewport-width; height: 44px;
|
||||||
background: device.added ? #f1f2f4 : device.selected ? #edf5fe : row-touch.has-hover ? #f4f5f6 : white;
|
background: device.added ? ControlStyle.disabled
|
||||||
|
: device.selected ? (row-touch.pressed ? ControlStyle.selected-pressed : row-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
|
||||||
|
: row-touch.pressed ? ControlStyle.pressed : row-touch.has-hover ? ControlStyle.hover : white;
|
||||||
ImGuiCheckBox { x: 8px; y: 11px; enabled: !device.added; checked: device.added || device.selected; }
|
ImGuiCheckBox { x: 8px; y: 11px; enabled: !device.added; checked: device.added || device.selected; }
|
||||||
Text { x: 36px; y: 3px; width: parent.width - 126px; height: 20px; text: device.name; color: device.added ? #92969e : #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; }
|
Text { x: 36px; y: 3px; width: parent.width - 126px; height: 20px; text: device.name; color: device.added ? #92969e : #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; }
|
||||||
Text { x: 36px; y: 23px; width: parent.width - 126px; height: 16px; text: device.id; font-size: 11px; color: device.added ? #92969e : #808080; }
|
Text { x: 36px; y: 23px; width: parent.width - 126px; height: 16px; text: device.id; font-size: 11px; color: device.added ? #92969e : #808080; }
|
||||||
@@ -962,7 +967,7 @@ export component GalleryView inherits Rectangle {
|
|||||||
row-touch := TouchArea { enabled: !device.added; clicked => { root.action("recent-select", device.id); } }
|
row-touch := TouchArea { enabled: !device.added; clicked => { root.action("recent-select", device.id); } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
|
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: -UiRadius.surface; y: -UiRadius.surface;
|
x: -UiRadius.surface; y: -UiRadius.surface;
|
||||||
width: parent.width + 2 * UiRadius.surface;
|
width: parent.width + 2 * UiRadius.surface;
|
||||||
@@ -970,25 +975,25 @@ export component GalleryView inherits Rectangle {
|
|||||||
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
|
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
|
||||||
border-color: white;
|
border-color: white;
|
||||||
}
|
}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
width: parent.width; height: parent.height;
|
width: parent.width; height: parent.height;
|
||||||
border-width: 1px; border-radius: UiRadius.surface;
|
border-radius: UiRadius.surface;
|
||||||
border-color: #d8dadd;
|
outline-color: UiSurface.edge;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; }
|
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; }
|
||||||
import-group := RoundedLineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
|
import-group := RoundedLineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
|
||||||
Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; }
|
Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; }
|
||||||
GalleryButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
|
GalleryButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
|
||||||
GalleryButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
|
GalleryButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add to DeskHub", "В DeskHub"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if root.confirming: Rectangle {
|
if root.confirming: Rectangle {
|
||||||
background: #00000042; TouchArea {}
|
background: #00000042; TouchArea {}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
|
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
|
||||||
width: 360px; height: confirmation-list.height + 118px;
|
width: 360px; height: confirmation-list.height + 118px;
|
||||||
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
|
background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
|
||||||
Text {
|
Text {
|
||||||
x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
|
x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
|
||||||
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
|
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
|
||||||
@@ -997,12 +1002,12 @@ export component GalleryView inherits Rectangle {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
|
x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
|
||||||
background: #eff3f8; border-radius: UiRadius.surface;
|
background: #eff3f8; border-radius: UiRadius.surface;
|
||||||
Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
|
Text { text: GalleryStrings.device-count(root.selected-count); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
}
|
}
|
||||||
confirmation-list := Rectangle {
|
confirmation-list := FlatSurface {
|
||||||
x: 20px; y: 58px; width: parent.width - 40px;
|
x: 20px; y: 58px; width: parent.width - 40px;
|
||||||
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px);
|
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px);
|
||||||
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: UiRadius.surface; clip: true;
|
background: #fcfdfe; outline-color: UiSurface.edge; border-radius: UiRadius.surface; clip: true;
|
||||||
confirmation-scroll := ScrollView {
|
confirmation-scroll := ScrollView {
|
||||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||||
viewport-width: self.width - 12px;
|
viewport-width: self.width - 12px;
|
||||||
@@ -1016,9 +1021,9 @@ export component GalleryView inherits Rectangle {
|
|||||||
width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px;
|
width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px;
|
||||||
background: device.connected ? #eaf6ee : #f0f2f5;
|
background: device.connected ? #eaf6ee : #f0f2f5;
|
||||||
Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; }
|
Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; }
|
||||||
Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет связи"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; }
|
Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет соединения"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; }
|
||||||
}
|
}
|
||||||
if index + 1 < root.selected-devices.length: Rectangle { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #edf0f3; }
|
if index + 1 < root.selected-devices.length: SurfaceSeparator { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; outline-color: UiSurface.separator; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
|
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
import { FontAwesomeIcons } from "common.slint";
|
import { FontAwesomeIcons, ControlStyle } from "common.slint";
|
||||||
|
|
||||||
// Shared by history and DeskHub; system choices precede the named groups.
|
// Shared by history and DeskHub; system choices precede the named groups.
|
||||||
export component GroupFilter inherits Rectangle {
|
export component GroupFilter inherits FlatSurface {
|
||||||
in property <[string]> model;
|
in property <[string]> model;
|
||||||
in property <int> system-count: 2;
|
in property <int> system-count: 2;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
in property <color> normal-border-color: #c9cdd2;
|
// An embedded picker shares its enclosing field's outline and focus state.
|
||||||
in property <color> hover-border-color: root.normal-border-color;
|
in property <bool> embedded: false;
|
||||||
in property <color> focus-border-color: #8db5df;
|
in property <bool> show-indicator: true;
|
||||||
|
in property <length> popup-min-width: 0px;
|
||||||
|
in property <length> text-size: 12px;
|
||||||
|
in property <int> text-weight: 400;
|
||||||
|
in property <color> text-color: #30343b;
|
||||||
|
in property <color> normal-background: ControlStyle.surface;
|
||||||
|
in property <color> normal-outline-color: UiSurface.edge;
|
||||||
|
in property <color> hover-outline-color: UiSurface.hover;
|
||||||
|
in property <color> focus-outline-color: UiSurface.focus;
|
||||||
in-out property <int> current-index: 0;
|
in-out property <int> current-index: 0;
|
||||||
|
out property <bool> has-focus: trigger-focus.has-focus || popup.is-open;
|
||||||
|
out property <bool> has-hover: root.enabled && trigger-touch.has-hover && !popup.is-open;
|
||||||
callback selected(int);
|
callback selected(int);
|
||||||
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
|
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
|
||||||
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
|
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
|
||||||
@@ -25,12 +36,16 @@ export component GroupFilter inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
width: 155px; height: 28px;
|
width: 155px; height: 28px;
|
||||||
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
|
background: root.embedded ? (root.enabled && trigger-touch.has-hover ? ControlStyle.hover : transparent)
|
||||||
border-width: 1px;
|
: !root.enabled ? ControlStyle.disabled
|
||||||
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color
|
: trigger-touch.pressed || popup.is-open ? ControlStyle.pressed
|
||||||
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color;
|
: trigger-touch.has-hover ? ControlStyle.hover : root.normal-background;
|
||||||
|
outline-enabled: !root.embedded;
|
||||||
|
outline-focused: popup.is-open || trigger-focus.has-focus;
|
||||||
|
outline-color: popup.is-open || trigger-focus.has-focus ? root.focus-outline-color
|
||||||
|
: trigger-touch.has-hover ? root.hover-outline-color : root.normal-outline-color;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
animate background, border-color { duration: 100ms; easing: ease-out; }
|
animate background, outline-color { duration: 100ms; easing: ease-out; }
|
||||||
forward-focus: trigger-focus;
|
forward-focus: trigger-focus;
|
||||||
accessible-role: combobox;
|
accessible-role: combobox;
|
||||||
accessible-enabled: root.enabled;
|
accessible-enabled: root.enabled;
|
||||||
@@ -52,16 +67,18 @@ export component GroupFilter inherits Rectangle {
|
|||||||
root.reveal-selection();
|
root.reveal-selection();
|
||||||
root.selected(root.current-index);
|
root.selected(root.current-index);
|
||||||
}
|
}
|
||||||
function open-menu() {
|
public function open-menu() {
|
||||||
if root.enabled && root.model.length > 0 {
|
if root.enabled && root.model.length > 0 {
|
||||||
popup-open-timer.running = false;
|
popup-open-timer.running = false;
|
||||||
root.popup-animation-enabled = false;
|
root.popup-animation-enabled = false;
|
||||||
root.popup-progress = 0;
|
root.popup-progress = 0;
|
||||||
root.reveal-selection();
|
root.reveal-selection();
|
||||||
|
trigger-focus.focus();
|
||||||
popup.show();
|
popup.show();
|
||||||
popup-open-timer.running = true;
|
popup-open-timer.running = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public function close-menu() { popup.close(); }
|
||||||
// Defer until the popup has its initial frame; animate its contents only,
|
// Defer until the popup has its initial frame; animate its contents only,
|
||||||
// keeping the native popup bounds and scrolling geometry stable.
|
// keeping the native popup bounds and scrolling geometry stable.
|
||||||
popup-open-timer := Timer {
|
popup-open-timer := Timer {
|
||||||
@@ -87,14 +104,19 @@ export component GroupFilter inherits Rectangle {
|
|||||||
return reject;
|
return reject;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
x: 10px; width: parent.width - 36px; height: parent.height;
|
x: root.embedded ? 8px : 10px;
|
||||||
|
width: parent.width - (root.show-indicator
|
||||||
|
? (root.embedded ? 26px : 36px) : (root.embedded ? 16px : 20px));
|
||||||
|
height: parent.height;
|
||||||
text: root.model[root.current-index];
|
text: root.model[root.current-index];
|
||||||
color: root.enabled ? #30343b : #92979f;
|
color: root.enabled ? root.text-color : #92979f;
|
||||||
font-size: 12px; overflow: elide; vertical-alignment: center;
|
font-size: root.text-size; font-weight: root.text-weight;
|
||||||
|
overflow: elide; vertical-alignment: center;
|
||||||
accessible-role: none;
|
accessible-role: none;
|
||||||
}
|
}
|
||||||
Text {
|
if root.show-indicator: Text {
|
||||||
x: parent.width - 25px; width: 16px; height: parent.height;
|
x: parent.width - (root.embedded ? 16px : 25px);
|
||||||
|
width: root.embedded ? 10px : 16px; height: parent.height;
|
||||||
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
|
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
|
||||||
font-weight: 900; font-size: 10px; color: #858c96;
|
font-weight: 900; font-size: 10px; color: #858c96;
|
||||||
horizontal-alignment: center; vertical-alignment: center;
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
@@ -107,22 +129,24 @@ export component GroupFilter inherits Rectangle {
|
|||||||
key-pressed(event) => { return root.handle-key(event); }
|
key-pressed(event) => { return root.handle-key(event); }
|
||||||
trigger-touch := TouchArea {
|
trigger-touch := TouchArea {
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
clicked => { trigger-focus.focus(); root.open-menu(); }
|
mouse-cursor: pointer;
|
||||||
|
clicked => { root.open-menu(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
popup := PopupWindow {
|
popup := PopupWindow {
|
||||||
x: 0px; y: root.height + 4px;
|
x: 0px; y: root.height + 4px;
|
||||||
width: root.width; height: root.visible-height + 8px;
|
width: max(root.width, root.popup-min-width); height: root.visible-height + 8px;
|
||||||
close-policy: close-on-click-outside;
|
close-policy: close-on-click-outside;
|
||||||
forward-focus: popup-focus;
|
forward-focus: popup-focus;
|
||||||
popup-focus := FocusScope {
|
popup-focus := FocusScope {
|
||||||
key-pressed(event) => { return root.handle-key(event); }
|
key-pressed(event) => { return root.handle-key(event); }
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
y: (1 - root.popup-progress) * -4px;
|
y: (1 - root.popup-progress) * -4px;
|
||||||
width: parent.width; height: parent.height;
|
width: parent.width; height: parent.height;
|
||||||
opacity: root.popup-progress;
|
opacity: root.popup-progress;
|
||||||
background: white; border-width: 1px;
|
background: white;
|
||||||
border-color: root.normal-border-color; border-radius: UiRadius.menu;
|
clip: true;
|
||||||
|
outline-color: root.normal-outline-color; border-radius: UiRadius.menu;
|
||||||
ScrollView {
|
ScrollView {
|
||||||
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
||||||
viewport-width: self.width;
|
viewport-width: self.width;
|
||||||
@@ -130,27 +154,30 @@ export component GroupFilter inherits Rectangle {
|
|||||||
viewport-y <=> root.scroll-y;
|
viewport-y <=> root.scroll-y;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width; height: root.list-height;
|
width: parent.width; height: root.list-height;
|
||||||
if root.has-divider: Rectangle {
|
if root.has-divider: SurfaceSeparator {
|
||||||
x: 6px; y: root.system-count * 26px + 3px;
|
x: 6px; y: root.system-count * 26px + 3px;
|
||||||
width: parent.width - 12px; height: 1px; background: #e9ebee;
|
width: parent.width - 12px; height: 1px; outline-color: UiSurface.separator;
|
||||||
}
|
}
|
||||||
for choice[index] in root.model: Rectangle {
|
for choice[index] in root.model: Rectangle {
|
||||||
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
|
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
|
||||||
width: parent.width; height: 26px; border-radius: UiRadius.control;
|
width: parent.width; height: 26px; border-radius: UiRadius.control;
|
||||||
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
|
background: index == root.current-index
|
||||||
|
? (option-touch.pressed ? ControlStyle.selected-pressed : option-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
|
||||||
|
: option-touch.pressed ? ControlStyle.pressed : option-touch.has-hover ? ControlStyle.hover : transparent;
|
||||||
animate background { duration: 100ms; easing: ease-out; }
|
animate background { duration: 100ms; easing: ease-out; }
|
||||||
accessible-role: list-item;
|
accessible-role: list-item;
|
||||||
accessible-label: choice;
|
accessible-label: choice;
|
||||||
Text {
|
Text {
|
||||||
x: 8px; width: parent.width - 34px; height: parent.height;
|
x: 8px; width: parent.width - 34px; height: parent.height;
|
||||||
text: choice; color: #30343b; font-size: 12px;
|
text: choice; color: index == root.current-index ? ControlStyle.selected-text : #30343b; font-size: 12px;
|
||||||
|
font-weight: index == root.current-index ? 600 : 400;
|
||||||
overflow: elide; vertical-alignment: center;
|
overflow: elide; vertical-alignment: center;
|
||||||
accessible-role: none;
|
accessible-role: none;
|
||||||
}
|
}
|
||||||
if index == root.current-index: Text {
|
if index == root.current-index: Text {
|
||||||
x: parent.width - 24px; width: 16px; height: parent.height;
|
x: parent.width - 24px; width: 16px; height: parent.height;
|
||||||
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
|
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
|
||||||
font-weight: 900; font-size: 10px; color: #4e87c0;
|
font-weight: 900; font-size: 10px; color: ControlStyle.selected-text;
|
||||||
horizontal-alignment: center; vertical-alignment: center;
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
accessible-role: none;
|
accessible-role: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
<defs>
|
<circle cx="32" cy="32" r="31" fill="white"/>
|
||||||
<radialGradient id="edge">
|
|
||||||
<stop offset="95%" stop-color="white"/>
|
|
||||||
<stop offset="100%" stop-color="white" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
<circle cx="32" cy="32" r="32" fill="url(#edge)"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 139 B |
@@ -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 { UiRadius } from "ui_radius.slint";
|
||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
import { IconButton, FontAwesomeIcons } from "common.slint";
|
import { IconButton, FontAwesomeIcons, ControlStyle } from "common.slint";
|
||||||
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
|
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
|
||||||
import { WorkbenchStyle } from "workbench_controls.slint";
|
import { WorkbenchStyle } from "workbench_controls.slint";
|
||||||
import "../assets/fonts/message_spacing.ttf";
|
import "../assets/fonts/message_spacing.ttf";
|
||||||
@@ -90,11 +91,11 @@ export component MessagePanel inherits SettingsDialog {
|
|||||||
text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e;
|
text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message-content := Rectangle {
|
message-content := FlatSurface {
|
||||||
x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
|
x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
|
||||||
width: parent.width - 48px; height: parent.height - self.y - 24px;
|
width: parent.width - 48px; height: parent.height - self.y - 24px;
|
||||||
background: WorkbenchStyle.surface;
|
background: WorkbenchStyle.surface;
|
||||||
border-radius: UiRadius.surface; border-width: 1px; border-color: WorkbenchStyle.border;
|
border-radius: UiRadius.surface; outline-color: UiSurface.edge;
|
||||||
if root.items.length > 0 || root.selected-id != "": Rectangle {
|
if root.items.length > 0 || root.selected-id != "": Rectangle {
|
||||||
x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
|
x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
|
||||||
background: WorkbenchStyle.field-surface;
|
background: WorkbenchStyle.field-surface;
|
||||||
@@ -112,19 +113,19 @@ export component MessagePanel inherits SettingsDialog {
|
|||||||
announcement-entries := VerticalLayout {
|
announcement-entries := VerticalLayout {
|
||||||
width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
|
width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
for item in root.items: Rectangle {
|
for item in root.items: FlatSurface {
|
||||||
height: 64px;
|
height: 64px;
|
||||||
background: root.selected-id == item.id ? #e8efff
|
background: root.selected-id == item.id
|
||||||
|
? (entry-touch.pressed ? ControlStyle.selected-pressed : entry-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
|
||||||
: entry-touch.pressed ? WorkbenchStyle.pressed-surface
|
: entry-touch.pressed ? WorkbenchStyle.pressed-surface
|
||||||
: entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
|
: entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
border-width: 1px;
|
outline-color: root.selected-id == item.id ? UiSurface.focus : UiSurface.edge;
|
||||||
border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border;
|
|
||||||
Text {
|
Text {
|
||||||
x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
|
x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
|
||||||
text: item.title; overflow: elide; font-size: 13px;
|
text: item.title; overflow: elide; font-size: 13px;
|
||||||
font-weight: item.read ? 400 : 600;
|
font-weight: item.read ? 400 : 600;
|
||||||
color: root.selected-id == item.id ? WorkbenchStyle.accent : WorkbenchStyle.text;
|
color: root.selected-id == item.id ? ControlStyle.selected-text : WorkbenchStyle.text;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
}
|
}
|
||||||
if !item.read: Rectangle {
|
if !item.read: Rectangle {
|
||||||
@@ -154,10 +155,10 @@ export component MessagePanel inherits SettingsDialog {
|
|||||||
color: WorkbenchStyle.muted; font-size: 13px;
|
color: WorkbenchStyle.muted; font-size: 13px;
|
||||||
horizontal-alignment: center; vertical-alignment: center;
|
horizontal-alignment: center; vertical-alignment: center;
|
||||||
}
|
}
|
||||||
content-divider := Rectangle {
|
content-divider := SurfaceSeparator {
|
||||||
x: announcement-list.x + announcement-list.width + 8px;
|
x: announcement-list.x + announcement-list.width + 8px;
|
||||||
y: 1px; width: 1px; height: parent.height - 2px;
|
y: 1px; width: 1px; height: parent.height - 2px;
|
||||||
background: WorkbenchStyle.separator;
|
vertical: true; outline-color: UiSurface.separator;
|
||||||
visible: root.items.length > 0 || root.selected-id != "";
|
visible: root.items.length > 0 || root.selected-id != "";
|
||||||
accessible-role: none;
|
accessible-role: none;
|
||||||
}
|
}
|
||||||
@@ -199,13 +200,14 @@ export component MessagePanel inherits SettingsDialog {
|
|||||||
x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
|
x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
|
||||||
icon: FontAwesomeIcons.trash-can; icon-size: 12px;
|
icon: FontAwesomeIcons.trash-can; icon-size: 12px;
|
||||||
icon-color: WorkbenchStyle.muted; danger: true;
|
icon-color: WorkbenchStyle.muted; danger: true;
|
||||||
|
normal-background: ControlStyle.surface;
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: MessageStrings.delete-message + " " + root.selected-title;
|
accessible-label: MessageStrings.delete-message + " " + root.selected-title;
|
||||||
accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); }
|
accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); }
|
||||||
clicked => { root.request-delete(root.selected-id, root.selected-revision); }
|
clicked => { root.request-delete(root.selected-id, root.selected-revision); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
|
SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
|
||||||
StyledText {
|
StyledText {
|
||||||
vertical-stretch: 0;
|
vertical-stretch: 0;
|
||||||
text: root.selected-body;
|
text: root.selected-body;
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
|
import { FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { LineEdit } from "std-widgets.slint";
|
import { LineEdit } from "std-widgets.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
|
import { ControlStyle } from "common.slint";
|
||||||
|
|
||||||
// Retain the standard editor's selection, clipboard, clear button and input
|
// Retain the standard editor's selection, clipboard, clear button and input
|
||||||
// method support. Only its outer frame is replaced by the shared shape.
|
// method support. Its frame receives the shared field tint and rounded shape.
|
||||||
export component RoundedLineEdit inherits Rectangle {
|
export component RoundedLineEdit inherits Rectangle {
|
||||||
in-out property <string> text;
|
in-out property <string> text;
|
||||||
in property <string> placeholder-text;
|
in property <string> placeholder-text;
|
||||||
in property <InputType> input-type: InputType.text;
|
in property <InputType> input-type: InputType.text;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
in property <length> font-size: 12px;
|
in property <length> font-size: 12px;
|
||||||
in property <color> normal-border-color: #dfe6ef;
|
in property <color> normal-outline-color: UiSurface.edge;
|
||||||
in property <brush> surrounding-background: white;
|
in property <brush> surrounding-background: white;
|
||||||
callback accepted();
|
callback accepted();
|
||||||
callback edited();
|
callback edited();
|
||||||
@@ -20,8 +22,8 @@ export component RoundedLineEdit inherits Rectangle {
|
|||||||
forward-focus: editor;
|
forward-focus: editor;
|
||||||
|
|
||||||
editor := LineEdit {
|
editor := LineEdit {
|
||||||
x: -1px; y: -1px;
|
x: -2px; y: -2px;
|
||||||
width: parent.width + 2px; height: parent.height + 2px;
|
width: parent.width + 4px; height: parent.height + 4px;
|
||||||
text <=> root.text;
|
text <=> root.text;
|
||||||
placeholder-text: root.placeholder-text;
|
placeholder-text: root.placeholder-text;
|
||||||
input-type: root.input-type;
|
input-type: root.input-type;
|
||||||
@@ -30,8 +32,15 @@ export component RoundedLineEdit inherits Rectangle {
|
|||||||
accepted => { root.accepted(); }
|
accepted => { root.accepted(); }
|
||||||
edited => { root.edited(); }
|
edited => { root.edited(); }
|
||||||
}
|
}
|
||||||
|
// Fluent's LineEdit paints its own background and exposes no fill override.
|
||||||
|
// A visual-only tint preserves the standard editor and its built-in actions.
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width; height: parent.height;
|
||||||
|
background: !root.enabled ? transparent
|
||||||
|
: editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint;
|
||||||
|
}
|
||||||
// The software renderer clips children to rectangular bounds. Mask the
|
// The software renderer clips children to rectangular bounds. Mask the
|
||||||
// standard editor's corners before drawing our rounded border on top.
|
// standard editor's corners before drawing the focus outline on top.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: -root.border-radius; y: -root.border-radius;
|
x: -root.border-radius; y: -root.border-radius;
|
||||||
width: parent.width + 2 * root.border-radius;
|
width: parent.width + 2 * root.border-radius;
|
||||||
@@ -40,11 +49,12 @@ export component RoundedLineEdit inherits Rectangle {
|
|||||||
border-radius: 2 * root.border-radius;
|
border-radius: 2 * root.border-radius;
|
||||||
border-color: root.surrounding-background;
|
border-color: root.surrounding-background;
|
||||||
}
|
}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
width: parent.width; height: parent.height;
|
width: parent.width; height: parent.height;
|
||||||
border-radius: root.border-radius;
|
border-radius: root.border-radius;
|
||||||
border-width: editor.has-focus ? 2px : 1px;
|
clip: true;
|
||||||
border-color: editor.has-focus ? #3564e3 : root.normal-border-color;
|
outline-color: editor.has-focus ? UiSurface.focus : root.normal-outline-color;
|
||||||
|
outline-focused: editor.has-focus;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint";
|
import { FontAwesomeIcons, FloatingBarStyle, ButtonStyle } from "common.slint";
|
||||||
|
|
||||||
export struct ControllerEntry {
|
export struct ControllerEntry {
|
||||||
remote-id: string,
|
remote-id: string,
|
||||||
@@ -19,7 +20,7 @@ export global ServerStrings {
|
|||||||
in property <string> cancel: "取消";
|
in property <string> cancel: "取消";
|
||||||
}
|
}
|
||||||
|
|
||||||
component SessionButton inherits Rectangle {
|
component SessionButton inherits FlatSurface {
|
||||||
in property <string> text;
|
in property <string> text;
|
||||||
in property <bool> danger: false;
|
in property <bool> danger: false;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
@@ -29,10 +30,13 @@ component SessionButton inherits Rectangle {
|
|||||||
width: label.preferred-width + 20px;
|
width: label.preferred-width + 20px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
border-width: 1px;
|
outline-enabled: !root.danger;
|
||||||
border-color: root.danger ? #d92d20 : #c9ced7;
|
outline-color: root.pointer-feedback && touch.pressed ? ButtonStyle.pressed-border
|
||||||
|
: root.pointer-feedback && touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
|
||||||
background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20)
|
background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20)
|
||||||
: (root.pointer-feedback && touch.pressed ? #dfe3e9 : root.pointer-feedback && touch.has-hover ? #f3f4f6 : white);
|
: (root.pointer-feedback && touch.pressed ? ButtonStyle.pressed
|
||||||
|
: root.pointer-feedback && touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface);
|
||||||
|
animate background, outline-color { duration: 120ms; }
|
||||||
opacity: root.enabled ? 1 : 0.5;
|
opacity: root.enabled ? 1 : 0.5;
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: root.text;
|
accessible-label: root.text;
|
||||||
@@ -72,9 +76,9 @@ component SessionButton inherits Rectangle {
|
|||||||
// Restore hover feedback only after a fresh pointer move or press.
|
// Restore hover feedback only after a fresh pointer move or press.
|
||||||
clicked => { root.pointer-feedback = false; root.keyboard-focus = false; keyboard.focus(); root.clicked(); }
|
clicked => { root.pointer-feedback = false; root.keyboard-focus = false; keyboard.focus(); root.clicked(); }
|
||||||
}
|
}
|
||||||
if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle {
|
if root.enabled && keyboard.has-focus && root.keyboard-focus: FlatSurface {
|
||||||
x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px;
|
x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px;
|
||||||
border-radius: UiRadius.control - 2px; border-width: 1px; border-color: root.danger ? white : #2463c7;
|
border-radius: UiRadius.control - 2px; outline-color: root.danger ? white : UiSurface.focus;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +111,10 @@ export component ServerStatusBar inherits Window {
|
|||||||
return reject;
|
return reject;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
x: 0px; y: 0px; width: parent.width; height: parent.height;
|
x: 0px; y: 0px; width: parent.width; height: parent.height;
|
||||||
background: touch.pressed ? #dfe3e9 : touch.has-hover ? #f3f4f6 : white;
|
background: touch.pressed ? #dfe3e9 : touch.has-hover ? #f3f4f6 : white;
|
||||||
border-width: 1px; border-color: ImGuiLineStyle.border;
|
outline-color: UiSurface.edge;
|
||||||
border-radius: FloatingBarStyle.corner-radius;
|
border-radius: FloatingBarStyle.corner-radius;
|
||||||
border-top-right-radius: 0px;
|
border-top-right-radius: 0px;
|
||||||
border-bottom-right-radius: 0px;
|
border-bottom-right-radius: 0px;
|
||||||
@@ -170,9 +174,9 @@ export component ServerWindow inherits Window {
|
|||||||
if event.text == Key.Escape { root.dismiss-panel(); return accept; }
|
if event.text == Key.Escape { root.dismiss-panel(); return accept; }
|
||||||
return reject;
|
return reject;
|
||||||
}
|
}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
width: root.width; height: root.height;
|
width: root.width; height: root.height;
|
||||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
|
background: white; outline-color: UiSurface.edge;
|
||||||
border-radius: UiRadius.surface;
|
border-radius: UiRadius.surface;
|
||||||
Text {
|
Text {
|
||||||
x: 10px; y: 10px; width: parent.width - 20px; height: 24px;
|
x: 10px; y: 10px; width: parent.width - 20px; height: 24px;
|
||||||
@@ -192,9 +196,9 @@ export component ServerWindow inherits Window {
|
|||||||
width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px);
|
width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px);
|
||||||
height: parent.height; border-radius: UiRadius.control;
|
height: parent.height; border-radius: UiRadius.control;
|
||||||
background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent;
|
background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent;
|
||||||
if root.controllers.length > 1: Rectangle {
|
if root.controllers.length > 1: FlatSurface {
|
||||||
x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px;
|
x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px;
|
||||||
border-radius: 7px; border-width: 1px; border-color: index == root.selected-controller ? #2463c7 : #8c939e;
|
border-radius: 7px; outline-color: index == root.selected-controller ? UiSurface.focus : UiSurface.edge;
|
||||||
if index == root.selected-controller: Rectangle { x: 3px; y: 3px; width: 7px; height: 7px; border-radius: 4px; background: #2463c7; }
|
if index == root.selected-controller: Rectangle { x: 3px; y: 3px; width: 7px; height: 7px; border-radius: 4px; background: #2463c7; }
|
||||||
}
|
}
|
||||||
name := Text {
|
name := Text {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint";
|
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons, ControlStyle, ButtonStyle } from "common.slint";
|
||||||
import { WorkbenchStyle } from "workbench_controls.slint";
|
import { WorkbenchStyle } from "workbench_controls.slint";
|
||||||
|
|
||||||
// Shared visual treatment for the global settings pages.
|
// Shared visual treatment for the global settings pages.
|
||||||
export component SettingsButton inherits Rectangle {
|
export component SettingsButton inherits FlatSurface {
|
||||||
in property <string> text;
|
in property <string> text;
|
||||||
in property <image> icon;
|
in property <image> icon;
|
||||||
in property <bool> primary: false;
|
in property <bool> primary: false;
|
||||||
@@ -14,13 +15,17 @@ export component SettingsButton inherits Rectangle {
|
|||||||
private property <length> content-padding: root.has-icon ? 8px : 12px;
|
private property <length> content-padding: root.has-icon ? 8px : 12px;
|
||||||
callback clicked();
|
callback clicked();
|
||||||
width: 88px; height: 32px; border-radius: UiRadius.control;
|
width: 88px; height: 32px; border-radius: UiRadius.control;
|
||||||
background: !root.enabled ? #f0f3f7
|
background: !root.enabled ? ControlStyle.disabled
|
||||||
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
|
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
|
||||||
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white;
|
: touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover
|
||||||
border-width: root.quiet || root.primary ? 0px : 1px;
|
: root.quiet ? transparent : ButtonStyle.surface;
|
||||||
border-color: !root.enabled ? WorkbenchStyle.border
|
outline-enabled: !root.quiet && !root.primary;
|
||||||
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border;
|
outline-color: !root.enabled ? ButtonStyle.disabled-border
|
||||||
|
: touch.pressed ? ButtonStyle.pressed-border
|
||||||
|
: touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
|
||||||
|
animate background, outline-color { duration: 120ms; }
|
||||||
accessible-role: button; accessible-label: root.text;
|
accessible-role: button; accessible-label: root.text;
|
||||||
|
accessible-enabled: root.enabled;
|
||||||
accessible-action-default => { if root.enabled { root.clicked(); } }
|
accessible-action-default => { if root.enabled { root.clicked(); } }
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: root.align-left ? root.content-padding : (parent.width - self.width) / 2;
|
x: root.align-left ? root.content-padding : (parent.width - self.width) / 2;
|
||||||
@@ -59,10 +64,7 @@ export component SettingsDialog inherits DialogSurface {
|
|||||||
callback action();
|
callback action();
|
||||||
callback close();
|
callback close();
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-color: WorkbenchStyle.separator;
|
outline-color: UiSurface.edge;
|
||||||
drop-shadow-color: #14294424;
|
|
||||||
drop-shadow-blur: 24px;
|
|
||||||
drop-shadow-offset-y: 8px;
|
|
||||||
TouchArea { }
|
TouchArea { }
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
|
x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
|
||||||
@@ -97,6 +99,7 @@ export component SettingsDialog inherits DialogSurface {
|
|||||||
width: 28px; height: 28px;
|
width: 28px; height: 28px;
|
||||||
icon: FontAwesomeIcons.xmark; icon-size: 14px;
|
icon: FontAwesomeIcons.xmark; icon-size: 14px;
|
||||||
icon-color: WorkbenchStyle.muted;
|
icon-color: WorkbenchStyle.muted;
|
||||||
|
normal-background: ControlStyle.surface;
|
||||||
hover-background: WorkbenchStyle.hover-surface;
|
hover-background: WorkbenchStyle.hover-surface;
|
||||||
pressed-background: WorkbenchStyle.pressed-surface;
|
pressed-background: WorkbenchStyle.pressed-surface;
|
||||||
accessible-role: button; accessible-label: root.close-label;
|
accessible-role: button; accessible-label: root.close-label;
|
||||||
@@ -109,9 +112,9 @@ export component SettingsDialog inherits DialogSurface {
|
|||||||
|
|
||||||
export component SettingsSelect inherits CompactComboBox {
|
export component SettingsSelect inherits CompactComboBox {
|
||||||
subtle: true;
|
subtle: true;
|
||||||
show-arrow: false;
|
show-arrow: true;
|
||||||
normal-border-color: WorkbenchStyle.control-border;
|
normal-outline-color: UiSurface.edge;
|
||||||
hover-border-color: WorkbenchStyle.hover-border;
|
hover-outline-color: UiSurface.hover;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +126,9 @@ export component SettingsGroup inherits Rectangle {
|
|||||||
x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title;
|
x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title;
|
||||||
font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center;
|
font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center;
|
||||||
}
|
}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
y: 24px; width: parent.width; height: parent.height - self.y;
|
y: 24px; width: parent.width; height: parent.height - self.y;
|
||||||
background: white; border-radius: UiRadius.surface; border-color: WorkbenchStyle.border; border-width: 1px;
|
background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
|
||||||
VerticalLayout { padding: 0px; spacing: 0px; @children }
|
VerticalLayout { padding: 0px; spacing: 0px; @children }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,8 +144,8 @@ export component SettingsRow inherits Rectangle {
|
|||||||
text: root.label; color: root.enabled ? #34445a : #9ba5b3;
|
text: root.label; color: root.enabled ? #34445a : #9ba5b3;
|
||||||
font-size: 12px; overflow: elide; vertical-alignment: center;
|
font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||||
}
|
}
|
||||||
if root.divider: Rectangle {
|
if root.divider: SurfaceSeparator {
|
||||||
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec;
|
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; outline-color: UiSurface.separator;
|
||||||
}
|
}
|
||||||
@children
|
@children
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { ComboBox, ScrollView } from "std-widgets.slint";
|
import { ComboBox, ScrollView } from "std-widgets.slint";
|
||||||
import { RemoteCursor } from "remote_cursor.slint";
|
import { RemoteCursor } from "remote_cursor.slint";
|
||||||
import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
|
import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiFontStyle, ControlStyle, ButtonStyle } from "common.slint";
|
||||||
|
|
||||||
export struct StreamTab {
|
export struct StreamTab {
|
||||||
remote-id: string,
|
remote-id: string,
|
||||||
@@ -25,6 +26,10 @@ export struct NetworkStatsRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export global StreamStrings {
|
export global StreamStrings {
|
||||||
|
in-out property <string> window-minimize: "Minimize";
|
||||||
|
in-out property <string> window-maximize: "Maximize";
|
||||||
|
in-out property <string> window-restore: "Restore window";
|
||||||
|
in-out property <string> window-close: "Close window";
|
||||||
in-out property <string> remote-update-title: "Remote update recommended";
|
in-out property <string> remote-update-title: "Remote update recommended";
|
||||||
in-out property <string> remote-update: "Update the remote device to the latest version for improved features and performance.";
|
in-out property <string> remote-update: "Update the remote device to the latest version for improved features and performance.";
|
||||||
in-out property <string> remote-update-dismiss: "Got it";
|
in-out property <string> remote-update-dismiss: "Got it";
|
||||||
@@ -46,11 +51,11 @@ export global StreamStrings {
|
|||||||
in-out property <string> privacy-disable: "Turn privacy off / recover";
|
in-out property <string> privacy-disable: "Turn privacy off / recover";
|
||||||
in-out property <string> control-mouse: "Control Mouse";
|
in-out property <string> control-mouse: "Control Mouse";
|
||||||
in-out property <string> release-mouse: "Release Mouse";
|
in-out property <string> release-mouse: "Release Mouse";
|
||||||
in-out property <string> audio: "Audio Capture";
|
in-out property <string> audio: "Play audio";
|
||||||
in-out property <string> mute: "Mute";
|
in-out property <string> mute: "Mute";
|
||||||
in-out property <string> select-file: "Select File to Send";
|
in-out property <string> select-file: "Select File to Send";
|
||||||
in-out property <string> show-stats: "Show Net Traffic Stats";
|
in-out property <string> show-stats: "Show network status";
|
||||||
in-out property <string> hide-stats: "Hide Net Traffic Stats";
|
in-out property <string> hide-stats: "Hide network status";
|
||||||
in-out property <string> fullscreen: "Fullscreen";
|
in-out property <string> fullscreen: "Fullscreen";
|
||||||
in-out property <string> exit-fullscreen: "Exit fullscreen";
|
in-out property <string> exit-fullscreen: "Exit fullscreen";
|
||||||
in-out property <string> disconnect: "Disconnect";
|
in-out property <string> disconnect: "Disconnect";
|
||||||
@@ -64,7 +69,7 @@ export global StreamStrings {
|
|||||||
in-out property <string> stats-out: "Out";
|
in-out property <string> stats-out: "Out";
|
||||||
in-out property <string> stats-loss-rate: "Loss Rate";
|
in-out property <string> stats-loss-rate: "Loss Rate";
|
||||||
in-out property <string> stats-resolution: "Res";
|
in-out property <string> stats-resolution: "Res";
|
||||||
in-out property <string> stats-video-latency: "Video delay";
|
in-out property <string> stats-video-latency: "Video latency";
|
||||||
in-out property <string> stats-connection-latency: "Connection RTT";
|
in-out property <string> stats-connection-latency: "Connection RTT";
|
||||||
in-out property <string> stats-connection-mode: "Mode";
|
in-out property <string> stats-connection-mode: "Mode";
|
||||||
in-out property <string> stats-encoder: "Remote encoder";
|
in-out property <string> stats-encoder: "Remote encoder";
|
||||||
@@ -80,14 +85,10 @@ struct ControlBarTooltip {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shared surface treatment for the capsule and its detached detail cards.
|
// Shared surface treatment for the capsule and its detached detail cards.
|
||||||
component StreamControlSurface inherits Rectangle {
|
component StreamControlSurface inherits FlatSurface {
|
||||||
background: white;
|
background: white;
|
||||||
border-width: 1px;
|
outline-color: UiSurface.edge;
|
||||||
border-color: #dbe5f1;
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
drop-shadow-color: #1d35571a;
|
|
||||||
drop-shadow-blur: 10px;
|
|
||||||
drop-shadow-offset-y: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the compact 16px icons inside roomier circular targets. This remains
|
// Keep the compact 16px icons inside roomier circular targets. This remains
|
||||||
@@ -121,14 +122,14 @@ component ControlBarButton inherits Rectangle {
|
|||||||
!root.show-logo && root.action-enabled && !root.muted && touch.pressed;
|
!root.show-logo && root.action-enabled && !root.muted && touch.pressed;
|
||||||
// Share the fill with the slash cutout so every icon stays legible while
|
// Share the fill with the slash cutout so every icon stays legible while
|
||||||
// transitioning between idle, hover and pressed states.
|
// transitioning between idle, hover and pressed states.
|
||||||
private property <color> button-fill: root.muted ? #eef0f3
|
private property <color> button-fill: root.muted ? ControlStyle.disabled
|
||||||
: root.button-pressed ? (root.danger ? #7a271a : #65a5f2)
|
: root.button-pressed ? (root.danger ? #7a271a : root.selected ? ControlStyle.selected-pressed : ControlStyle.pressed)
|
||||||
: root.button-hovered ? (root.danger ? (root.selected ? #912018 : #b42318)
|
: root.button-hovered ? (root.danger ? (root.selected ? #912018 : #b42318)
|
||||||
: root.selected ? #86baff : #a7ceff)
|
: root.selected ? ControlStyle.selected-hover : ControlStyle.hover)
|
||||||
: root.danger ? (root.selected ? #b42318 : #d92d20)
|
: root.danger ? (root.selected ? #b42318 : #d92d20)
|
||||||
: root.selected ? #b3d5fd : #deecff;
|
: root.selected ? ControlStyle.selected : ControlStyle.surface;
|
||||||
private property <color> button-foreground: root.muted ? #8a8f96
|
private property <color> button-foreground: root.muted ? #8a8f96
|
||||||
: root.danger ? white : #24272d;
|
: root.danger ? white : root.selected ? ControlStyle.selected-text : #303f55;
|
||||||
animate button-fill, button-foreground {
|
animate button-fill, button-foreground {
|
||||||
duration: 90ms;
|
duration: 90ms;
|
||||||
easing: ease-out;
|
easing: ease-out;
|
||||||
@@ -242,14 +243,21 @@ component VideoSettingRow inherits VerticalLayout {
|
|||||||
Text { text: root.label; color: #5c626c; font-size: 11px; height: 18px; }
|
Text { text: root.label; color: #5c626c; font-size: 11px; height: 18px; }
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: 4px;
|
spacing: 4px;
|
||||||
for option[index] in root.options: Rectangle {
|
for option[index] in root.options: FlatSurface {
|
||||||
height: 27px;
|
height: 27px;
|
||||||
background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff
|
background: !root.enabled ? ControlStyle.disabled
|
||||||
: option-touch.has-hover ? #eaf3ff : #f5f6f8;
|
: root.selected == index ? (option-touch.pressed ? ButtonStyle.selected-pressed : option-touch.has-hover ? ButtonStyle.selected-hover : ButtonStyle.selected)
|
||||||
|
: option-touch.pressed ? ButtonStyle.pressed
|
||||||
|
: option-touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
border-width: 1px;
|
outline-color: !root.enabled ? ButtonStyle.disabled-border
|
||||||
border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6;
|
: root.selected == index ? ButtonStyle.selected-pressed
|
||||||
Text { text: option; color: root.enabled ? #30343b : #8b8f95;
|
: option-touch.pressed ? ButtonStyle.pressed-border
|
||||||
|
: option-touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
|
||||||
|
animate background, outline-color { duration: 120ms; }
|
||||||
|
Text { text: option; color: !root.enabled ? #8b8f95
|
||||||
|
: root.selected == index ? white : #30343b;
|
||||||
|
font-weight: root.selected == index ? 600 : 400;
|
||||||
font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
|
font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
|
||||||
option-touch := TouchArea {
|
option-touch := TouchArea {
|
||||||
mouse-cursor: root.enabled ? pointer : default;
|
mouse-cursor: root.enabled ? pointer : default;
|
||||||
@@ -806,7 +814,7 @@ export component StreamWindow inherits Window {
|
|||||||
: root.window-active ? #e6e6e6
|
: root.window-active ? #e6e6e6
|
||||||
: transparent;
|
: transparent;
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: "Minimize";
|
accessible-label: StreamStrings.window-minimize;
|
||||||
accessible-action-default => { stream-minimize-touch.clicked(); }
|
accessible-action-default => { stream-minimize-touch.clicked(); }
|
||||||
Rectangle {
|
Rectangle {
|
||||||
x: 8px;
|
x: 8px;
|
||||||
@@ -832,7 +840,7 @@ export component StreamWindow inherits Window {
|
|||||||
: root.window-active ? #e6e6e6
|
: root.window-active ? #e6e6e6
|
||||||
: transparent;
|
: transparent;
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: root.window-maximized ? "Restore" : "Maximize";
|
accessible-label: root.window-maximized ? StreamStrings.window-restore : StreamStrings.window-maximize;
|
||||||
accessible-action-default => { stream-maximize-touch.clicked(); }
|
accessible-action-default => { stream-maximize-touch.clicked(); }
|
||||||
if !root.window-maximized: Rectangle {
|
if !root.window-maximized: Rectangle {
|
||||||
x: 8px;
|
x: 8px;
|
||||||
@@ -882,7 +890,7 @@ export component StreamWindow inherits Window {
|
|||||||
: root.window-active ? #e6e6e6
|
: root.window-active ? #e6e6e6
|
||||||
: transparent;
|
: transparent;
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: "Close";
|
accessible-label: StreamStrings.window-close;
|
||||||
accessible-action-default => { stream-close-touch.clicked(); }
|
accessible-action-default => { stream-close-touch.clicked(); }
|
||||||
Path {
|
Path {
|
||||||
width: parent.width;
|
width: parent.width;
|
||||||
@@ -899,10 +907,10 @@ export component StreamWindow inherits Window {
|
|||||||
clicked => { root.close-stream-window(); }
|
clicked => { root.close-stream-window(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Rectangle {
|
SurfaceSeparator {
|
||||||
y: parent.height - 1px;
|
y: parent.height - 1px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: #d5d3d0;
|
outline-color: UiSurface.separator;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,11 +930,11 @@ export component StreamWindow inherits Window {
|
|||||||
z: 10;
|
z: 10;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: 1px;
|
spacing: 1px;
|
||||||
for tab[index] in root.tabs: tab-item := Rectangle {
|
for tab[index] in root.tabs: tab-item := FlatSurface {
|
||||||
width: min(190px, max(110px, (parent.width - 12px) / root.tabs.length));
|
width: min(190px, max(110px, (parent.width - 12px) / root.tabs.length));
|
||||||
background: index == root.selected-tab ? white : tab-touch.has-hover || root.dragging-tab == index ? #e5e7eb : #d8dbe0;
|
background: index == root.selected-tab ? white : tab-touch.has-hover || root.dragging-tab == index ? #e5e7eb : #d8dbe0;
|
||||||
border-width: root.dragging-tab == index ? 1px : 0px;
|
outline-enabled: root.dragging-tab == index;
|
||||||
border-color: #2463c7;
|
outline-color: UiSurface.focus;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width - 27px;
|
width: parent.width - 27px;
|
||||||
height: parent.height;
|
height: parent.height;
|
||||||
@@ -1199,11 +1207,11 @@ export component StreamWindow inherits Window {
|
|||||||
tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen;
|
tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen;
|
||||||
clicked => { root.close-control-menus(); root.toggle-fullscreen(); }
|
clicked => { root.close-control-menus(); root.toggle-fullscreen(); }
|
||||||
}
|
}
|
||||||
if root.control-content-visible: Rectangle {
|
if root.control-content-visible: SurfaceSeparator {
|
||||||
opacity: root.control-content-progress;
|
opacity: root.control-content-progress;
|
||||||
x: close-button.x - 6px; y: 12px;
|
x: close-button.x - 6px; y: 12px;
|
||||||
width: 1px; height: 14px;
|
width: 1px; height: 14px;
|
||||||
background: #dbe5f1;
|
vertical: true; outline-color: UiSurface.separator;
|
||||||
}
|
}
|
||||||
close-button := ControlBarButton {
|
close-button := ControlBarButton {
|
||||||
reveal-progress: root.control-content-progress;
|
reveal-progress: root.control-content-progress;
|
||||||
@@ -1408,12 +1416,12 @@ export component StreamWindow inherits Window {
|
|||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
if index < root.stats-rows.length - 2: Rectangle {
|
if index < root.stats-rows.length - 2: SurfaceSeparator {
|
||||||
x: 4px;
|
x: 4px;
|
||||||
y: parent.height - 1px;
|
y: parent.height - 1px;
|
||||||
width: parent.width - 8px;
|
width: parent.width - 8px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: #edf0f4;
|
outline-color: UiSurface.separator;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1422,16 +1430,16 @@ export component StreamWindow inherits Window {
|
|||||||
text: StreamStrings.stats-encoder + "\n" + StreamStrings.stats-decoder;
|
text: StreamStrings.stats-encoder + "\n" + StreamStrings.stats-decoder;
|
||||||
font-size: ImGuiFontStyle.small;
|
font-size: ImGuiFontStyle.small;
|
||||||
}
|
}
|
||||||
Rectangle {
|
FlatSurface {
|
||||||
x: 0px;
|
x: 0px;
|
||||||
y: root.stats-header-height + root.stats-rows.length * stats-table.row-height
|
y: root.stats-header-height + root.stats-rows.length * stats-table.row-height
|
||||||
+ 70px;
|
+ 70px;
|
||||||
width: parent.width; height: 44px;
|
width: parent.width; height: 44px;
|
||||||
background: #f7f9fc; border-radius: UiRadius.control;
|
background: #f7f9fc; border-radius: UiRadius.control;
|
||||||
border-width: 1px; border-color: #e8edf4;
|
outline-color: UiSurface.edge;
|
||||||
Rectangle {
|
SurfaceSeparator {
|
||||||
x: 8px; y: 22px; width: parent.width - 16px; height: 1px;
|
x: 8px; y: 22px; width: parent.width - 16px; height: 1px;
|
||||||
background: #e8edf4;
|
outline-color: UiSurface.separator;
|
||||||
}
|
}
|
||||||
for codec[index] in [
|
for codec[index] in [
|
||||||
{ label: StreamStrings.stats-encoder, value: root.stats-encoder },
|
{ label: StreamStrings.stats-encoder, value: root.stats-encoder },
|
||||||
@@ -1639,16 +1647,16 @@ export component StreamWindow inherits Window {
|
|||||||
padding: 8px; spacing: 1px;
|
padding: 8px; spacing: 1px;
|
||||||
for display[index] in root.displays: Rectangle {
|
for display[index] in root.displays: Rectangle {
|
||||||
height: 29px;
|
height: 29px;
|
||||||
background: index == display-menu.hovered-index && display-touch.pressed ? #bfd4f2
|
background: index == root.selected-display
|
||||||
: index == display-menu.hovered-index ? #d8e6f8
|
? (index == display-menu.hovered-index ? (display-touch.pressed ? ControlStyle.selected-pressed : ControlStyle.selected-hover) : ControlStyle.selected)
|
||||||
: index == root.selected-display ? #e8eef9
|
: index == display-menu.hovered-index ? (display-touch.pressed ? ControlStyle.pressed : ControlStyle.hover) : transparent;
|
||||||
: transparent;
|
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
Text {
|
Text {
|
||||||
x: 8px;
|
x: 8px;
|
||||||
width: parent.width - 34px;
|
width: parent.width - 34px;
|
||||||
text: display;
|
text: display;
|
||||||
color: #30343b;
|
color: index == root.selected-display ? ControlStyle.selected-text : #30343b;
|
||||||
|
font-weight: index == root.selected-display ? 600 : 400;
|
||||||
font-size: ImGuiFontStyle.body;
|
font-size: ImGuiFontStyle.body;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
@@ -1657,7 +1665,7 @@ export component StreamWindow inherits Window {
|
|||||||
x: parent.width - 24px;
|
x: parent.width - 24px;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
text: FontAwesomeIcons.check;
|
text: FontAwesomeIcons.check;
|
||||||
color: #2463c7;
|
color: ControlStyle.selected-text;
|
||||||
font-family: "Font Awesome 6 Free";
|
font-family: "Font Awesome 6 Free";
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -1695,7 +1703,9 @@ export component StreamWindow inherits Window {
|
|||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
padding: 8px; spacing: 1px;
|
padding: 8px; spacing: 1px;
|
||||||
for shortcut in root.shortcut-items: Rectangle {
|
for shortcut in root.shortcut-items: Rectangle {
|
||||||
height: 29px; background: shortcut-touch.has-hover ? #e8eef9 : transparent;
|
height: 29px;
|
||||||
|
border-radius: UiRadius.control;
|
||||||
|
background: shortcut-touch.pressed ? ControlStyle.pressed : shortcut-touch.has-hover ? ControlStyle.hover : transparent;
|
||||||
Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
||||||
shortcut-touch := TouchArea {
|
shortcut-touch := TouchArea {
|
||||||
mouse-cursor: MouseCursor.pointer;
|
mouse-cursor: MouseCursor.pointer;
|
||||||
@@ -1745,11 +1755,8 @@ export component StreamWindow inherits Window {
|
|||||||
y: min(max(64px, parent.height * 0.18), max(16px, parent.height - self.height - 16px));
|
y: min(max(64px, parent.height * 0.18), max(16px, parent.height - self.height - 16px));
|
||||||
width: min(400px, parent.width - 48px);
|
width: min(400px, parent.width - 48px);
|
||||||
height: remote-update-message.y + remote-update-message.height + 84px;
|
height: remote-update-message.y + remote-update-message.height + 84px;
|
||||||
border-color: #e5e9f0;
|
outline-color: UiSurface.edge;
|
||||||
border-radius: UiRadius.surface;
|
border-radius: UiRadius.surface;
|
||||||
drop-shadow-color: #07142b30;
|
|
||||||
drop-shadow-blur: 32px;
|
|
||||||
drop-shadow-offset-y: 12px;
|
|
||||||
z: 14;
|
z: 14;
|
||||||
TouchArea {
|
TouchArea {
|
||||||
mouse-cursor: default;
|
mouse-cursor: default;
|
||||||
@@ -1774,8 +1781,8 @@ export component StreamWindow inherits Window {
|
|||||||
IconButton {
|
IconButton {
|
||||||
x: parent.width - 44px; y: 12px; width: 32px; height: 32px;
|
x: parent.width - 44px; y: 12px; width: 32px; height: 32px;
|
||||||
icon: FontAwesomeIcons.xmark; icon-size: 14px;
|
icon: FontAwesomeIcons.xmark; icon-size: 14px;
|
||||||
icon-color: #8a93a3;
|
icon-color: ControlStyle.icon;
|
||||||
hover-background: #f2f4f7;
|
normal-background: ControlStyle.surface;
|
||||||
clicked => { root.dismiss-remote-update(); }
|
clicked => { root.dismiss-remote-update(); }
|
||||||
}
|
}
|
||||||
PrimaryButton {
|
PrimaryButton {
|
||||||
@@ -1789,10 +1796,10 @@ export component StreamWindow inherits Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transfer progress overlay at bottom-left.
|
// Transfer progress overlay at bottom-left.
|
||||||
if root.file-transfer-visible: Rectangle {
|
if root.file-transfer-visible: FlatSurface {
|
||||||
x: root.file-panel-x; y: root.file-panel-y;
|
x: root.file-panel-x; y: root.file-panel-y;
|
||||||
width: root.file-panel-width; height: root.file-panel-height;
|
width: root.file-panel-width; height: root.file-panel-height;
|
||||||
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.surface; z: 7;
|
background: white; outline-color: UiSurface.edge; border-radius: UiRadius.surface; z: 7;
|
||||||
// Header/padding and drags leaving the panel belong to local UI.
|
// Header/padding and drags leaving the panel belong to local UI.
|
||||||
TouchArea {
|
TouchArea {
|
||||||
mouse-cursor: default;
|
mouse-cursor: default;
|
||||||
@@ -1801,6 +1808,7 @@ export component StreamWindow inherits Window {
|
|||||||
Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; }
|
Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; }
|
||||||
IconButton {
|
IconButton {
|
||||||
x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px;
|
x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px;
|
||||||
|
normal-background: ControlStyle.surface;
|
||||||
clicked => { root.close-file-transfer(); }
|
clicked => { root.close-file-transfer(); }
|
||||||
}
|
}
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -1840,12 +1848,11 @@ export component StreamWindow inherits Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if root.custom-titlebar && !root.fullscreen-enabled: Rectangle {
|
if root.custom-titlebar && !root.fullscreen-enabled: FlatSurface {
|
||||||
width: parent.width;
|
width: parent.width;
|
||||||
height: parent.height;
|
height: parent.height;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-width: 1px;
|
outline-color: UiSurface.edge;
|
||||||
border-color: #aaa8a6;
|
|
||||||
border-radius:
|
border-radius:
|
||||||
root.window-maximized ? 0px : root.window-corner-radius;
|
root.window-maximized ? 0px : root.window-corner-radius;
|
||||||
z: 200;
|
z: 200;
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ inline int ApplyMainWindowStrings(
|
|||||||
localization::detail::ClampLanguageIndex(language_index);
|
localization::detail::ClampLanguageIndex(language_index);
|
||||||
window->global<ui::GalleryStrings>().set_language(language);
|
window->global<ui::GalleryStrings>().set_language(language);
|
||||||
auto& strings = window->global<ui::UiStrings>();
|
auto& strings = window->global<ui::UiStrings>();
|
||||||
|
strings.set_window_minimize(Text(localization::window_minimize[language]));
|
||||||
|
strings.set_window_close(Text(localization::window_close[language]));
|
||||||
|
strings.set_save(Text(localization::save[language]));
|
||||||
|
strings.set_copyright(Text(localization::copyright_notice[language]));
|
||||||
|
strings.set_license(Text(localization::license_notice[language]));
|
||||||
auto& messages = window->global<ui::MessageStrings>();
|
auto& messages = window->global<ui::MessageStrings>();
|
||||||
messages.set_messages(Text(localization::messages[language]));
|
messages.set_messages(Text(localization::messages[language]));
|
||||||
messages.set_unread(Text(localization::message_unread[language]));
|
messages.set_unread(Text(localization::message_unread[language]));
|
||||||
@@ -81,7 +86,7 @@ inline int ApplyMainWindowStrings(
|
|||||||
strings.set_ok(Text(localization::ok[language]));
|
strings.set_ok(Text(localization::ok[language]));
|
||||||
strings.set_cancel(Text(localization::cancel[language]));
|
strings.set_cancel(Text(localization::cancel[language]));
|
||||||
strings.set_new_password(Text(localization::new_password[language]));
|
strings.set_new_password(Text(localization::new_password[language]));
|
||||||
strings.set_invalid_password(Text(localization::max_password_len[language]));
|
strings.set_invalid_password(Text(localization::password_format_invalid[language]));
|
||||||
strings.set_edit_alias(
|
strings.set_edit_alias(
|
||||||
Text(localization::input_connection_alias[language]));
|
Text(localization::input_connection_alias[language]));
|
||||||
strings.set_delete_connection(
|
strings.set_delete_connection(
|
||||||
@@ -93,7 +98,6 @@ inline int ApplyMainWindowStrings(
|
|||||||
strings.set_capture_method_auto(
|
strings.set_capture_method_auto(
|
||||||
Text(localization::screen_capture_method_auto[language]));
|
Text(localization::screen_capture_method_auto[language]));
|
||||||
strings.set_codec(Text(localization::video_encode_format[language]));
|
strings.set_codec(Text(localization::video_encode_format[language]));
|
||||||
strings.set_turn_relay(Text(localization::enable_turn[language]));
|
|
||||||
strings.set_force_relay(Text(localization::force_relay[language]));
|
strings.set_force_relay(Text(localization::force_relay[language]));
|
||||||
strings.set_self_hosted(
|
strings.set_self_hosted(
|
||||||
Text(localization::self_hosted_server_config[language]));
|
Text(localization::self_hosted_server_config[language]));
|
||||||
@@ -102,6 +106,7 @@ inline int ApplyMainWindowStrings(
|
|||||||
strings.set_save_remote_previews(Text(localization::save_remote_previews[language]));
|
strings.set_save_remote_previews(Text(localization::save_remote_previews[language]));
|
||||||
strings.set_unknown_platform(Text(localization::unknown_platform[language]));
|
strings.set_unknown_platform(Text(localization::unknown_platform[language]));
|
||||||
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language]));
|
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language]));
|
||||||
|
strings.set_lock_on_disconnect(Text(localization::lock_on_disconnect[language]));
|
||||||
strings.set_file_save_path(
|
strings.set_file_save_path(
|
||||||
Text(localization::file_transfer_save_path[language]));
|
Text(localization::file_transfer_save_path[language]));
|
||||||
strings.set_default_desktop(Text(localization::default_desktop[language]));
|
strings.set_default_desktop(Text(localization::default_desktop[language]));
|
||||||
@@ -192,6 +197,10 @@ inline int ApplyStreamWindowStrings(
|
|||||||
const int language =
|
const int language =
|
||||||
localization::detail::ClampLanguageIndex(language_index);
|
localization::detail::ClampLanguageIndex(language_index);
|
||||||
auto& strings = window->template global<ui::StreamStrings>();
|
auto& strings = window->template global<ui::StreamStrings>();
|
||||||
|
strings.set_window_minimize(Text(localization::window_minimize[language]));
|
||||||
|
strings.set_window_maximize(Text(localization::window_maximize[language]));
|
||||||
|
strings.set_window_restore(Text(localization::window_restore[language]));
|
||||||
|
strings.set_window_close(Text(localization::window_close[language]));
|
||||||
strings.set_remote_update_title(Text(localization::remote_update_title[language]));
|
strings.set_remote_update_title(Text(localization::remote_update_title[language]));
|
||||||
strings.set_remote_update(Text(localization::remote_update[language]));
|
strings.set_remote_update(Text(localization::remote_update[language]));
|
||||||
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
|
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Flat surfaces share solid outlines in software and GPU renderers.
|
||||||
|
// Interaction states use their actual colors, including focus and validation.
|
||||||
|
export global UiSurface {
|
||||||
|
out property <color> edge: #d7dfe9;
|
||||||
|
out property <color> hover: #8eabd2;
|
||||||
|
out property <color> focus: #3564e3;
|
||||||
|
out property <color> error: #d65252;
|
||||||
|
out property <color> separator: #e1e7ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export component FlatSurface inherits Rectangle {
|
||||||
|
in property <color> outline-color: UiSurface.edge;
|
||||||
|
in property <bool> outline-enabled: true;
|
||||||
|
in property <bool> outline-focused: false;
|
||||||
|
border-width: !root.outline-enabled ? 0px : root.outline-focused ? 2px : 1px;
|
||||||
|
border-color: root.outline-color;
|
||||||
|
border-top-left-radius: root.border-radius;
|
||||||
|
border-top-right-radius: root.border-radius;
|
||||||
|
border-bottom-left-radius: root.border-radius;
|
||||||
|
border-bottom-right-radius: root.border-radius;
|
||||||
|
|
||||||
|
@children
|
||||||
|
}
|
||||||
|
|
||||||
|
export component SurfaceSeparator inherits Rectangle {
|
||||||
|
in property <bool> vertical: false;
|
||||||
|
in property <color> outline-color: UiSurface.separator;
|
||||||
|
height: 1px;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: root.vertical ? 1px : root.width;
|
||||||
|
height: root.vertical ? root.height : 1px;
|
||||||
|
background: root.outline-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
|
import { FlatSurface, UiSurface } from "ui_surface.slint";
|
||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
|
import { ControlStyle, ButtonStyle } from "common.slint";
|
||||||
// Colors and spacing for the connection workbench; shapes are shared app-wide.
|
// Colors and spacing for the connection workbench; shapes are shared app-wide.
|
||||||
export global WorkbenchStyle {
|
export global WorkbenchStyle {
|
||||||
out property <color> canvas: #f2f5f9;
|
out property <color> canvas: #edf2f8;
|
||||||
out property <color> surface: #ffffff;
|
out property <color> surface: #ffffff;
|
||||||
out property <color> border: #c6cfdd;
|
|
||||||
out property <color> control-border: #b2bfd1;
|
|
||||||
out property <color> hover-border: #7f97b8;
|
|
||||||
out property <color> field-surface: #f5f7fb;
|
out property <color> field-surface: #f5f7fb;
|
||||||
out property <color> hover-surface: #eef3fb;
|
out property <color> hover-surface: ControlStyle.hover;
|
||||||
out property <color> pressed-surface: #e4ebf6;
|
out property <color> pressed-surface: ControlStyle.pressed;
|
||||||
out property <color> separator: #e1e7ef;
|
out property <color> separator: #e1e7ef;
|
||||||
out property <color> text: #1c2940;
|
out property <color> text: #1c2940;
|
||||||
out property <color> muted: #69788e;
|
out property <color> muted: #69788e;
|
||||||
@@ -16,18 +15,19 @@ export global WorkbenchStyle {
|
|||||||
out property <color> success: #18835a;
|
out property <color> success: #18835a;
|
||||||
}
|
}
|
||||||
|
|
||||||
export component WorkbenchButton inherits Rectangle {
|
export component WorkbenchButton inherits FlatSurface {
|
||||||
in property <string> text;
|
in property <string> text;
|
||||||
in property <length> text-size: 13px;
|
in property <length> text-size: 13px;
|
||||||
in property <color> text-color: root.destructive || root.primary ? white : WorkbenchStyle.text;
|
in property <color> text-color: root.destructive || root.primary ? white : WorkbenchStyle.text;
|
||||||
in property <color> normal-border-color: WorkbenchStyle.control-border;
|
in property <color> normal-outline-color: ButtonStyle.border;
|
||||||
|
in property <color> normal-background: ButtonStyle.surface;
|
||||||
in property <string> icon;
|
in property <string> icon;
|
||||||
in property <string> tooltip;
|
in property <string> tooltip;
|
||||||
in property <bool> tooltip-above: false;
|
in property <bool> tooltip-above: false;
|
||||||
in property <bool> tooltip-suppressed: false;
|
in property <bool> tooltip-suppressed: false;
|
||||||
in property <color> icon-color: root.destructive ? white : root.primary
|
in property <color> icon-color: root.destructive ? white : root.primary
|
||||||
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
|
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
|
||||||
: WorkbenchStyle.muted;
|
: ControlStyle.icon;
|
||||||
in property <bool> primary: false;
|
in property <bool> primary: false;
|
||||||
in property <bool> destructive: false;
|
in property <bool> destructive: false;
|
||||||
in property <bool> soft: false;
|
in property <bool> soft: false;
|
||||||
@@ -45,24 +45,23 @@ export component WorkbenchButton inherits Rectangle {
|
|||||||
width: 112px;
|
width: 112px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
border-width: root.show-focus ? 2px : root.soft ? 0px : 1px;
|
outline-enabled: root.show-focus || (!root.quiet && !root.soft && !root.primary && !root.destructive);
|
||||||
border-color: root.destructive
|
outline-focused: root.show-focus;
|
||||||
? (root.show-focus ? #7a271a : touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20)
|
outline-color: !root.enabled ? ButtonStyle.disabled-border
|
||||||
: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent)
|
: root.show-focus ? WorkbenchStyle.accent
|
||||||
: root.primary ? WorkbenchStyle.accent
|
: touch.pressed ? ButtonStyle.pressed-border
|
||||||
: touch.has-hover ? WorkbenchStyle.hover-border
|
: touch.has-hover ? ButtonStyle.hover-border : root.normal-outline-color;
|
||||||
: root.quiet ? transparent : root.normal-border-color;
|
|
||||||
background: root.destructive
|
background: root.destructive
|
||||||
? (touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20)
|
? (touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20)
|
||||||
: root.soft
|
: root.soft
|
||||||
? (root.primary
|
? (root.primary
|
||||||
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #edf2ff)
|
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #dce7ff)
|
||||||
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.field-surface))
|
: (touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface))
|
||||||
: root.primary
|
: root.primary
|
||||||
? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent)
|
? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent)
|
||||||
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : root.quiet ? transparent : white);
|
: (touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover : root.quiet ? transparent : root.normal-background);
|
||||||
opacity: root.enabled ? 1 : 0.45;
|
opacity: root.enabled ? 1 : 0.45;
|
||||||
animate background, border-color { duration: 120ms; }
|
animate background, outline-color { duration: 120ms; }
|
||||||
forward-focus: button-focus;
|
forward-focus: button-focus;
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: root.tooltip != "" ? root.tooltip : root.text;
|
accessible-label: root.tooltip != "" ? root.tooltip : root.text;
|
||||||
@@ -92,6 +91,11 @@ export component WorkbenchButton inherits Rectangle {
|
|||||||
accessible-role: none;
|
accessible-role: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if root.show-focus && (root.primary || root.destructive): Rectangle {
|
||||||
|
x: 3px; y: 3px; width: parent.width - 6px; height: parent.height - 6px;
|
||||||
|
border-radius: max(0px, root.border-radius - 3px);
|
||||||
|
border-width: 1px; border-color: white;
|
||||||
|
}
|
||||||
button-focus := FocusScope {
|
button-focus := FocusScope {
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
focus-gained(reason) => {
|
focus-gained(reason) => {
|
||||||
@@ -150,7 +154,7 @@ export component WorkbenchButton inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A raised surface keeps transient action feedback distinct from the workbench.
|
// A raised surface keeps transient action feedback distinct from the workbench.
|
||||||
export component WorkbenchNotice inherits Rectangle {
|
export component WorkbenchNotice inherits FlatSurface {
|
||||||
in property <string> text;
|
in property <string> text;
|
||||||
in property <string> icon;
|
in property <string> icon;
|
||||||
in property <bool> error: false;
|
in property <bool> error: false;
|
||||||
@@ -159,12 +163,8 @@ export component WorkbenchNotice inherits Rectangle {
|
|||||||
width: min(root.width-limit, max(280px, message.preferred-width + 64px));
|
width: min(root.width-limit, max(280px, message.preferred-width + 64px));
|
||||||
height: 64px;
|
height: 64px;
|
||||||
background: root.error ? #fff1f0 : #e9f6ef;
|
background: root.error ? #fff1f0 : #e9f6ef;
|
||||||
border-width: 1px;
|
outline-color: root.error ? UiSurface.error : UiSurface.edge;
|
||||||
border-color: root.error ? #f0c4bf : #b9dfc9;
|
|
||||||
border-radius: UiRadius.surface;
|
border-radius: UiRadius.surface;
|
||||||
drop-shadow-color: #14294424;
|
|
||||||
drop-shadow-blur: 24px;
|
|
||||||
drop-shadow-offset-y: 6px;
|
|
||||||
accessible-role: AccessibleRole.text;
|
accessible-role: AccessibleRole.text;
|
||||||
accessible-label: root.text;
|
accessible-label: root.text;
|
||||||
Text {
|
Text {
|
||||||
@@ -192,9 +192,8 @@ export component WorkbenchNotice inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export component WorkbenchCard inherits Rectangle {
|
export component WorkbenchCard inherits FlatSurface {
|
||||||
background: WorkbenchStyle.surface;
|
background: WorkbenchStyle.surface;
|
||||||
border-width: 1px;
|
outline-color: UiSurface.edge;
|
||||||
border-color: WorkbenchStyle.border;
|
|
||||||
border-radius: UiRadius.surface;
|
border-radius: UiRadius.surface;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#include "lan_auth_limiter.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <exception>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
DeviceAuthLimiter::DeviceAuthLimiter(Now now, size_t source_failure_limit,
|
||||||
|
size_t target_failure_limit)
|
||||||
|
: now_(std::move(now)),
|
||||||
|
source_failure_limit_(source_failure_limit),
|
||||||
|
target_failure_limit_(target_failure_limit) {
|
||||||
|
if (source_failure_limit == 0 || target_failure_limit == 0)
|
||||||
|
throw std::invalid_argument(
|
||||||
|
"Authentication failure limits must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeviceAuthLimiter::Prune(Buckets& buckets, Clock::time_point now) {
|
||||||
|
for (auto it = buckets.begin(); it != buckets.end();) {
|
||||||
|
if (it->second.expires <= now && it->second.pending == 0)
|
||||||
|
it = buckets.erase(it);
|
||||||
|
else
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAuthLimiter::Result DeviceAuthLimiter::Verify(
|
||||||
|
const std::string& source, const std::string& target,
|
||||||
|
const std::function<bool()>& verify) {
|
||||||
|
// Reserve both budgets atomically, then release the lock for slow hashing.
|
||||||
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
|
const auto now = now_();
|
||||||
|
if (now >= next_cleanup_) {
|
||||||
|
Prune(sources_, now);
|
||||||
|
Prune(targets_, now);
|
||||||
|
next_cleanup_ = now + std::chrono::minutes(1);
|
||||||
|
}
|
||||||
|
// Missing source metadata must not become a per-connection bypass.
|
||||||
|
const std::string source_key = source.empty() ? "unknown" : source;
|
||||||
|
auto retry = [now](Buckets& buckets, const std::string& key, size_t limit) {
|
||||||
|
const auto it = buckets.find(key);
|
||||||
|
if (it == buckets.end()) return 0;
|
||||||
|
if (it->second.expires <= now) {
|
||||||
|
it->second.count = 0;
|
||||||
|
it->second.expires = now + kWindow;
|
||||||
|
}
|
||||||
|
if (it->second.count < limit)
|
||||||
|
return it->second.count + it->second.pending >= limit ? 1 : 0;
|
||||||
|
return static_cast<int>(
|
||||||
|
std::chrono::ceil<std::chrono::seconds>(it->second.expires - now)
|
||||||
|
.count());
|
||||||
|
};
|
||||||
|
const int source_retry = retry(sources_, source_key, source_failure_limit_);
|
||||||
|
const int target_retry = retry(targets_, target, target_failure_limit_);
|
||||||
|
const int retry_after = std::max(source_retry, target_retry);
|
||||||
|
if (retry_after > 0) return {false, retry_after};
|
||||||
|
|
||||||
|
// Do not evict live failures when flooded with new IDs/IPs. Deny new keys
|
||||||
|
// until expiry frees capacity, bounding memory without resetting lockouts.
|
||||||
|
if ((!sources_.count(source_key) && sources_.size() >= kMaxEntries) ||
|
||||||
|
(!targets_.count(target) && targets_.size() >= kMaxEntries)) {
|
||||||
|
return {false, static_cast<int>(kWindow.count())};
|
||||||
|
}
|
||||||
|
for (auto entry : {std::make_pair(&sources_, source_key),
|
||||||
|
std::make_pair(&targets_, target)}) {
|
||||||
|
auto& bucket =
|
||||||
|
entry.first->try_emplace(entry.second, Failures{0, 0, now + kWindow})
|
||||||
|
.first->second;
|
||||||
|
++bucket.pending;
|
||||||
|
}
|
||||||
|
lock.unlock();
|
||||||
|
bool authenticated = false;
|
||||||
|
std::exception_ptr error;
|
||||||
|
try {
|
||||||
|
authenticated = verify();
|
||||||
|
} catch (...) {
|
||||||
|
error = std::current_exception();
|
||||||
|
}
|
||||||
|
lock.lock();
|
||||||
|
const auto finished = now_();
|
||||||
|
auto finish = [&](Buckets& buckets, const std::string& key, size_t limit) {
|
||||||
|
auto& bucket = buckets.at(key);
|
||||||
|
--bucket.pending;
|
||||||
|
if (bucket.expires <= finished) {
|
||||||
|
bucket.count = 0;
|
||||||
|
bucket.expires = finished + kWindow;
|
||||||
|
}
|
||||||
|
// The reservation already owns this bucket. Start a full cooldown at the
|
||||||
|
// threshold; blocked requests do not extend it.
|
||||||
|
if (!authenticated && ++bucket.count == limit)
|
||||||
|
bucket.expires = finished + kWindow;
|
||||||
|
if (bucket.count == 0 && bucket.pending == 0) buckets.erase(key);
|
||||||
|
};
|
||||||
|
finish(sources_, source_key, source_failure_limit_);
|
||||||
|
finish(targets_, target, target_failure_limit_);
|
||||||
|
lock.unlock();
|
||||||
|
if (error) std::rethrow_exception(error);
|
||||||
|
return {authenticated, 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
int CredentialWorkLimiter::Admit(const std::string& source) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
const auto now = now_();
|
||||||
|
constexpr auto window = std::chrono::minutes(1);
|
||||||
|
if (now >= next_cleanup_) {
|
||||||
|
for (auto it = sources_.begin(); it != sources_.end();) {
|
||||||
|
if (it->second.expires <= now)
|
||||||
|
it = sources_.erase(it);
|
||||||
|
else
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
next_cleanup_ = now + window;
|
||||||
|
}
|
||||||
|
const auto key = source.empty() ? "unknown" : source;
|
||||||
|
if (!sources_.count(key) && sources_.size() >= DeviceAuthLimiter::kMaxEntries)
|
||||||
|
return 60;
|
||||||
|
auto& local =
|
||||||
|
sources_.try_emplace(key, Window{0, now + window}).first->second;
|
||||||
|
if (local.expires <= now) local = {0, now + window};
|
||||||
|
auto retry = [now](const Window& value, size_t limit) {
|
||||||
|
return value.count < limit
|
||||||
|
? 0
|
||||||
|
: static_cast<int>(std::chrono::ceil<std::chrono::seconds>(
|
||||||
|
value.expires - now)
|
||||||
|
.count());
|
||||||
|
};
|
||||||
|
const int delay = retry(local, source_limit_);
|
||||||
|
if (delay > 0) return delay;
|
||||||
|
++local.count;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk::lan
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-24
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _LAN_AUTH_LIMITER_H_
|
||||||
|
#define _LAN_AUTH_LIMITER_H_
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
// Keep password verification and retry policy aligned with crossdesk-server.
|
||||||
|
// Failure budgets keyed by transport peer IP and credential target, never a
|
||||||
|
// connection handle. Use separate instances for independent auth domains.
|
||||||
|
class DeviceAuthLimiter {
|
||||||
|
public:
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
using Now = std::function<Clock::time_point()>;
|
||||||
|
static constexpr size_t kSourceFailures = 20;
|
||||||
|
static constexpr size_t kTargetFailures = 5;
|
||||||
|
static constexpr size_t kMaxEntries = 16384; // Per dimension.
|
||||||
|
static constexpr std::chrono::seconds kWindow{900};
|
||||||
|
|
||||||
|
struct Result {
|
||||||
|
bool authenticated = false;
|
||||||
|
int retry_after = 0; // Positive only when verification was not attempted.
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit DeviceAuthLimiter(Now now = Clock::now,
|
||||||
|
size_t source_failure_limit = kSourceFailures,
|
||||||
|
size_t target_failure_limit = kTargetFailures);
|
||||||
|
Result Verify(const std::string& source, const std::string& target,
|
||||||
|
const std::function<bool()>& verify);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Failures {
|
||||||
|
size_t count = 0;
|
||||||
|
size_t pending = 0;
|
||||||
|
Clock::time_point expires;
|
||||||
|
};
|
||||||
|
using Buckets = std::unordered_map<std::string, Failures>;
|
||||||
|
void Prune(Buckets& buckets, Clock::time_point now);
|
||||||
|
|
||||||
|
Now now_;
|
||||||
|
const size_t source_failure_limit_, target_failure_limit_;
|
||||||
|
std::mutex mutex_;
|
||||||
|
Buckets sources_, targets_;
|
||||||
|
Clock::time_point next_cleanup_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admission before expensive work, including successful authentication,
|
||||||
|
// registration and password changes. Separate from credential failure budgets.
|
||||||
|
class CredentialWorkLimiter {
|
||||||
|
public:
|
||||||
|
explicit CredentialWorkLimiter(
|
||||||
|
DeviceAuthLimiter::Now now = DeviceAuthLimiter::Clock::now,
|
||||||
|
size_t source_limit = 60)
|
||||||
|
: now_(std::move(now)), source_limit_(source_limit) {
|
||||||
|
if (!source_limit_)
|
||||||
|
throw std::invalid_argument("Credential source limit must be positive");
|
||||||
|
}
|
||||||
|
int Admit(const std::string& source);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Window {
|
||||||
|
size_t count = 0;
|
||||||
|
DeviceAuthLimiter::Clock::time_point expires{};
|
||||||
|
};
|
||||||
|
DeviceAuthLimiter::Now now_;
|
||||||
|
std::mutex mutex_;
|
||||||
|
const size_t source_limit_;
|
||||||
|
DeviceAuthLimiter::Clock::time_point next_cleanup_{};
|
||||||
|
std::unordered_map<std::string, Window> sources_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace crossdesk::lan
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
#include "lan_identity.h"
|
||||||
|
|
||||||
|
#include <openssl/crypto.h>
|
||||||
|
#include <openssl/pem.h>
|
||||||
|
#include <openssl/rand.h>
|
||||||
|
#include <openssl/x509v3.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <charconv>
|
||||||
|
#include <fstream>
|
||||||
|
#include <memory>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "lan_password.h"
|
||||||
|
#ifdef _WIN32
|
||||||
|
// Winsock must precede windows.h; the networking/ACL headers need its types.
|
||||||
|
// clang-format off
|
||||||
|
#include <winsock2.h>
|
||||||
|
#include <windows.h>
|
||||||
|
#include <ws2tcpip.h>
|
||||||
|
#include <Aclapi.h>
|
||||||
|
#include <iphlpapi.h>
|
||||||
|
#include <netioapi.h>
|
||||||
|
#include <sddl.h>
|
||||||
|
// clang-format on
|
||||||
|
#else
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <ifaddrs.h>
|
||||||
|
#include <net/if.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#ifdef __APPLE__
|
||||||
|
#include <net/if_dl.h>
|
||||||
|
#include <net/if_types.h>
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
namespace {
|
||||||
|
void Require(bool ok, const char* error) {
|
||||||
|
if (!ok) throw std::runtime_error(error);
|
||||||
|
}
|
||||||
|
std::string Hex(const unsigned char* data, size_t size) {
|
||||||
|
static constexpr char digits[] = "0123456789abcdef";
|
||||||
|
std::string result(size * 2, '0');
|
||||||
|
for (size_t i = 0; i < size; ++i) {
|
||||||
|
result[2 * i] = digits[data[i] >> 4];
|
||||||
|
result[2 * i + 1] = digits[data[i] & 15];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
bool IsHex(const std::string& text, size_t size) {
|
||||||
|
return text.size() == size &&
|
||||||
|
std::all_of(text.begin(), text.end(), [](char c) {
|
||||||
|
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#ifdef _WIN32
|
||||||
|
std::vector<unsigned char> CurrentUser() {
|
||||||
|
HANDLE token = nullptr;
|
||||||
|
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
|
||||||
|
"Cannot open user token");
|
||||||
|
DWORD size = 0;
|
||||||
|
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
|
||||||
|
std::vector<unsigned char> data(size);
|
||||||
|
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
|
||||||
|
CloseHandle(token);
|
||||||
|
Require(ok, "Cannot read user token");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
PSECURITY_DESCRIPTOR PrivateSecurityDescriptor(bool directory) {
|
||||||
|
auto user = CurrentUser();
|
||||||
|
LPWSTR sid = nullptr;
|
||||||
|
Require(ConvertSidToStringSidW(
|
||||||
|
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
|
||||||
|
"Cannot read user SID");
|
||||||
|
// An elevated token can default to Administrators as the owner even when
|
||||||
|
// its DACL grants access only to TokenUser. Always set both explicitly.
|
||||||
|
const auto acl = L"O:" + std::wstring(sid) + L"D:P(A;" +
|
||||||
|
(directory ? L"OICI" : L"") + L";FA;;;" + sid + L")";
|
||||||
|
LocalFree(sid);
|
||||||
|
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||||
|
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
|
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
|
||||||
|
"Cannot protect LAN file");
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
|
||||||
|
const auto temporary = path.parent_path() / (".write-" + RandomHex(16));
|
||||||
|
#ifdef _WIN32
|
||||||
|
auto descriptor = PrivateSecurityDescriptor(false);
|
||||||
|
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
|
||||||
|
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &security,
|
||||||
|
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||||
|
LocalFree(descriptor);
|
||||||
|
Require(file != INVALID_HANDLE_VALUE, "Cannot create LAN file");
|
||||||
|
DWORD written = 0;
|
||||||
|
bool ok = WriteFile(file, text.data(), static_cast<DWORD>(text.size()),
|
||||||
|
&written, nullptr) &&
|
||||||
|
written == text.size() && FlushFileBuffers(file);
|
||||||
|
CloseHandle(file);
|
||||||
|
if (!ok || !MoveFileExW(temporary.c_str(), path.c_str(),
|
||||||
|
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
|
||||||
|
DeleteFileW(temporary.c_str());
|
||||||
|
throw std::runtime_error("Cannot persist LAN file");
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
int fd =
|
||||||
|
open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
|
||||||
|
Require(fd >= 0, "Cannot create private LAN file");
|
||||||
|
size_t offset = 0;
|
||||||
|
while (offset < text.size()) {
|
||||||
|
const auto count = write(fd, text.data() + offset, text.size() - offset);
|
||||||
|
if (count <= 0) break;
|
||||||
|
offset += static_cast<size_t>(count);
|
||||||
|
}
|
||||||
|
bool ok = offset == text.size() && fsync(fd) == 0;
|
||||||
|
close(fd);
|
||||||
|
if (!ok || rename(temporary.c_str(), path.c_str()) != 0) {
|
||||||
|
unlink(temporary.c_str());
|
||||||
|
throw std::runtime_error("Cannot persist LAN file");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#ifdef _WIN32
|
||||||
|
using FileHandle = std::unique_ptr<void, decltype(&CloseHandle)>;
|
||||||
|
FileHandle OpenLanPath(const std::filesystem::path& path, DWORD access) {
|
||||||
|
// Keep the object open without FILE_SHARE_DELETE so validation and legacy
|
||||||
|
// owner repair cannot operate on a replacement at the same path.
|
||||||
|
HANDLE file = CreateFileW(
|
||||||
|
path.c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
|
||||||
|
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
|
||||||
|
nullptr);
|
||||||
|
Require(file != INVALID_HANDLE_VALUE, "Cannot open LAN path");
|
||||||
|
return {file, CloseHandle};
|
||||||
|
}
|
||||||
|
void CheckPrivateWindowsPath(const std::filesystem::path& path) {
|
||||||
|
auto file = OpenLanPath(path, READ_CONTROL);
|
||||||
|
BY_HANDLE_FILE_INFORMATION info{};
|
||||||
|
Require(GetFileInformationByHandle(file.get(), &info) &&
|
||||||
|
!(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT),
|
||||||
|
"LAN path cannot be a reparse point");
|
||||||
|
auto user = CurrentUser();
|
||||||
|
auto sid = reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid;
|
||||||
|
PSID owner = nullptr;
|
||||||
|
PACL acl = nullptr;
|
||||||
|
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||||
|
Require(GetSecurityInfo(
|
||||||
|
file.get(), SE_FILE_OBJECT,
|
||||||
|
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
|
||||||
|
nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS,
|
||||||
|
"Cannot read LAN file permissions");
|
||||||
|
std::unique_ptr<void, decltype(&LocalFree)> security(descriptor, LocalFree);
|
||||||
|
bool ok = acl && acl->AceCount > 0;
|
||||||
|
// Only the exact protected, full-access DACL written by older versions is
|
||||||
|
// eligible for owner repair. Never adopt a shared or foreign-user profile.
|
||||||
|
SECURITY_DESCRIPTOR_CONTROL control{};
|
||||||
|
DWORD revision = 0;
|
||||||
|
bool legacy = owner && IsWellKnownSid(owner, WinBuiltinAdministratorsSid) &&
|
||||||
|
GetSecurityDescriptorControl(descriptor, &control, &revision) &&
|
||||||
|
(control & SE_DACL_PROTECTED) && acl && acl->AceCount == 1;
|
||||||
|
for (DWORD i = 0; ok && i < acl->AceCount; ++i) {
|
||||||
|
void* raw = nullptr;
|
||||||
|
if (!GetAce(acl, i, &raw)) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const auto* ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
|
||||||
|
ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
|
||||||
|
EqualSid(const_cast<DWORD*>(&ace->SidStart), sid);
|
||||||
|
const BYTE flags = (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
||||||
|
? OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
|
||||||
|
: 0;
|
||||||
|
legacy =
|
||||||
|
legacy && ace->Mask == FILE_ALL_ACCESS && ace->Header.AceFlags == flags;
|
||||||
|
}
|
||||||
|
Require(ok && owner && (EqualSid(owner, sid) || legacy),
|
||||||
|
"LAN files must be accessible only by the current user");
|
||||||
|
if (legacy) {
|
||||||
|
auto writable = OpenLanPath(path, WRITE_OWNER);
|
||||||
|
BY_HANDLE_FILE_INFORMATION current{};
|
||||||
|
Require(GetFileInformationByHandle(writable.get(), ¤t) &&
|
||||||
|
current.dwVolumeSerialNumber == info.dwVolumeSerialNumber &&
|
||||||
|
current.nFileIndexHigh == info.nFileIndexHigh &&
|
||||||
|
current.nFileIndexLow == info.nFileIndexLow &&
|
||||||
|
SetSecurityInfo(writable.get(), SE_FILE_OBJECT,
|
||||||
|
OWNER_SECURITY_INFORMATION, sid, nullptr,
|
||||||
|
nullptr, nullptr) == ERROR_SUCCESS,
|
||||||
|
"Cannot repair LAN file owner");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void CreatePrivateDirectory(const std::filesystem::path& path) {
|
||||||
|
if (!std::filesystem::exists(path)) {
|
||||||
|
auto descriptor = PrivateSecurityDescriptor(true);
|
||||||
|
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
|
||||||
|
bool ok = CreateDirectoryW(path.c_str(), &security);
|
||||||
|
LocalFree(descriptor);
|
||||||
|
Require(ok, "Cannot create LAN directory");
|
||||||
|
}
|
||||||
|
CheckPrivateWindowsPath(path);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
std::string Read(const std::filesystem::path& path) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
CheckPrivateWindowsPath(path);
|
||||||
|
#else
|
||||||
|
struct stat status{};
|
||||||
|
Require(lstat(path.c_str(), &status) == 0 && S_ISREG(status.st_mode) &&
|
||||||
|
status.st_uid == getuid() && (status.st_mode & 0077) == 0,
|
||||||
|
"LAN file must be owned by this user with mode 0600");
|
||||||
|
#endif
|
||||||
|
Require(std::filesystem::file_size(path) <= 64 * 1024, "LAN file too large");
|
||||||
|
std::ifstream input(path, std::ios::binary);
|
||||||
|
Require(input.good(), "Cannot read LAN file");
|
||||||
|
return {std::istreambuf_iterator<char>(input), {}};
|
||||||
|
}
|
||||||
|
std::string BioText(BIO* bio) {
|
||||||
|
char* data = nullptr;
|
||||||
|
long size = BIO_get_mem_data(bio, &data);
|
||||||
|
Require(size > 0, "Cannot encode LAN identity");
|
||||||
|
return {data, static_cast<size_t>(size)};
|
||||||
|
}
|
||||||
|
struct Secret {
|
||||||
|
std::string value;
|
||||||
|
~Secret() { OPENSSL_cleanse(value.data(), value.size()); }
|
||||||
|
};
|
||||||
|
std::string Unhex(const std::string& value, size_t bytes) {
|
||||||
|
Require(IsHex(value, bytes * 2), "Invalid saved LAN password");
|
||||||
|
std::string result(bytes, '\0');
|
||||||
|
auto digit = [](char c) { return c <= '9' ? c - '0' : c - 'a' + 10; };
|
||||||
|
for (size_t i = 0; i < bytes; ++i)
|
||||||
|
result[i] =
|
||||||
|
static_cast<char>((digit(value[2 * i]) << 4) | digit(value[2 * i + 1]));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
std::string PasswordKey(const std::filesystem::path& directory, bool create) {
|
||||||
|
const auto path = directory / "password-key";
|
||||||
|
if (create && !std::filesystem::exists(path)) {
|
||||||
|
Secret key{std::string(32, '\0')};
|
||||||
|
Require(
|
||||||
|
RAND_bytes(reinterpret_cast<unsigned char*>(key.value.data()), 32) == 1,
|
||||||
|
"Cannot generate LAN password storage key");
|
||||||
|
PrivateWrite(path, key.value);
|
||||||
|
}
|
||||||
|
auto key = Read(path);
|
||||||
|
Require(key.size() == 32, "Invalid LAN password storage key");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
// The independent local key and credential files use owner-only permissions.
|
||||||
|
// Encryption avoids plaintext in identity.json; it does not protect against
|
||||||
|
// an attacker who can read both this user's key and credential files.
|
||||||
|
nlohmann::json EncryptPassword(const std::string& password,
|
||||||
|
const std::string& key,
|
||||||
|
const std::string& context) {
|
||||||
|
const auto nonce = Unhex(RandomHex(12), 12);
|
||||||
|
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> cipher(
|
||||||
|
EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free);
|
||||||
|
std::array<unsigned char, 32> data{}, tag{};
|
||||||
|
int size = 0, tail = 0;
|
||||||
|
Require(
|
||||||
|
cipher &&
|
||||||
|
EVP_EncryptInit_ex(
|
||||||
|
cipher.get(), EVP_aes_256_gcm(), nullptr,
|
||||||
|
reinterpret_cast<const unsigned char*>(key.data()),
|
||||||
|
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
|
||||||
|
EVP_EncryptUpdate(
|
||||||
|
cipher.get(), nullptr, &size,
|
||||||
|
reinterpret_cast<const unsigned char*>(context.data()),
|
||||||
|
static_cast<int>(context.size())) == 1 &&
|
||||||
|
EVP_EncryptUpdate(
|
||||||
|
cipher.get(), data.data(), &size,
|
||||||
|
reinterpret_cast<const unsigned char*>(password.data()),
|
||||||
|
static_cast<int>(password.size())) == 1 &&
|
||||||
|
EVP_EncryptFinal_ex(cipher.get(), data.data() + size, &tail) == 1 &&
|
||||||
|
EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_GET_TAG, 16,
|
||||||
|
tag.data()) == 1,
|
||||||
|
"Cannot save LAN password");
|
||||||
|
return {{"version", 1},
|
||||||
|
{"algorithm", "aes-256-gcm"},
|
||||||
|
{"nonce", Hex(reinterpret_cast<const unsigned char*>(nonce.data()),
|
||||||
|
nonce.size())},
|
||||||
|
{"data", Hex(data.data(), size + tail)},
|
||||||
|
{"tag", Hex(tag.data(), 16)}};
|
||||||
|
}
|
||||||
|
std::string DecryptPassword(const nlohmann::json& record,
|
||||||
|
const std::string& key,
|
||||||
|
const std::string& context) {
|
||||||
|
Require(record.at("version") == 1 && record.at("algorithm") == "aes-256-gcm",
|
||||||
|
"Invalid saved LAN password");
|
||||||
|
const auto nonce = Unhex(record.at("nonce"), 12);
|
||||||
|
const auto data = Unhex(record.at("data"), 6);
|
||||||
|
auto tag = Unhex(record.at("tag"), 16);
|
||||||
|
Secret plain{std::string(32, '\0')};
|
||||||
|
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> cipher(
|
||||||
|
EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free);
|
||||||
|
int size = 0, tail = 0;
|
||||||
|
Require(cipher &&
|
||||||
|
EVP_DecryptInit_ex(
|
||||||
|
cipher.get(), EVP_aes_256_gcm(), nullptr,
|
||||||
|
reinterpret_cast<const unsigned char*>(key.data()),
|
||||||
|
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
|
||||||
|
EVP_DecryptUpdate(
|
||||||
|
cipher.get(), nullptr, &size,
|
||||||
|
reinterpret_cast<const unsigned char*>(context.data()),
|
||||||
|
static_cast<int>(context.size())) == 1 &&
|
||||||
|
EVP_DecryptUpdate(
|
||||||
|
cipher.get(),
|
||||||
|
reinterpret_cast<unsigned char*>(plain.value.data()), &size,
|
||||||
|
reinterpret_cast<const unsigned char*>(data.data()),
|
||||||
|
static_cast<int>(data.size())) == 1 &&
|
||||||
|
EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_SET_TAG, 16,
|
||||||
|
tag.data()) == 1 &&
|
||||||
|
EVP_DecryptFinal_ex(
|
||||||
|
cipher.get(),
|
||||||
|
reinterpret_cast<unsigned char*>(plain.value.data()) + size,
|
||||||
|
&tail) == 1 &&
|
||||||
|
size + tail == 6,
|
||||||
|
"Cannot read saved LAN password");
|
||||||
|
auto password = plain.value.substr(0, 6);
|
||||||
|
Require(ValidPassword(password), "Invalid saved LAN password");
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
void CreateCertificate(const std::filesystem::path& key_path,
|
||||||
|
const std::filesystem::path& cert_path) {
|
||||||
|
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
|
||||||
|
EVP_PKEY_Q_keygen(nullptr, nullptr, "EC", "prime256v1"), EVP_PKEY_free);
|
||||||
|
std::unique_ptr<X509, decltype(&X509_free)> cert(X509_new(), X509_free);
|
||||||
|
Require(key && cert, "Cannot generate LAN identity");
|
||||||
|
X509_set_version(cert.get(), 2);
|
||||||
|
ASN1_INTEGER_set(X509_get_serialNumber(cert.get()), 1);
|
||||||
|
X509_gmtime_adj(X509_getm_notBefore(cert.get()), -300);
|
||||||
|
X509_gmtime_adj(X509_getm_notAfter(cert.get()), 10L * 365 * 86400);
|
||||||
|
X509_set_pubkey(cert.get(), key.get());
|
||||||
|
auto* name = X509_get_subject_name(cert.get());
|
||||||
|
X509_NAME_add_entry_by_txt(
|
||||||
|
name, "CN", MBSTRING_ASC,
|
||||||
|
reinterpret_cast<const unsigned char*>("CrossDesk LAN"), -1, -1, 0);
|
||||||
|
X509_set_issuer_name(cert.get(), name);
|
||||||
|
for (const auto& extension :
|
||||||
|
{std::pair<int, const char*>{NID_basic_constraints, "critical,CA:FALSE"},
|
||||||
|
{NID_key_usage, "critical,digitalSignature"},
|
||||||
|
{NID_ext_key_usage, "serverAuth"}}) {
|
||||||
|
X509_EXTENSION* ext = X509V3_EXT_conf_nid(
|
||||||
|
nullptr, nullptr, extension.first, const_cast<char*>(extension.second));
|
||||||
|
Require(ext != nullptr, "Cannot create certificate extension");
|
||||||
|
const int result = X509_add_ext(cert.get(), ext, -1);
|
||||||
|
X509_EXTENSION_free(ext);
|
||||||
|
Require(result == 1, "Cannot install certificate extension");
|
||||||
|
}
|
||||||
|
Require(X509_sign(cert.get(), key.get(), EVP_sha256()) > 0,
|
||||||
|
"Cannot sign LAN certificate");
|
||||||
|
std::unique_ptr<BIO, decltype(&BIO_free)> bio(BIO_new(BIO_s_mem()), BIO_free);
|
||||||
|
Require(PEM_write_bio_PrivateKey(bio.get(), key.get(), nullptr, nullptr, 0,
|
||||||
|
nullptr, nullptr) == 1,
|
||||||
|
"Cannot encode private key");
|
||||||
|
PrivateWrite(key_path, BioText(bio.get()));
|
||||||
|
(void)BIO_reset(bio.get());
|
||||||
|
Require(PEM_write_bio_X509(bio.get(), cert.get()) == 1,
|
||||||
|
"Cannot encode certificate");
|
||||||
|
PrivateWrite(cert_path, BioText(bio.get()));
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
unsigned ParsePort(const std::string& value) {
|
||||||
|
unsigned port = 0;
|
||||||
|
auto [end, error] =
|
||||||
|
std::from_chars(value.data(), value.data() + value.size(), port);
|
||||||
|
Require(error == std::errc{} && end == value.data() + value.size() &&
|
||||||
|
port > 0 && port <= 65535,
|
||||||
|
"Invalid LAN port");
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
bool IsIpv4(const std::string& value) {
|
||||||
|
unsigned octets[4]{};
|
||||||
|
size_t offset = 0;
|
||||||
|
for (unsigned i = 0; i < 4; ++i) {
|
||||||
|
size_t end = value.find('.', offset);
|
||||||
|
if (i == 3) end = value.size();
|
||||||
|
if (end == std::string::npos || end <= offset || end - offset > 3 ||
|
||||||
|
(end - offset > 1 && value[offset] == '0'))
|
||||||
|
return false;
|
||||||
|
for (size_t j = offset; j < end; ++j) {
|
||||||
|
if (value[j] < '0' || value[j] > '9') return false;
|
||||||
|
octets[i] = octets[i] * 10 + value[j] - '0';
|
||||||
|
}
|
||||||
|
if (octets[i] > 255) return false;
|
||||||
|
offset = end + 1;
|
||||||
|
}
|
||||||
|
return offset == value.size() + 1 && octets[0] > 0 && octets[0] != 127 &&
|
||||||
|
octets[0] < 224;
|
||||||
|
}
|
||||||
|
std::pair<std::string, unsigned> ParseEndpoint(const std::string& value,
|
||||||
|
unsigned default_port) {
|
||||||
|
std::string address = value;
|
||||||
|
const auto colon = address.find(':');
|
||||||
|
unsigned port = default_port;
|
||||||
|
if (colon != std::string::npos) {
|
||||||
|
port = ParsePort(address.substr(colon + 1));
|
||||||
|
address.resize(colon);
|
||||||
|
}
|
||||||
|
Require(IsIpv4(address) && port > 0 && port <= 65535,
|
||||||
|
"Enter a numeric IPv4 address or IPv4:port");
|
||||||
|
return {address, port};
|
||||||
|
}
|
||||||
|
std::vector<Interface> PhysicalInterfaces() {
|
||||||
|
std::vector<Interface> result;
|
||||||
|
#ifdef _WIN32
|
||||||
|
ULONG bytes = 16384;
|
||||||
|
std::vector<unsigned char> buffer(bytes);
|
||||||
|
auto* adapters = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buffer.data());
|
||||||
|
ULONG code =
|
||||||
|
GetAdaptersAddresses(AF_INET,
|
||||||
|
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST |
|
||||||
|
GAA_FLAG_SKIP_DNS_SERVER,
|
||||||
|
nullptr, adapters, &bytes);
|
||||||
|
if (code == ERROR_BUFFER_OVERFLOW) {
|
||||||
|
buffer.resize(bytes);
|
||||||
|
adapters = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buffer.data());
|
||||||
|
code = GetAdaptersAddresses(AF_INET, 0, nullptr, adapters, &bytes);
|
||||||
|
}
|
||||||
|
if (code != NO_ERROR) return result;
|
||||||
|
for (auto* a = adapters; a; a = a->Next) {
|
||||||
|
if (a->OperStatus != IfOperStatusUp ||
|
||||||
|
(a->IfType != IF_TYPE_ETHERNET_CSMACD &&
|
||||||
|
a->IfType != IF_TYPE_IEEE80211) ||
|
||||||
|
a->PhysicalAddressLength == 0)
|
||||||
|
continue;
|
||||||
|
// HardwareInterface rejects software adapters that imitate Ethernet.
|
||||||
|
MIB_IF_ROW2 row{};
|
||||||
|
row.InterfaceLuid = a->Luid;
|
||||||
|
if (GetIfEntry2(&row) != NO_ERROR ||
|
||||||
|
!row.InterfaceAndOperStatusFlags.HardwareInterface)
|
||||||
|
continue;
|
||||||
|
for (auto* u = a->FirstUnicastAddress; u; u = u->Next) {
|
||||||
|
char address[INET_ADDRSTRLEN]{};
|
||||||
|
inet_ntop(
|
||||||
|
AF_INET,
|
||||||
|
&reinterpret_cast<sockaddr_in*>(u->Address.lpSockaddr)->sin_addr,
|
||||||
|
address, sizeof(address));
|
||||||
|
if (IsIpv4(address)) result.push_back({address});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
ifaddrs* list = nullptr;
|
||||||
|
if (getifaddrs(&list) != 0) return result;
|
||||||
|
for (auto* a = list; a; a = a->ifa_next) {
|
||||||
|
if (!a->ifa_addr || a->ifa_addr->sa_family != AF_INET ||
|
||||||
|
!(a->ifa_flags & IFF_UP) ||
|
||||||
|
(a->ifa_flags & (IFF_LOOPBACK | IFF_POINTOPOINT)))
|
||||||
|
continue;
|
||||||
|
bool physical = false;
|
||||||
|
#ifdef __APPLE__
|
||||||
|
for (auto* link = list; link; link = link->ifa_next)
|
||||||
|
if (link->ifa_addr && link->ifa_addr->sa_family == AF_LINK &&
|
||||||
|
std::string(link->ifa_name) == a->ifa_name) {
|
||||||
|
const auto* dl = reinterpret_cast<sockaddr_dl*>(link->ifa_addr);
|
||||||
|
physical = dl->sdl_type == IFT_ETHER && dl->sdl_alen == 6 &&
|
||||||
|
std::string(a->ifa_name).rfind("en", 0) == 0;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
physical = std::filesystem::exists(std::filesystem::path("/sys/class/net") /
|
||||||
|
a->ifa_name / "device");
|
||||||
|
#endif
|
||||||
|
if (!physical) continue;
|
||||||
|
char address[INET_ADDRSTRLEN]{};
|
||||||
|
inet_ntop(AF_INET, &reinterpret_cast<sockaddr_in*>(a->ifa_addr)->sin_addr,
|
||||||
|
address, sizeof(address));
|
||||||
|
if (IsIpv4(address)) result.push_back({address});
|
||||||
|
}
|
||||||
|
freeifaddrs(list);
|
||||||
|
#endif
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
std::string RandomHex(size_t bytes) {
|
||||||
|
std::vector<unsigned char> data(bytes);
|
||||||
|
Require(RAND_bytes(data.data(), static_cast<int>(bytes)) == 1,
|
||||||
|
"Random generator failed");
|
||||||
|
return Hex(data.data(), bytes);
|
||||||
|
}
|
||||||
|
bool ValidPassword(const std::string& password) {
|
||||||
|
return password.size() == 6 &&
|
||||||
|
password.find_first_not_of(
|
||||||
|
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy"
|
||||||
|
"z") == std::string::npos;
|
||||||
|
}
|
||||||
|
bool VerifyLanCertificate(X509* cert) {
|
||||||
|
if (!cert || !X509_get0_pubkey(cert)) return false;
|
||||||
|
return X509_cmp_current_time(X509_get0_notBefore(cert)) < 0 &&
|
||||||
|
X509_cmp_current_time(X509_get0_notAfter(cert)) > 0 &&
|
||||||
|
X509_verify(cert, X509_get0_pubkey(cert)) == 1 &&
|
||||||
|
X509_check_purpose(cert, X509_PURPOSE_SSL_SERVER, 0) == 1 &&
|
||||||
|
(X509_get_key_usage(cert) & KU_DIGITAL_SIGNATURE) != 0;
|
||||||
|
}
|
||||||
|
Identity::Identity(std::filesystem::path directory)
|
||||||
|
: directory_(std::move(directory)) {}
|
||||||
|
void Identity::Load() {
|
||||||
|
DevicePassword::CheckSupport();
|
||||||
|
std::filesystem::create_directories(directory_.parent_path());
|
||||||
|
#ifdef _WIN32
|
||||||
|
CreatePrivateDirectory(directory_);
|
||||||
|
#else
|
||||||
|
std::filesystem::create_directories(directory_);
|
||||||
|
struct stat directory_status{};
|
||||||
|
Require(lstat(directory_.c_str(), &directory_status) == 0 &&
|
||||||
|
S_ISDIR(directory_status.st_mode) &&
|
||||||
|
directory_status.st_uid == getuid(),
|
||||||
|
"Invalid LAN directory owner or type");
|
||||||
|
Require(chmod(directory_.c_str(), 0700) == 0, "Cannot protect LAN directory");
|
||||||
|
#endif
|
||||||
|
const auto store = directory_ / "identity.json";
|
||||||
|
if (!std::filesystem::exists(store)) {
|
||||||
|
Require(!std::filesystem::exists(private_key()) &&
|
||||||
|
!std::filesystem::exists(certificate()),
|
||||||
|
"Incomplete LAN identity; manual recovery required");
|
||||||
|
CreateCertificate(private_key(), certificate());
|
||||||
|
state_ = {
|
||||||
|
{"version", 1}, {"device_id", RandomHex(16)}, {"verifier", nullptr}};
|
||||||
|
Save(state_);
|
||||||
|
} else
|
||||||
|
state_ = nlohmann::json::parse(Read(store));
|
||||||
|
Require(state_.at("version") == 1 &&
|
||||||
|
IsHex(state_.at("device_id").get<std::string>(), 32),
|
||||||
|
"Invalid LAN identity store");
|
||||||
|
// Legacy trust records no longer affect connections. Drop them from memory
|
||||||
|
// and from the next ordinary settings/credential save, without requiring a
|
||||||
|
// migration write before an outgoing connection can authenticate.
|
||||||
|
state_.erase("trust");
|
||||||
|
// Migrate the single endpoint in memory; the next ordinary save persists it.
|
||||||
|
// Ports belong to exact IP addresses, including addresses currently offline.
|
||||||
|
auto& settings = state_["settings"];
|
||||||
|
if (settings.is_null()) settings = nlohmann::json::object();
|
||||||
|
Require(settings.is_object(), "Invalid LAN settings");
|
||||||
|
const auto address = settings.value("address", std::string{});
|
||||||
|
Require(address.empty() || IsIpv4(address), "Invalid LAN listener address");
|
||||||
|
if (!settings.contains("ports")) {
|
||||||
|
settings["ports"] = nlohmann::json::object();
|
||||||
|
if (!address.empty())
|
||||||
|
settings["ports"][address] = settings.contains("port")
|
||||||
|
? settings["port"]
|
||||||
|
: nlohmann::json(52888u);
|
||||||
|
}
|
||||||
|
Require(settings["ports"].is_object(), "Invalid LAN port settings");
|
||||||
|
for (const auto& [ip, port] : settings["ports"].items())
|
||||||
|
Require(IsIpv4(ip) && port.is_number_integer() && port > 0 && port <= 65535,
|
||||||
|
"Invalid LAN port settings");
|
||||||
|
settings["address"] = address;
|
||||||
|
settings["version"] = 2;
|
||||||
|
settings.erase("enabled");
|
||||||
|
settings.erase("interface");
|
||||||
|
settings.erase("port");
|
||||||
|
id_ = state_.at("device_id");
|
||||||
|
auto pem = Read(certificate());
|
||||||
|
std::unique_ptr<BIO, decltype(&BIO_free)> bio(
|
||||||
|
BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())), BIO_free);
|
||||||
|
std::unique_ptr<X509, decltype(&X509_free)> cert(
|
||||||
|
PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), X509_free);
|
||||||
|
Require(VerifyLanCertificate(cert.get()),
|
||||||
|
"LAN certificate invalid or expired");
|
||||||
|
auto secret = Read(private_key());
|
||||||
|
std::unique_ptr<BIO, decltype(&BIO_free)> key_bio(
|
||||||
|
BIO_new_mem_buf(secret.data(), static_cast<int>(secret.size())),
|
||||||
|
BIO_free);
|
||||||
|
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
|
||||||
|
PEM_read_bio_PrivateKey(key_bio.get(), nullptr, nullptr, nullptr),
|
||||||
|
EVP_PKEY_free);
|
||||||
|
Require(key && X509_check_private_key(cert.get(), key.get()) == 1,
|
||||||
|
"LAN private key does not match certificate");
|
||||||
|
OPENSSL_cleanse(secret.data(), secret.size());
|
||||||
|
if (!state_["verifier"].is_null()) {
|
||||||
|
const auto& v = state_["verifier"];
|
||||||
|
if (v.at("version") == 1) {
|
||||||
|
Require(v.at("algorithm") == "scrypt-32768-8-1" &&
|
||||||
|
IsHex(v.at("salt"), 32) && IsHex(v.at("hash"), 64),
|
||||||
|
"Invalid LAN password verifier");
|
||||||
|
} else if (v.at("version") == 2) {
|
||||||
|
// Legacy PAKE records are read only to recover the locally encrypted
|
||||||
|
// password for migration. They are never used by the network protocol.
|
||||||
|
Require(v.at("algorithm") == "spake2p-p256-scrypt-32768-8-1" &&
|
||||||
|
IsHex(v.at("salt"), 32) && IsHex(v.at("w0"), 64) &&
|
||||||
|
IsHex(v.at("L"), 130),
|
||||||
|
"Invalid LAN password verifier");
|
||||||
|
} else {
|
||||||
|
Require(CurrentVerifier(v), "Invalid LAN password verifier");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Lifetime lockouts are obsolete; retain only expiring in-memory cooldowns.
|
||||||
|
state_.erase("password_attempts");
|
||||||
|
}
|
||||||
|
void Identity::Save(const nlohmann::json& state) {
|
||||||
|
PrivateWrite(directory_ / "identity.json", state.dump(2));
|
||||||
|
}
|
||||||
|
std::pair<std::string, nlohmann::json> Identity::GeneratePassword() {
|
||||||
|
constexpr std::string_view alphabet =
|
||||||
|
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||||
|
constexpr auto unbiased_limit = 256 - 256 % alphabet.size();
|
||||||
|
std::string password;
|
||||||
|
// Match cloud password generation: unbiased ASCII alphanumeric characters,
|
||||||
|
// regenerating all-numeric results. Existing numeric passwords remain valid.
|
||||||
|
do {
|
||||||
|
password.clear();
|
||||||
|
while (password.size() < 6) {
|
||||||
|
unsigned char byte = 0;
|
||||||
|
Require(RAND_bytes(&byte, 1) == 1, "Cannot generate LAN password");
|
||||||
|
if (byte < unbiased_limit) password += alphabet[byte % alphabet.size()];
|
||||||
|
}
|
||||||
|
} while (password.find_first_not_of("0123456789") == std::string::npos);
|
||||||
|
return {password, RegisterPassword(password)};
|
||||||
|
}
|
||||||
|
void Identity::SetPassword(const std::string& password,
|
||||||
|
const nlohmann::json& verifier) {
|
||||||
|
Require(ValidPassword(password),
|
||||||
|
"LAN password must contain exactly 6 ASCII letters or digits");
|
||||||
|
Require(CurrentVerifier(verifier), "Invalid LAN password verifier");
|
||||||
|
Secret key{PasswordKey(directory_, !state_.contains("password"))};
|
||||||
|
auto next = state_;
|
||||||
|
next["verifier"] = verifier;
|
||||||
|
next["password"] = EncryptPassword(
|
||||||
|
password, key.value,
|
||||||
|
"crossdesk.lan.password.v1:" + id_ + ":" + verifier.dump());
|
||||||
|
Save(next);
|
||||||
|
state_ = std::move(next);
|
||||||
|
}
|
||||||
|
std::string Identity::ReadPassword() const {
|
||||||
|
// Verifier-only legacy records require a local password entry to migrate.
|
||||||
|
if (!state_.contains("password")) return {};
|
||||||
|
Secret key{PasswordKey(directory_, false)};
|
||||||
|
return DecryptPassword(
|
||||||
|
state_.at("password"), key.value,
|
||||||
|
"crossdesk.lan.password.v1:" + id_ + ":" + verifier().dump());
|
||||||
|
}
|
||||||
|
nlohmann::json Identity::RegisterPassword(const std::string& password) {
|
||||||
|
Require(ValidPassword(password),
|
||||||
|
"LAN password must contain exactly 6 ASCII letters or digits");
|
||||||
|
const auto record = DevicePassword::Hash(password);
|
||||||
|
Require(record.has_value(), "Cannot derive LAN password verifier");
|
||||||
|
return {{"version", 3}, {"salt", record->salt}, {"hash", record->hash}};
|
||||||
|
}
|
||||||
|
bool Identity::CurrentVerifier(const nlohmann::json& verifier) {
|
||||||
|
return verifier.is_object() && verifier.value("version", 0) == 3 &&
|
||||||
|
verifier.contains("salt") && verifier["salt"].is_string() &&
|
||||||
|
verifier.contains("hash") && verifier["hash"].is_string() &&
|
||||||
|
DevicePassword::IsCurrent({verifier["salt"], verifier["hash"]});
|
||||||
|
}
|
||||||
|
bool Identity::VerifyPassword(const nlohmann::json& verifier,
|
||||||
|
const std::string& password) {
|
||||||
|
if (!ValidPassword(password) || !CurrentVerifier(verifier)) return false;
|
||||||
|
return DevicePassword::Verify(
|
||||||
|
DevicePasswordRecord{verifier["salt"], verifier["hash"]}, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Identity::SaveSettings(const std::string& address, unsigned port) {
|
||||||
|
Require(IsIpv4(address) && port > 0 && port <= 65535,
|
||||||
|
"Invalid LAN listener address");
|
||||||
|
auto next = state_;
|
||||||
|
const auto interfaces = PhysicalInterfaces();
|
||||||
|
const auto selected = std::find_if(
|
||||||
|
interfaces.begin(), interfaces.end(),
|
||||||
|
[&](const auto& i) { return i.address == address; });
|
||||||
|
Require(selected != interfaces.end(), "Selected LAN interface is unavailable");
|
||||||
|
next["settings"]["ports"][address] = port;
|
||||||
|
Save(next);
|
||||||
|
state_ = std::move(next);
|
||||||
|
}
|
||||||
|
unsigned Identity::PortFor(const std::string& address) const {
|
||||||
|
return state_.at("settings").at("ports").value(address, 52888u);
|
||||||
|
}
|
||||||
|
bool Identity::HasPortFor(const std::string& address) const {
|
||||||
|
return state_.at("settings").at("ports").contains(address);
|
||||||
|
}
|
||||||
|
void Identity::SelectAddress(const std::string& address) {
|
||||||
|
const auto interfaces = PhysicalInterfaces();
|
||||||
|
Require(std::any_of(interfaces.begin(), interfaces.end(),
|
||||||
|
[&](const auto& i) { return i.address == address; }),
|
||||||
|
"Selected LAN interface is unavailable");
|
||||||
|
if (state_["settings"].value("address", "") == address) return;
|
||||||
|
auto next = state_;
|
||||||
|
next["settings"]["address"] = address;
|
||||||
|
Save(next);
|
||||||
|
state_ = std::move(next);
|
||||||
|
}
|
||||||
|
} // namespace crossdesk::lan
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-24
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _LAN_IDENTITY_H_
|
||||||
|
#define _LAN_IDENTITY_H_
|
||||||
|
|
||||||
|
#include <openssl/x509.h>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
struct Interface {
|
||||||
|
std::string address;
|
||||||
|
};
|
||||||
|
struct Target {
|
||||||
|
std::string address;
|
||||||
|
// Learned from the current connection, never pinned.
|
||||||
|
std::string device_id;
|
||||||
|
unsigned port = 52888;
|
||||||
|
};
|
||||||
|
unsigned ParsePort(const std::string& value);
|
||||||
|
std::pair<std::string, unsigned> ParseEndpoint(const std::string& value,
|
||||||
|
unsigned default_port);
|
||||||
|
bool IsIpv4(const std::string& value);
|
||||||
|
std::vector<Interface> PhysicalInterfaces();
|
||||||
|
std::string RandomHex(size_t bytes);
|
||||||
|
bool ValidPassword(const std::string& password);
|
||||||
|
bool VerifyLanCertificate(X509* cert);
|
||||||
|
|
||||||
|
// Files are separate from cloud settings and thumbnail credentials. A corrupt
|
||||||
|
// store is an error; it must never be replaced with a newly generated identity.
|
||||||
|
class Identity {
|
||||||
|
public:
|
||||||
|
explicit Identity(std::filesystem::path directory);
|
||||||
|
void Load();
|
||||||
|
static std::pair<std::string, nlohmann::json> GeneratePassword();
|
||||||
|
void SetPassword(const std::string& password, const nlohmann::json& verifier);
|
||||||
|
std::string ReadPassword() const;
|
||||||
|
static nlohmann::json RegisterPassword(const std::string& password);
|
||||||
|
static bool CurrentVerifier(const nlohmann::json& verifier);
|
||||||
|
static bool VerifyPassword(const nlohmann::json& verifier,
|
||||||
|
const std::string& password);
|
||||||
|
void SaveSettings(const std::string& address, unsigned port);
|
||||||
|
// An unconfigured address uses the default port; offline addresses keep
|
||||||
|
// their saved port. Selection only controls the address shown in the UI.
|
||||||
|
unsigned PortFor(const std::string& address) const;
|
||||||
|
bool HasPortFor(const std::string& address) const;
|
||||||
|
void SelectAddress(const std::string& address);
|
||||||
|
const std::string& id() const { return id_; }
|
||||||
|
std::filesystem::path certificate() const {
|
||||||
|
return directory_ / "certificate.pem";
|
||||||
|
}
|
||||||
|
std::filesystem::path private_key() const {
|
||||||
|
return directory_ / "private-key.pem";
|
||||||
|
}
|
||||||
|
const nlohmann::json& verifier() const { return state_.at("verifier"); }
|
||||||
|
const nlohmann::json& settings() const { return state_.at("settings"); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void Save(const nlohmann::json& state);
|
||||||
|
std::filesystem::path directory_;
|
||||||
|
std::string id_;
|
||||||
|
nlohmann::json state_;
|
||||||
|
};
|
||||||
|
} // namespace crossdesk::lan
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#include "lan_password.h"
|
||||||
|
|
||||||
|
#include <openssl/core_names.h>
|
||||||
|
#include <openssl/crypto.h>
|
||||||
|
#include <openssl/kdf.h>
|
||||||
|
#include <openssl/params.h>
|
||||||
|
#include <openssl/rand.h>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
namespace {
|
||||||
|
// An application format, with a hex salt in password_salt and hex digest here.
|
||||||
|
// Only known work factors are accepted, bounding CPU/memory for corrupt data.
|
||||||
|
constexpr char kPrefix[] = "argon2id$v=19$m=19456,t=2,p=1$";
|
||||||
|
constexpr size_t kSaltBytes = 16, kDigestBytes = 32;
|
||||||
|
|
||||||
|
bool ValidInput(const std::string& password) {
|
||||||
|
return password.size() <= 256 && password.find('\0') == std::string::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Hex(const unsigned char* data, size_t size) {
|
||||||
|
constexpr char digits[] = "0123456789abcdef";
|
||||||
|
std::string result;
|
||||||
|
result.reserve(size * 2);
|
||||||
|
for (size_t i = 0; i < size; ++i) {
|
||||||
|
result += digits[data[i] >> 4];
|
||||||
|
result += digits[data[i] & 15];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Unhex(const std::string& text, unsigned char* output, size_t size) {
|
||||||
|
if (text.size() != size * 2) return false;
|
||||||
|
auto digit = [](char c) -> int {
|
||||||
|
if (c >= '0' && c <= '9') return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||||
|
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < size; ++i) {
|
||||||
|
const int hi = digit(text[i * 2]), lo = digit(text[i * 2 + 1]);
|
||||||
|
if (hi < 0 || lo < 0) return false;
|
||||||
|
output[i] = static_cast<unsigned char>((hi << 4) | lo);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
using Kdf = std::unique_ptr<EVP_KDF, decltype(&EVP_KDF_free)>;
|
||||||
|
using Context = std::unique_ptr<EVP_KDF_CTX, decltype(&EVP_KDF_CTX_free)>;
|
||||||
|
|
||||||
|
std::optional<DevicePasswordRecord> Derive(
|
||||||
|
const std::string& password,
|
||||||
|
const std::array<unsigned char, kSaltBytes>& salt) {
|
||||||
|
Kdf kdf(EVP_KDF_fetch(nullptr, "ARGON2ID", nullptr), EVP_KDF_free);
|
||||||
|
if (!kdf) return std::nullopt;
|
||||||
|
Context context(EVP_KDF_CTX_new(kdf.get()), EVP_KDF_CTX_free);
|
||||||
|
if (!context) return std::nullopt;
|
||||||
|
uint32_t memory = 19456, iterations = 2, lanes = 1, threads = 1,
|
||||||
|
version = 0x13;
|
||||||
|
OSSL_PARAM params[] = {
|
||||||
|
OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_PASSWORD,
|
||||||
|
const_cast<char*>(password.data()),
|
||||||
|
password.size()),
|
||||||
|
OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT,
|
||||||
|
const_cast<unsigned char*>(salt.data()),
|
||||||
|
salt.size()),
|
||||||
|
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_MEMCOST, &memory),
|
||||||
|
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ITER, &iterations),
|
||||||
|
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_LANES, &lanes),
|
||||||
|
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_THREADS, &threads),
|
||||||
|
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_VERSION, &version),
|
||||||
|
OSSL_PARAM_construct_end()};
|
||||||
|
std::array<unsigned char, kDigestBytes> digest{};
|
||||||
|
if (EVP_KDF_derive(context.get(), digest.data(), digest.size(), params) != 1)
|
||||||
|
return std::nullopt;
|
||||||
|
DevicePasswordRecord result{Hex(salt.data(), salt.size()),
|
||||||
|
kPrefix + Hex(digest.data(), digest.size())};
|
||||||
|
OPENSSL_cleanse(digest.data(), digest.size());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
namespace DevicePassword {
|
||||||
|
void CheckSupport() {
|
||||||
|
// Exercise the provider and parameters, not just algorithm discovery.
|
||||||
|
if (!Hash("startup-check"))
|
||||||
|
throw std::runtime_error(
|
||||||
|
"Argon2id or the secure random source is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<DevicePasswordRecord> Hash(const std::string& password) {
|
||||||
|
if (!ValidInput(password)) return std::nullopt;
|
||||||
|
std::array<unsigned char, kSaltBytes> salt{};
|
||||||
|
if (RAND_bytes(salt.data(), salt.size()) != 1) return std::nullopt;
|
||||||
|
return Derive(password, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsCurrent(const DevicePasswordRecord& record) {
|
||||||
|
std::array<unsigned char, kSaltBytes> salt{};
|
||||||
|
std::array<unsigned char, kDigestBytes> digest{};
|
||||||
|
return record.hash.rfind(kPrefix, 0) == 0 &&
|
||||||
|
Unhex(record.salt, salt.data(), salt.size()) &&
|
||||||
|
Unhex(record.hash.substr(sizeof(kPrefix) - 1), digest.data(),
|
||||||
|
digest.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Verify(const DevicePasswordRecord& record, const std::string& password) {
|
||||||
|
if (!ValidInput(password) || !IsCurrent(record)) return false;
|
||||||
|
std::array<unsigned char, kSaltBytes> salt{};
|
||||||
|
Unhex(record.salt, salt.data(), salt.size());
|
||||||
|
const auto computed = Derive(password, salt);
|
||||||
|
return computed && computed->hash.size() == record.hash.size() &&
|
||||||
|
CRYPTO_memcmp(computed->hash.data(), record.hash.data(),
|
||||||
|
computed->hash.size()) == 0;
|
||||||
|
}
|
||||||
|
} // namespace DevicePassword
|
||||||
|
|
||||||
|
} // namespace crossdesk::lan
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-24
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _LAN_PASSWORD_H_
|
||||||
|
#define _LAN_PASSWORD_H_
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
// Keep the password record format aligned with crossdesk-server.
|
||||||
|
// Stored in identity.json. The hash identifies the algorithm,
|
||||||
|
// version and work factors; unrecognized formats never fall back to SHA-256.
|
||||||
|
struct DevicePasswordRecord {
|
||||||
|
std::string salt;
|
||||||
|
std::string hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace DevicePassword {
|
||||||
|
void CheckSupport();
|
||||||
|
std::optional<DevicePasswordRecord> Hash(const std::string& password);
|
||||||
|
// LAN accepts only current Argon2id records. Older LAN records are migrated
|
||||||
|
// locally from the encrypted password before connections are accepted.
|
||||||
|
bool Verify(const DevicePasswordRecord& record, const std::string& password);
|
||||||
|
bool IsCurrent(const DevicePasswordRecord& record);
|
||||||
|
} // namespace DevicePassword
|
||||||
|
|
||||||
|
} // namespace crossdesk::lan
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,806 @@
|
|||||||
|
#include "lan_session_manager.h"
|
||||||
|
|
||||||
|
#include <openssl/crypto.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
namespace {
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
template <class T>
|
||||||
|
bool Ready(std::future<T>& future) {
|
||||||
|
return future.valid() &&
|
||||||
|
future.wait_for(std::chrono::seconds(0)) == std::future_status::ready;
|
||||||
|
}
|
||||||
|
void Check(bool condition) {
|
||||||
|
if (!condition) throw std::runtime_error("Invalid LAN protocol message");
|
||||||
|
}
|
||||||
|
bool Id(const Json& value) {
|
||||||
|
if (!value.is_string()) return false;
|
||||||
|
const auto& s = value.get_ref<const std::string&>();
|
||||||
|
return s.size() == 32 && std::all_of(s.begin(), s.end(), [](char c) {
|
||||||
|
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Json Capabilities() {
|
||||||
|
return Json::array({"host-only-ipv4", "dtls-srtp-all", "password-auth-v1"});
|
||||||
|
}
|
||||||
|
void Capable(const Json& j) {
|
||||||
|
const auto& caps = j.at("capabilities");
|
||||||
|
Check(caps.is_array() && caps.size() <= 16);
|
||||||
|
for (const auto& required : Capabilities())
|
||||||
|
Check(std::find(caps.begin(), caps.end(), required) != caps.end());
|
||||||
|
}
|
||||||
|
void Wipe(std::string& text) {
|
||||||
|
if (!text.empty()) OPENSSL_cleanse(text.data(), text.size());
|
||||||
|
text.clear();
|
||||||
|
}
|
||||||
|
const char* FailureReason(const Session& s, const char* fallback) {
|
||||||
|
switch (s.failure_code.load()) {
|
||||||
|
case MiniRtcDirectInvalidSignal:
|
||||||
|
return "InvalidSignal";
|
||||||
|
case MiniRtcDirectSignalSendFailed:
|
||||||
|
return "SignalSendFailed";
|
||||||
|
case MiniRtcDirectDtlsHandshakeFailed:
|
||||||
|
return "DtlsHandshakeFailed";
|
||||||
|
case MiniRtcDirectRequiredCryptoUnsupported:
|
||||||
|
return "RequiredCryptoUnsupported";
|
||||||
|
case MiniRtcDirectSrtpInstallFailed:
|
||||||
|
return "SrtpInstallFailed";
|
||||||
|
case MiniRtcDirectNetworkPolicyFailed:
|
||||||
|
return "NetworkPolicyFailed";
|
||||||
|
case MiniRtcDirectFingerprintMissing:
|
||||||
|
return "FingerprintMissing";
|
||||||
|
case MiniRtcDirectFingerprintMismatch:
|
||||||
|
return "FingerprintMismatch";
|
||||||
|
case MiniRtcDirectSrtpKeyExportFailed:
|
||||||
|
return "SrtpKeyExportFailed";
|
||||||
|
default:
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
int Session::SendSignal(const char* data, size_t size, void* context) {
|
||||||
|
auto* s = static_cast<Session*>(context);
|
||||||
|
if (!s || s->closing || !data || !size || size > 256 * 1024) return -1;
|
||||||
|
std::lock_guard lock(s->signal_mutex);
|
||||||
|
if (s->closing || s->signal_count >= 256 ||
|
||||||
|
s->signal_bytes + size > 1024 * 1024)
|
||||||
|
return -1;
|
||||||
|
++s->signal_count;
|
||||||
|
s->signal_bytes += size;
|
||||||
|
s->signals.emplace_back(data, size);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
void Session::Error(MiniRtcDirectError error, void* context) {
|
||||||
|
if (auto* s = static_cast<Session*>(context)) {
|
||||||
|
int empty = -1;
|
||||||
|
s->failure_code.compare_exchange_strong(empty, error);
|
||||||
|
s->failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void Session::Status(ConnectionStatus status) {
|
||||||
|
if (closing) return;
|
||||||
|
if (status == ConnectionStatus::Connected)
|
||||||
|
ready = true;
|
||||||
|
else if (status == ConnectionStatus::Closed ||
|
||||||
|
status == ConnectionStatus::Failed ||
|
||||||
|
status == ConnectionStatus::Disconnected)
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
SessionManager::SessionManager(std::filesystem::path path, Hooks hooks)
|
||||||
|
: identity_(std::move(path)), hooks_(std::move(hooks)) {
|
||||||
|
identity_.Load();
|
||||||
|
access_password_ = identity_.ReadPassword();
|
||||||
|
new_password_ = access_password_;
|
||||||
|
if (!Identity::CurrentVerifier(identity_.verifier())) {
|
||||||
|
if (!access_password_.empty()) {
|
||||||
|
// Migrate the saved password, retaining its value, identity and settings.
|
||||||
|
SetPassword(access_password_);
|
||||||
|
} else if (identity_.verifier().is_null()) {
|
||||||
|
// Generate the initial password before automatically accepting connections.
|
||||||
|
ResetPassword();
|
||||||
|
} else {
|
||||||
|
hooks_.error("LAN password is not ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionManager::~SessionManager() {
|
||||||
|
Stop();
|
||||||
|
while (!Quiescent()) {
|
||||||
|
Tick();
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||||
|
}
|
||||||
|
transport_.Shutdown();
|
||||||
|
Wipe(new_password_);
|
||||||
|
Wipe(access_password_);
|
||||||
|
}
|
||||||
|
void SessionManager::Configure(const std::string& address, unsigned port) {
|
||||||
|
if (stopping_) return;
|
||||||
|
if (!port || port > 65535) throw std::runtime_error("Invalid LAN port");
|
||||||
|
if (!Identity::CurrentVerifier(identity_.verifier()))
|
||||||
|
throw std::runtime_error("LAN password is not ready");
|
||||||
|
ReplaceListener(address, port, true);
|
||||||
|
}
|
||||||
|
void SessionManager::ReplaceListener(const std::string& address, unsigned port,
|
||||||
|
bool save, bool allow_fallback) {
|
||||||
|
const auto previous = listeners_.find(address);
|
||||||
|
if (previous != listeners_.end() && previous->second.port == port) return;
|
||||||
|
const auto next = [&] {
|
||||||
|
try {
|
||||||
|
return transport_.Listen(identity_, address, port);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
const std::string error = e.what();
|
||||||
|
if (!allow_fallback ||
|
||||||
|
(error != "LanPortInUse" && error != "LanPortPermissionDenied" &&
|
||||||
|
error != "LanPortCheckFailed"))
|
||||||
|
throw;
|
||||||
|
// Keep the OS-assigned socket bound through persistence/publication;
|
||||||
|
// probing then releasing a free port would introduce an allocation race.
|
||||||
|
return transport_.Listen(identity_, address, 0);
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
try {
|
||||||
|
if (save) identity_.SaveSettings(address, next.port);
|
||||||
|
} catch (...) {
|
||||||
|
transport_.StopAccept(next.id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
const auto old = previous == listeners_.end() ? 0 : previous->second.id;
|
||||||
|
listeners_[address] = {next.id, next.port};
|
||||||
|
SetListenerError(address, next.port, "");
|
||||||
|
if (old) RetireListener(old);
|
||||||
|
}
|
||||||
|
void SessionManager::RetireListener(uint64_t id) {
|
||||||
|
transport_.StopAccept(id);
|
||||||
|
// Only cancel unfinished authentication on this retired endpoint. Other
|
||||||
|
// addresses and sessions that already authenticated keep their channels.
|
||||||
|
for (auto& [_, s] : sessions_)
|
||||||
|
if (s->incoming && s->channel->listener == id &&
|
||||||
|
s->phase < Session::AwaitOffer)
|
||||||
|
Close(s, "ListenerChanged");
|
||||||
|
}
|
||||||
|
void SessionManager::ResetPassword() {
|
||||||
|
if (stopping_ || reset_job_.valid()) return;
|
||||||
|
reset_job_ =
|
||||||
|
std::async(std::launch::async, [current = access_password_]() mutable {
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
auto result = Identity::GeneratePassword();
|
||||||
|
if (result.first != current) {
|
||||||
|
Wipe(current);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Wipe(result.first);
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
Wipe(current);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void SessionManager::SetPassword(std::string password) {
|
||||||
|
if (!ValidPassword(password)) {
|
||||||
|
Wipe(password);
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LAN password must contain exactly 6 ASCII letters or digits");
|
||||||
|
}
|
||||||
|
if (stopping_ || reset_job_.valid() ||
|
||||||
|
(password == access_password_ &&
|
||||||
|
Identity::CurrentVerifier(identity_.verifier()))) {
|
||||||
|
Wipe(password);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reset_job_ = std::async(
|
||||||
|
std::launch::async, [password = std::move(password)]() mutable {
|
||||||
|
try {
|
||||||
|
auto record = Identity::RegisterPassword(password);
|
||||||
|
return std::make_pair(std::move(password), std::move(record));
|
||||||
|
} catch (...) {
|
||||||
|
Wipe(password);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void SessionManager::SetListenerError(const std::string& address, unsigned port,
|
||||||
|
const std::string& error) {
|
||||||
|
if (error.empty()) {
|
||||||
|
listener_errors_.erase(address);
|
||||||
|
listener_warnings_.erase(address);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto previous = listener_errors_.find(address);
|
||||||
|
if (previous != listener_errors_.end() && previous->second.port == port &&
|
||||||
|
previous->second.error == error)
|
||||||
|
return;
|
||||||
|
ListenerIssue issue{address, port, error};
|
||||||
|
listener_errors_[address] = issue;
|
||||||
|
listener_warnings_[address] = std::move(issue);
|
||||||
|
}
|
||||||
|
std::string SessionManager::ListenerError(const std::string& address) const {
|
||||||
|
const auto it = listener_errors_.find(address);
|
||||||
|
return it == listener_errors_.end() ? "" : it->second.error;
|
||||||
|
}
|
||||||
|
std::optional<SessionManager::ListenerIssue> SessionManager::TakeListenerWarning() {
|
||||||
|
if (listener_warnings_.empty()) return std::nullopt;
|
||||||
|
auto it = listener_warnings_.begin();
|
||||||
|
auto issue = std::move(it->second);
|
||||||
|
listener_warnings_.erase(it);
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
void SessionManager::RefreshListeners() {
|
||||||
|
if (stopping_ || !Identity::CurrentVerifier(identity_.verifier())) return;
|
||||||
|
const auto available = [&](const auto& address) {
|
||||||
|
return std::any_of(interfaces_.begin(), interfaces_.end(),
|
||||||
|
[&](const auto& i) { return i.address == address; });
|
||||||
|
};
|
||||||
|
for (auto it = listeners_.begin(); it != listeners_.end();) {
|
||||||
|
if (available(it->first)) {
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
RetireListener(it->second.id);
|
||||||
|
it = listeners_.erase(it);
|
||||||
|
}
|
||||||
|
for (auto it = listener_errors_.begin(); it != listener_errors_.end();) {
|
||||||
|
if (available(it->first)) {
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
listener_warnings_.erase(it->first);
|
||||||
|
it = listener_errors_.erase(it);
|
||||||
|
}
|
||||||
|
for (const auto& interface : interfaces_) {
|
||||||
|
const auto port = identity_.PortFor(interface.address);
|
||||||
|
const bool initialize = !identity_.HasPortFor(interface.address);
|
||||||
|
try {
|
||||||
|
// Persist the first choice even when the default works. Later conflicts
|
||||||
|
// must not silently change an endpoint users may already have shared.
|
||||||
|
ReplaceListener(interface.address, port, initialize, initialize);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
SetListenerError(interface.address, port, e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string SessionManager::TakeNewPassword() {
|
||||||
|
return std::exchange(new_password_, {});
|
||||||
|
}
|
||||||
|
SessionManager::SessionPtr SessionManager::Connect(const std::string& address,
|
||||||
|
unsigned port,
|
||||||
|
std::string password) {
|
||||||
|
if (stopping_ || sessions_.size() >= 24)
|
||||||
|
throw std::runtime_error("LAN connection limit reached");
|
||||||
|
if (!ValidPassword(password))
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LAN password must contain exactly 6 ASCII letters or digits");
|
||||||
|
if (RetryAfter(address, port) > 0) {
|
||||||
|
Wipe(password);
|
||||||
|
throw std::runtime_error("AuthenticationRateLimited");
|
||||||
|
}
|
||||||
|
auto s = std::make_shared<Session>();
|
||||||
|
const auto [ip, endpoint_port] = ParseEndpoint(address, port);
|
||||||
|
s->target = Target{ip, {}, endpoint_port};
|
||||||
|
s->channel = transport_.Connect(s->target);
|
||||||
|
s->password = std::move(password);
|
||||||
|
s->epoch = epoch_;
|
||||||
|
s->key = "lan/" + RandomHex(16);
|
||||||
|
s->deadline = Clock::now() + std::chrono::seconds(8);
|
||||||
|
sessions_[s->channel->id] = s;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
bool SessionManager::Send(const SessionPtr& s, Json j, bool sequenced) {
|
||||||
|
j["version"] = 2;
|
||||||
|
if (sequenced) {
|
||||||
|
j["session_id"] = s->session_id;
|
||||||
|
j["seq"] = s->send_seq + 1;
|
||||||
|
}
|
||||||
|
if (s->closing || !transport_.Send(s->channel, j.dump())) {
|
||||||
|
Close(s, "SignalSendFailed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (sequenced) ++s->send_seq;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
int SessionManager::RetryAfter(const std::string& address,
|
||||||
|
unsigned port) const {
|
||||||
|
const auto [ip, endpoint_port] = ParseEndpoint(address, port);
|
||||||
|
const auto it = retry_at_.find(ip + ":" + std::to_string(endpoint_port));
|
||||||
|
const auto now = Clock::now();
|
||||||
|
return it == retry_at_.end() || it->second <= now
|
||||||
|
? 0
|
||||||
|
: static_cast<int>(
|
||||||
|
std::chrono::ceil<std::chrono::seconds>(it->second - now)
|
||||||
|
.count());
|
||||||
|
}
|
||||||
|
void SessionManager::Fail(const SessionPtr& s, const std::string& error,
|
||||||
|
int retry_after) {
|
||||||
|
Json reply = {{"type", "error"}, {"error", error}};
|
||||||
|
// Use the same failure codes and retry_after semantics as cloud Join.
|
||||||
|
if (error == "InvalidCredentials")
|
||||||
|
reply["error_code"] = "AUTHENTICATION_FAILED";
|
||||||
|
if (error == "AuthenticationRateLimited")
|
||||||
|
reply["error_code"] = "AUTHENTICATION_THROTTLED";
|
||||||
|
if (error == "Busy") reply["error_code"] = "CREDENTIAL_SERVICE_BUSY";
|
||||||
|
if (retry_after > 0) reply["retry_after"] = retry_after;
|
||||||
|
if (s->session_id.empty() ||
|
||||||
|
(s->incoming && s->phase < Session::AwaitOffer)) {
|
||||||
|
reply["request_id"] = s->auth_request;
|
||||||
|
Send(s, std::move(reply));
|
||||||
|
} else {
|
||||||
|
Send(s, std::move(reply), true);
|
||||||
|
}
|
||||||
|
Close(s, error);
|
||||||
|
}
|
||||||
|
void SessionManager::Prepare(const SessionPtr& s) {
|
||||||
|
size_t admitted = 0;
|
||||||
|
for (auto& [_, other] : sessions_)
|
||||||
|
if (!other->closing && (other->phase == Session::Preparing ||
|
||||||
|
other->phase >= Session::AwaitOffer))
|
||||||
|
++admitted;
|
||||||
|
if (admitted >= 8 || prepare_jobs_.size() >= 2) {
|
||||||
|
Fail(s, "Busy");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s->phase = Session::Preparing;
|
||||||
|
if (s->incoming) {
|
||||||
|
s->session_id = RandomHex(16);
|
||||||
|
s->local_id = identity_.id();
|
||||||
|
s->remote_id = RandomHex(16);
|
||||||
|
}
|
||||||
|
auto work = hooks_.prepare(s);
|
||||||
|
prepare_jobs_.push_back(
|
||||||
|
{s, std::async(std::launch::async, [s, work = std::move(work)] {
|
||||||
|
if (s->closing) return Prepared{};
|
||||||
|
Prepared prepared = work();
|
||||||
|
if (!prepared.peer || s->closing) return prepared;
|
||||||
|
MiniRtcDirectOptions options{};
|
||||||
|
options.struct_size = sizeof(options);
|
||||||
|
options.version = 1;
|
||||||
|
options.local_id = s->local_id.c_str();
|
||||||
|
options.remote_id = s->remote_id.c_str();
|
||||||
|
options.session_id = s->session_id.c_str();
|
||||||
|
options.local_address = s->channel->local_address.c_str();
|
||||||
|
options.remote_address = s->channel->remote_address.c_str();
|
||||||
|
options.offerer = !s->incoming;
|
||||||
|
options.send_signal = Session::SendSignal;
|
||||||
|
options.on_error = Session::Error;
|
||||||
|
options.context = s.get();
|
||||||
|
if (InitDirectPeer(prepared.peer, &options) != 0 ||
|
||||||
|
(!s->closing &&
|
||||||
|
StartDirectSession(prepared.peer, !s->incoming) != 0))
|
||||||
|
s->failed = true;
|
||||||
|
return prepared;
|
||||||
|
})});
|
||||||
|
}
|
||||||
|
void SessionManager::Message(const SessionPtr& s, const std::string& text) {
|
||||||
|
auto j = Json::parse(text);
|
||||||
|
Check(j.is_object() && j.at("version").is_number_integer() &&
|
||||||
|
j.at("version") == 2 && j.at("type").is_string() &&
|
||||||
|
!j.contains("user_id") && !j.contains("remote_user_id"));
|
||||||
|
const auto type = j.at("type").get<std::string>();
|
||||||
|
if (type == "error" && (s->session_id.empty() ||
|
||||||
|
(s->incoming && s->phase < Session::AwaitOffer))) {
|
||||||
|
Check(!j.contains("session_id") && !j.contains("seq") &&
|
||||||
|
j.at("request_id") == s->auth_request);
|
||||||
|
const auto error = j.at("error").get<std::string>();
|
||||||
|
Check(error.size() <= 80);
|
||||||
|
if (!s->incoming &&
|
||||||
|
(error == "AuthenticationRateLimited" || error == "Busy") &&
|
||||||
|
j.contains("retry_after")) {
|
||||||
|
Check(j["retry_after"].is_number_integer() && j["retry_after"] > 0 &&
|
||||||
|
j["retry_after"] <= 900);
|
||||||
|
const auto now = Clock::now();
|
||||||
|
for (auto it = retry_at_.begin(); it != retry_at_.end();)
|
||||||
|
if (it->second <= now)
|
||||||
|
it = retry_at_.erase(it);
|
||||||
|
else
|
||||||
|
++it;
|
||||||
|
if (retry_at_.size() < 64)
|
||||||
|
retry_at_[s->target.address + ":" + std::to_string(s->target.port)] =
|
||||||
|
now + std::chrono::seconds(j["retry_after"].get<int>());
|
||||||
|
}
|
||||||
|
Close(s, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s->phase == Session::Preparing && !s->incoming) {
|
||||||
|
Check(j.at("session_id") == s->session_id &&
|
||||||
|
j.at("seq").is_number_unsigned() &&
|
||||||
|
j.at("seq").get<uint64_t>() ==
|
||||||
|
s->receive_seq + s->pending_receive.size() + 1 &&
|
||||||
|
!j.contains("request_id"));
|
||||||
|
// Identity is already bound by auth_result. Process cancellation without
|
||||||
|
// waiting for Peer preparation, before the following WSS close can erase
|
||||||
|
// its reason; earlier queued SDP/candidates never grant authorization.
|
||||||
|
if (type == "bye" || type == "error") {
|
||||||
|
const auto reason = type == "bye" ? std::string("RemoteClosed")
|
||||||
|
: j.at("error").get<std::string>();
|
||||||
|
Check(!reason.empty() && reason.size() <= 80);
|
||||||
|
Close(s, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Check(type == "answer" || type == "candidate");
|
||||||
|
Check(s->pending_receive.size() < 128);
|
||||||
|
s->pending_receive.push_back(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s->phase == Session::AwaitAuth) {
|
||||||
|
Check(type == "auth" && !j.contains("session_id") && !j.contains("seq") &&
|
||||||
|
Id(j.at("request_id")) && j.at("password").is_string() &&
|
||||||
|
!j.contains("share") && !j.contains("proof"));
|
||||||
|
Capable(j);
|
||||||
|
s->auth_request = j.at("request_id");
|
||||||
|
auto& submitted = j["password"].get_ref<std::string&>();
|
||||||
|
if (!ValidPassword(submitted)) {
|
||||||
|
Wipe(submitted);
|
||||||
|
Fail(s, "InvalidCredentials");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (auth_jobs_.size() >= 2) {
|
||||||
|
Wipe(submitted);
|
||||||
|
Fail(s, "Busy", 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto delay =
|
||||||
|
credential_work_limiter_.Admit(s->channel->remote_address);
|
||||||
|
if (delay > 0) {
|
||||||
|
Wipe(submitted);
|
||||||
|
Fail(s, "AuthenticationRateLimited", delay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s->phase = Session::Authenticating;
|
||||||
|
auth_jobs_.push_back(
|
||||||
|
{s,
|
||||||
|
std::async(std::launch::async,
|
||||||
|
[this, password = std::exchange(submitted, {}),
|
||||||
|
record = identity_.verifier(),
|
||||||
|
source = s->channel->remote_address,
|
||||||
|
target = identity_.id()]() mutable {
|
||||||
|
try {
|
||||||
|
auto result = auth_limiter_.Verify(source, target, [&] {
|
||||||
|
return Identity::VerifyPassword(record, password);
|
||||||
|
});
|
||||||
|
Wipe(password);
|
||||||
|
return result;
|
||||||
|
} catch (...) {
|
||||||
|
Wipe(password);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
})});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s->phase == Session::AwaitAuthResult) {
|
||||||
|
Check(type == "auth_result" && j.at("request_id") == s->auth_request &&
|
||||||
|
!j.contains("seq"));
|
||||||
|
if (!j.value("success", false)) {
|
||||||
|
Close(s, "InvalidCredentials");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Check(Id(j.at("session_id")) && Id(j.at("host_peer_id")) &&
|
||||||
|
Id(j.at("controller_peer_id")) &&
|
||||||
|
j.at("controller_peer_id") != j.at("host_peer_id"));
|
||||||
|
s->target.device_id = j.at("host_peer_id");
|
||||||
|
s->session_id = j.at("session_id");
|
||||||
|
s->local_id = j.at("controller_peer_id");
|
||||||
|
s->remote_id = j.at("host_peer_id");
|
||||||
|
Prepare(s);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Check(s->peer && j.at("session_id") == s->session_id &&
|
||||||
|
j.at("seq").is_number_unsigned() &&
|
||||||
|
j.at("seq").get<uint64_t>() == s->receive_seq + 1 &&
|
||||||
|
!j.contains("request_id"));
|
||||||
|
++s->receive_seq;
|
||||||
|
if (type == "bye" || type == "error") {
|
||||||
|
const auto reason = type == "bye" ? std::string("RemoteClosed")
|
||||||
|
: j.at("error").get<std::string>();
|
||||||
|
Check(!reason.empty() && reason.size() <= 80);
|
||||||
|
Close(s, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "offer") {
|
||||||
|
Check(s->incoming && s->phase == Session::AwaitOffer);
|
||||||
|
s->phase = Session::Negotiating;
|
||||||
|
s->deadline = Clock::now() + std::chrono::seconds(15);
|
||||||
|
} else if (type == "answer") {
|
||||||
|
Check(!s->incoming && s->phase == Session::AwaitAnswer);
|
||||||
|
s->phase = Session::Negotiating;
|
||||||
|
} else
|
||||||
|
Check(type == "candidate" &&
|
||||||
|
(s->phase == Session::AwaitAnswer ||
|
||||||
|
s->phase == Session::Negotiating || s->phase == Session::Connected));
|
||||||
|
if (ReceiveDirectSignal(s->peer, text.data(), text.size()) != 0)
|
||||||
|
Fail(s, "InvalidSignal");
|
||||||
|
}
|
||||||
|
void SessionManager::Dispose(const SessionPtr& s) {
|
||||||
|
if (!s->peer) return;
|
||||||
|
auto* peer = std::exchange(s->peer, nullptr);
|
||||||
|
auto owner = s->callback_owner;
|
||||||
|
auto drain = s->drain_callbacks;
|
||||||
|
cleanup_jobs_.push_back(
|
||||||
|
std::async(std::launch::async, [s, peer, owner, drain]() mutable {
|
||||||
|
if (drain) drain();
|
||||||
|
CloseDirectSession(peer);
|
||||||
|
DestroyPeer(&peer);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
void SessionManager::Close(const SessionPtr& s, const std::string& error) {
|
||||||
|
if (!s || s->closing.exchange(true)) return;
|
||||||
|
s->authorized = false;
|
||||||
|
if (error.empty() && !s->session_id.empty() &&
|
||||||
|
s->phase >= Session::AwaitOffer)
|
||||||
|
transport_.Send(s->channel, Json{{"version", 2},
|
||||||
|
{"type", "bye"},
|
||||||
|
{"session_id", s->session_id},
|
||||||
|
{"seq", ++s->send_seq}}
|
||||||
|
.dump());
|
||||||
|
s->phase = Session::Closing;
|
||||||
|
s->error = error;
|
||||||
|
Wipe(s->password);
|
||||||
|
transport_.Close(s->channel);
|
||||||
|
hooks_.closed(s);
|
||||||
|
Dispose(s);
|
||||||
|
if (!error.empty() && !s->incoming && !stopping_) hooks_.error(error);
|
||||||
|
}
|
||||||
|
void SessionManager::Stop() {
|
||||||
|
if (stopping_) return;
|
||||||
|
stopping_ = true;
|
||||||
|
for (const auto& [_, listener] : listeners_) transport_.StopAccept(listener.id);
|
||||||
|
listeners_.clear();
|
||||||
|
listener_errors_.clear();
|
||||||
|
listener_warnings_.clear();
|
||||||
|
for (auto& [_, s] : sessions_) Close(s);
|
||||||
|
}
|
||||||
|
bool SessionManager::Quiescent() const {
|
||||||
|
return (!stopping_ || network_drained_) && sessions_.empty() &&
|
||||||
|
auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() &&
|
||||||
|
!reset_job_.valid();
|
||||||
|
}
|
||||||
|
std::string SessionManager::PendingWork() const {
|
||||||
|
return "sessions=" + std::to_string(sessions_.size()) +
|
||||||
|
", auth=" + std::to_string(auth_jobs_.size()) +
|
||||||
|
", preparing=" + std::to_string(prepare_jobs_.size()) +
|
||||||
|
", cleanup=" + std::to_string(cleanup_jobs_.size()) +
|
||||||
|
", reset=" + std::to_string(reset_job_.valid()) +
|
||||||
|
", network_drained=" + std::to_string(network_drained_);
|
||||||
|
}
|
||||||
|
void SessionManager::Tick() {
|
||||||
|
if (Ready(reset_job_)) {
|
||||||
|
try {
|
||||||
|
auto [password, verifier] = reset_job_.get();
|
||||||
|
if (!stopping_) {
|
||||||
|
const bool changed = verifier != identity_.verifier();
|
||||||
|
try {
|
||||||
|
identity_.SetPassword(password, verifier);
|
||||||
|
} catch (...) {
|
||||||
|
Wipe(password);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
if (changed) ++epoch_;
|
||||||
|
Wipe(new_password_);
|
||||||
|
Wipe(access_password_);
|
||||||
|
access_password_ = password;
|
||||||
|
new_password_ = std::move(password);
|
||||||
|
for (auto& [_, s] : sessions_)
|
||||||
|
if (s->incoming && !s->authorized && s->epoch != epoch_)
|
||||||
|
Fail(s, "CredentialsChanged");
|
||||||
|
interface_check_ = {}; // Start listening as soon as credentials are ready.
|
||||||
|
} else
|
||||||
|
Wipe(password);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
hooks_.error(e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto it = auth_jobs_.begin(); it != auth_jobs_.end();) {
|
||||||
|
if (!Ready(it->result)) {
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto s = it->session;
|
||||||
|
DeviceAuthLimiter::Result result;
|
||||||
|
bool failed = false;
|
||||||
|
try {
|
||||||
|
result = it->result.get();
|
||||||
|
} catch (...) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
it = auth_jobs_.erase(it);
|
||||||
|
if (s->closing || stopping_ || s->epoch != epoch_) continue;
|
||||||
|
if (failed) {
|
||||||
|
Fail(s, "Busy", 1);
|
||||||
|
} else if (result.retry_after > 0) {
|
||||||
|
Fail(s, "AuthenticationRateLimited", result.retry_after);
|
||||||
|
} else if (!result.authenticated) {
|
||||||
|
Fail(s, "InvalidCredentials");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
Prepare(s);
|
||||||
|
} catch (...) {
|
||||||
|
Fail(s, "PeerPreparationFailed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto it = prepare_jobs_.begin(); it != prepare_jobs_.end();) {
|
||||||
|
if (!Ready(it->result)) {
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto s = it->session;
|
||||||
|
try {
|
||||||
|
auto p = it->result.get();
|
||||||
|
s->peer = p.peer;
|
||||||
|
s->callback_owner = std::move(p.callback_owner);
|
||||||
|
s->drain_callbacks = std::move(p.drain_callbacks);
|
||||||
|
} catch (...) {
|
||||||
|
s->failed = true;
|
||||||
|
}
|
||||||
|
it = prepare_jobs_.erase(it);
|
||||||
|
if (s->closing || stopping_ || (s->incoming && s->epoch != epoch_)) {
|
||||||
|
Dispose(s);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!s->peer || s->failed) {
|
||||||
|
Fail(s, FailureReason(*s, "PeerPreparationFailed"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hooks_.attached(s);
|
||||||
|
transport_.Authenticated(s->channel);
|
||||||
|
if (s->incoming) {
|
||||||
|
s->phase = Session::AwaitOffer;
|
||||||
|
s->deadline = Clock::now() + std::chrono::seconds(5);
|
||||||
|
Send(s, {{"type", "auth_result"},
|
||||||
|
{"request_id", s->auth_request},
|
||||||
|
{"success", true},
|
||||||
|
{"session_id", s->session_id},
|
||||||
|
{"host_peer_id", s->local_id},
|
||||||
|
{"controller_peer_id", s->remote_id}});
|
||||||
|
} else {
|
||||||
|
s->phase = Session::AwaitAnswer;
|
||||||
|
s->deadline = Clock::now() + std::chrono::seconds(15);
|
||||||
|
while (!s->pending_receive.empty() && !s->closing) {
|
||||||
|
auto text = std::move(s->pending_receive.front());
|
||||||
|
s->pending_receive.pop_front();
|
||||||
|
try {
|
||||||
|
Message(s, text);
|
||||||
|
} catch (...) {
|
||||||
|
Fail(s, "InvalidSignal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto& event : transport_.Poll()) {
|
||||||
|
auto it = sessions_.find(event.channel->id);
|
||||||
|
if (it == sessions_.end()) {
|
||||||
|
const auto listener = listeners_.find(event.channel->local_address);
|
||||||
|
if (event.kind != Transport::Event::Open || !event.channel->incoming ||
|
||||||
|
stopping_ || listener == listeners_.end() ||
|
||||||
|
event.channel->listener != listener->second.id) {
|
||||||
|
transport_.Close(event.channel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto s = std::make_shared<Session>();
|
||||||
|
s->channel = event.channel;
|
||||||
|
s->incoming = true;
|
||||||
|
s->key = "lan/" + RandomHex(16);
|
||||||
|
s->epoch = epoch_;
|
||||||
|
sessions_[event.channel->id] = s;
|
||||||
|
it = sessions_.find(event.channel->id);
|
||||||
|
}
|
||||||
|
auto s = it->second;
|
||||||
|
if (s->closing) continue;
|
||||||
|
if (event.kind == Transport::Event::Open) {
|
||||||
|
s->deadline = Clock::now() + std::chrono::seconds(10);
|
||||||
|
s->phase = s->incoming ? Session::AwaitAuth : Session::AwaitAuthResult;
|
||||||
|
if (!s->incoming) {
|
||||||
|
s->auth_request = RandomHex(16);
|
||||||
|
Json auth = {{"type", "auth"},
|
||||||
|
{"request_id", s->auth_request},
|
||||||
|
{"password", std::exchange(s->password, {})},
|
||||||
|
{"capabilities", Capabilities()}};
|
||||||
|
Send(s, auth);
|
||||||
|
Wipe(auth["password"].get_ref<std::string&>());
|
||||||
|
}
|
||||||
|
} else if (event.kind == Transport::Event::Closed)
|
||||||
|
Close(s, event.payload.empty() ? "ConnectionClosed" : event.payload);
|
||||||
|
else
|
||||||
|
try {
|
||||||
|
Message(s, event.payload);
|
||||||
|
} catch (...) {
|
||||||
|
Fail(s, "InvalidSignal");
|
||||||
|
}
|
||||||
|
Wipe(event.payload);
|
||||||
|
}
|
||||||
|
const auto now = Clock::now();
|
||||||
|
for (auto& [_, s] : sessions_) {
|
||||||
|
if (s->closing) continue;
|
||||||
|
if (s->failed) {
|
||||||
|
Fail(s, FailureReason(*s, "SecureTransportFailed"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s->phase != Session::Connected && now >= s->deadline) {
|
||||||
|
Fail(s, s->phase == Session::Negotiating ? "IceConnectionTimeout"
|
||||||
|
: "Timeout");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::deque<std::string> signals;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(s->signal_mutex);
|
||||||
|
signals.swap(s->signals);
|
||||||
|
}
|
||||||
|
for (const auto& raw : signals) try {
|
||||||
|
auto j = Json::parse(raw);
|
||||||
|
Check(j.at("transmission_id") == s->session_id &&
|
||||||
|
j.at("user_id") == s->local_id &&
|
||||||
|
j.at("remote_user_id") == s->remote_id);
|
||||||
|
const auto type = j.at("type").get<std::string>();
|
||||||
|
Json out;
|
||||||
|
if (type == "offer" || type == "answer")
|
||||||
|
out = {{"type", type}, {"sdp", j.at("sdp")}};
|
||||||
|
else {
|
||||||
|
Check(type == "new_candidate");
|
||||||
|
out = {{"type", "candidate"}, {"ufrag", j.at("ufrag")}};
|
||||||
|
if (j.at("sdp") == "a=end-of-candidates\r\n")
|
||||||
|
out["end_of_candidates"] = true;
|
||||||
|
else
|
||||||
|
out["candidate"] = j.at("sdp");
|
||||||
|
}
|
||||||
|
if (!Send(s, std::move(out), true)) break;
|
||||||
|
} catch (...) {
|
||||||
|
Fail(s, "InvalidLocalSignal");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (s->ready.exchange(false) && !s->closing && !s->failed) {
|
||||||
|
if (s->phase != Session::Negotiating || stopping_ ||
|
||||||
|
(s->incoming && s->epoch != epoch_)) {
|
||||||
|
Fail(s, "CredentialsChanged");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s->phase = Session::Connected;
|
||||||
|
s->authorized = true;
|
||||||
|
hooks_.connected(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stopping_ && now - interface_check_ >= std::chrono::seconds(2)) {
|
||||||
|
interface_check_ = now;
|
||||||
|
interfaces_ = PhysicalInterfaces();
|
||||||
|
RefreshListeners();
|
||||||
|
for (auto& [_, s] : sessions_)
|
||||||
|
if (s->phase != Session::Connecting &&
|
||||||
|
!s->channel->local_address.empty() &&
|
||||||
|
std::none_of(interfaces_.begin(), interfaces_.end(),
|
||||||
|
[&](const auto& i) {
|
||||||
|
return i.address == s->channel->local_address;
|
||||||
|
}))
|
||||||
|
Close(s, "InterfaceRemoved");
|
||||||
|
}
|
||||||
|
for (auto it = cleanup_jobs_.begin(); it != cleanup_jobs_.end();)
|
||||||
|
if (Ready(*it)) {
|
||||||
|
try {
|
||||||
|
it->get();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
it = cleanup_jobs_.erase(it);
|
||||||
|
} else
|
||||||
|
++it;
|
||||||
|
for (auto it = sessions_.begin(); it != sessions_.end();) {
|
||||||
|
const auto& s = it->second;
|
||||||
|
const bool pending =
|
||||||
|
std::any_of(auth_jobs_.begin(), auth_jobs_.end(),
|
||||||
|
[&](const auto& j) { return j.session == s; }) ||
|
||||||
|
std::any_of(prepare_jobs_.begin(), prepare_jobs_.end(),
|
||||||
|
[&](const auto& j) { return j.session == s; });
|
||||||
|
if (s->closing && !pending) {
|
||||||
|
s->phase = Session::Closed;
|
||||||
|
it = sessions_.erase(it);
|
||||||
|
} else
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
if (stopping_ && !network_drained_ && sessions_.empty() &&
|
||||||
|
auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() &&
|
||||||
|
!reset_job_.valid()) {
|
||||||
|
transport_.Shutdown();
|
||||||
|
network_drained_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace crossdesk::lan
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-24
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _LAN_SESSION_MANAGER_H_
|
||||||
|
#define _LAN_SESSION_MANAGER_H_
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
|
#include <future>
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include "lan_auth_limiter.h"
|
||||||
|
#include "lan_transport.h"
|
||||||
|
#include "minirtc.h"
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
struct Session {
|
||||||
|
enum Phase {
|
||||||
|
Connecting,
|
||||||
|
AwaitAuth,
|
||||||
|
Authenticating,
|
||||||
|
Preparing,
|
||||||
|
AwaitAuthResult,
|
||||||
|
AwaitOffer,
|
||||||
|
AwaitAnswer,
|
||||||
|
Negotiating,
|
||||||
|
Connected,
|
||||||
|
Closing,
|
||||||
|
Closed
|
||||||
|
} phase = Connecting;
|
||||||
|
Transport::ChannelPtr channel;
|
||||||
|
Target target;
|
||||||
|
std::string key, session_id, local_id, remote_id, auth_request;
|
||||||
|
std::string password, error;
|
||||||
|
bool incoming = false;
|
||||||
|
uint64_t epoch = 0, receive_seq = 0, send_seq = 0;
|
||||||
|
std::chrono::steady_clock::time_point deadline;
|
||||||
|
PeerPtr* peer = nullptr; // Only published/cleared by the manager.
|
||||||
|
std::atomic<bool> closing{false}, authorized{false}, ready{false},
|
||||||
|
failed{false};
|
||||||
|
std::atomic<int> failure_code{-1};
|
||||||
|
std::mutex signal_mutex;
|
||||||
|
size_t signal_count = 0, signal_bytes = 0;
|
||||||
|
std::deque<std::string> signals, pending_receive;
|
||||||
|
std::shared_ptr<void> callback_owner;
|
||||||
|
std::function<void()> drain_callbacks;
|
||||||
|
static int SendSignal(const char* data, size_t size, void* context);
|
||||||
|
static void Error(MiniRtcDirectError error, void* context);
|
||||||
|
void Status(ConnectionStatus status);
|
||||||
|
};
|
||||||
|
|
||||||
|
// All public methods run on the GUI manager thread. Only Session's bounded
|
||||||
|
// callback entry points are callable by RTC workers.
|
||||||
|
class SessionManager {
|
||||||
|
public:
|
||||||
|
using SessionPtr = std::shared_ptr<Session>;
|
||||||
|
struct Prepared {
|
||||||
|
PeerPtr* peer = nullptr;
|
||||||
|
std::shared_ptr<void> callback_owner;
|
||||||
|
std::function<void()> drain_callbacks;
|
||||||
|
};
|
||||||
|
struct Hooks {
|
||||||
|
// Called on the manager to take device snapshots; returned work runs off
|
||||||
|
// UI.
|
||||||
|
std::function<std::function<Prepared()>(const SessionPtr&)> prepare;
|
||||||
|
std::function<void(const SessionPtr&)> attached, connected, closed;
|
||||||
|
std::function<void(const std::string&)> error;
|
||||||
|
};
|
||||||
|
SessionManager(std::filesystem::path directory, Hooks hooks);
|
||||||
|
~SessionManager();
|
||||||
|
Identity& identity() { return identity_; }
|
||||||
|
// Refreshed by Tick; UI and listener maintenance share the same snapshot.
|
||||||
|
const std::vector<Interface>& interfaces() const { return interfaces_; }
|
||||||
|
void Configure(const std::string& address, unsigned port);
|
||||||
|
bool password_pending() const { return reset_job_.valid(); }
|
||||||
|
void ResetPassword();
|
||||||
|
void SetPassword(std::string password);
|
||||||
|
bool listening() const { return !listeners_.empty(); }
|
||||||
|
struct ListenerIssue {
|
||||||
|
std::string address;
|
||||||
|
unsigned port;
|
||||||
|
std::string error;
|
||||||
|
};
|
||||||
|
std::string ListenerError(const std::string& address) const;
|
||||||
|
std::optional<ListenerIssue> TakeListenerWarning();
|
||||||
|
std::string TakeNewPassword();
|
||||||
|
int RetryAfter(const std::string& address, unsigned port) const;
|
||||||
|
SessionPtr Connect(const std::string& address, unsigned port,
|
||||||
|
std::string password);
|
||||||
|
void Close(const SessionPtr& session, const std::string& error = {});
|
||||||
|
void Tick();
|
||||||
|
void Stop();
|
||||||
|
bool Quiescent() const;
|
||||||
|
std::string PendingWork() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void Prepare(const SessionPtr& session);
|
||||||
|
void Message(const SessionPtr& session, const std::string& text);
|
||||||
|
bool Send(const SessionPtr& session, nlohmann::json message,
|
||||||
|
bool sequenced = false);
|
||||||
|
void Dispose(const SessionPtr& session);
|
||||||
|
void Fail(const SessionPtr& session, const std::string& error,
|
||||||
|
int retry_after = 0);
|
||||||
|
void RefreshListeners();
|
||||||
|
void ReplaceListener(const std::string& address, unsigned port, bool save,
|
||||||
|
bool allow_fallback = false);
|
||||||
|
void RetireListener(uint64_t id);
|
||||||
|
void SetListenerError(const std::string& address, unsigned port,
|
||||||
|
const std::string& error);
|
||||||
|
Identity identity_;
|
||||||
|
Hooks hooks_;
|
||||||
|
Transport transport_;
|
||||||
|
std::vector<Interface> interfaces_;
|
||||||
|
std::map<uint64_t, SessionPtr> sessions_;
|
||||||
|
struct AuthJob {
|
||||||
|
SessionPtr session;
|
||||||
|
std::future<DeviceAuthLimiter::Result> result;
|
||||||
|
};
|
||||||
|
struct PrepareJob {
|
||||||
|
SessionPtr session;
|
||||||
|
std::future<Prepared> result;
|
||||||
|
};
|
||||||
|
std::vector<AuthJob> auth_jobs_;
|
||||||
|
std::vector<PrepareJob> prepare_jobs_;
|
||||||
|
std::vector<std::future<void>> cleanup_jobs_;
|
||||||
|
DeviceAuthLimiter auth_limiter_;
|
||||||
|
CredentialWorkLimiter credential_work_limiter_;
|
||||||
|
std::map<std::string, std::chrono::steady_clock::time_point> retry_at_;
|
||||||
|
struct Listener {
|
||||||
|
uint64_t id;
|
||||||
|
unsigned port;
|
||||||
|
};
|
||||||
|
std::map<std::string, Listener> listeners_;
|
||||||
|
std::map<std::string, ListenerIssue> listener_errors_, listener_warnings_;
|
||||||
|
uint64_t epoch_ = 1;
|
||||||
|
bool stopping_ = false, network_drained_ = false;
|
||||||
|
std::chrono::steady_clock::time_point interface_check_{};
|
||||||
|
std::future<std::pair<std::string, nlohmann::json>> reset_job_;
|
||||||
|
std::string new_password_, access_password_;
|
||||||
|
};
|
||||||
|
} // namespace crossdesk::lan
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
#include "lan_transport.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <deque>
|
||||||
|
#include <future>
|
||||||
|
#include <map>
|
||||||
|
#include <mutex>
|
||||||
|
#include <set>
|
||||||
|
#include <thread>
|
||||||
|
#include <websocketpp/client.hpp>
|
||||||
|
#include <websocketpp/config/asio.hpp>
|
||||||
|
#include <websocketpp/config/asio_client.hpp>
|
||||||
|
#include <websocketpp/server.hpp>
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
namespace {
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
using Server = websocketpp::server<websocketpp::config::asio_tls>;
|
||||||
|
using Client = websocketpp::client<websocketpp::config::asio_tls_client>;
|
||||||
|
using Hdl = websocketpp::connection_hdl;
|
||||||
|
using Error = websocketpp::lib::error_code;
|
||||||
|
using Context = websocketpp::lib::asio::ssl::context;
|
||||||
|
constexpr const char* kPath = "/crossdesk/lan/v2";
|
||||||
|
constexpr const char* kProtocol = "crossdesk.lan.v2";
|
||||||
|
void ValidateListener(const std::string& address, unsigned port) {
|
||||||
|
if (port > 65535) throw std::runtime_error("Invalid LAN port");
|
||||||
|
const auto interfaces = PhysicalInterfaces();
|
||||||
|
if (!IsIpv4(address) ||
|
||||||
|
std::none_of(interfaces.begin(), interfaces.end(),
|
||||||
|
[&](const auto& i) { return i.address == address; }))
|
||||||
|
throw std::runtime_error("Selected LAN interface is unavailable");
|
||||||
|
}
|
||||||
|
Error ConfigureAcceptor(asio::ip::tcp::acceptor& acceptor, bool automatic_port) {
|
||||||
|
Error error;
|
||||||
|
#ifdef _WIN32
|
||||||
|
(void)automatic_port;
|
||||||
|
acceptor.set_option(asio::socket_base::reuse_address(false), error);
|
||||||
|
if (error) return error;
|
||||||
|
const BOOL exclusive = TRUE;
|
||||||
|
if (::setsockopt(acceptor.native_handle(), SOL_SOCKET, SO_EXCLUSIVEADDRUSE,
|
||||||
|
reinterpret_cast<const char*>(&exclusive),
|
||||||
|
sizeof(exclusive)) == SOCKET_ERROR)
|
||||||
|
return Error(WSAGetLastError(), asio::error::get_system_category());
|
||||||
|
#else
|
||||||
|
// Fixed ports may rebind after TIME_WAIT. OS-assigned ports need no reuse;
|
||||||
|
// exclusive allocation also avoids wildcard listener overlap on BSD.
|
||||||
|
acceptor.set_option(asio::socket_base::reuse_address(!automatic_port), error);
|
||||||
|
#endif
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
void CheckListenError(const Error& error) {
|
||||||
|
if (!error) return;
|
||||||
|
if (error == asio::error::address_in_use)
|
||||||
|
throw std::runtime_error("LanPortInUse");
|
||||||
|
if (error == asio::error::access_denied)
|
||||||
|
throw std::runtime_error("LanPortPermissionDenied");
|
||||||
|
throw std::runtime_error("Cannot listen on LAN port: " + error.message());
|
||||||
|
}
|
||||||
|
void CheckExistingListener(const std::string& address, unsigned port) {
|
||||||
|
#ifdef __APPLE__
|
||||||
|
// BSD permits a specific-address listener alongside a wildcard listener with
|
||||||
|
// SO_REUSEADDR. Detect that live listener without treating TIME_WAIT or our
|
||||||
|
// established sessions as occupied ports. No application data is sent.
|
||||||
|
asio::io_context io;
|
||||||
|
asio::ip::tcp::socket socket(io);
|
||||||
|
asio::steady_timer deadline(io, std::chrono::milliseconds(200));
|
||||||
|
Error result;
|
||||||
|
bool timed_out = false;
|
||||||
|
socket.async_connect(
|
||||||
|
{asio::ip::make_address_v4(address), static_cast<unsigned short>(port)},
|
||||||
|
[&](const Error& error) {
|
||||||
|
result = error;
|
||||||
|
deadline.cancel();
|
||||||
|
});
|
||||||
|
deadline.async_wait([&](const Error& error) {
|
||||||
|
if (error) return;
|
||||||
|
timed_out = true;
|
||||||
|
Error ignored;
|
||||||
|
socket.close(ignored);
|
||||||
|
});
|
||||||
|
io.run();
|
||||||
|
if (timed_out) throw std::runtime_error("LanPortCheckFailed");
|
||||||
|
if (!result) throw std::runtime_error("LanPortInUse");
|
||||||
|
if (result != asio::error::connection_refused) CheckListenError(result);
|
||||||
|
#else
|
||||||
|
(void)address;
|
||||||
|
(void)port;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
std::shared_ptr<Context> TlsContext() {
|
||||||
|
auto context = std::make_shared<Context>(Context::tls);
|
||||||
|
if (SSL_CTX_set_min_proto_version(context->native_handle(), TLS1_3_VERSION) !=
|
||||||
|
1 ||
|
||||||
|
SSL_CTX_set_max_early_data(context->native_handle(), 0) != 1)
|
||||||
|
throw std::runtime_error("TLS 1.3 is required for LAN connections");
|
||||||
|
SSL_CTX_set_options(context->native_handle(), SSL_OP_NO_COMPRESSION);
|
||||||
|
SSL_CTX_set_session_cache_mode(context->native_handle(), SSL_SESS_CACHE_OFF);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
struct Transport::Channel::State {
|
||||||
|
std::atomic<bool> closing{false};
|
||||||
|
std::mutex budget_mutex;
|
||||||
|
size_t sent_count = 0, sent_bytes = 0, received_count = 0, received_bytes = 0;
|
||||||
|
bool authenticated = false, opened = false, terminal = false;
|
||||||
|
Clock::time_point created = Clock::now(), last_ping = Clock::now(),
|
||||||
|
ping_sent{}, ping_window = Clock::now();
|
||||||
|
unsigned inbound_pings = 0;
|
||||||
|
std::string pending_ping, error;
|
||||||
|
std::function<void(const std::string&)> send, ping;
|
||||||
|
std::function<void()> close, graceful_close;
|
||||||
|
};
|
||||||
|
struct Transport::Impl {
|
||||||
|
asio::io_context io;
|
||||||
|
asio::executor_work_guard<asio::io_context::executor_type> work{
|
||||||
|
io.get_executor()};
|
||||||
|
asio::steady_timer timer{io};
|
||||||
|
std::thread thread;
|
||||||
|
std::atomic<bool> stopping{false};
|
||||||
|
std::atomic<uint64_t> next_id{1};
|
||||||
|
std::map<uint64_t, std::shared_ptr<Server>> servers;
|
||||||
|
std::map<uint64_t, std::shared_ptr<Client>> clients;
|
||||||
|
std::map<Hdl, ChannelPtr, std::owner_less<Hdl>> channels;
|
||||||
|
size_t preauth = 0;
|
||||||
|
std::set<uint64_t> retired_listeners;
|
||||||
|
std::mutex event_mutex;
|
||||||
|
std::vector<Event> events;
|
||||||
|
Impl() {
|
||||||
|
Tick();
|
||||||
|
thread = std::thread([this] { io.run(); });
|
||||||
|
}
|
||||||
|
bool AdmissionBlocked() {
|
||||||
|
std::lock_guard lock(event_mutex);
|
||||||
|
// Keep closed-but-not-yet-consumed channel events in the admission budget.
|
||||||
|
return events.size() >= 64;
|
||||||
|
}
|
||||||
|
void Emit(Event::Kind kind, const ChannelPtr& c, std::string payload = {}) {
|
||||||
|
std::lock_guard lock(event_mutex);
|
||||||
|
events.push_back({kind, c, std::move(payload)});
|
||||||
|
}
|
||||||
|
void Finish(const ChannelPtr& c) {
|
||||||
|
if (c->state->terminal) return;
|
||||||
|
c->state->closing = true;
|
||||||
|
c->state->terminal = true;
|
||||||
|
if (c->incoming && !c->state->authenticated) --preauth;
|
||||||
|
Emit(Event::Closed, c, c->state->error);
|
||||||
|
}
|
||||||
|
void Abort(const ChannelPtr& c) {
|
||||||
|
if (c->state->terminal) return;
|
||||||
|
c->state->closing = true;
|
||||||
|
if (c->state->close) c->state->close();
|
||||||
|
}
|
||||||
|
void Tick() {
|
||||||
|
timer.expires_after(std::chrono::seconds(1));
|
||||||
|
timer.async_wait([this](const Error& error) {
|
||||||
|
if (error || stopping) return;
|
||||||
|
const auto now = Clock::now();
|
||||||
|
std::vector<ChannelPtr> expired;
|
||||||
|
for (const auto& [_, c] : channels) {
|
||||||
|
auto& s = *c->state;
|
||||||
|
if (s.terminal || s.closing) continue;
|
||||||
|
if ((!s.opened && now - s.created >= std::chrono::seconds(8)) ||
|
||||||
|
(!s.pending_ping.empty() &&
|
||||||
|
now - s.ping_sent >= std::chrono::seconds(15))) {
|
||||||
|
s.error = s.opened ? "HeartbeatTimeout" : "ConnectionTimeout";
|
||||||
|
expired.push_back(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s.opened && s.pending_ping.empty() &&
|
||||||
|
now - s.last_ping >= std::chrono::seconds(5)) {
|
||||||
|
s.pending_ping = RandomHex(8);
|
||||||
|
s.last_ping = s.ping_sent = now;
|
||||||
|
s.ping(s.pending_ping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto& c : expired) Abort(c);
|
||||||
|
for (auto it = retired_listeners.begin();
|
||||||
|
it != retired_listeners.end();) {
|
||||||
|
const auto id = *it;
|
||||||
|
if (std::none_of(channels.begin(), channels.end(),
|
||||||
|
[id](const auto& item) {
|
||||||
|
return item.second->listener == id;
|
||||||
|
})) {
|
||||||
|
servers.erase(id);
|
||||||
|
it = retired_listeners.erase(it);
|
||||||
|
} else
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
Tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
template <class Endpoint>
|
||||||
|
void Attach(Endpoint* endpoint, Hdl hdl, const ChannelPtr& c) {
|
||||||
|
channels[hdl] = c;
|
||||||
|
c->state->send = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)](
|
||||||
|
const std::string& text) {
|
||||||
|
Error e;
|
||||||
|
endpoint->send(hdl, text, websocketpp::frame::opcode::text, e);
|
||||||
|
if (e)
|
||||||
|
if (auto c = cweak.lock()) Abort(c);
|
||||||
|
};
|
||||||
|
c->state->ping = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)](
|
||||||
|
const std::string& nonce) {
|
||||||
|
Error e;
|
||||||
|
endpoint->ping(hdl, nonce, e);
|
||||||
|
if (e)
|
||||||
|
if (auto c = cweak.lock()) Abort(c);
|
||||||
|
};
|
||||||
|
c->state->graceful_close = [this, endpoint, hdl,
|
||||||
|
cweak = std::weak_ptr<Channel>(c)] {
|
||||||
|
Error error;
|
||||||
|
endpoint->close(hdl, websocketpp::close::status::going_away, "", error);
|
||||||
|
if (error)
|
||||||
|
if (auto c = cweak.lock()) Abort(c);
|
||||||
|
};
|
||||||
|
c->state->close = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)] {
|
||||||
|
Error e;
|
||||||
|
auto connection = endpoint->get_con_from_hdl(hdl, e);
|
||||||
|
if (!e && connection) {
|
||||||
|
connection->get_raw_socket().cancel(e);
|
||||||
|
connection->get_raw_socket().shutdown(
|
||||||
|
asio::ip::tcp::socket::shutdown_both, e);
|
||||||
|
connection->get_raw_socket().close(e);
|
||||||
|
}
|
||||||
|
if (auto c = cweak.lock()) Finish(c);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
template <class Endpoint>
|
||||||
|
void Configure(Endpoint* endpoint) {
|
||||||
|
endpoint->clear_access_channels(websocketpp::log::alevel::all);
|
||||||
|
endpoint->clear_error_channels(websocketpp::log::elevel::all);
|
||||||
|
endpoint->init_asio(&io);
|
||||||
|
endpoint->set_max_message_size(256 * 1024);
|
||||||
|
endpoint->set_max_http_body_size(0);
|
||||||
|
endpoint->set_open_handshake_timeout(8000);
|
||||||
|
endpoint->set_close_handshake_timeout(1000);
|
||||||
|
endpoint->set_open_handler([this, endpoint](Hdl hdl) {
|
||||||
|
auto it = channels.find(hdl);
|
||||||
|
if (it == channels.end()) return;
|
||||||
|
auto c = it->second;
|
||||||
|
if (c->state->closing) {
|
||||||
|
Abort(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Error e;
|
||||||
|
auto connection = endpoint->get_con_from_hdl(hdl, e);
|
||||||
|
if (e) {
|
||||||
|
Abort(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto local = connection->get_raw_socket().local_endpoint(e);
|
||||||
|
if (e) {
|
||||||
|
Abort(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto remote = connection->get_raw_socket().remote_endpoint(e);
|
||||||
|
if (e) {
|
||||||
|
Abort(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!local.address().is_v4() || !remote.address().is_v4()) {
|
||||||
|
Abort(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c->local_address = local.address().to_string();
|
||||||
|
c->remote_address = remote.address().to_string();
|
||||||
|
const auto interfaces = PhysicalInterfaces();
|
||||||
|
if (!IsIpv4(c->remote_address) ||
|
||||||
|
std::none_of(
|
||||||
|
interfaces.begin(), interfaces.end(),
|
||||||
|
[&](const auto& i) { return i.address == c->local_address; })) {
|
||||||
|
c->state->error = "UnsupportedNetworkPath";
|
||||||
|
Abort(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto protocol =
|
||||||
|
c->incoming
|
||||||
|
? connection->get_subprotocol()
|
||||||
|
: connection->get_response_header("Sec-WebSocket-Protocol");
|
||||||
|
if (protocol != kProtocol) {
|
||||||
|
c->state->error = "IncompatibleProtocol";
|
||||||
|
Abort(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c->state->opened = true;
|
||||||
|
Emit(Event::Open, c);
|
||||||
|
});
|
||||||
|
auto closed = [this, endpoint](Hdl hdl) {
|
||||||
|
auto it = channels.find(hdl);
|
||||||
|
if (it != channels.end()) {
|
||||||
|
auto& state = *it->second->state;
|
||||||
|
if (state.error.empty() && !state.opened && !state.closing) {
|
||||||
|
Error error;
|
||||||
|
auto connection = endpoint->get_con_from_hdl(hdl, error);
|
||||||
|
state.error =
|
||||||
|
!error && connection
|
||||||
|
? "ConnectionFailed: " + connection->get_ec().message()
|
||||||
|
: "ConnectionFailed";
|
||||||
|
}
|
||||||
|
const auto id = it->second->id;
|
||||||
|
const bool incoming = it->second->incoming;
|
||||||
|
Finish(it->second);
|
||||||
|
channels.erase(it);
|
||||||
|
if (!incoming) asio::post(io, [this, id] { clients.erase(id); });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
endpoint->set_close_handler(closed);
|
||||||
|
endpoint->set_fail_handler(closed);
|
||||||
|
endpoint->set_message_handler(
|
||||||
|
[this](Hdl hdl, typename Endpoint::message_ptr message) {
|
||||||
|
auto it = channels.find(hdl);
|
||||||
|
if (it == channels.end()) return;
|
||||||
|
auto c = it->second;
|
||||||
|
auto& s = *c->state;
|
||||||
|
if (s.closing) return;
|
||||||
|
const auto& text = message->get_payload();
|
||||||
|
if (message->get_opcode() != websocketpp::frame::opcode::text ||
|
||||||
|
text.empty() || text.size() > 256 * 1024 ||
|
||||||
|
++s.received_count > 256 ||
|
||||||
|
(s.received_bytes += text.size()) > 1024 * 1024) {
|
||||||
|
Abort(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Emit(Event::Message, c, text);
|
||||||
|
});
|
||||||
|
endpoint->set_ping_handler([this](Hdl hdl, std::string nonce) {
|
||||||
|
auto it = channels.find(hdl);
|
||||||
|
if (it == channels.end()) return false;
|
||||||
|
auto c = it->second;
|
||||||
|
auto& s = *c->state;
|
||||||
|
auto now = Clock::now();
|
||||||
|
if (now - s.ping_window >= std::chrono::seconds(5)) {
|
||||||
|
s.ping_window = now;
|
||||||
|
s.inbound_pings = 0;
|
||||||
|
}
|
||||||
|
if (s.closing || nonce.size() != 16 || ++s.inbound_pings > 2) {
|
||||||
|
Abort(c);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
endpoint->set_pong_handler([this](Hdl hdl, std::string nonce) {
|
||||||
|
auto it = channels.find(hdl);
|
||||||
|
if (it == channels.end()) return;
|
||||||
|
auto& s = *it->second->state;
|
||||||
|
if (!s.pending_ping.empty() && nonce == s.pending_ping)
|
||||||
|
s.pending_ping.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Transport::Transport() : impl_(std::make_unique<Impl>()) {}
|
||||||
|
Transport::~Transport() { Shutdown(); }
|
||||||
|
Transport::Listener Transport::Listen(const Identity& identity,
|
||||||
|
const std::string& address,
|
||||||
|
unsigned port) {
|
||||||
|
ValidateListener(address, port);
|
||||||
|
auto context = TlsContext();
|
||||||
|
context->use_certificate_chain_file(identity.certificate().string());
|
||||||
|
context->use_private_key_file(identity.private_key().string(), Context::pem);
|
||||||
|
auto promise = std::make_shared<std::promise<Listener>>();
|
||||||
|
auto future = promise->get_future();
|
||||||
|
asio::post(impl_->io, [this, address, port, context, promise] {
|
||||||
|
try {
|
||||||
|
if (port) CheckExistingListener(address, port);
|
||||||
|
auto server = std::make_shared<Server>();
|
||||||
|
impl_->Configure(server.get());
|
||||||
|
const uint64_t id = impl_->next_id++;
|
||||||
|
server->set_tls_init_handler([context](Hdl) { return context; });
|
||||||
|
server->set_tcp_pre_init_handler(
|
||||||
|
[this, endpoint = server.get(), id](Hdl hdl) {
|
||||||
|
Error error;
|
||||||
|
auto connection = endpoint->get_con_from_hdl(hdl, error);
|
||||||
|
if (error) return;
|
||||||
|
if (impl_->stopping || impl_->retired_listeners.count(id) ||
|
||||||
|
impl_->preauth >= 16 || impl_->AdmissionBlocked() ||
|
||||||
|
impl_->channels.size() >= 24) {
|
||||||
|
connection->get_raw_socket().close(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto c = std::make_shared<Channel>();
|
||||||
|
c->id = impl_->next_id++;
|
||||||
|
c->listener = id;
|
||||||
|
c->incoming = true;
|
||||||
|
c->state = std::make_shared<Channel::State>();
|
||||||
|
++impl_->preauth;
|
||||||
|
impl_->Attach(endpoint, hdl, c);
|
||||||
|
});
|
||||||
|
server->set_validate_handler([endpoint = server.get()](Hdl hdl) {
|
||||||
|
auto c = endpoint->get_con_from_hdl(hdl);
|
||||||
|
if (c->get_resource() != kPath || !c->get_request_body().empty() ||
|
||||||
|
!c->get_request_header("Transfer-Encoding").empty() ||
|
||||||
|
(!c->get_request_header("Content-Length").empty() &&
|
||||||
|
c->get_request_header("Content-Length") != "0"))
|
||||||
|
return false;
|
||||||
|
const auto& protocols = c->get_requested_subprotocols();
|
||||||
|
if (protocols.size() != 1 || protocols[0] != kProtocol) return false;
|
||||||
|
c->select_subprotocol(kProtocol);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
auto bound_acceptor = std::make_shared<Server::acceptor_ptr>();
|
||||||
|
server->set_tcp_pre_bind_handler(
|
||||||
|
[port, bound_acceptor](Server::acceptor_ptr acceptor) {
|
||||||
|
*bound_acceptor = acceptor;
|
||||||
|
return ConfigureAcceptor(*acceptor, port == 0);
|
||||||
|
});
|
||||||
|
Error listen_error;
|
||||||
|
server->listen(
|
||||||
|
asio::ip::tcp::endpoint(asio::ip::make_address_v4(address),
|
||||||
|
static_cast<unsigned short>(port)),
|
||||||
|
listen_error);
|
||||||
|
CheckListenError(listen_error);
|
||||||
|
const auto actual_port = server->get_local_endpoint(listen_error).port();
|
||||||
|
CheckListenError(listen_error);
|
||||||
|
#ifndef _WIN32
|
||||||
|
if (!port) {
|
||||||
|
// Allocation is already exclusive. Enable TIME_WAIT reuse before any
|
||||||
|
// accepted socket inherits options so the saved port can restart too.
|
||||||
|
(*bound_acceptor)->set_option(asio::socket_base::reuse_address(true),
|
||||||
|
listen_error);
|
||||||
|
CheckListenError(listen_error);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
server->set_tcp_pre_bind_handler({});
|
||||||
|
server->start_accept();
|
||||||
|
impl_->servers[id] = server;
|
||||||
|
promise->set_value({id, actual_port});
|
||||||
|
} catch (...) {
|
||||||
|
promise->set_exception(std::current_exception());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return future.get();
|
||||||
|
}
|
||||||
|
void Transport::StopAccept(uint64_t listener) {
|
||||||
|
asio::post(impl_->io, [this, listener] {
|
||||||
|
auto it = impl_->servers.find(listener);
|
||||||
|
if (it != impl_->servers.end()) {
|
||||||
|
Error e;
|
||||||
|
it->second->stop_listening(e);
|
||||||
|
impl_->retired_listeners.insert(listener);
|
||||||
|
for (auto& [_, c] : impl_->channels)
|
||||||
|
if (c->listener == listener && !c->state->authenticated) {
|
||||||
|
c->state->error = "ListenerChanged";
|
||||||
|
impl_->Abort(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Transport::ChannelPtr Transport::Connect(const Target& target) {
|
||||||
|
if (!IsIpv4(target.address) || target.port == 0 || target.port > 65535)
|
||||||
|
throw std::runtime_error("Invalid LAN endpoint");
|
||||||
|
auto c = std::make_shared<Channel>();
|
||||||
|
c->id = impl_->next_id++;
|
||||||
|
c->state = std::make_shared<Channel::State>();
|
||||||
|
asio::post(impl_->io, [this, c, target] {
|
||||||
|
try {
|
||||||
|
if (impl_->stopping || impl_->channels.size() >= 24 ||
|
||||||
|
impl_->AdmissionBlocked()) {
|
||||||
|
impl_->Finish(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto client = std::make_shared<Client>();
|
||||||
|
impl_->Configure(client.get());
|
||||||
|
auto context = TlsContext();
|
||||||
|
context->set_verify_mode(asio::ssl::verify_peer);
|
||||||
|
context->set_verify_callback(
|
||||||
|
[c](bool, asio::ssl::verify_context& verification) {
|
||||||
|
X509_STORE_CTX* store = verification.native_handle();
|
||||||
|
auto* certificate = X509_STORE_CTX_get_current_cert(store);
|
||||||
|
// Self-signed LAN certificates establish the encrypted channel.
|
||||||
|
// Password verification authorizes access at the receiving host;
|
||||||
|
// it does not authenticate this certificate's device identity.
|
||||||
|
const bool valid = X509_STORE_CTX_get_error_depth(store) == 0 &&
|
||||||
|
VerifyLanCertificate(certificate);
|
||||||
|
if (!valid) c->state->error = "InvalidCertificate";
|
||||||
|
return valid;
|
||||||
|
});
|
||||||
|
client->set_tls_init_handler([context](Hdl) { return context; });
|
||||||
|
Error error;
|
||||||
|
auto connection = client->get_connection(
|
||||||
|
"wss://" + target.address + ":" + std::to_string(target.port) + kPath,
|
||||||
|
error);
|
||||||
|
if (error) {
|
||||||
|
impl_->Finish(c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connection->add_subprotocol(kProtocol);
|
||||||
|
impl_->Attach(client.get(), connection->get_handle(), c);
|
||||||
|
impl_->clients[c->id] = client;
|
||||||
|
client->connect(connection);
|
||||||
|
} catch (...) {
|
||||||
|
impl_->Abort(c);
|
||||||
|
impl_->Finish(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
bool Transport::Send(const ChannelPtr& c, std::string message) {
|
||||||
|
if (!c || impl_->stopping || c->state->closing || message.empty() ||
|
||||||
|
message.size() > 256 * 1024)
|
||||||
|
return false;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(c->state->budget_mutex);
|
||||||
|
if (c->state->sent_count >= 256 ||
|
||||||
|
c->state->sent_bytes + message.size() > 1024 * 1024)
|
||||||
|
return false;
|
||||||
|
++c->state->sent_count;
|
||||||
|
c->state->sent_bytes += message.size();
|
||||||
|
}
|
||||||
|
asio::post(impl_->io, [c, message = std::move(message)] {
|
||||||
|
if (!c->state->terminal && c->state->send) c->state->send(message);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void Transport::Authenticated(const ChannelPtr& c) {
|
||||||
|
asio::post(impl_->io, [this, c] {
|
||||||
|
if (c->incoming && !c->state->terminal && !c->state->authenticated) {
|
||||||
|
c->state->authenticated = true;
|
||||||
|
--impl_->preauth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void Transport::Close(const ChannelPtr& c) {
|
||||||
|
if (!c) return;
|
||||||
|
c->state->closing = true;
|
||||||
|
asio::post(impl_->io, [this, c] {
|
||||||
|
if (c->state->opened && !c->state->terminal && c->state->graceful_close)
|
||||||
|
c->state->graceful_close();
|
||||||
|
else
|
||||||
|
impl_->Abort(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
std::vector<Transport::Event> Transport::Poll() {
|
||||||
|
std::lock_guard lock(impl_->event_mutex);
|
||||||
|
std::vector<Event> result;
|
||||||
|
result.swap(impl_->events);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
void Transport::Shutdown() {
|
||||||
|
if (!impl_ || impl_->stopping.exchange(true)) return;
|
||||||
|
asio::post(impl_->io, [this] {
|
||||||
|
Error e;
|
||||||
|
impl_->timer.cancel();
|
||||||
|
for (auto& [_, server] : impl_->servers) server->stop_listening(e);
|
||||||
|
for (auto& [_, c] : impl_->channels) impl_->Abort(c);
|
||||||
|
impl_->work.reset();
|
||||||
|
});
|
||||||
|
if (impl_->thread.joinable()) impl_->thread.join();
|
||||||
|
}
|
||||||
|
} // namespace crossdesk::lan
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-24
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _LAN_TRANSPORT_H_
|
||||||
|
#define _LAN_TRANSPORT_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "lan_identity.h"
|
||||||
|
|
||||||
|
namespace crossdesk::lan {
|
||||||
|
// One network thread; callbacks enqueue bounded value events for the UI-owned
|
||||||
|
// session manager. No network callback calls MiniRTC or destroys a Peer.
|
||||||
|
class Transport {
|
||||||
|
public:
|
||||||
|
struct Channel;
|
||||||
|
using ChannelPtr = std::shared_ptr<Channel>;
|
||||||
|
struct Event {
|
||||||
|
enum Kind { Open, Message, Closed } kind;
|
||||||
|
ChannelPtr channel;
|
||||||
|
std::string payload;
|
||||||
|
};
|
||||||
|
Transport();
|
||||||
|
~Transport();
|
||||||
|
Transport(const Transport&) = delete;
|
||||||
|
Transport& operator=(const Transport&) = delete;
|
||||||
|
struct Listener {
|
||||||
|
uint64_t id;
|
||||||
|
unsigned port;
|
||||||
|
};
|
||||||
|
// Port zero lets the OS allocate and hold a free port on this address.
|
||||||
|
Listener Listen(const Identity& identity, const std::string& address,
|
||||||
|
unsigned port);
|
||||||
|
void StopAccept(uint64_t listener);
|
||||||
|
ChannelPtr Connect(const Target& target);
|
||||||
|
bool Send(const ChannelPtr& channel, std::string message);
|
||||||
|
void Authenticated(const ChannelPtr& channel);
|
||||||
|
void Close(const ChannelPtr& channel);
|
||||||
|
std::vector<Event> Poll();
|
||||||
|
void Shutdown();
|
||||||
|
struct Channel {
|
||||||
|
uint64_t id = 0, listener = 0;
|
||||||
|
bool incoming = false;
|
||||||
|
// Published by Open, immutable thereafter.
|
||||||
|
std::string local_address, remote_address;
|
||||||
|
|
||||||
|
private:
|
||||||
|
friend class Transport;
|
||||||
|
struct State;
|
||||||
|
std::shared_ptr<State> state;
|
||||||
|
};
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
} // namespace crossdesk::lan
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -40,6 +40,7 @@ std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config) {
|
|||||||
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
|
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
|
||||||
{"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"},
|
{"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"},
|
||||||
{"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"},
|
{"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"},
|
||||||
|
{"lock_on_disconnect", "lock_on_disconnect", OnOff(config.IsLockOnDisconnect()), "on / off"},
|
||||||
{"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"},
|
{"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"},
|
||||||
{"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"},
|
{"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"},
|
||||||
{"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "on / off"},
|
{"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "on / off"},
|
||||||
@@ -120,6 +121,8 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
|||||||
save = [&config, enabled] { return config.SetPrivacyScreen(enabled); };
|
save = [&config, enabled] { return config.SetPrivacyScreen(enabled); };
|
||||||
effect = SettingEffect::next_connection;
|
effect = SettingEffect::next_connection;
|
||||||
message = "console_settings_next_connection";
|
message = "console_settings_next_connection";
|
||||||
|
} else if (key == "lock_on_disconnect") {
|
||||||
|
save = [&config, enabled] { return config.SetLockOnDisconnect(enabled); };
|
||||||
} else if (key == "autostart") {
|
} else if (key == "autostart") {
|
||||||
save = [&config, enabled] { return config.SetAutostart(enabled); };
|
save = [&config, enabled] { return config.SetAutostart(enabled); };
|
||||||
effect = SettingEffect::next_login;
|
effect = SettingEffect::next_login;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-25
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "screen_lock.h"
|
||||||
|
|
||||||
|
#include <dbus/dbus.h>
|
||||||
|
|
||||||
|
#include "rd_log.h"
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
bool LockScreen() {
|
||||||
|
dbus_threads_init_default();
|
||||||
|
DBusError error;
|
||||||
|
dbus_error_init(&error);
|
||||||
|
DBusConnection* connection = dbus_bus_get_private(DBUS_BUS_SESSION, &error);
|
||||||
|
if (!connection) {
|
||||||
|
LOG_WARN("Cannot connect to screen lock service: {}",
|
||||||
|
error.message ? error.message : "session bus unavailable");
|
||||||
|
dbus_error_free(&error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dbus_connection_set_exit_on_disconnect(connection, false);
|
||||||
|
|
||||||
|
// Address the current desktop's session bus, including headless desktops;
|
||||||
|
// never lock every logged-in user or an unrelated system login session.
|
||||||
|
struct Service { const char* name; const char* path; };
|
||||||
|
constexpr Service services[] = {
|
||||||
|
{"org.freedesktop.ScreenSaver", "/ScreenSaver"},
|
||||||
|
{"org.gnome.ScreenSaver", "/org/gnome/ScreenSaver"},
|
||||||
|
{"org.cinnamon.ScreenSaver", "/org/cinnamon/ScreenSaver"},
|
||||||
|
{"org.mate.ScreenSaver", "/org/mate/ScreenSaver"},
|
||||||
|
{"org.xfce.ScreenSaver", "/org/xfce/ScreenSaver"},
|
||||||
|
};
|
||||||
|
bool accepted = false;
|
||||||
|
for (const auto& service : services) {
|
||||||
|
DBusMessage* message = dbus_message_new_method_call(
|
||||||
|
service.name, service.path, service.name, "Lock");
|
||||||
|
if (!message) break;
|
||||||
|
dbus_message_set_auto_start(message, false);
|
||||||
|
DBusMessage* reply = dbus_connection_send_with_reply_and_block(
|
||||||
|
connection, message, 1000, &error);
|
||||||
|
dbus_message_unref(message);
|
||||||
|
if (reply) {
|
||||||
|
accepted = dbus_message_get_type(reply) == DBUS_MESSAGE_TYPE_METHOD_RETURN;
|
||||||
|
dbus_message_unref(reply);
|
||||||
|
}
|
||||||
|
dbus_error_free(&error);
|
||||||
|
if (accepted) break;
|
||||||
|
}
|
||||||
|
dbus_connection_close(connection);
|
||||||
|
dbus_connection_unref(connection);
|
||||||
|
if (!accepted) LOG_WARN("No desktop screen lock service accepted the request");
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-25
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "screen_lock.h"
|
||||||
|
|
||||||
|
#include <dlfcn.h>
|
||||||
|
|
||||||
|
#include "rd_log.h"
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
bool LockScreen() {
|
||||||
|
// macOS has no public immediate-lock API. Resolve login.framework at
|
||||||
|
// runtime so an OS without this private entry point fails gracefully.
|
||||||
|
static void* framework = dlopen(
|
||||||
|
"/System/Library/PrivateFrameworks/login.framework/login",
|
||||||
|
RTLD_LAZY | RTLD_LOCAL);
|
||||||
|
using LockScreenFunction = int (*)();
|
||||||
|
static const auto lock = framework
|
||||||
|
? reinterpret_cast<LockScreenFunction>(
|
||||||
|
dlsym(framework, "SACLockScreenImmediate"))
|
||||||
|
: nullptr;
|
||||||
|
if (!lock) {
|
||||||
|
LOG_WARN("macOS screen lock API is unavailable");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int result = lock();
|
||||||
|
if (result != 0) LOG_WARN("macOS screen lock request failed: {}", result);
|
||||||
|
return result == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -45,6 +45,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
|
|||||||
if (CreateConnectionPeer() != 0) CloseConnectionPeer();
|
if (CreateConnectionPeer() != 0) CloseConnectionPeer();
|
||||||
}
|
}
|
||||||
HandleConnectionStatusChange();
|
HandleConnectionStatusChange();
|
||||||
|
HandleScreenLock();
|
||||||
HandleWindowsServiceIntegration();
|
HandleWindowsServiceIntegration();
|
||||||
devices_.UpdateInteractions();
|
devices_.UpdateInteractions();
|
||||||
ShareLocalCursorState();
|
ShareLocalCursorState();
|
||||||
@@ -71,6 +72,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
|
|||||||
devices_.DestroyDevices();
|
devices_.DestroyDevices();
|
||||||
devices_.DestroyFactories();
|
devices_.DestroyFactories();
|
||||||
privacy_.Shutdown();
|
privacy_.Shutdown();
|
||||||
|
HandleScreenLock();
|
||||||
WriteUnattendedStatus(settings_.ActiveIdentity(), false, session);
|
WriteUnattendedStatus(settings_.ActiveIdentity(), false, session);
|
||||||
SDL_Quit();
|
SDL_Quit();
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -197,8 +197,9 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
|
|||||||
optimistic.available = local_service_available_;
|
optimistic.available = local_service_available_;
|
||||||
optimistic.interactive_stage = local_interactive_stage_;
|
optimistic.interactive_stage = local_interactive_stage_;
|
||||||
const auto message = BuildWindowsServiceStatusAction(optimistic).to_json();
|
const auto message = BuildWindowsServiceStatusAction(optimistic).to_json();
|
||||||
SendReliableDataFrame(peer_, message.data(), message.size(),
|
for (const auto& id : ConnectedControllers())
|
||||||
control_data_label_.c_str());
|
SendControllerData(id, message.data(), message.size(),
|
||||||
|
control_data_label_.c_str());
|
||||||
}
|
}
|
||||||
result.reset();
|
result.reset();
|
||||||
}
|
}
|
||||||
@@ -293,10 +294,11 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
|
|||||||
RemoteAction remote_action =
|
RemoteAction remote_action =
|
||||||
BuildWindowsServiceStatusAction(broadcast_status);
|
BuildWindowsServiceStatusAction(broadcast_status);
|
||||||
std::string msg = remote_action.to_json();
|
std::string msg = remote_action.to_json();
|
||||||
int ret = SendReliableDataFrame(peer_, msg.data(), msg.size(),
|
for (const auto& id : ConnectedControllers()) {
|
||||||
control_data_label_.c_str());
|
const int ret = SendControllerData(id, msg.data(), msg.size(),
|
||||||
if (ret != 0) {
|
control_data_label_.c_str());
|
||||||
LOG_WARN("Broadcast Windows service status failed, ret={}", ret);
|
if (ret != 0)
|
||||||
|
LOG_WARN("Windows service status send failed, peer={}, ret={}", id, ret);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "windows_input_injector.h"
|
#include "windows_input_injector.h"
|
||||||
#include "windows_input_marker.h"
|
#include "windows_input_marker.h"
|
||||||
#include "windows_key_metadata.h"
|
#include "windows_key_metadata.h"
|
||||||
|
#include "windows_text_input.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -393,52 +394,17 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
|
|||||||
}
|
}
|
||||||
|
|
||||||
int PlatformKeyboardCapturer::SendTextCommand(const std::string& text) {
|
int PlatformKeyboardCapturer::SendTextCommand(const std::string& text) {
|
||||||
if (text.empty() || text.size() > kMaxTextInputBytes ||
|
|
||||||
text.find('\0') != std::string::npos) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
// Validate the entire UTF-8 commit before injecting any characters.
|
|
||||||
const int byte_count = static_cast<int>(text.size());
|
|
||||||
const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
|
|
||||||
text.data(), byte_count, nullptr, 0);
|
|
||||||
if (length == 0) return -1;
|
|
||||||
std::wstring characters(length, L'\0');
|
|
||||||
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(),
|
|
||||||
byte_count, characters.data(), length) != length) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<INPUT> inputs;
|
std::vector<INPUT> inputs;
|
||||||
inputs.reserve(characters.size() * 2);
|
if (!BuildWindowsTextInputs(text, inputs)) return -1;
|
||||||
for (size_t index = 0; index < characters.size(); ++index) {
|
|
||||||
const wchar_t character = characters[index];
|
|
||||||
INPUT input{};
|
|
||||||
input.type = INPUT_KEYBOARD;
|
|
||||||
input.ki.dwExtraInfo = static_cast<ULONG_PTR>(kInjectedKeyboardInputMarker);
|
|
||||||
if (character == L'\r' || character == L'\n' || character == L'\t') {
|
|
||||||
input.ki.wVk = character == L'\t' ? VK_TAB : VK_RETURN;
|
|
||||||
if (character == L'\r' && index + 1 < characters.size() &&
|
|
||||||
characters[index + 1] == L'\n') {
|
|
||||||
++index;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// VK_PACKET carries UTF-16 units, including both halves of emoji.
|
|
||||||
input.ki.wScan = static_cast<WORD>(character);
|
|
||||||
input.ki.dwFlags = KEYEVENTF_UNICODE;
|
|
||||||
}
|
|
||||||
inputs.push_back(input);
|
|
||||||
input.ki.dwFlags |= KEYEVENTF_KEYUP;
|
|
||||||
inputs.push_back(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One batch keeps surrogate pairs and key releases together with the text.
|
// One batch keeps surrogate pairs and key releases together with the text.
|
||||||
const UINT count = static_cast<UINT>(inputs.size());
|
const UINT count = static_cast<UINT>(inputs.size());
|
||||||
const UINT sent = SendInputOnUserDesktop(inputs.data(), count);
|
const UINT sent = SendWindowsTextInputs(
|
||||||
|
inputs, [](INPUT* batch, UINT size) {
|
||||||
|
return SendInputOnUserDesktop(batch, size);
|
||||||
|
});
|
||||||
if (sent != count) {
|
if (sent != count) {
|
||||||
const DWORD error = GetLastError();
|
const DWORD error = GetLastError();
|
||||||
if (sent < count && sent % 2 != 0) {
|
|
||||||
SendInputOnUserDesktop(inputs[sent]); // Best-effort release.
|
|
||||||
}
|
|
||||||
LOG_WARN(
|
LOG_WARN(
|
||||||
"SendInput failed for committed text, sent={}, expected={}, err={}",
|
"SendInput failed for committed text, sent={}, expected={}, err={}",
|
||||||
sent, count, error);
|
sent, count, error);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-26
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _WINDOWS_TEXT_INPUT_H_
|
||||||
|
#define _WINDOWS_TEXT_INPUT_H_
|
||||||
|
|
||||||
|
#include <Windows.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "remote_action.h"
|
||||||
|
#include "windows_input_marker.h"
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
inline bool BuildWindowsTextInputs(const std::string& text,
|
||||||
|
std::vector<INPUT>& inputs) {
|
||||||
|
inputs.clear();
|
||||||
|
if (text.empty() || text.size() > kMaxTextInputBytes ||
|
||||||
|
text.find('\0') != std::string::npos) return false;
|
||||||
|
// Validate the entire commit before injecting any characters.
|
||||||
|
const int bytes = static_cast<int>(text.size());
|
||||||
|
const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
|
||||||
|
text.data(), bytes, nullptr, 0);
|
||||||
|
if (length == 0) return false;
|
||||||
|
std::wstring characters(length, L'\0');
|
||||||
|
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), bytes,
|
||||||
|
characters.data(), length) != length) return false;
|
||||||
|
|
||||||
|
inputs.reserve(characters.size() * 2);
|
||||||
|
for (size_t index = 0; index < characters.size(); ++index) {
|
||||||
|
const wchar_t character = characters[index];
|
||||||
|
INPUT input{};
|
||||||
|
input.type = INPUT_KEYBOARD;
|
||||||
|
input.ki.dwExtraInfo = static_cast<ULONG_PTR>(kInjectedKeyboardInputMarker);
|
||||||
|
if (character == L'\r' || character == L'\n' || character == L'\t') {
|
||||||
|
input.ki.wVk = character == L'\t' ? VK_TAB : VK_RETURN;
|
||||||
|
if (character == L'\r' && index + 1 < characters.size() &&
|
||||||
|
characters[index + 1] == L'\n') ++index;
|
||||||
|
} else {
|
||||||
|
input.ki.wScan = static_cast<WORD>(character);
|
||||||
|
input.ki.dwFlags = KEYEVENTF_UNICODE;
|
||||||
|
}
|
||||||
|
inputs.push_back(input);
|
||||||
|
input.ki.dwFlags |= KEYEVENTF_KEYUP;
|
||||||
|
inputs.push_back(input);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A partial batch must never be replayed. Release its final key if necessary,
|
||||||
|
// retaining the original count and error for the desktop-routing decision.
|
||||||
|
template <typename Sender>
|
||||||
|
UINT SendWindowsTextInputs(std::vector<INPUT>& inputs, Sender send) {
|
||||||
|
const UINT count = static_cast<UINT>(inputs.size());
|
||||||
|
SetLastError(ERROR_SUCCESS);
|
||||||
|
const UINT sent = send(inputs.data(), count);
|
||||||
|
if (sent == count) return sent;
|
||||||
|
const DWORD error = GetLastError();
|
||||||
|
if (sent < count && sent % 2 != 0) send(&inputs[sent], 1);
|
||||||
|
SetLastError(error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE);
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-25
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "screen_lock.h"
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include "rd_log.h"
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
bool LockScreen() {
|
||||||
|
if (LockWorkStation()) return true;
|
||||||
|
LOG_WARN("LockWorkStation failed: {}", GetLastError());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-26
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SECURE_TEXT_INPUT_H_
|
||||||
|
#define _SECURE_TEXT_INPUT_H_
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "remote_action.h"
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
|
||||||
|
inline constexpr char kSecureTextInputCommandPrefix[] = "text:";
|
||||||
|
// JSON can escape each text byte as six ASCII bytes. Leave room for metadata.
|
||||||
|
inline constexpr size_t kSecureInputCommandBufferBytes =
|
||||||
|
6 * kMaxTextInputBytes + 1024;
|
||||||
|
|
||||||
|
struct SecureTextInputRequest {
|
||||||
|
std::string text;
|
||||||
|
std::string stage;
|
||||||
|
std::string desktop;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline std::string BuildSecureTextInputCommand(
|
||||||
|
const std::string& text, const std::string& stage = {},
|
||||||
|
const std::string& desktop = {}) {
|
||||||
|
return kSecureTextInputCommandPrefix +
|
||||||
|
nlohmann::json{{"text", text}, {"stage", stage},
|
||||||
|
{"desktop", desktop}}.dump();
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool ParseSecureTextInputCommand(const std::string& command,
|
||||||
|
SecureTextInputRequest& request) {
|
||||||
|
request = {};
|
||||||
|
if (command.size() > kSecureInputCommandBufferBytes ||
|
||||||
|
command.rfind(kSecureTextInputCommandPrefix, 0) != 0) return false;
|
||||||
|
const auto json = nlohmann::json::parse(
|
||||||
|
command.substr(sizeof(kSecureTextInputCommandPrefix) - 1), nullptr, false);
|
||||||
|
if (!json.is_object() || !json.contains("text") ||
|
||||||
|
!json["text"].is_string() || !json.contains("stage") ||
|
||||||
|
!json["stage"].is_string() || !json.contains("desktop") ||
|
||||||
|
!json["desktop"].is_string()) return false;
|
||||||
|
const auto& text = json["text"].get_ref<const std::string&>();
|
||||||
|
const auto& stage = json["stage"].get_ref<const std::string&>();
|
||||||
|
const auto& desktop = json["desktop"].get_ref<const std::string&>();
|
||||||
|
if (text.empty() || text.size() > kMaxTextInputBytes ||
|
||||||
|
text.find('\0') != std::string::npos || stage.size() > 64 ||
|
||||||
|
desktop.size() > 256 || stage.find('\0') != std::string::npos ||
|
||||||
|
desktop.find('\0') != std::string::npos) return false;
|
||||||
|
request = {text, stage, desktop};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
#include "path_manager.h"
|
#include "path_manager.h"
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
#include "session_helper_shared.h"
|
#include "session_helper_shared.h"
|
||||||
|
#include "secure_text_input.h"
|
||||||
#include "unattended_config.h"
|
#include "unattended_config.h"
|
||||||
#include "unattended_policy.h"
|
#include "unattended_policy.h"
|
||||||
#include "rdp_session_handoff.h"
|
#include "rdp_session_handoff.h"
|
||||||
@@ -1421,7 +1422,7 @@ void CrossDeskServiceHost::IpcServerLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
char buffer[1024] = {0};
|
char buffer[kSecureInputCommandBufferBytes] = {0};
|
||||||
DWORD bytes_read = 0;
|
DWORD bytes_read = 0;
|
||||||
DWORD transfer_error = ERROR_SUCCESS;
|
DWORD transfer_error = ERROR_SUCCESS;
|
||||||
if (pipe_deadline_detail::Transfer(
|
if (pipe_deadline_detail::Transfer(
|
||||||
@@ -2235,6 +2236,14 @@ void CrossDeskServiceHost::RecordSessionEvent(DWORD event_type,
|
|||||||
std::string CrossDeskServiceHost::HandleIpcCommand(const std::string& command,
|
std::string CrossDeskServiceHost::HandleIpcCommand(const std::string& command,
|
||||||
DWORD client_session_id,
|
DWORD client_session_id,
|
||||||
HANDLE client_pipe) {
|
HANDLE client_pipe) {
|
||||||
|
// Password text is case/whitespace sensitive. Parse before normalizing the
|
||||||
|
// legacy command verbs, and never accept the caller's desktop selection.
|
||||||
|
if (command.rfind(kSecureTextInputCommandPrefix, 0) == 0) {
|
||||||
|
SecureTextInputRequest request;
|
||||||
|
if (!ParseSecureTextInputCommand(command, request))
|
||||||
|
return BuildErrorJson("invalid_text_input");
|
||||||
|
return SendSecureDesktopTextInput(client_session_id, request.text);
|
||||||
|
}
|
||||||
std::string normalized = ToLower(Trim(command));
|
std::string normalized = ToLower(Trim(command));
|
||||||
if (normalized == kRdpHandoffCommand) {
|
if (normalized == kRdpHandoffCommand) {
|
||||||
bool observed_rdp = false;
|
bool observed_rdp = false;
|
||||||
@@ -2618,6 +2627,16 @@ std::string CrossDeskServiceHost::SendSecureDesktopMouseInput(
|
|||||||
1000);
|
1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string CrossDeskServiceHost::SendSecureDesktopTextInput(
|
||||||
|
DWORD client_session_id, const std::string& text) {
|
||||||
|
SecureInputTarget target;
|
||||||
|
const auto error = ResolveSecureInputTarget(client_session_id, target);
|
||||||
|
if (!error.empty()) return error;
|
||||||
|
return QueryNamedPipeMessage(
|
||||||
|
GetCrossDeskSecureInputHelperPipeName(target.session_id),
|
||||||
|
BuildSecureTextInputCommand(text, target.stage, target.desktop), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
bool InstallCrossDeskService(const std::wstring& binary_path) {
|
bool InstallCrossDeskService(const std::wstring& binary_path) {
|
||||||
SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS);
|
SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS);
|
||||||
if (manager == nullptr) {
|
if (manager == nullptr) {
|
||||||
@@ -2892,4 +2911,9 @@ std::string SendCrossDeskSecureDesktopMouseInput(int x, int y, int wheel,
|
|||||||
BuildSecureDesktopMouseIpcCommand(x, y, wheel, flag), timeout_ms);
|
BuildSecureDesktopMouseIpcCommand(x, y, wheel, flag), timeout_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string SendCrossDeskSecureDesktopTextInput(const std::string& text,
|
||||||
|
DWORD timeout_ms) {
|
||||||
|
return QueryDesktopInput(BuildSecureTextInputCommand(text), timeout_ms);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ class CrossDeskServiceHost {
|
|||||||
bool extended = false);
|
bool extended = false);
|
||||||
std::string SendSecureDesktopMouseInput(DWORD client_session_id,
|
std::string SendSecureDesktopMouseInput(DWORD client_session_id,
|
||||||
int x, int y, int wheel, int flag);
|
int x, int y, int wheel, int flag);
|
||||||
|
std::string SendSecureDesktopTextInput(DWORD client_session_id,
|
||||||
|
const std::string& text);
|
||||||
|
|
||||||
static void WINAPI ServiceMain(DWORD argc, LPWSTR* argv);
|
static void WINAPI ServiceMain(DWORD argc, LPWSTR* argv);
|
||||||
static BOOL WINAPI ConsoleControlHandler(DWORD control_type);
|
static BOOL WINAPI ConsoleControlHandler(DWORD control_type);
|
||||||
@@ -197,6 +199,8 @@ std::string SendCrossDeskSecureDesktopKeyInput(int key_code, bool is_down,
|
|||||||
std::string SendCrossDeskSecureDesktopMouseInput(int x, int y, int wheel,
|
std::string SendCrossDeskSecureDesktopMouseInput(int x, int y, int wheel,
|
||||||
int flag,
|
int flag,
|
||||||
DWORD timeout_ms = 1000);
|
DWORD timeout_ms = 1000);
|
||||||
|
std::string SendCrossDeskSecureDesktopTextInput(const std::string& text,
|
||||||
|
DWORD timeout_ms = 1000);
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
#include "platform/path_backend.h"
|
#include "platform/path_backend.h"
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
#include "session_helper_shared.h"
|
#include "session_helper_shared.h"
|
||||||
|
#include "secure_text_input.h"
|
||||||
#include "secure_desktop_dxgi_capture.h"
|
#include "secure_desktop_dxgi_capture.h"
|
||||||
#include "secure_desktop_frame_pacer.h"
|
#include "secure_desktop_frame_pacer.h"
|
||||||
#include "secure_desktop_nv12_cache.h"
|
#include "secure_desktop_nv12_cache.h"
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
#include "windows_cursor_state.h"
|
#include "windows_cursor_state.h"
|
||||||
#include "cursor_draw.h"
|
#include "cursor_draw.h"
|
||||||
#include "windows_input_marker.h"
|
#include "windows_input_marker.h"
|
||||||
|
#include "windows_text_input.h"
|
||||||
#include "windows_key_metadata.h"
|
#include "windows_key_metadata.h"
|
||||||
#include "../windows_thread_dpi.h"
|
#include "../windows_thread_dpi.h"
|
||||||
|
|
||||||
@@ -948,6 +950,31 @@ InputInjectionResult InjectKeyboardInput(
|
|||||||
return BuildInputSuccess(std::move(desktop_switch));
|
return BuildInputSuccess(std::move(desktop_switch));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InputInjectionResult InjectTextInput(
|
||||||
|
const crossdesk::SecureTextInputRequest& request) {
|
||||||
|
std::vector<INPUT> inputs;
|
||||||
|
if (!crossdesk::BuildWindowsTextInputs(request.text, inputs)) {
|
||||||
|
return BuildInputFailure("invalid_text_input", ERROR_INVALID_PARAMETER,
|
||||||
|
BuildDesktopSwitchDetails(request.stage,
|
||||||
|
request.desktop));
|
||||||
|
}
|
||||||
|
ScopedInteractiveDesktop desktop;
|
||||||
|
DesktopSwitchDetails desktop_switch;
|
||||||
|
if (!EnsureThreadInteractiveDesktopForStage(request.stage, request.desktop,
|
||||||
|
&desktop, &desktop_switch)) {
|
||||||
|
return BuildInputFailure("switch_interactive_desktop_failed", GetLastError(),
|
||||||
|
desktop_switch);
|
||||||
|
}
|
||||||
|
const UINT sent = crossdesk::SendWindowsTextInputs(
|
||||||
|
inputs, [](INPUT* batch, UINT count) {
|
||||||
|
return SendInput(count, batch, sizeof(INPUT));
|
||||||
|
});
|
||||||
|
if (sent != inputs.size()) {
|
||||||
|
return BuildInputFailure("send_input_failed", GetLastError(), desktop_switch);
|
||||||
|
}
|
||||||
|
return BuildInputSuccess(std::move(desktop_switch));
|
||||||
|
}
|
||||||
|
|
||||||
void ParseInteractionTail(const std::string& tail,
|
void ParseInteractionTail(const std::string& tail,
|
||||||
std::string* interactive_stage_out,
|
std::string* interactive_stage_out,
|
||||||
std::string* interactive_desktop_out) {
|
std::string* interactive_desktop_out) {
|
||||||
@@ -1833,6 +1860,21 @@ std::vector<uint8_t> StartSecureDesktopSharedCapture(
|
|||||||
std::vector<uint8_t> HandleSecureInputHelperCommand(
|
std::vector<uint8_t> HandleSecureInputHelperCommand(
|
||||||
const std::string& command,
|
const std::string& command,
|
||||||
SecureSharedCaptureState* capture_state) {
|
SecureSharedCaptureState* capture_state) {
|
||||||
|
if (command.rfind(crossdesk::kSecureTextInputCommandPrefix, 0) == 0) {
|
||||||
|
crossdesk::SecureTextInputRequest request;
|
||||||
|
if (!crossdesk::ParseSecureTextInputCommand(command, request))
|
||||||
|
return BuildTextResponseBytes(BuildErrorJson("invalid_text_input"));
|
||||||
|
const auto result = InjectTextInput(request);
|
||||||
|
if (!result.ok) {
|
||||||
|
// Text may contain a login password. Never echo it in logs or replies.
|
||||||
|
LOG_WARN("Secure input helper text injection failed: error={}, code={}, "
|
||||||
|
"stage={}, target={}, current={}",
|
||||||
|
result.error, result.error_code, result.desktop.stage,
|
||||||
|
result.desktop.target_desktop, result.desktop.current_desktop);
|
||||||
|
return BuildTextResponseBytes(BuildInputFailureJson(result).dump());
|
||||||
|
}
|
||||||
|
return BuildTextResponseBytes("{\"ok\":true,\"injected\":\"text\"}");
|
||||||
|
}
|
||||||
if (command == "ping") {
|
if (command == "ping") {
|
||||||
return BuildTextResponseBytes("{\"ok\":true,\"reply\":\"pong\"}");
|
return BuildTextResponseBytes("{\"ok\":true,\"reply\":\"pong\"}");
|
||||||
}
|
}
|
||||||
@@ -1922,7 +1964,7 @@ std::vector<uint8_t> HandleSecureInputHelperCommand(
|
|||||||
void HandleSecureInputHelperPipeClient(
|
void HandleSecureInputHelperPipeClient(
|
||||||
HANDLE pipe, HANDLE event_handle,
|
HANDLE pipe, HANDLE event_handle,
|
||||||
std::shared_ptr<SecureSharedCaptureState> capture_state) {
|
std::shared_ptr<SecureSharedCaptureState> capture_state) {
|
||||||
char buffer[1024] = {0};
|
char buffer[crossdesk::kSecureInputCommandBufferBytes] = {0};
|
||||||
DWORD bytes_read = 0;
|
DWORD bytes_read = 0;
|
||||||
DWORD error = ERROR_SUCCESS;
|
DWORD error = ERROR_SUCCESS;
|
||||||
if (crossdesk::pipe_deadline_detail::Transfer(
|
if (crossdesk::pipe_deadline_detail::Transfer(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
|
|
||||||
#include <Winsock2.h>
|
#include <Winsock2.h>
|
||||||
|
#include <Windows.h>
|
||||||
#include <iphlpapi.h>
|
#include <iphlpapi.h>
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -25,19 +26,38 @@ std::string GetMac() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string GetHostName() {
|
std::string GetHostName() {
|
||||||
WSADATA data = {};
|
// Read Unicode directly; Winsock's narrow hostname is not guaranteed UTF-8.
|
||||||
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) {
|
DWORD size = 0;
|
||||||
LOG_ERROR("WSAStartup failed");
|
if (!GetComputerNameExW(ComputerNameDnsHostname, nullptr, &size) &&
|
||||||
|
GetLastError() != ERROR_MORE_DATA) {
|
||||||
|
LOG_ERROR("GetComputerNameExW size query failed: {}", GetLastError());
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
char hostname[256] = {};
|
if (size == 0) return {};
|
||||||
if (gethostname(hostname, sizeof(hostname)) == SOCKET_ERROR) {
|
|
||||||
LOG_ERROR("gethostname failed: {}", WSAGetLastError());
|
std::wstring hostname(size, L'\0');
|
||||||
WSACleanup();
|
if (!GetComputerNameExW(ComputerNameDnsHostname, hostname.data(), &size)) {
|
||||||
|
LOG_ERROR("GetComputerNameExW failed: {}", GetLastError());
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
WSACleanup();
|
if (size == 0) return {};
|
||||||
return hostname;
|
|
||||||
|
const int length = static_cast<int>(size);
|
||||||
|
const int utf8_size =
|
||||||
|
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, hostname.data(),
|
||||||
|
length, nullptr, 0, nullptr, nullptr);
|
||||||
|
if (utf8_size == 0) {
|
||||||
|
LOG_ERROR("Hostname UTF-8 size query failed: {}", GetLastError());
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
std::string utf8_name(utf8_size, '\0');
|
||||||
|
if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, hostname.data(),
|
||||||
|
length, utf8_name.data(), utf8_size, nullptr,
|
||||||
|
nullptr) == 0) {
|
||||||
|
LOG_ERROR("Hostname UTF-8 conversion failed: {}", GetLastError());
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return utf8_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IsWaylandSession() { return false; }
|
bool IsWaylandSession() { return false; }
|
||||||
|
|||||||
@@ -11,11 +11,14 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -29,53 +32,41 @@
|
|||||||
#include "stb_image_write.h"
|
#include "stb_image_write.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
|
||||||
void ScaleNv12ToABGR(char* src, int src_w, int src_h, int dst_w, int dst_h,
|
// Keep enough detail for high-DPI cards, independently of the display used
|
||||||
char* dst_rgba) {
|
// when the session closes. Preserve the source aspect ratio without bars.
|
||||||
uint8_t* y = reinterpret_cast<uint8_t*>(src);
|
constexpr int kPreviewMaxEdge = 1024;
|
||||||
uint8_t* uv = y + src_w * src_h;
|
|
||||||
|
|
||||||
float src_aspect = float(src_w) / src_h;
|
bool ScaleNv12ToABGR(const char* src, int src_w, int src_h, int dst_w,
|
||||||
float dst_aspect = float(dst_w) / dst_h;
|
int dst_h, uint8_t* dst_rgba) {
|
||||||
int fit_w = dst_w, fit_h = dst_h;
|
const auto* y = reinterpret_cast<const uint8_t*>(src);
|
||||||
if (src_aspect > dst_aspect) {
|
const auto* uv = y + size_t(src_w) * src_h;
|
||||||
fit_h = int(dst_w / src_aspect);
|
const int src_cw = (src_w + 1) / 2, src_ch = (src_h + 1) / 2;
|
||||||
} else {
|
const int dst_cw = (dst_w + 1) / 2, dst_ch = (dst_h + 1) / 2;
|
||||||
fit_w = int(dst_h * src_aspect);
|
std::vector<uint8_t> y_i420(size_t(src_w) * src_h);
|
||||||
}
|
std::vector<uint8_t> u_i420(size_t(src_cw) * src_ch);
|
||||||
|
std::vector<uint8_t> v_i420(u_i420.size());
|
||||||
|
if (libyuv::NV12ToI420(y, src_w, uv, src_cw * 2, y_i420.data(), src_w,
|
||||||
|
u_i420.data(), src_cw, v_i420.data(), src_cw,
|
||||||
|
src_w, src_h) != 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
std::vector<uint8_t> y_i420(src_w * src_h);
|
std::vector<uint8_t> y_fit(size_t(dst_w) * dst_h);
|
||||||
std::vector<uint8_t> u_i420((src_w / 2) * (src_h / 2));
|
std::vector<uint8_t> u_fit(size_t(dst_cw) * dst_ch);
|
||||||
std::vector<uint8_t> v_i420((src_w / 2) * (src_h / 2));
|
std::vector<uint8_t> v_fit(u_fit.size());
|
||||||
libyuv::NV12ToI420(y, src_w, uv, src_w, y_i420.data(), src_w, u_i420.data(),
|
if (libyuv::I420Scale(y_i420.data(), src_w, u_i420.data(), src_cw,
|
||||||
src_w / 2, v_i420.data(), src_w / 2, src_w, src_h);
|
v_i420.data(), src_cw, src_w, src_h, y_fit.data(), dst_w,
|
||||||
|
u_fit.data(), dst_cw, v_fit.data(), dst_cw, dst_w,
|
||||||
std::vector<uint8_t> y_fit(fit_w * fit_h);
|
dst_h, libyuv::kFilterBox) != 0)
|
||||||
std::vector<uint8_t> u_fit((fit_w + 1) / 2 * (fit_h + 1) / 2);
|
return false;
|
||||||
std::vector<uint8_t> v_fit((fit_w + 1) / 2 * (fit_h + 1) / 2);
|
return libyuv::I420ToABGR(y_fit.data(), dst_w, u_fit.data(), dst_cw,
|
||||||
libyuv::I420Scale(y_i420.data(), src_w, u_i420.data(), src_w / 2,
|
v_fit.data(), dst_cw, dst_rgba, dst_w * 4,
|
||||||
v_i420.data(), src_w / 2, src_w, src_h, y_fit.data(), fit_w,
|
dst_w, dst_h) == 0;
|
||||||
u_fit.data(), (fit_w + 1) / 2, v_fit.data(),
|
|
||||||
(fit_w + 1) / 2, fit_w, fit_h, libyuv::kFilterBilinear);
|
|
||||||
|
|
||||||
std::vector<uint8_t> abgr(fit_w * fit_h * 4);
|
|
||||||
libyuv::I420ToABGR(y_fit.data(), fit_w, u_fit.data(), (fit_w + 1) / 2,
|
|
||||||
v_fit.data(), (fit_w + 1) / 2, abgr.data(), fit_w * 4,
|
|
||||||
fit_w, fit_h);
|
|
||||||
|
|
||||||
std::memset(dst_rgba, 0, dst_w * dst_h * 4);
|
|
||||||
for (int i = 0; i < dst_w * dst_h; ++i) {
|
|
||||||
dst_rgba[i * 4 + 3] = static_cast<char>(0xFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int row = 0; row < fit_h; ++row) {
|
|
||||||
int dst_offset =
|
|
||||||
((row + (dst_h - fit_h) / 2) * dst_w + (dst_w - fit_w) / 2) * 4;
|
|
||||||
std::memcpy(dst_rgba + dst_offset, abgr.data() + row * fit_w * 4,
|
|
||||||
fit_w * 4);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
Thumbnail::Thumbnail(std::string save_path) {
|
Thumbnail::Thumbnail(std::string save_path) {
|
||||||
if (!save_path.empty()) {
|
if (!save_path.empty()) {
|
||||||
save_path_ = save_path;
|
save_path_ = save_path;
|
||||||
@@ -97,19 +88,6 @@ Thumbnail::Thumbnail(std::string save_path, unsigned char* aes128_key,
|
|||||||
std::filesystem::create_directories(save_path_);
|
std::filesystem::create_directories(save_path_);
|
||||||
}
|
}
|
||||||
|
|
||||||
Thumbnail::~Thumbnail() {
|
|
||||||
if (rgba_buffer_) {
|
|
||||||
delete[] rgba_buffer_;
|
|
||||||
rgba_buffer_ = nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int Thumbnail::SetThumbnailDpiScale(float dpi_scale) {
|
|
||||||
thumbnail_width_ = static_cast<int>(thumbnail_width_ * dpi_scale);
|
|
||||||
thumbnail_height_ = static_cast<int>(thumbnail_height_ * dpi_scale);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
bool IsRemoteId(const std::string& id) {
|
bool IsRemoteId(const std::string& id) {
|
||||||
@@ -118,7 +96,14 @@ bool IsRemoteId(const std::string& id) {
|
|||||||
[](unsigned char c) { return c >= '0' && c <= '9'; });
|
[](unsigned char c) { return c >= '0' && c <= '9'; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool IsLanRecordId(const std::string& id) {
|
||||||
|
return id.size() == 68 && id.rfind("lan-", 0) == 0 &&
|
||||||
|
std::all_of(id.begin() + 4, id.end(), [](char c) {
|
||||||
|
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||||
|
});
|
||||||
|
}
|
||||||
std::string ConnectionKey(const Thumbnail::RecentConnection& connection) {
|
std::string ConnectionKey(const Thumbnail::RecentConnection& connection) {
|
||||||
|
if (!connection.lan.device_id.empty()) return connection.remote_id;
|
||||||
return connection.remote_id + (connection.remember_password ? "Y" : "N") +
|
return connection.remote_id + (connection.remember_password ? "Y" : "N") +
|
||||||
connection.remote_host_name +
|
connection.remote_host_name +
|
||||||
(connection.remember_password ? "@" + connection.password : "");
|
(connection.remember_password ? "@" + connection.password : "");
|
||||||
@@ -126,17 +111,37 @@ std::string ConnectionKey(const Thumbnail::RecentConnection& connection) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
std::string Thumbnail::LanRecordId(const std::string& device_id) {
|
||||||
|
unsigned char digest[32]{};
|
||||||
|
unsigned int size = 0;
|
||||||
|
const std::string input = "lan_direct/" + device_id;
|
||||||
|
if (EVP_Digest(input.data(), input.size(), digest, &size, EVP_sha256(),
|
||||||
|
nullptr) != 1 ||
|
||||||
|
size != 32)
|
||||||
|
return {};
|
||||||
|
std::ostringstream text;
|
||||||
|
text << "lan-" << std::hex << std::setfill('0');
|
||||||
|
for (const auto byte : digest) text << std::setw(2) << unsigned(byte);
|
||||||
|
return text.str();
|
||||||
|
}
|
||||||
|
|
||||||
bool Thumbnail::WriteRecord(const RecentConnection& connection) {
|
bool Thumbnail::WriteRecord(const RecentConnection& connection) {
|
||||||
|
const auto password = connection.remember_password
|
||||||
|
? AES_encrypt(connection.password, aes128_key_, aes128_iv_) : "";
|
||||||
|
if (connection.remember_password && password.empty()) return false;
|
||||||
const auto path =
|
const auto path =
|
||||||
std::filesystem::path(save_path_) / (connection.remote_id + ".json");
|
std::filesystem::path(save_path_) / (connection.remote_id + ".json");
|
||||||
const auto temporary = path.string() + ".tmp";
|
const auto temporary = path.string() + ".tmp";
|
||||||
const nlohmann::json record = {
|
const nlohmann::json record = {
|
||||||
|
{"version", 2},
|
||||||
|
{"kind", connection.lan.device_id.empty() ? "cloud_id" : "lan_direct"},
|
||||||
|
{"lan",
|
||||||
|
{{"address", connection.lan.address},
|
||||||
|
{"port", connection.lan.port},
|
||||||
|
{"device_id", connection.lan.device_id}}},
|
||||||
{"remote_id", connection.remote_id},
|
{"remote_id", connection.remote_id},
|
||||||
{"host_name", connection.remote_host_name},
|
{"host_name", connection.remote_host_name},
|
||||||
{"password",
|
{"password", password},
|
||||||
connection.remember_password
|
|
||||||
? AES_encrypt(connection.password, aes128_key_, aes128_iv_)
|
|
||||||
: ""},
|
|
||||||
{"platform", HostPlatformName(connection.platform)}};
|
{"platform", HostPlatformName(connection.platform)}};
|
||||||
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
||||||
output << record.dump();
|
output << record.dump();
|
||||||
@@ -167,14 +172,25 @@ bool Thumbnail::ReadRecord(const std::filesystem::path& path,
|
|||||||
if (!record.is_object()) return false;
|
if (!record.is_object()) return false;
|
||||||
try {
|
try {
|
||||||
connection->remote_id = record.value("remote_id", "");
|
connection->remote_id = record.value("remote_id", "");
|
||||||
if (!IsRemoteId(connection->remote_id) ||
|
const auto kind = record.value("kind", "cloud_id");
|
||||||
path.stem() != connection->remote_id)
|
if (kind == "lan_direct") {
|
||||||
|
const auto& target = record.at("lan");
|
||||||
|
connection->lan = {target.at("address"), target.at("device_id"),
|
||||||
|
target.at("port")};
|
||||||
|
if (!IsLanRecordId(connection->remote_id) ||
|
||||||
|
connection->lan.device_id.size() != 32 ||
|
||||||
|
connection->remote_id != LanRecordId(connection->lan.device_id) ||
|
||||||
|
!connection->lan.port || connection->lan.port > 65535 ||
|
||||||
|
connection->lan.address.size() > 15)
|
||||||
|
return false;
|
||||||
|
} else if (kind != "cloud_id" || !IsRemoteId(connection->remote_id))
|
||||||
return false;
|
return false;
|
||||||
|
if (path.stem() != connection->remote_id) return false;
|
||||||
connection->remote_host_name = record.value("host_name", "");
|
connection->remote_host_name = record.value("host_name", "");
|
||||||
const auto password = record.value("password", "");
|
const auto password = record.value("password", "");
|
||||||
connection->remember_password = !password.empty();
|
|
||||||
connection->password =
|
connection->password =
|
||||||
password.empty() ? "" : AES_decrypt(password, aes128_key_, aes128_iv_);
|
password.empty() ? "" : AES_decrypt(password, aes128_key_, aes128_iv_);
|
||||||
|
connection->remember_password = !connection->password.empty();
|
||||||
connection->platform =
|
connection->platform =
|
||||||
ParseHostPlatform(record.value("platform", "unknown"));
|
ParseHostPlatform(record.value("platform", "unknown"));
|
||||||
const auto image = path.parent_path() / (connection->remote_id + ".png");
|
const auto image = path.parent_path() / (connection->remote_id + ".png");
|
||||||
@@ -201,30 +217,35 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height,
|
|||||||
const std::string& remote_id,
|
const std::string& remote_id,
|
||||||
const std::string& host_name,
|
const std::string& host_name,
|
||||||
const std::string& password,
|
const std::string& password,
|
||||||
HostPlatform platform) {
|
HostPlatform platform, const LanTarget* lan) {
|
||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
// Successful connections are history even without saved credentials.
|
// Successful connections are history even without saved credentials.
|
||||||
if (!IsRemoteId(remote_id)) return -1;
|
if (lan ? (!IsLanRecordId(remote_id) ||
|
||||||
|
remote_id != LanRecordId(lan->device_id))
|
||||||
|
: !IsRemoteId(remote_id))
|
||||||
|
return -1;
|
||||||
RecentConnection connection;
|
RecentConnection connection;
|
||||||
ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"),
|
ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"),
|
||||||
&connection);
|
&connection);
|
||||||
connection.remote_id = remote_id;
|
connection.remote_id = remote_id;
|
||||||
if (!host_name.empty()) connection.remote_host_name = host_name;
|
if (!host_name.empty()) connection.remote_host_name = host_name;
|
||||||
|
connection.lan = lan ? *lan : LanTarget{};
|
||||||
connection.password = password;
|
connection.password = password;
|
||||||
connection.remember_password = !password.empty();
|
connection.remember_password = !connection.password.empty();
|
||||||
if (platform != HostPlatform::Unknown) connection.platform = platform;
|
if (platform != HostPlatform::Unknown) connection.platform = platform;
|
||||||
if (!WriteRecord(connection)) return -1;
|
if (!WriteRecord(connection)) return -1;
|
||||||
|
|
||||||
const auto image = std::filesystem::path(save_path_) / (remote_id + ".png");
|
const auto image = std::filesystem::path(save_path_) / (remote_id + ".png");
|
||||||
if (save_previews_ && nv12 && width > 0 && height > 0) {
|
if (save_previews_ && nv12 && width > 0 && height > 0) {
|
||||||
if (!rgba_buffer_) {
|
const double scale = std::min(1.0, double(kPreviewMaxEdge) /
|
||||||
rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4];
|
std::max(width, height));
|
||||||
}
|
const int image_width = std::max(1, int(std::lround(width * scale)));
|
||||||
ScaleNv12ToABGR(const_cast<char*>(nv12), width, height, thumbnail_width_,
|
const int image_height = std::max(1, int(std::lround(height * scale)));
|
||||||
thumbnail_height_, rgba_buffer_);
|
std::vector<uint8_t> rgba(size_t(image_width) * image_height * 4);
|
||||||
if (!stbi_write_png(image.string().c_str(), thumbnail_width_,
|
if (!ScaleNv12ToABGR(nv12, width, height, image_width, image_height,
|
||||||
thumbnail_height_, 4, rgba_buffer_,
|
rgba.data()) ||
|
||||||
thumbnail_width_ * 4))
|
!stbi_write_png(image.string().c_str(), image_width, image_height, 4,
|
||||||
|
rgba.data(), image_width * 4))
|
||||||
return -1;
|
return -1;
|
||||||
} else if (!save_previews_) {
|
} else if (!save_previews_) {
|
||||||
std::error_code error;
|
std::error_code error;
|
||||||
@@ -235,11 +256,8 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height,
|
|||||||
}
|
}
|
||||||
|
|
||||||
int Thumbnail::LoadThumbnail(
|
int Thumbnail::LoadThumbnail(
|
||||||
std::vector<std::pair<std::string, RecentConnection>>& recent_connections,
|
std::vector<std::pair<std::string, RecentConnection>>& recent_connections) {
|
||||||
int* width, int* height) {
|
|
||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
if (width) *width = thumbnail_width_;
|
|
||||||
if (height) *height = thumbnail_height_;
|
|
||||||
return LoadThumbnailLocked(recent_connections);
|
return LoadThumbnailLocked(recent_connections);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,32 +341,80 @@ int Thumbnail::LoadThumbnailLocked(
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Thumbnail::DecodeImage(const std::filesystem::path& image_path,
|
bool Thumbnail::DecodeImage(const std::filesystem::path& image_path,
|
||||||
|
int target_width, int target_height,
|
||||||
std::vector<unsigned char>* rgba, int* width,
|
std::vector<unsigned char>* rgba, int* width,
|
||||||
int* height) const {
|
int* height) const {
|
||||||
if (!rgba || !width || !height) {
|
if (!rgba || !width || !height || target_width <= 0 || target_height <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
rgba->clear();
|
||||||
int channels = 0;
|
*width = *height = 0;
|
||||||
unsigned char* pixels =
|
int source_width = 0, source_height = 0, channels = 0;
|
||||||
stbi_load(image_path.string().c_str(), width, height, &channels, 4);
|
std::unique_lock lock(mutex_);
|
||||||
if (!pixels || *width <= 0 || *height <= 0) {
|
const std::unique_ptr<unsigned char, decltype(&stbi_image_free)> pixels(
|
||||||
if (pixels) {
|
stbi_load(image_path.string().c_str(), &source_width, &source_height,
|
||||||
stbi_image_free(pixels);
|
&channels, 4), stbi_image_free);
|
||||||
}
|
lock.unlock();
|
||||||
rgba->clear();
|
if (!pixels || source_width <= 0 || source_height <= 0)
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
const size_t byte_count =
|
// Match image-fit: cover, cropping once from the original cached image.
|
||||||
static_cast<size_t>(*width) * static_cast<size_t>(*height) * 4;
|
int crop_width = source_width, crop_height = source_height;
|
||||||
rgba->assign(pixels, pixels + byte_count);
|
if (int64_t(source_width) * target_height >
|
||||||
stbi_image_free(pixels);
|
int64_t(source_height) * target_width)
|
||||||
|
crop_width = std::max(1, int(int64_t(source_height) * target_width /
|
||||||
|
target_height));
|
||||||
|
else
|
||||||
|
crop_height = std::max(1, int(int64_t(source_width) * target_height /
|
||||||
|
target_width));
|
||||||
|
const double scale = std::min({1.0, double(crop_width) / target_width,
|
||||||
|
double(crop_height) / target_height});
|
||||||
|
const int output_width = std::max(1, int(std::lround(target_width * scale)));
|
||||||
|
const int output_height = std::max(1, int(std::lround(target_height * scale)));
|
||||||
|
const auto* cropped = pixels.get() +
|
||||||
|
(size_t((source_height - crop_height) / 2) * source_width +
|
||||||
|
(source_width - crop_width) / 2) * 4;
|
||||||
|
|
||||||
|
// ARGBScale falls back to bilinear for general box downsampling. Scale
|
||||||
|
// separate color planes to get area filtering without another YUV conversion.
|
||||||
|
const size_t source_area = size_t(crop_width) * crop_height;
|
||||||
|
const size_t output_area = size_t(output_width) * output_height;
|
||||||
|
std::vector<uint8_t> source_planes(source_area * 4);
|
||||||
|
std::vector<uint8_t> output_planes(output_area * 4);
|
||||||
|
libyuv::SplitARGBPlane(cropped, source_width * 4,
|
||||||
|
source_planes.data(), crop_width,
|
||||||
|
source_planes.data() + source_area, crop_width,
|
||||||
|
source_planes.data() + source_area * 2, crop_width,
|
||||||
|
source_planes.data() + source_area * 3, crop_width,
|
||||||
|
crop_width, crop_height);
|
||||||
|
for (size_t channel = 0; channel < 4; ++channel) {
|
||||||
|
if (libyuv::ScalePlane(source_planes.data() + source_area * channel,
|
||||||
|
crop_width, crop_width, crop_height,
|
||||||
|
output_planes.data() + output_area * channel,
|
||||||
|
output_width, output_width, output_height,
|
||||||
|
libyuv::kFilterBox) != 0)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rgba->resize(output_area * 4);
|
||||||
|
libyuv::MergeARGBPlane(output_planes.data(), output_width,
|
||||||
|
output_planes.data() + output_area, output_width,
|
||||||
|
output_planes.data() + output_area * 2, output_width,
|
||||||
|
output_planes.data() + output_area * 3, output_width,
|
||||||
|
rgba->data(), output_width * 4,
|
||||||
|
output_width, output_height);
|
||||||
|
*width = output_width;
|
||||||
|
*height = output_height;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
int Thumbnail::DeleteThumbnail(const std::string& filename_keyword) {
|
int Thumbnail::DeleteThumbnail(const std::string& filename_keyword) {
|
||||||
std::lock_guard lock(mutex_);
|
std::lock_guard lock(mutex_);
|
||||||
|
if (IsLanRecordId(filename_keyword)) {
|
||||||
|
for (const auto* extension : {".json", ".png"})
|
||||||
|
std::filesystem::remove(std::filesystem::path(save_path_) /
|
||||||
|
(filename_keyword + extension));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const auto remote_id = filename_keyword.substr(0, 9);
|
const auto remote_id = filename_keyword.substr(0, 9);
|
||||||
if (!IsRemoteId(remote_id)) return -1;
|
if (!IsRemoteId(remote_id)) return -1;
|
||||||
for (const auto& entry : std::filesystem::directory_iterator(save_path_)) {
|
for (const auto& entry : std::filesystem::directory_iterator(save_path_)) {
|
||||||
@@ -411,14 +477,14 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
|
|||||||
ctx = EVP_CIPHER_CTX_new();
|
ctx = EVP_CIPHER_CTX_new();
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
LOG_ERROR("Error in EVP_CIPHER_CTX_new");
|
LOG_ERROR("Error in EVP_CIPHER_CTX_new");
|
||||||
return plaintext;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
ret = EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv);
|
ret = EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv);
|
||||||
if (1 != ret) {
|
if (1 != ret) {
|
||||||
LOG_ERROR("Error in EVP_EncryptInit_ex");
|
LOG_ERROR("Error in EVP_EncryptInit_ex");
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
return plaintext;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
ret = EVP_EncryptUpdate(
|
ret = EVP_EncryptUpdate(
|
||||||
@@ -428,7 +494,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
|
|||||||
if (1 != ret) {
|
if (1 != ret) {
|
||||||
LOG_ERROR("Error in EVP_EncryptUpdate");
|
LOG_ERROR("Error in EVP_EncryptUpdate");
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
return plaintext;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
ciphertext_len = len;
|
ciphertext_len = len;
|
||||||
@@ -436,7 +502,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
|
|||||||
if (1 != ret) {
|
if (1 != ret) {
|
||||||
LOG_ERROR("Error in EVP_EncryptFinal_ex");
|
LOG_ERROR("Error in EVP_EncryptFinal_ex");
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
return plaintext;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
ciphertext_len += len;
|
ciphertext_len += len;
|
||||||
@@ -448,7 +514,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext,
|
|||||||
if (1 != ret) {
|
if (1 != ret) {
|
||||||
LOG_ERROR("Error in OPENSSL_buf2hexstr_ex");
|
LOG_ERROR("Error in OPENSSL_buf2hexstr_ex");
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
return plaintext;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
@@ -471,13 +537,13 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext,
|
|||||||
&ciphertext_buf_len, ciphertext.c_str(), '\0');
|
&ciphertext_buf_len, ciphertext.c_str(), '\0');
|
||||||
if (1 != ret) {
|
if (1 != ret) {
|
||||||
LOG_ERROR("Error in OPENSSL_hexstr2buf_ex");
|
LOG_ERROR("Error in OPENSSL_hexstr2buf_ex");
|
||||||
return ciphertext;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx = EVP_CIPHER_CTX_new();
|
ctx = EVP_CIPHER_CTX_new();
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
LOG_ERROR("Error in EVP_CIPHER_CTX_new");
|
LOG_ERROR("Error in EVP_CIPHER_CTX_new");
|
||||||
return ciphertext;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
ret = EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv);
|
ret = EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv);
|
||||||
@@ -485,7 +551,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext,
|
|||||||
LOG_ERROR("Error in EVP_DecryptInit_ex");
|
LOG_ERROR("Error in EVP_DecryptInit_ex");
|
||||||
|
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
return ciphertext;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
ret = EVP_DecryptUpdate(ctx, plaintext, &plaintext_len, ciphertext_buf,
|
ret = EVP_DecryptUpdate(ctx, plaintext, &plaintext_len, ciphertext_buf,
|
||||||
@@ -494,7 +560,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext,
|
|||||||
LOG_ERROR("Error in EVP_DecryptUpdate");
|
LOG_ERROR("Error in EVP_DecryptUpdate");
|
||||||
|
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
return ciphertext;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
ret =
|
ret =
|
||||||
@@ -503,7 +569,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext,
|
|||||||
LOG_ERROR("Error in EVP_DecryptFinal_ex");
|
LOG_ERROR("Error in EVP_DecryptFinal_ex");
|
||||||
|
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
EVP_CIPHER_CTX_free(ctx);
|
||||||
return ciphertext;
|
return {};
|
||||||
}
|
}
|
||||||
plaintext_len += plaintext_final_len;
|
plaintext_len += plaintext_final_len;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* @Author: DI JUNKUN
|
* @Author: DI JUNKUN
|
||||||
* @Date: 2024-11-07
|
* @Date: 2024-11-07
|
||||||
* Copyright (c) 2024 by DI JUNKUN, All Rights Reserved.
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef _THUMBNAIL_H_
|
#ifndef _THUMBNAIL_H_
|
||||||
@@ -20,12 +20,18 @@ namespace crossdesk {
|
|||||||
|
|
||||||
class Thumbnail {
|
class Thumbnail {
|
||||||
public:
|
public:
|
||||||
|
struct LanTarget {
|
||||||
|
std::string address, device_id;
|
||||||
|
unsigned port = 52888;
|
||||||
|
};
|
||||||
|
static std::string LanRecordId(const std::string& device_id);
|
||||||
struct RecentConnection {
|
struct RecentConnection {
|
||||||
std::filesystem::path image_path;
|
std::filesystem::path image_path;
|
||||||
std::string remote_id;
|
std::string remote_id;
|
||||||
std::string remote_host_name;
|
std::string remote_host_name;
|
||||||
std::string password;
|
std::string password;
|
||||||
bool remember_password = false;
|
bool remember_password = false;
|
||||||
|
LanTarget lan;
|
||||||
HostPlatform platform = HostPlatform::Unknown;
|
HostPlatform platform = HostPlatform::Unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,10 +39,6 @@ class Thumbnail {
|
|||||||
Thumbnail(std::string save_path);
|
Thumbnail(std::string save_path);
|
||||||
explicit Thumbnail(std::string save_path, unsigned char* aes128_key,
|
explicit Thumbnail(std::string save_path, unsigned char* aes128_key,
|
||||||
unsigned char* aes128_iv);
|
unsigned char* aes128_iv);
|
||||||
~Thumbnail();
|
|
||||||
|
|
||||||
public:
|
|
||||||
int SetThumbnailDpiScale(float dpi_scale);
|
|
||||||
|
|
||||||
// Serialized with writes so disabling also clears any in-flight preview.
|
// Serialized with writes so disabling also clears any in-flight preview.
|
||||||
int SetSavePreviews(bool enabled);
|
int SetSavePreviews(bool enabled);
|
||||||
@@ -45,16 +47,18 @@ class Thumbnail {
|
|||||||
// credentials (and clears any remembered password from a previous session).
|
// credentials (and clears any remembered password from a previous session).
|
||||||
int SaveToThumbnail(const char* nv12, int width, int height,
|
int SaveToThumbnail(const char* nv12, int width, int height,
|
||||||
const std::string& remote_id,
|
const std::string& remote_id,
|
||||||
const std::string& host_name,
|
const std::string& host_name, const std::string& password,
|
||||||
const std::string& password,
|
HostPlatform platform = HostPlatform::Unknown,
|
||||||
HostPlatform platform = HostPlatform::Unknown);
|
const LanTarget* lan = nullptr);
|
||||||
|
|
||||||
int LoadThumbnail(
|
int LoadThumbnail(
|
||||||
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>&
|
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>&
|
||||||
recent_connections,
|
recent_connections);
|
||||||
int* width, int* height);
|
|
||||||
|
|
||||||
|
// Crop from the saved master and downsample for a physical-pixel viewport.
|
||||||
|
// Low-resolution legacy images are never enlarged in the cache.
|
||||||
bool DecodeImage(const std::filesystem::path& image_path,
|
bool DecodeImage(const std::filesystem::path& image_path,
|
||||||
|
int target_width, int target_height,
|
||||||
std::vector<unsigned char>* rgba, int* width,
|
std::vector<unsigned char>* rgba, int* width,
|
||||||
int* height) const;
|
int* height) const;
|
||||||
|
|
||||||
@@ -94,11 +98,8 @@ class Thumbnail {
|
|||||||
unsigned char* iv);
|
unsigned char* iv);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::mutex mutex_;
|
mutable std::mutex mutex_;
|
||||||
bool save_previews_ = true;
|
bool save_previews_ = true;
|
||||||
int thumbnail_width_ = 160;
|
|
||||||
int thumbnail_height_ = 90;
|
|
||||||
char* rgba_buffer_ = nullptr;
|
|
||||||
std::string save_path_ = "thumbnails/";
|
std::string save_path_ = "thumbnails/";
|
||||||
|
|
||||||
unsigned char aes128_key_[16];
|
unsigned char aes128_key_[16];
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ rule("slint")
|
|||||||
|
|
||||||
before_build_file(function(target, sourcefile, opt)
|
before_build_file(function(target, sourcefile, opt)
|
||||||
import("core.project.depend")
|
import("core.project.depend")
|
||||||
|
import("core.project.project")
|
||||||
import("core.tools.gcc.parse_deps", {alias = "parse_deps"})
|
import("core.tools.gcc.parse_deps", {alias = "parse_deps"})
|
||||||
import("utils.progress")
|
import("utils.progress")
|
||||||
|
|
||||||
@@ -70,8 +71,46 @@ rule("slint")
|
|||||||
return files
|
return files
|
||||||
end
|
end
|
||||||
|
|
||||||
local depopts = {dependfile = target:dependfile(outputs.header),
|
local function input_hashes(files)
|
||||||
values = argv, files = dependencies(),
|
-- Rapid edits can share an mtime with the last generation too.
|
||||||
|
local hashes = {}
|
||||||
|
for _, file in ipairs(files) do
|
||||||
|
if file ~= compiler then
|
||||||
|
table.insert(hashes, os.isfile(file) and hash.sha256(file) or "missing")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return hashes
|
||||||
|
end
|
||||||
|
|
||||||
|
local function invalidate_consumers(changed_files)
|
||||||
|
for _, consumer in pairs(project.targets()) do
|
||||||
|
local uses_target = consumer == target
|
||||||
|
for _, dependency in ipairs(consumer:orderdeps()) do
|
||||||
|
uses_target = uses_target or dependency == target
|
||||||
|
end
|
||||||
|
if uses_target then
|
||||||
|
for _, dependfile in ipairs(consumer:dependfiles()) do
|
||||||
|
local info = depend.load(dependfile, {target = consumer})
|
||||||
|
for _, file in ipairs(info and info.files or {}) do
|
||||||
|
if changed_files[path.absolute(file)] then
|
||||||
|
os.tryrm(dependfile)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Archiving/linking uses the same coarse mtime comparison.
|
||||||
|
if consumer:targetfile() then
|
||||||
|
os.tryrm(consumer:dependfile())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local dependfile = target:dependfile(outputs.header)
|
||||||
|
local previous = depend.load(dependfile) or {}
|
||||||
|
local files = dependencies()
|
||||||
|
local depopts = {dependfile = dependfile,
|
||||||
|
values = {argv, input_hashes(files)}, files = files,
|
||||||
changed = target:is_rebuilt() or missing_output or not os.isfile(depfile)}
|
changed = target:is_rebuilt() or missing_output or not os.isfile(depfile)}
|
||||||
-- Slint preserves timestamps of unchanged outputs. Use the dependency
|
-- Slint preserves timestamps of unchanged outputs. Use the dependency
|
||||||
-- cache's successful-generation time, not the oldest output's time.
|
-- cache's successful-generation time, not the oldest output's time.
|
||||||
@@ -79,8 +118,24 @@ rule("slint")
|
|||||||
progress.show(opt.progress, "${color.build.object}generating.slint %s", sourcefile)
|
progress.show(opt.progress, "${color.build.object}generating.slint %s", sourcefile)
|
||||||
os.mkdir(path.directory(outputs.header))
|
os.mkdir(path.directory(outputs.header))
|
||||||
os.vrunv(compiler, argv)
|
os.vrunv(compiler, argv)
|
||||||
|
-- Xmake compares mtimes in whole seconds. A generated header can
|
||||||
|
-- change in the same second as a consumer's last compilation, so
|
||||||
|
-- ordering alone cannot prevent mixing incompatible UI bindings.
|
||||||
|
local generated, changed_files = {}, {}
|
||||||
|
for _, file in ipairs(table.join({outputs.header}, outputs.sources)) do
|
||||||
|
local absolute = path.absolute(file)
|
||||||
|
generated[absolute] = hash.sha256(absolute)
|
||||||
|
if generated[absolute] ~= (previous.generated or {})[absolute] then
|
||||||
|
changed_files[absolute] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not table.empty(changed_files) then
|
||||||
|
invalidate_consumers(changed_files)
|
||||||
|
end
|
||||||
-- Save the freshly discovered dependencies on the first build too.
|
-- Save the freshly discovered dependencies on the first build too.
|
||||||
depopts.files = dependencies()
|
depopts.files = dependencies()
|
||||||
|
depopts.values = {argv, input_hashes(depopts.files)}
|
||||||
|
return {generated = generated}
|
||||||
end, depopts)
|
end, depopts)
|
||||||
end)
|
end)
|
||||||
rule_end()
|
rule_end()
|
||||||
|
|||||||
@@ -86,11 +86,14 @@ function setup_targets()
|
|||||||
add_packages("concurrentqueue")
|
add_packages("concurrentqueue")
|
||||||
add_files("apps/desktop/src/common/*.cpp")
|
add_files("apps/desktop/src/common/*.cpp")
|
||||||
if is_os("windows") then
|
if is_os("windows") then
|
||||||
add_files("apps/desktop/src/platform/windows/system_info.cpp")
|
add_files("apps/desktop/src/platform/windows/system_info.cpp",
|
||||||
|
"apps/desktop/src/platform/windows/screen_lock.cpp")
|
||||||
elseif is_os("macosx") then
|
elseif is_os("macosx") then
|
||||||
add_files("apps/desktop/src/platform/macos/system_info.cpp")
|
add_files("apps/desktop/src/platform/macos/system_info.cpp",
|
||||||
|
"apps/desktop/src/platform/macos/screen_lock.cpp")
|
||||||
elseif is_os("linux") then
|
elseif is_os("linux") then
|
||||||
add_files("apps/desktop/src/platform/linux/system_info.cpp",
|
add_files("apps/desktop/src/platform/linux/system_info.cpp",
|
||||||
|
"apps/desktop/src/platform/linux/screen_lock.cpp",
|
||||||
"apps/desktop/src/platform/linux/common/*.cpp")
|
"apps/desktop/src/platform/linux/common/*.cpp")
|
||||||
add_includedirs("apps/desktop/src/platform/linux/common")
|
add_includedirs("apps/desktop/src/platform/linux/common")
|
||||||
end
|
end
|
||||||
@@ -563,7 +566,9 @@ function setup_targets()
|
|||||||
-- version and use fmt's supported runtime-parser fallback here.
|
-- version and use fmt's supported runtime-parser fallback here.
|
||||||
add_defines("FMT_CONSTEVAL=")
|
add_defines("FMT_CONSTEVAL=")
|
||||||
add_packages("slint", {public = true})
|
add_packages("slint", {public = true})
|
||||||
add_packages("libyuv", "tinyfiledialogs", "openssl3", "libsdl3", "nlohmann_json")
|
add_packages("libyuv", "tinyfiledialogs", "openssl3", "libsdl3", "nlohmann_json", "asio", "websocketpp")
|
||||||
|
add_defines("ASIO_STANDALONE")
|
||||||
|
add_files("apps/desktop/src/network/lan/*.cpp")
|
||||||
if not is_config("CROSSDESK_PORTABLE", true) then
|
if not is_config("CROSSDESK_PORTABLE", true) then
|
||||||
add_defines("CROSSDESK_HAS_UPDATER=1", {public = true})
|
add_defines("CROSSDESK_HAS_UPDATER=1", {public = true})
|
||||||
add_deps("updater_core")
|
add_deps("updater_core")
|
||||||
@@ -599,7 +604,7 @@ function setup_targets()
|
|||||||
add_deps("windows_unattended_config")
|
add_deps("windows_unattended_config")
|
||||||
add_files("apps/desktop/src/platform/windows/gui/application/unattended_application.cpp")
|
add_files("apps/desktop/src/platform/windows/gui/application/unattended_application.cpp")
|
||||||
add_links("opengl32")
|
add_links("opengl32")
|
||||||
add_syslinks("Comctl32", {public = true})
|
add_syslinks("Comctl32", "Iphlpapi", "Ws2_32", "Advapi32", {public = true})
|
||||||
add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp",
|
add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp",
|
||||||
"apps/desktop/src/platform/windows/gui/slint_backend.cpp",
|
"apps/desktop/src/platform/windows/gui/slint_backend.cpp",
|
||||||
"apps/desktop/src/platform/windows/gui/window_icons.cpp",
|
"apps/desktop/src/platform/windows/gui/window_icons.cpp",
|
||||||
@@ -659,6 +664,7 @@ function setup_targets()
|
|||||||
target("crossdesk_service")
|
target("crossdesk_service")
|
||||||
set_kind("binary")
|
set_kind("binary")
|
||||||
add_packages("nlohmann_json")
|
add_packages("nlohmann_json")
|
||||||
|
add_includedirs("libs/wire/include")
|
||||||
add_deps("rd_log", "path_manager", "windows_unattended_config")
|
add_deps("rd_log", "path_manager", "windows_unattended_config")
|
||||||
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
|
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
|
||||||
add_files("apps/desktop/src/platform/windows/service/main.cpp",
|
add_files("apps/desktop/src/platform/windows/service/main.cpp",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ apps/harmony/.native/mobile-project/entry/build/default/outputs/default/entry-de
|
|||||||
| Android 功能 | 鸿蒙实现 |
|
| Android 功能 | 鸿蒙实现 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 前台自动登录、空闲后台停止、服务器隔离 | 独立 identity peer;只有主动远控才创建 controller peer;拒绝任何入站被控连接 |
|
| 前台自动登录、空闲后台停止、服务器隔离 | 独立 identity peer;只有主动远控才创建 controller peer;拒绝任何入站被控连接 |
|
||||||
|
| 电脑隐私屏 | 悬浮菜单直接开关,无详情页;按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠;5 秒状态失效、15 秒指令超时与跨会话隔离 |
|
||||||
| 首次隐私确认、撤回与离线阅读 | 同意前不发起身份、公告、在线状态或版本网络请求;撤回结束会话并清除预览 |
|
| 首次隐私确认、撤回与离线阅读 | 同意前不发起身份、公告、在线状态或版本网络请求;撤回结束会话并清除预览 |
|
||||||
| 加密身份、可选记住访问密码 | 系统 Asset Store,设备首次解锁后可读,禁止跨设备同步;普通 Preferences 不存密码 |
|
| 加密身份、可选记住访问密码 | 系统 Asset Store,设备首次解锁后可读,禁止跨设备同步;普通 Preferences 不存密码 |
|
||||||
| 最近连接、删除、在线状态 | 最近 20 台、在线优先、真实信令订阅、30 秒刷新和 60 秒过期;删除同步清理密码与预览 |
|
| 最近连接、删除、在线状态 | 最近 20 台、在线优先、真实信令订阅、30 秒刷新和 60 秒过期;删除同步清理密码与预览 |
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { RemotePrivacyScreen, PrivacyStatus } from './RemotePrivacyScreen';
|
||||||
import bridge from 'libcrossdesk.so';
|
import bridge from 'libcrossdesk.so';
|
||||||
import { common, bundleManager } from '@kit.AbilityKit';
|
import { common, bundleManager } from '@kit.AbilityKit';
|
||||||
import { display, promptAction } from '@kit.ArkUI';
|
import { display, promptAction } from '@kit.ArkUI';
|
||||||
@@ -13,7 +14,7 @@ interface Command {
|
|||||||
logs?: string; certificates?: string; directory?: string; previewDirectory?: string; remote?: string; password?: string; version?: string;
|
logs?: string; certificates?: string; directory?: string; previewDirectory?: string; remote?: string; password?: string; version?: string;
|
||||||
id?: string; x?: number; y?: number; flag?: number; scroll?: number; code?: number; down?: boolean;
|
id?: string; x?: number; y?: number; flag?: number; scroll?: number; code?: number; down?: boolean;
|
||||||
index?: number; muted?: boolean; visible?: boolean; text?: string; message?: Object; quality?: number; frameRate?: number;
|
index?: number; muted?: boolean; visible?: boolean; text?: string; message?: Object; quality?: number; frameRate?: number;
|
||||||
preference?: number; request?: number; path?: string; name?: string; body?: string; local?: boolean;
|
preference?: number; blockLocalInput?: boolean; request?: number; path?: string; name?: string; body?: string; local?: boolean;
|
||||||
}
|
}
|
||||||
interface NativeEvent {
|
interface NativeEvent {
|
||||||
type: string; session: number; connection?: number; status?: number; controller?: boolean; generation?: number;
|
type: string; session: number; connection?: number; status?: number; controller?: boolean; generation?: number;
|
||||||
@@ -25,7 +26,7 @@ export interface RemoteDisplay { name: string; }
|
|||||||
interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; }
|
interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; }
|
||||||
interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; }
|
interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; }
|
||||||
interface ServiceStatus { available: boolean; }
|
interface ServiceStatus { available: boolean; }
|
||||||
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; }
|
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; }
|
||||||
interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; }
|
interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; }
|
||||||
interface SignalType { type: string; }
|
interface SignalType { type: string; }
|
||||||
|
|
||||||
@@ -73,6 +74,12 @@ export class MobileState {
|
|||||||
videoSelectedPreference: number = 1;
|
videoSelectedPreference: number = 1;
|
||||||
videoSupported: boolean = false;
|
videoSupported: boolean = false;
|
||||||
videoFeedback: string = '';
|
videoFeedback: string = '';
|
||||||
|
privacyStatus: string = '';
|
||||||
|
privacyButtonTitle: string = '隐私屏';
|
||||||
|
privacyFeedback: string = '';
|
||||||
|
privacyEnabled: boolean = false;
|
||||||
|
privacyActive: boolean = false;
|
||||||
|
privacyCanToggle: boolean = false;
|
||||||
networkTime: number = 0;
|
networkTime: number = 0;
|
||||||
unread: number = 0;
|
unread: number = 0;
|
||||||
announcements: AnnouncementItem[] = [];
|
announcements: AnnouncementItem[] = [];
|
||||||
@@ -86,6 +93,7 @@ export class MobileController {
|
|||||||
store: MobileStore;
|
store: MobileStore;
|
||||||
platform: MobilePlatform;
|
platform: MobilePlatform;
|
||||||
video: VideoSettings = new VideoSettings();
|
video: VideoSettings = new VideoSettings();
|
||||||
|
private privacyScreen: RemotePrivacyScreen = new RemotePrivacyScreen();
|
||||||
network: NetworkStatistics = new NetworkStatistics();
|
network: NetworkStatistics = new NetworkStatistics();
|
||||||
presence: RecentPresence;
|
presence: RecentPresence;
|
||||||
inbox: AnnouncementInbox;
|
inbox: AnnouncementInbox;
|
||||||
@@ -159,7 +167,7 @@ export class MobileController {
|
|||||||
this.state.update = ''; this.state.updateStatus = ''; this.state.page = 'home'; this.state.modal = 'consent';
|
this.state.update = ''; this.state.updateStatus = ''; this.state.page = 'home'; this.state.modal = 'consent';
|
||||||
}
|
}
|
||||||
resume(): void {
|
resume(): void {
|
||||||
this.foreground = true; this.platform.resume();
|
this.foreground = true; this.platform.resume(); this.syncPrivacy();
|
||||||
if (this.state.consent) { this.ensureSignal(); this.checkLocal(); this.inbox.connect(this.state.signal === 1); }
|
if (this.state.consent) { this.ensureSignal(); this.checkLocal(); this.inbox.connect(this.state.signal === 1); }
|
||||||
else this.state.modal = 'consent';
|
else this.state.modal = 'consent';
|
||||||
}
|
}
|
||||||
@@ -236,6 +244,7 @@ export class MobileController {
|
|||||||
this.network = new NetworkStatistics(); let edge = 1920;
|
this.network = new NetworkStatistics(); let edge = 1920;
|
||||||
try { const screen = display.getDefaultDisplaySync(); edge = Math.max(screen.width, screen.height); } catch {}
|
try { const screen = display.getDefaultDisplaySync(); edge = Math.max(screen.width, screen.height); } catch {}
|
||||||
this.video = new VideoSettings(this.state.videoPreference, edge); this.syncVideo();
|
this.video = new VideoSettings(this.state.videoPreference, edge); this.syncVideo();
|
||||||
|
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
|
||||||
this.store.put('lastRemote', this.state.remote); this.state.password = '';
|
this.store.put('lastRemote', this.state.remote); this.state.password = '';
|
||||||
this.ensureSignal(); this.joinIfReady();
|
this.ensureSignal(); this.joinIfReady();
|
||||||
}
|
}
|
||||||
@@ -247,6 +256,7 @@ export class MobileController {
|
|||||||
end(reason: string): void {
|
end(reason: string): void {
|
||||||
this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect();
|
this.release(); this.send({ op: 'disconnect' }); this.platform.disconnect();
|
||||||
this.connection++; this.uiEpoch++; this.pendingPassword = ''; this.joinSent = false; this.sendingFile = false;
|
this.connection++; this.uiEpoch++; this.pendingPassword = ''; this.joinSent = false; this.sendingFile = false;
|
||||||
|
this.privacyScreen = new RemotePrivacyScreen(); this.syncPrivacy();
|
||||||
this.state.connecting = this.state.ready = false; this.state.page = 'home'; this.state.modal = ''; this.state.password = '';
|
this.state.connecting = this.state.ready = false; this.state.page = 'home'; this.state.modal = ''; this.state.password = '';
|
||||||
this.state.receivedPath = this.state.receivedName = this.state.remoteClipboard = this.state.clipboardText = ''; this.remoteVersionRequest = 0;
|
this.state.receivedPath = this.state.receivedName = this.state.remoteClipboard = this.state.clipboardText = ''; this.remoteVersionRequest = 0;
|
||||||
if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.settingsTimeout = -1;
|
if (this.settingsTimeout >= 0) clearTimeout(this.settingsTimeout); this.settingsTimeout = -1;
|
||||||
@@ -339,6 +349,21 @@ export class MobileController {
|
|||||||
this.state.documentTitle = title; this.state.documentBack = back; this.state.page = 'document'; this.state.modal = '';
|
this.state.documentTitle = title; this.state.documentBack = back; this.state.page = 'document'; this.state.modal = '';
|
||||||
} catch { this.toast('无法读取文档'); }
|
} catch { this.toast('无法读取文档'); }
|
||||||
}
|
}
|
||||||
|
togglePrivacy(): void {
|
||||||
|
if (!this.state.ready || !this.state.consent) return;
|
||||||
|
const enable = !this.privacyScreen.active(), request = this.privacyScreen.begin(this.now());
|
||||||
|
if (request < 0) return;
|
||||||
|
this.send({ op: 'privacy', flag: enable ? 1 : 2, request: request,
|
||||||
|
blockLocalInput: true });
|
||||||
|
this.syncPrivacy();
|
||||||
|
}
|
||||||
|
private syncPrivacy(): void {
|
||||||
|
const now = this.now(); this.privacyScreen.tick(now);
|
||||||
|
this.state.privacyStatus = this.privacyScreen.status(now); this.state.privacyFeedback = this.privacyScreen.feedback(now);
|
||||||
|
this.state.privacyButtonTitle = this.privacyScreen.buttonTitle(now);
|
||||||
|
this.state.privacyEnabled = this.privacyScreen.enabled(now);
|
||||||
|
this.state.privacyActive = this.privacyScreen.active(); this.state.privacyCanToggle = this.privacyScreen.canToggle(now);
|
||||||
|
}
|
||||||
private syncVideo(): void {
|
private syncVideo(): void {
|
||||||
this.state.videoQuality = this.video.selection.quality; this.state.videoRate = this.video.selection.frameRate;
|
this.state.videoQuality = this.video.selection.quality; this.state.videoRate = this.video.selection.frameRate;
|
||||||
this.state.videoSelectedPreference = this.video.selection.preference; this.state.videoSupported = this.video.supported;
|
this.state.videoSelectedPreference = this.video.selection.preference; this.state.videoSupported = this.video.supported;
|
||||||
@@ -348,6 +373,7 @@ export class MobileController {
|
|||||||
try { const events = JSON.parse(bridge.mobilePoll()) as NativeEvent[]; events.forEach((event: NativeEvent) => this.event(event)); }
|
try { const events = JSON.parse(bridge.mobilePoll()) as NativeEvent[]; events.forEach((event: NativeEvent) => this.event(event)); }
|
||||||
catch { this.state.signalText = '原生通信库不可用,请重新构建并安装'; }
|
catch { this.state.signalText = '原生通信库不可用,请重新构建并安装'; }
|
||||||
if (++this.seconds % 20 === 0) {
|
if (++this.seconds % 20 === 0) {
|
||||||
|
if (this.state.ready) this.syncPrivacy();
|
||||||
this.state.networkTime = this.now(); this.presence.tick(this.state.networkTime); this.history();
|
this.state.networkTime = this.now(); this.presence.tick(this.state.networkTime); this.history();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,7 +421,8 @@ export class MobileController {
|
|||||||
if (e.sending && ((e.progress ?? 0) < 0 || (e.progress ?? 0) >= 1)) this.sendingFile = false;
|
if (e.sending && ((e.progress ?? 0) < 0 || (e.progress ?? 0) >= 1)) this.sendingFile = false;
|
||||||
this.state.transfer = (e.progress ?? 0) < 0 ? `${e.name} 传输失败` : `${e.sending ? '发送' : '接收'} ${e.name} · ${(e.progress ?? 0) >= 1 ? '已完成' : Math.floor((e.progress ?? 0) * 100) + '%'}`;
|
this.state.transfer = (e.progress ?? 0) < 0 ? `${e.name} 传输失败` : `${e.sending ? '发送' : '接收'} ${e.name} · ${(e.progress ?? 0) >= 1 ? '已完成' : Math.floor((e.progress ?? 0) * 100) + '%'}`;
|
||||||
if (e.path) { this.state.receivedPath = e.path; this.state.receivedName = e.name ?? 'received-file'; }
|
if (e.path) { this.state.receivedPath = e.path; this.state.receivedName = e.name ?? 'received-file'; }
|
||||||
} else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
|
} else if (e.type === 'privacyFailed') { this.privacyScreen.failed(Number(e.request)); this.syncPrivacy(); }
|
||||||
|
else if (e.type === 'settingsFailed') { this.video.expire(Number(e.request)); this.syncVideo(); }
|
||||||
else if (e.type === 'preview') {
|
else if (e.type === 'preview') {
|
||||||
this.previewPending = false;
|
this.previewPending = false;
|
||||||
if (e.ok && e.path && this.previewEligible && this.state.previews) {
|
if (e.ok && e.path && this.previewEligible && this.state.previews) {
|
||||||
@@ -435,6 +462,7 @@ export class MobileController {
|
|||||||
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
|
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
|
||||||
}
|
}
|
||||||
private data(message: ControlData): void {
|
private data(message: ControlData): void {
|
||||||
|
if (message.type === 10 && message.privacy_status) { this.privacyScreen.receive(message.privacy_status, this.now()); this.syncPrivacy(); return; }
|
||||||
if (message.type === 5) { this.serviceAvailable = message.service_status?.available === true; return; }
|
if (message.type === 5) { this.serviceAvailable = message.service_status?.available === true; return; }
|
||||||
if (message.type === 12 && message.video_settings) {
|
if (message.type === 12 && message.video_settings) {
|
||||||
const v = message.video_settings;
|
const v = message.video_settings;
|
||||||
|
|||||||
@@ -133,9 +133,10 @@ export struct MobileRemoteView {
|
|||||||
if (index === 7) this.menu = 'network';
|
if (index === 7) this.menu = 'network';
|
||||||
if (index === 8) this.controller?.sas();
|
if (index === 8) this.controller?.sas();
|
||||||
if (index === 9) { this.state.modal = 'clipboard'; this.menu = ''; }
|
if (index === 9) { this.state.modal = 'clipboard'; this.menu = ''; }
|
||||||
|
if (index === 10) this.controller?.togglePrivacy();
|
||||||
}
|
}
|
||||||
private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板']; }
|
private labels(): string[] { return ['键盘', this.state.relative ? '相对鼠标' : '绝对鼠标', this.mouseVisible ? '收起鼠标' : '虚拟鼠标', '显示器', '画面设置', this.state.muted ? '静音' : '声音', '发送文件', '网络状态', 'Ctrl+Alt+Del', '剪贴板', this.state.privacyButtonTitle]; }
|
||||||
private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard']; }
|
private icons(): string[] { return ['keyboard', 'mouse', 'mouse', 'display', 'sliders', this.state.muted ? 'muted' : 'audio', 'folder', 'chart', 'lock', 'clipboard', 'lock']; }
|
||||||
private columns(): number { return this.viewHeight < 400 && this.viewWidth >= 400 ? 4 : 3; }
|
private columns(): number { return this.viewHeight < 400 && this.viewWidth >= 400 ? 4 : 3; }
|
||||||
private menuWidth(): number { return Math.max(1, Math.min(this.viewWidth - 16, this.columns() === 4 ? 380 : Math.max(280, Math.min(340, this.viewWidth - 160)))); }
|
private menuWidth(): number { return Math.max(1, Math.min(this.viewWidth - 16, this.columns() === 4 ? 380 : Math.max(280, Math.min(340, this.viewWidth - 160)))); }
|
||||||
private menuX(): number { return clamp(this.orbX > this.viewWidth / 2 ? this.orbX - this.menuWidth() - 12 : this.orbX + 66, 8, this.viewWidth - this.menuWidth() - 8); }
|
private menuX(): number { return clamp(this.orbX > this.viewWidth / 2 ? this.orbX - this.menuWidth() - 12 : this.orbX + 66, 8, this.viewWidth - this.menuWidth() - 8); }
|
||||||
@@ -212,12 +213,20 @@ export struct MobileRemoteView {
|
|||||||
GridRow({ columns: this.columns(), gutter: 7 }) {
|
GridRow({ columns: this.columns(), gutter: 7 }) {
|
||||||
ForEach(this.labels(), (label: string, index: number) => {
|
ForEach(this.labels(), (label: string, index: number) => {
|
||||||
GridCol() {
|
GridCol() {
|
||||||
Column({ space: 4 }) { MobileIcon({ name: this.icons()[index], iconExtent: 19 }); Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1) }
|
Column({ space: 4 }) {
|
||||||
.height(52).width('100%').justifyContent(FlexAlign.Center).backgroundColor('#F0F0F2').borderRadius(10)
|
MobileIcon({ name: this.icons()[index], iconExtent: 19 })
|
||||||
.accessibilityText(label).accessibilityLevel('yes').onClick(() => this.action(index))
|
Text(label).fontSize(10).fontColor(MobileTheme.ink).maxLines(1)
|
||||||
|
}
|
||||||
|
.height(52).width('100%').justifyContent(FlexAlign.Center)
|
||||||
|
.backgroundColor('#F0F0F2').borderRadius(10)
|
||||||
|
.enabled(index !== 10 || this.state.ready && this.state.privacyCanToggle)
|
||||||
|
.opacity(index === 10 && (!this.state.ready || !this.state.privacyCanToggle) ? 0.45 : 1)
|
||||||
|
.accessibilitySelected(index === 10 && this.state.privacyEnabled)
|
||||||
|
.accessibilityText(index === 10 ? `${label},${this.state.privacyStatus}` : label).accessibilityLevel('yes').onClick(() => this.action(index))
|
||||||
}
|
}
|
||||||
}, (label: string) => label)
|
}, (label: string) => label)
|
||||||
}
|
}
|
||||||
|
if (this.state.privacyFeedback) { Text(this.state.privacyFeedback).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
|
||||||
if (this.state.transfer) { Text(this.state.transfer).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
|
if (this.state.transfer) { Text(this.state.transfer).fontSize(11).fontColor(MobileTheme.secondary).width('100%') }
|
||||||
if (this.state.receivedPath) { Button('保存收到的文件', { type: ButtonType.Normal }).fontSize(14).fontColor(MobileTheme.blue).backgroundColor(Color.White).height(42).width('100%').onClick(() => this.controller?.saveFile()) }
|
if (this.state.receivedPath) { Button('保存收到的文件', { type: ButtonType.Normal }).fontSize(14).fontColor(MobileTheme.blue).backgroundColor(Color.White).height(42).width('100%').onClick(() => this.controller?.saveFile()) }
|
||||||
} else if (this.menu === 'video') {
|
} else if (this.menu === 'video') {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
export interface PrivacyStatus {
|
||||||
|
state: number; supported: boolean; input_block_supported: boolean;
|
||||||
|
overlay_active: boolean; input_blocked: boolean; remote_paused: boolean;
|
||||||
|
revision: number; reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemotePrivacyScreen {
|
||||||
|
snapshot?: PrivacyStatus;
|
||||||
|
pending: boolean = false;
|
||||||
|
requestId: number = 0;
|
||||||
|
private requestRevision: number = 0;
|
||||||
|
private receivedAt: number = 0;
|
||||||
|
private requestedAt: number = 0;
|
||||||
|
private failure: string = '';
|
||||||
|
|
||||||
|
receive(s: PrivacyStatus, now: number): void {
|
||||||
|
if (!Number.isInteger(s.state) || s.state < 0 || s.state > 5 ||
|
||||||
|
!Number.isInteger(s.revision) || s.revision < 0 || s.revision > 0xffffffff ||
|
||||||
|
typeof s.supported !== 'boolean' || typeof s.input_block_supported !== 'boolean' ||
|
||||||
|
typeof s.overlay_active !== 'boolean' || typeof s.input_blocked !== 'boolean' ||
|
||||||
|
typeof s.remote_paused !== 'boolean' || typeof s.reason !== 'string' || s.reason.includes('\0')) return;
|
||||||
|
if (s.state === 3 && (!s.supported || !s.overlay_active || s.remote_paused)) return;
|
||||||
|
if (this.snapshot && ((s.revision - this.snapshot.revision) | 0) < 0) return;
|
||||||
|
this.snapshot = s; this.receivedAt = now; this.failure = '';
|
||||||
|
if (this.pending && ((s.revision - this.requestRevision) | 0) > 0 && s.state !== 2 && s.state !== 4) this.pending = false;
|
||||||
|
}
|
||||||
|
active(): boolean {
|
||||||
|
const s = this.snapshot;
|
||||||
|
return s !== undefined && (s.overlay_active || s.remote_paused || s.state === 2 || s.state === 3 || s.state === 4);
|
||||||
|
}
|
||||||
|
fresh(now: number): boolean { return this.snapshot !== undefined && now - this.receivedAt <= 5000; }
|
||||||
|
enabled(now: number): boolean {
|
||||||
|
const s = this.snapshot;
|
||||||
|
return s !== undefined && this.fresh(now) && s.state === 3 && s.overlay_active && !s.remote_paused;
|
||||||
|
}
|
||||||
|
buttonTitle(now: number): string {
|
||||||
|
if (this.enabled(now)) return '隐私屏已开启';
|
||||||
|
const s = this.snapshot;
|
||||||
|
if (this.fresh(now) && s?.supported && s.state === 1 && !this.active()) return '隐私屏已关闭';
|
||||||
|
return '隐私屏';
|
||||||
|
}
|
||||||
|
feedback(now: number): string {
|
||||||
|
const s = this.snapshot;
|
||||||
|
if (!this.failure && (this.pending || this.fresh(now) && (s?.state === 2 || s?.state === 4))) return '';
|
||||||
|
if (!this.failure && !this.pending && this.fresh(now) && s?.supported &&
|
||||||
|
(this.enabled(now) || s.state === 1 && !this.active())) return '';
|
||||||
|
const detail = this.detail(now);
|
||||||
|
return this.status(now) + (detail ? ':' + detail : '');
|
||||||
|
}
|
||||||
|
canToggle(now: number): boolean {
|
||||||
|
const s = this.snapshot;
|
||||||
|
return s !== undefined && !this.pending && this.fresh(now) && s.state !== 4 && (this.active() || s.supported && s.state === 1);
|
||||||
|
}
|
||||||
|
begin(now: number): number {
|
||||||
|
this.tick(now);
|
||||||
|
if (!this.canToggle(now) || !this.snapshot) return -1;
|
||||||
|
this.pending = true; this.requestedAt = now; this.requestRevision = this.snapshot.revision; this.failure = '';
|
||||||
|
this.requestId = (this.requestId + 1) >>> 0;
|
||||||
|
return this.requestId;
|
||||||
|
}
|
||||||
|
failed(id: number): void {
|
||||||
|
if (this.pending && this.requestId === id) { this.pending = false; this.failure = '指令发送失败,请重试'; }
|
||||||
|
}
|
||||||
|
tick(now: number): void {
|
||||||
|
if (this.pending && now - this.requestedAt >= 15000) {
|
||||||
|
this.pending = false; this.failure = '请求超时,尚未确认电脑的隐私屏状态';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status(now: number): string {
|
||||||
|
if (this.failure) return this.failure;
|
||||||
|
const s = this.snapshot;
|
||||||
|
if (!s) return '等待电脑返回隐私屏状态;旧版电脑端可能不支持';
|
||||||
|
if (!this.fresh(now)) return '隐私屏状态已过期,等待电脑确认';
|
||||||
|
if (s.state === 2) return '正在开启隐私屏…';
|
||||||
|
if (s.state === 4) return '正在关闭隐私屏…';
|
||||||
|
if (this.pending) return '指令已发送,等待电脑确认…';
|
||||||
|
if (s.state === 5) return '隐私屏异常';
|
||||||
|
if (!s.supported || s.state === 0) return '隐私屏暂不可用';
|
||||||
|
if (s.state === 3 && s.overlay_active && !s.remote_paused)
|
||||||
|
return '隐私屏已开启';
|
||||||
|
return s.state === 1 && !this.active() ? '隐私屏已关闭' : '隐私屏状态待确认';
|
||||||
|
}
|
||||||
|
detail(now: number): string {
|
||||||
|
const s = this.snapshot;
|
||||||
|
return s && this.fresh(now) && (s.state === 0 || s.state === 5 || !s.supported) ? s.reason : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ struct Mobile {
|
|||||||
.padding({ left: 14, right: 14 }).onFocus(() => this.idFocused = true).onBlur(() => this.idFocused = false)
|
.padding({ left: 14, right: 14 }).onFocus(() => this.idFocused = true).onBlur(() => this.idFocused = false)
|
||||||
.onChange((value: string) => this.state.remote = this.deviceId(value)).onSubmit(() => this.controller?.requestConnection(this.state.remote))
|
.onChange((value: string) => this.state.remote = this.deviceId(value)).onSubmit(() => this.controller?.requestConnection(this.state.remote))
|
||||||
Column() {
|
Column() {
|
||||||
MobilePrimaryButton({ label: '连接', arrow: true, buttonHeight: 54, available: this.state.remote.length > 0,
|
MobilePrimaryButton({ label: '连接', buttonHeight: 54, available: this.state.remote.length > 0,
|
||||||
action: () => this.controller?.requestConnection(this.state.remote) })
|
action: () => this.controller?.requestConnection(this.state.remote) })
|
||||||
}.width(96)
|
}.width(96)
|
||||||
}.width('100%')
|
}.width('100%')
|
||||||
@@ -486,7 +486,7 @@ struct Mobile {
|
|||||||
Toggle({ type: ToggleType.Switch, isOn: this.state.remember }).selectedColor(MobileTheme.green)
|
Toggle({ type: ToggleType.Switch, isOn: this.state.remember }).selectedColor(MobileTheme.green)
|
||||||
.onChange((value: boolean) => this.state.remember = value).accessibilityText('保存密码')
|
.onChange((value: boolean) => this.state.remember = value).accessibilityText('保存密码')
|
||||||
}.width('100%').margin({ top: 18, bottom: 18 })
|
}.width('100%').margin({ top: 18, bottom: 18 })
|
||||||
MobilePrimaryButton({ label: '连接', arrow: true, available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) })
|
MobilePrimaryButton({ label: '连接', available: this.state.password.length > 0, action: () => this.controller?.begin(this.state.password) })
|
||||||
}.padding({ left: 22, right: 22, top: 10, bottom: 22 }).width('100%')
|
}.padding({ left: 22, right: 22, top: 10, bottom: 22 }).width('100%')
|
||||||
}.width(this.viewWidth < 600 ? '100%' : Math.min(520, this.viewWidth - 48)).constraintSize({ maxHeight: this.viewHeight * 0.92 })
|
}.width(this.viewWidth < 600 ? '100%' : Math.min(520, this.viewWidth - 48)).constraintSize({ maxHeight: this.viewHeight * 0.92 })
|
||||||
.backgroundColor(MobileTheme.background).borderRadius(24).scrollBar(BarState.Off)
|
.backgroundColor(MobileTheme.background).borderRadius(24).scrollBar(BarState.Off)
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ class MobileSession {
|
|||||||
s->Event({{"type", "connection"}, {"status", status}}, true);
|
s->Event({{"type", "connection"}, {"status", status}}, true);
|
||||||
if (status == Connected && !s->connected_) {
|
if (status == Connected && !s->connected_) {
|
||||||
s->connecting_ = false; s->connected_ = true;
|
s->connecting_ = false; s->connected_ = true;
|
||||||
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, false, s->version_, HostPlatform::HarmonyOS);
|
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, true, s->version_, HostPlatform::HarmonyOS);
|
||||||
s->Send(info); FreeRemoteAction(info);
|
s->Send(info); FreeRemoteAction(info);
|
||||||
RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio);
|
RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio);
|
||||||
s->audio_.Start();
|
s->audio_.Start();
|
||||||
@@ -328,6 +328,12 @@ class MobileSession {
|
|||||||
auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>());
|
auto bytes=mobile_controller::VideoSettingsMessage(q,fps,pref,v.at("request").get<uint32_t>());
|
||||||
if(SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0)
|
if(SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)!=0)
|
||||||
Event({{"type","settingsFailed"},{"request",v.at("request")}},true);
|
Event({{"type","settingsFailed"},{"request",v.at("request")}},true);
|
||||||
|
} else if (op=="privacy") {
|
||||||
|
const auto bytes = mobile_controller::PrivacyMessage(
|
||||||
|
v.at("flag").get<int>(), v.at("blockLocalInput").get<bool>());
|
||||||
|
if (bytes.empty() ||
|
||||||
|
SendReliableDataFrame(controller_, bytes.data(), bytes.size(), kControlStream) != 0)
|
||||||
|
Event({{"type", "privacyFailed"}, {"request", v.at("request")}}, true);
|
||||||
} else if (op=="sas") {
|
} else if (op=="sas") {
|
||||||
const auto bytes=mobile_controller::SecureAttentionMessage();
|
const auto bytes=mobile_controller::SecureAttentionMessage();
|
||||||
const bool ok=SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)==0;
|
const bool ok=SendReliableDataFrame(controller_,bytes.data(),bytes.size(),kControlStream)==0;
|
||||||
|
|||||||
@@ -61,11 +61,14 @@ def bootstrap(env):
|
|||||||
seed = Path.home() / '.xmake/repositories/xmake-repo'
|
seed = Path.home() / '.xmake/repositories/xmake-repo'
|
||||||
source = seed if (seed / '.git').exists() else 'https://github.com/xmake-io/xmake-repo.git'
|
source = seed if (seed / '.git').exists() else 'https://github.com/xmake-io/xmake-repo.git'
|
||||||
run(['git', 'clone', '--no-hardlinks', source, repository], env=env)
|
run(['git', 'clone', '--no-hardlinks', source, repository], env=env)
|
||||||
run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env)
|
run(['git', 'remote', 'set-url', 'origin', 'https://github.com/xmake-io/xmake-repo.git'], cwd=repository, env=env)
|
||||||
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip()
|
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip()
|
||||||
if revision != PACKAGE_REVISION:
|
if revision != PACKAGE_REVISION:
|
||||||
run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, env=env)
|
run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, env=env)
|
||||||
run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, env=env)
|
run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, env=env)
|
||||||
|
# Mark this pinned checkout as ready so Xmake's first-run repository update
|
||||||
|
# cannot replace it with a mirror or advance it beyond PACKAGE_REVISION.
|
||||||
|
(repository / 'updated').touch()
|
||||||
|
|
||||||
|
|
||||||
def mobile_project():
|
def mobile_project():
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F20000000000000000001 /* RemotePrivacyScreen.swift */; };
|
||||||
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
|
100000000000000000000030 /* RemoteSystemKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000030 /* RemoteSystemKeyboard.swift */; };
|
||||||
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
|
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
|
||||||
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
|
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
C10F20000000000000000001 /* RemotePrivacyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePrivacyScreen.swift; sourceTree = "<group>"; };
|
||||||
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
|
200000000000000000000030 /* RemoteSystemKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSystemKeyboard.swift; sourceTree = "<group>"; };
|
||||||
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
|
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
|
||||||
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
|
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
|
||||||
@@ -118,6 +120,7 @@
|
|||||||
20000000000000000000001C /* AppUpdateChecker.swift */,
|
20000000000000000000001C /* AppUpdateChecker.swift */,
|
||||||
200000000000000000000018 /* RecentConnection.swift */,
|
200000000000000000000018 /* RecentConnection.swift */,
|
||||||
200000000000000000000016 /* RemoteVideoSettings.swift */,
|
200000000000000000000016 /* RemoteVideoSettings.swift */,
|
||||||
|
C10F20000000000000000001 /* RemotePrivacyScreen.swift */,
|
||||||
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
|
200000000000000000000017 /* RemoteNetworkStatistics.swift */,
|
||||||
200000000000000000000003 /* ContentView.swift */,
|
200000000000000000000003 /* ContentView.swift */,
|
||||||
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
|
20000000000000000000000E /* ThirdPartyLicensesView.swift */,
|
||||||
@@ -274,7 +277,7 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/zsh;
|
shellPath = /bin/zsh;
|
||||||
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\"\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/generate_third_party_licenses.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --target-info \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/minirtc-target-info.txt\" --xmake-version \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\" --platform \"${PLATFORM_NAME}\"\n";
|
shellScript = "set -e\npython3 \"${PROJECT_DIR}/scripts/prepare_source_metadata.py\" --output \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SourceMetadata.json\"\n\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\npython3 \"${PROJECT_DIR}/scripts/verify_native_build.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --xmake-version \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-version.txt\" --xmake-repository \"$(cat \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/xmake-repository.txt\")\"\npython3 \"${PROJECT_DIR}/scripts/package_openssl_ios.py\" --inputs \"${PROJECT_DIR}/Vendor/${PLATFORM_NAME}/${CONFIGURATION}/libCrossDeskMiniRTC.a.inputs.sha256\" --output \"${BUILT_PRODUCTS_DIR}/OpenSSL.framework\" --platform \"${PLATFORM_NAME}\"\n";
|
||||||
};
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
@@ -291,6 +294,7 @@
|
|||||||
10000000000000000000001C /* AppUpdateChecker.swift in Sources */,
|
10000000000000000000001C /* AppUpdateChecker.swift in Sources */,
|
||||||
100000000000000000000018 /* RecentConnection.swift in Sources */,
|
100000000000000000000018 /* RecentConnection.swift in Sources */,
|
||||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
|
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */,
|
||||||
|
C10F10000000000000000001 /* RemotePrivacyScreen.swift in Sources */,
|
||||||
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
|
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */,
|
||||||
100000000000000000000003 /* ContentView.swift in Sources */,
|
100000000000000000000003 /* ContentView.swift in Sources */,
|
||||||
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */,
|
100000000000000000000009 /* ThirdPartyLicensesView.swift in Sources */,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user