Compare commits

...
66 Commits
Author SHA1 Message Date
dijunkun 086da155f0 [feat] add website update button to about dialog 2026-10-19 23:27:12 +08:00
dijunkun 8a7de23c7f [feat] show sponsorship prompt once per version 2026-10-19 23:06:14 +08:00
dijunkun e7a49361f7 [feat] add a one-time sponsorship prompt 2026-10-19 17:39:50 +08:00
dijunkun 9661d56ed5 [fix] remove conflicting update dialog height constraints 2026-10-19 11:47:50 +08:00
dijunkun 28451e3e03 [fix] unify update and about dialog geometry across platforms 2026-10-19 11:39:42 +08:00
dijunkun 44f19a595b [fix] keep about dialog layout stable during update checks 2026-10-19 11:29:19 +08:00
dijunkun 9a4661e327 [fix] allow controlled devices to queue files during transfers 2026-10-19 11:16:12 +08:00
dijunkun 7df0b6118e [feat] add file transfer cancellation controls 2026-10-19 11:11:00 +08:00
dijunkun b2d3834944 [feat] unify persistent file transfer notifications 2026-10-19 10:53:55 +08:00
dijunkun 7662b80995 [chore] update MiniRTC documentation reference 2026-10-19 04:55:02 +08:00
dijunkun 0b7273d79d [chore] replace issue templates with bilingual forms 2026-10-19 04:45:36 +08:00
dijunkun 695dacf87a [docs] improve README guides and refresh desktop screenshots 2026-10-19 04:39:28 +08:00
dijunkun 1ef372d27c [docs] refine README badge layout and styling 2026-10-19 04:30:09 +08:00
dijunkun 19bbd3b596 [docs] add separate sponsorship badges for each platform 2026-10-19 04:25:21 +08:00
dijunkun 1b21bb8640 [chore] add repository sponsorship links 2026-10-19 04:16:12 +08:00
dijunkun fc5fe9ac61 [feat] add sponsorship links to about and update dialogs 2026-10-19 04:12:14 +08:00
dijunkun a7adf342d7 [ui] clarify feature and performance benefits in remote update notice 2026-10-19 02:02:04 +08:00
dijunkun c8d03d9a5e [ui] simplify remote update dialog layout 2026-10-19 01:58:54 +08:00
dijunkun d8fe1053c2 [ui] refine device tooltips with compact adaptive cards 2026-10-19 01:53:57 +08:00
dijunkun fe93c7e0e4 [ui] refine recent connection card actions 2026-10-19 01:43:52 +08:00
dijunkun c61cf176cb [fix] limit remote ID input to nine digits without flicker 2026-10-19 01:21:35 +08:00
dijunkun 65a5288986 [ui] reorder mouse controls in mobile floating menus 2026-10-18 22:16:32 +08:00
dijunkun 36f7213162 [feat] allow pinch zoom outside the remote image on mobile 2026-10-18 22:09:35 +08:00
dijunkun acd56dfc29 [feat] add vertical pointer edge panning on mobile 2026-10-18 22:02:17 +08:00
dijunkun b948422bfe [feat] add Android system keyboard input and improve computer keyboard, refs #118 2026-10-18 21:20:37 +08:00
dijunkun 42856be8b8 [feat] add iOS system keyboard input and improve computer keyboard 2026-10-18 20:15:45 +08:00
dijunkun 02c3952be0 [feat] activate existing window on duplicate launches, refs #119 2026-10-18 14:17:26 +08:00
dijunkun 34eb2b1fe3 [chore] update MiniRTC for web ICE connectivity 2026-10-17 15:24:42 +08:00
dijunkun 0901d61533 [fix] restore Windows remote control after RDP disconnects 2026-10-16 18:20:45 +08:00
dijunkun 40670de954 [fix] accept web video settings and update MiniRTC QoS 2026-10-16 13:44:24 +08:00
dijunkun 33cbfa5b8a [chore] update MiniRTC for web video settings negotiation 2026-10-16 10:57:54 +08:00
dijunkun 81430d78d9 [chore] update MiniRTC for web quality fixes and 30fps cap 2026-10-16 10:12:38 +08:00
dijunkun aed6ad99c2 [chore] update MiniRTC for web QoS flow control 2026-10-16 09:40:58 +08:00
dijunkun 1e2520d472 [fix] update mobile license catalog recipe checksums 2026-10-16 01:14:11 +08:00
kunkundi 697843cccd [docs] document HarmonyOS support and project copyright 2026-10-16 00:53:54 +08:00
kunkundi 45bf6ec4ee [feat] add HarmonyOS desktop and mobile clients,issue #117 2026-10-16 00:42:55 +08:00
kunkundi 43a0789c1e [refactor] share mobile controller protocol and video timing 2026-10-16 00:40:07 +08:00
kunkundi c4756b298b [refactor] share file transfer core across desktop and mobile 2026-10-16 00:38:13 +08:00
dijunkun 47e6385f9a [ui] refine floating notices and dismiss them on interaction 2026-10-14 23:40:15 +08:00
dijunkun 9f610bfc9b [ui] update macOS permission dialog to match desktop styling 2026-10-14 22:56:29 +08:00
dijunkun 006526389a [docs] update README for DeskHub and refresh desktop screenshots 2026-10-14 21:06:06 +08:00
dijunkun e76d2bf903 [ui] unify settings dialogs and improve announcement controls 2026-10-14 20:18:51 +08:00
kunkundi 2cf0d79543 [ui] refine desktop layouts and unify control styling 2026-10-14 19:29:08 +08:00
dijunkun 3043b5f110 [fix] follow the selected device cursor in DeskHub 2026-10-14 10:20:31 +08:00
dijunkun 5721222724 [fix] disable already added devices in DeskHub recent picker 2026-10-14 10:20:20 +08:00
dijunkun 3ee0d42494 [feat] add DeskHub group control 2026-10-14 10:20:05 +08:00
dijunkun 417ef7d58c [fix] disconnect remote sessions when closing DeskHub 2026-10-14 04:17:44 +08:00
dijunkun 0a6356085d [feat] show video latency in DeskHub network status 2026-10-14 04:17:38 +08:00
dijunkun 61ff9b2787 [feat] add resizable DeskHub focus previews 2026-10-14 04:17:31 +08:00
dijunkun 4855b2d3b6 [fix] preserve DeskHub previews and control during fullscreen changes 2026-10-14 04:17:23 +08:00
dijunkun 2ac197c2de [ui] streamline DeskHub toolbar layout and buttons 2026-10-14 04:17:17 +08:00
dijunkun b90359b08d [fix] sync DeskHub previews with tile size and unify tooltips 2026-10-14 02:58:08 +08:00
dijunkun 5199b2df11 [feat] add standalone DeskHub window and resizable device table 2026-10-14 02:37:06 +08:00
dijunkun fd86dba212 [fix] preserve remote cursors when macOS hides the pointer, refs #116 2026-10-13 21:49:06 +08:00
dijunkun 2a9887ab8a [fix] fix UPnP leaks and disable UPnP on iOS, refs #115 2026-10-13 20:42:57 +08:00
dijunkun 435feb419c [ui] simplify DeskHub controls and unify device status styling 2026-10-13 18:12:57 +08:00
dijunkun 4e394ca650 [ui] place DeskHub thumbnails above the focused desktop and compact labels 2026-10-13 16:11:27 +08:00
dijunkun 4dfd8c3005 [feat] allocate DeskHub video bandwidth automatically 2026-10-13 15:39:50 +08:00
dijunkun de16eb3b92 [refactor] use MiniRTC APIs for DeskHub media pause controls 2026-10-13 15:39:34 +08:00
dijunkun 3bd0676672 [feat] add DeskHub stream controls and network status 2026-10-13 10:45:48 +08:00
dijunkun 73b4d0f645 [feat] add DeskHub device wall and shared group actions 2026-10-12 03:08:04 +08:00
dijunkun 54dbd04a8d [fix] isolate background connections and password retries 2026-10-12 03:08:00 +08:00
dijunkun c8907a00f9 [feat] negotiate per-device preview and audio subscriptions 2026-10-12 03:07:10 +08:00
dijunkun c9e0078bfb [fix] persist successful connections without saved passwords 2026-10-12 03:06:33 +08:00
dijunkun e156ff5277 [fix] handle Markdown separators in release notes 2026-10-12 03:06:18 +08:00
dijunkun b44a203b19 [fix] update MiniRTC with DTLS retransmission recovery 2026-10-12 03:06:03 +08:00
285 changed files with 25949 additions and 4378 deletions
+3
View File
@@ -0,0 +1,3 @@
github: kunkundi
ko_fi: kunkundi
custom: ["https://afdian.com/a/kunkundi"]
+130
View File
@@ -0,0 +1,130 @@
name: 问题反馈 / Bug report
description: 报告 CrossDesk 的错误或异常行为。Report a bug or unexpected behavior in CrossDesk.
title: "[Bug] "
labels: [bug]
assignees: [kunkundi]
body:
- type: markdown
attributes:
value: |
请先查看 [常见问题 / FAQ](https://github.com/kunkundi/crossdesk/blob/deskhub/docs/FAQ.md) 并搜索 [已有问题 / Existing issues](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
Please check the FAQ and existing issues first. You can write in Chinese or English.
- type: textarea
id: problem
attributes:
label: 问题与预期 / Problem and expected behavior
description: 描述实际发生的情况、错误提示,以及期望的行为。Describe what happened, any error messages, and what you expected.
placeholder: |
实际结果 / Actual behavior:
预期结果 / Expected behavior:
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: 复现步骤 / Steps to reproduce
description: 说明操作步骤和出现频率;偶发问题可描述发生前的操作。Include the steps and frequency. For intermittent issues, describe what you were doing.
placeholder: |
1.
2.
3.
出现频率 / Frequency:
validations:
required: true
- type: dropdown
id: clients
attributes:
label: 涉及客户端 / Affected clients
description: 可多选;Web 与原生移动端请分别选择。Select all that apply; distinguish Web from native mobile apps.
multiple: true
options:
- 桌面客户端(Windows / macOS / Linux) / Desktop client
- iOS / iPadOS(原生 / Native)
- Android(原生 / Native)
- 鸿蒙 PC / HarmonyOS PC
- 鸿蒙手机或平板 / HarmonyOS mobile
- Web 客户端 / Web client
- 服务端 / Server
- 其他 / Other
validations:
required: true
- type: textarea
id: controller
attributes:
label: 控制端或本机环境 / Controller or local environment
description: |
控制端是发起远程操作的设备。请填写完整系统和客户端版本;Web 用户另填浏览器及其版本。自行构建请附提交号。
仅涉及安装、启动或服务端时,在此填写发生问题的本机环境。
The controller initiates remote access. Include full OS and client versions; Web users should also include the browser and its version. For source builds, include the commit.
For installation, startup, or server-only issues, describe the affected local environment here.
placeholder: |
操作系统及版本 / OS and version:
CPU 架构(x64 / arm64 等) / CPU architecture:
CrossDesk 版本或提交号 / CrossDesk version or commit:
浏览器及版本(仅 Web) / Browser and version (Web only):
validations:
required: true
- type: textarea
id: host
attributes:
label: 被控端环境 / Remote host environment
description: |
被控端是接受远程操作的电脑。连接或会话问题请补充这一端的信息;不涉及远程设备时可留空。
The host is the computer being controlled. Include its details for connection or session issues; leave empty if no remote device is involved.
placeholder: |
操作系统及版本 / OS and version:
CPU 架构(x64 / arm64 等) / CPU architecture:
CrossDesk 版本或提交号 / CrossDesk version or commit:
validations:
required: false
- type: dropdown
id: service
attributes:
label: 服务部署方式 / Server deployment
options:
- 公共服务 / Public service
- 自托管 / Self-hosted
- 不确定 / Unknown
- 不适用 / Not applicable
validations:
required: false
- type: dropdown
id: connection
attributes:
label: 连接方式 / Connection type
description: 可在会话的网络状态中查看;无法确认时选择“不确定”。Check the session's network status, or choose Unknown.
options:
- P2P 直连 / Direct P2P
- TURN 中继 / TURN relay
- 尚未建立连接 / Connection not established
- 不确定 / Unknown
- 不适用 / Not applicable
validations:
required: false
- type: textarea
id: context
attributes:
label: 补充环境 / Additional environment
description: |
按问题补充相关信息,例如普通会话或桌面墙、自托管服务端版本、局域网或公网、VPN;画面问题可补充显卡、编码格式、硬件编解码设置和显示器情况。
Include relevant details such as a normal session or DeskHub, self-hosted server version, LAN or Internet, and VPN use. For video issues, add GPU, codec, hardware codec settings, and display configuration.
validations:
required: false
- type: textarea
id: attachments
attributes:
label: 日志与截图 / Logs and screenshots
description: |
可粘贴相关日志,或拖入日志文件、截图和录屏。上传前请隐去连接密码等敏感信息。
Paste relevant logs or attach log files, screenshots, and recordings. Redact connection passwords and other private information before uploading.
validations:
required: false
@@ -0,0 +1,72 @@
name: 需求建议 / Feature request
description: 提出 CrossDesk 的新功能或改进建议。Suggest a new feature or improvement for CrossDesk.
title: "[Feature] "
labels: [enhancement]
assignees: [kunkundi]
body:
- type: markdown
attributes:
value: |
提交前请搜索 [已有建议 / Existing requests](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
Please search existing requests before submitting. You can write in Chinese or English.
- type: textarea
id: use-case
attributes:
label: 使用场景与问题 / Use case and problem
description: 你希望完成什么?目前遇到什么限制?What are you trying to do, and what limitation do you encounter?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: 功能建议与预期效果 / Proposal and expected outcome
description: 描述希望新增或改进的行为,以及理想的使用方式。Describe the behavior you would like to add or improve and how you expect to use it.
validations:
required: true
- type: dropdown
id: scope
attributes:
label: 适用范围 / Scope
description: 可多选;通用建议可选择“跨平台或通用”。Select all that apply, or choose Cross-platform or general.
multiple: true
options:
- 跨平台或通用 / Cross-platform or general
- 桌面客户端(Windows / macOS / Linux) / Desktop client
- iOS / iPadOS
- Android
- 鸿蒙 PC / HarmonyOS PC
- 鸿蒙手机或平板 / HarmonyOS mobile
- Web 客户端 / Web client
- 桌面墙 / DeskHub
- 自托管服务 / Self-hosted services
- 文档或安装体验 / Documentation or installation
- 其他 / Other
validations:
required: false
- type: textarea
id: impact
attributes:
label: 影响范围与使用频率 / Impact and frequency
description: 哪些用户或场景会受益?你会多频繁地使用?Who would benefit, in which situations, and how often would you use this?
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: 替代方案 / Alternatives
description: 目前如何处理这个需求?是否考虑过其他实现方式?How do you handle this today, and what other approaches have you considered?
validations:
required: false
- type: textarea
id: references
attributes:
label: 参考资料与补充说明 / References and additional context
description: 可提供参考链接、截图、示意图或其他要求。Include links, screenshots, mockups, or any additional requirements.
validations:
required: false
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 常见问题 / FAQ
url: https://github.com/kunkundi/crossdesk/blob/deskhub/docs/FAQ.md
about: 连接、黑屏、系统权限与安装排查。Troubleshoot connections, blank video, permissions, and installation.
- name: 使用指南(中文)
url: https://github.com/kunkundi/crossdesk/blob/deskhub/docs/USER_GUIDE.md
about: 查看连接、桌面墙、移动端、无人值守和自托管的使用说明。
- name: User guide (English)
url: https://github.com/kunkundi/crossdesk/blob/deskhub/docs/USER_GUIDE_EN.md
about: Read about connections, DeskHub, mobile clients, unattended access, and self-hosting.
-35
View File
@@ -1,35 +0,0 @@
---
name: 问题反馈
about: 请在此提交问题报告,以便持续优化产品。
title: ''
labels: bug
assignees: kunkundi
---
**描述问题**
清晰简洁地描述遇到的错误。
**复现步骤**
复现该问题的步骤:
1. 前往 '...'
2. 点击 '....'
3. 出现错误
**预期行为**
清晰简洁地描述你期望发生的行为。
**截图**
如果适用,请添加截图以帮助说明问题。
**桌面端信息(请填写以下内容):**
- 操作系统: [例如 Windows 11]
- 版本: [例如 v1.1.10]
**移动端信息(请填写以下内容):**
- 设备: [例如 iPhone 17]
- 操作系统: [例如 iOS 26.1]
- 浏览器: [例如 系统浏览器、Safari]
**补充信息**
在此添加与问题相关的其他上下文内容。
-28
View File
@@ -1,28 +0,0 @@
---
name: 需求建议
about: 请在此提交功能需求或改进建议,以便后续迭代参考。
title: ''
labels: enhancement
assignees: kunkundi
---
**功能/改进建议描述**
清晰简洁地描述希望新增的功能或改进的内容。
**使用场景 / 背景**
说明该功能或改进的使用场景,以及解决后带来的价值。
**预期效果**
描述你认为最理想的功能表现或改进效果。
**参考示例(可选)**
提供类似功能截图、参考链接或其他说明,帮助更好理解需求。
**优先级(可选)**
- [ ] 高
- [ ] 中
- [ ] 低
**补充信息(可选)**
其他相关信息或特殊要求。
+17 -9
View File
@@ -1,18 +1,18 @@
# CrossDesk Privacy Policy # CrossDesk Privacy Policy
Last updated: October 10, 2026 Last updated: October 15, 2026
[中文](#中文) | [English](#english) [中文](#中文) | [English](#english)
## 中文 ## 中文
更新日期:2026 年 10 月 10 日 更新日期:2026 年 10 月 15 日
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。 CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
### 一、适用范围 ### 一、适用范围
本政策适用于 CrossDesk 桌面客户端、iOS 与 Android 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。 本政策适用于 CrossDesk 桌面客户端、iOS、Android 与鸿蒙移动客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。 如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
@@ -50,11 +50,13 @@ Android 中的设备身份及您选择保存的访问密码使用 Android Keysto
iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。 iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。
Android 默认信令连接同样进行 TLS 证书校验。 Android 与鸿蒙移动端默认信令连接同样进行 TLS 证书校验。
鸿蒙移动端使用系统 Asset Store 加密保存设备身份及您选择保存的访问密码,明确禁止凭据跨设备同步。设置和连接记录按服务器隔离。远程预览默认关闭,单独确认后从下一次连接起保存一张 640×360 预览;预览保存在应用私有缓存中,不上传,可随时清除,也可能被系统回收。关闭预览或撤回联网授权会清除预览。接收文件保存在应用私有目录,您可在会话中通过系统文件选择器导出。
### 五、移动端权限与授权选择 ### 五、移动端权限与授权选择
**隐私授权。** iOS 与 Android 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。 **隐私授权。** iOS、Android 与鸿蒙移动端 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。 **撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。
@@ -64,6 +66,8 @@ Android 客户端同样作为控制端接收远程画面与音频,不采集本
Android 远程会话优先使用系统画中画在切换应用后继续显示画面。画中画不可用时,通过前台服务和连接通知保持您已建立的远程连接;后台暂停本地画面渲染和音频播放,不申请悬浮窗权限。通知权限用于提供返回及断开入口,CPU 唤醒锁用于会话后台期间维持连接,返回前台或会话结束时释放。您可以在应用或连接通知中断开,移除应用任务也会结束会话及保活服务。 Android 远程会话优先使用系统画中画在切换应用后继续显示画面。画中画不可用时,通过前台服务和连接通知保持您已建立的远程连接;后台暂停本地画面渲染和音频播放,不申请悬浮窗权限。通知权限用于提供返回及断开入口,CPU 唤醒锁用于会话后台期间维持连接,返回前台或会话结束时释放。您可以在应用或连接通知中断开,移除应用任务也会结束会话及保活服务。
鸿蒙手机和平板只作为控制端,不申请本机录屏、输入注入、摄像头或麦克风权限;文件选择和剪贴板操作由您主动触发。已建立会话在切入后台时优先使用系统画中画,提供静音控制;画中画不可用时申请数据传输后台任务并显示系统通知。若系统不允许或停止后台任务且画中画不可用,会话会断开。后台切换释放远端按键;空闲后台不维持信令连接。完成联网授权后,应用在前台检查鸿蒙本机版本,并在连接后检查远端版本;版本请求不携带设备身份或远端版本。
应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。 应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。
### 六、查询与删除请求 ### 六、查询与删除请求
@@ -86,13 +90,13 @@ Android 远程会话优先使用系统画中画在切换应用后继续显示画
## English ## English
Updated: October 2, 2026 Updated: October 15, 2026
CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software. CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software.
### 1. Scope ### 1. Scope
This policy applies to the CrossDesk desktop, iOS and Android clients and the default network services provided by the CrossDesk project maintainers ("we" or "us"). This policy applies to the CrossDesk desktop, iOS, Android and HarmonyOS mobile clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply. If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply.
@@ -130,11 +134,13 @@ On Android, device identities and access passwords you choose to save are encryp
On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people. On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people.
Default signaling connections on Android also validate TLS certificates. Default signaling connections on Android and HarmonyOS mobile also validate TLS certificates.
HarmonyOS mobile uses the system Asset Store to encrypt device identities and saved access passwords, with cross-device credential synchronization disabled. Settings and connection records are scoped to the server. Remote previews are off by default. After separate confirmation, one 640×360 preview is saved per connection starting with the next connection. Previews remain in private app cache, are never uploaded, can be cleared and may be reclaimed by the system. Disabling previews or withdrawing network consent clears them. Received files remain in private app storage and can be exported through the system document picker during the session.
### 5. Mobile permissions and consent choices ### 5. Mobile permissions and consent choices
**Privacy authorization.** On first use of iOS or Android, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again. **Privacy authorization.** On first use of iOS, Android or HarmonyOS mobile, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
**Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data. **Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data.
@@ -144,6 +150,8 @@ The Android client also receives remote screens and audio as a controller and do
Android remote sessions prefer system picture-in-picture to continue displaying video when you switch apps. If picture-in-picture is unavailable, a foreground service and connection notification maintain the remote connection you have established. Local video rendering and audio playback pause in the background; no overlay permission is requested. Notification permission provides return and disconnect actions. A CPU wake lock helps maintain the connection while the session is backgrounded and is released on foreground return or session end. You can disconnect in the app or its connection notification; removing the app task also ends the session and its foreground service. Android remote sessions prefer system picture-in-picture to continue displaying video when you switch apps. If picture-in-picture is unavailable, a foreground service and connection notification maintain the remote connection you have established. Local video rendering and audio playback pause in the background; no overlay permission is requested. Notification permission provides return and disconnect actions. A CPU wake lock helps maintain the connection while the session is backgrounded and is released on foreground return or session end. You can disconnect in the app or its connection notification; removing the app task also ends the session and its foreground service.
HarmonyOS phones and tablets act only as controllers and do not request local screen recording, input injection, camera or microphone permissions. File selection and clipboard actions are user initiated. An established session prefers system picture-in-picture with a mute control when backgrounded. If unavailable, the app requests a data-transfer background task with a system notification. The session ends if the system disallows or stops that task while picture-in-picture is unavailable. Backgrounding releases remote keys; idle background sessions do not retain signaling. After network consent, the foreground app checks the local HarmonyOS version and checks the remote version after connection. These requests contain neither device identity nor the remote version.
The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking. The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking.
### 6. Access and deletion requests ### 6. Access and deletion requests
+108 -215
View File
@@ -1,25 +1,71 @@
<div align="center"> <div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" /> <img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
<h1>CrossDesk</h1> <h1>CrossDesk</h1>
<p>轻量、跨平台的远程桌面,让电脑、浏览器和 iPhone / iPad 连接到同一张桌面。</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>
<div align="center"> <div align="center">
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android-blue)](#download) [![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml) [![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) [![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers) [![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
[![爱发电](https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-946CE6?logo=afdian&logoColor=white)](https://afdian.com/a/kunkundi)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF6433?logo=kofi&logoColor=white)](https://ko-fi.com/kunkundi)
[![GitHub Sponsors](https://img.shields.io/badge/GitHub_Sponsors-C93C45?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/kunkundi)
</div> </div>
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS / Android 客户端控制电脑。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。 CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
> 本文按当前仓库代码整理。截图展示当前 Slint 桌面界面,使用演示 ID、密码、在线状态和连接记录;缩略图来自公开页面;下载包的功能与系统要求请同时核对对应 Release 说明。原生 iOS 客户端的构建、签名方式见下文。 [主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [Android](#android) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md) <a id="features"></a>
## 主要功能
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
- **部署与运维:** P2P 直连、TURN 中继、SRTP 媒体加密、自托管,以及 Windows 登录前远控和 Linux 无头运行。
<a id="download"></a>
## 下载与平台支持
从 [官方网站](https://www.crossdesk.cn/) 或 [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) 下载与你的系统和 CPU 架构匹配的安装包;也可直接打开 [Web 客户端](https://web.crossdesk.cn/)。
| 平台 | 支持范围与安装要求 |
| --- | --- |
| Windows | **控制 / 被控**;Windows 10+,x64;`.exe` 安装包,也支持便携构建 |
| macOS | **控制 / 被控**;macOS 14.0+;按 Intel / Apple Silicon 选择 x64 / arm64 `.pkg` |
| Linux | **控制 / 被控**;Ubuntu 20.04+、glibc 2.31 基线;amd64 / arm64 `.deb` |
| iOS / iPadOS | **仅控制**;iOS 16.0+;发布包为未签名应用,需自行签名,或使用 Xcode 构建安装 |
| Android | **仅控制**;Android 8.0+,arm64;下载已签名 APK 安装,或自行构建 |
| 鸿蒙 PC(开发版) | **控制 / 被控**;API 26,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| 鸿蒙手机 / 平板(开发版) | **仅控制**;API 20+,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| Web | **仅控制**;支持 WebRTC 的浏览器;无需在控制端安装客户端 |
系统要求按当前源码与构建配置整理,具体安装包以对应 Release 说明为准。各客户端的功能范围见 [移动端与鸿蒙](#mobile)。
- **Linux:** 已有桌面选普通版 `crossdesk-linux-…deb`;需要独立 Xfce 桌面的服务器选 `crossdesk-virtual-desktop-xfce-…deb`。两者选择其一,安装与启动方法见 [使用指南](docs/USER_GUIDE.md#installation) 和 [无头运行](docs/HEADLESS.md)。
- **macOS:** 首次运行时按提示授予屏幕录制及辅助功能权限;前者用于桌面采集,后者用于远程键鼠输入。[权限说明](docs/USER_GUIDE.md#installation)
- **iOS / 鸿蒙:** 签名与构建步骤分别见 [iOS](apps/ios/README.md) 和 [鸿蒙](apps/harmony/README.md),不要将未签名产物当作可直接安装的应用。
<a id="quick-start"></a>
## 快速连接
1. **准备被控电脑。** 安装并运行 CrossDesk,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
<a id="preview"></a> <a id="preview"></a>
@@ -27,255 +73,102 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
### 桌面客户端 ### 桌面客户端
主窗口集中展示本机 ID、连接密码、远程连接入口和近期连接记录。 本机凭据、远程连接和近期设备集中在主窗口,左上角进入桌面墙。
<p align="center"> <p align="center">
<img src="docs/images/desktop-main-zh.png" width="800" alt="当前桌面客户端示例:已连接服务器,三台在线设备均显示页面缩略图" /> <img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk 主窗口:本机凭据、远程连接入口和近期设备;数据为演示" />
</p> </p>
### Web 客户端 <a id="deskhub"></a>
在浏览器中输入远程设备 ID 和密码,无需在控制端安装桌面客户端。 ### 桌面墙(DeskHub)
按分组查看多台设备,点击画面放大,再选择接管操控;支持批量连接、静音与文件分发。
<p align="center"> <p align="center">
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端当前连接首页" /> <img src="docs/images/desktop-deskhub-zh.png" width="960" alt="DeskHub 桌面墙:六台演示设备的远程画面与管理工具栏" />
</p> </p>
图片来源与更新方式见 [截图说明](docs/images/README.md)。 群控会向所有已连接设备发送输入;关闭桌面墙会断开全部远程连接。操作范围与详细用法见 [桌面墙使用指南](docs/USER_GUIDE.md#deskhub)。截图使用演示数据,更多界面及来源见 [使用指南](docs/USER_GUIDE.md) 与 [截图说明](docs/images/README.md)。
## 主要功能
| 能力 | 当前支持 |
| --- | --- |
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS / Android 作为控制端 |
| 实时画面与声音 | H.264 / AV1、硬件编解码、远端声音播放;会话中可调整画质、30 / 60 fps 与画面偏好 |
| 多设备与多显示器 | 近期连接、设备别名、会话标签与远端显示器切换 |
| 输入与协作 | 键鼠输入、远端光标同步、组合键、文本剪贴板同步、文件传输 |
| 网络与部署 | P2P 直连、TURN 中继与强制中继、SRTP 媒体加密、自托管信令与中继服务 |
| 隐私屏 | 被连接时自动开启,或在会话工具栏切换;可用性取决于被控端系统与采集方式 |
| Windows 锁屏控制 | 通过 CrossDesk Service 支持锁屏、登录界面及安全桌面的输入转发 |
| Linux 无头运行 | 复用已有桌面和应用,或创建独立 Xvfb 虚拟桌面;终端控制台可查看状态、修改密码和设置,见 [运行说明](docs/HEADLESS.md) |
各客户端的入口和能力有所不同;桌面会话操作见下文,iOS 的具体功能见 [开发说明](apps/ios/README.md)。
<a id="download"></a>
## 下载与安装
前往 [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) 或 [官方网站](https://www.crossdesk.cn/),按系统与 CPU 架构选择安装包。
| 平台 | 当前源码 / CI 基线 | 安装方式 |
| --- | --- | --- |
| Windows | Windows 10+,x64 | `.exe` 安装包;便携构建也受支持 |
| macOS | macOS 14.0+,Intel / Apple Silicon | 选择 x64 或 arm64 的 `.pkg` |
| Linux | Ubuntu 20.04+,amd64 / arm64,glibc 2.31 基线 | 普通版 / Xfce 虚拟桌面版 `.deb` |
| iOS / iPadOS | iOS 16.0+,arm64 真机 | 原生客户端;使用 Xcode 构建并签名,CI 产物为未签名应用 |
| Android | Android 8.0+,arm64 | 原生控制端;使用 Gradle 构建 APK,见 [开发说明](apps/android/README.md) |
| Web | 支持 WebRTC 的浏览器 | 访问 [web.crossdesk.cn](https://web.crossdesk.cn/) |
Linux 每个架构提供两个可独立安装的包,**选择其中一个**:
| 安装包 | 适用场景 |
| --- | --- |
| `crossdesk-linux-<arch>-<version>.deb`(推荐) | 已有桌面的电脑;不会额外安装 Xfce 或 Xvfb |
| `crossdesk-virtual-desktop-xfce-linux-<arch>-<version>.deb` | 需要创建独立桌面的服务器;APT 会额外安装 **Xfce、Xvfb 和 D-Bus 会话工具** |
普通版安装示例(将文件名替换为实际下载名称,arm64 选择对应架构):
```bash
sudo apt install "./crossdesk-linux-amd64-<version>.deb"
```
如需虚拟桌面版,改为安装:
```bash
sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
```
两个包包含相同的完整程序,互相替换并保留用户配置;普通版自行补齐依赖后也能创建虚拟桌面。安装虚拟桌面版不会自动启动会话或切换默认桌面,启动方法见 [Linux 无头运行](docs/HEADLESS.md)。应用更新的下载按钮会选择已安装的包类型。已有版本升级后不会自动删除先前安装的 Xfce。
**macOS 首次运行:** 按应用提示,在“系统设置 → 隐私与安全性”中授予 CrossDesk **屏幕录制**(新系统可能显示为“屏幕与系统音频录制”)及 **辅助功能**权限,再按系统提示重新打开应用。前者用于捕获桌面,后者用于远程键鼠输入。
<a id="quick-start"></a>
## 快速连接
### 从另一台电脑连接
1. **准备被控电脑。** 安装并运行 CrossDesk,等待底部显示“已连接服务器”。在左侧“本桌面”查看 **本机 ID** 和 **密码**,提供给控制端。
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 ID,点击 **→**。
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连,也可编辑别名或删除记录。
本机密码旁的眼睛按钮用于显示 / 隐藏密码,刷新按钮用于生成新的随机密码,铅笔按钮用于手动修改。密码统一为 **6 位数字或英文字母**。修改或刷新时需保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
### 从浏览器连接
1. 保持被控电脑上的 CrossDesk 运行,并确认已连接服务器。
2. 打开 [Web 客户端](https://web.crossdesk.cn/),输入 **远程设备 ID** 和 **密码**,点击“连接”。
3. 连接后使用页面提供的显示器、鼠标模式和键盘控件操作远端;手机和平板也可通过浏览器访问。
如果使用自建服务器,需要让控制端与被控端接入同一服务;Web 部署配置见 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
<a id="session"></a> <a id="session"></a>
## 会话中的常用操作
远程窗口通过会话标签切换设备,控制栏提供以下操作;控制栏收起时先点击展开按钮。将鼠标悬停在图标上可查看提示。
| 入口 | 用途 |
| --- | --- |
| 显示器 | 切换远端显示器 |
| 键盘 | 发送组合键;Windows 的 `Ctrl+Alt+Del` 依赖远端服务 |
| 鼠标 | 控制 / 释放远端鼠标 |
| 扬声器 | 播放声音 / 静音 |
| 文件夹 | 选择文件发送到远端,并查看传输进度 |
| 画面设置 | 在当前会话中调整画面质量、采集帧率与画面偏好 |
| 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继及加密状态 |
| 隐私屏 | 开启或关闭被控电脑的隐私屏;悬停查看当前状态与可用操作 |
| 全屏 | 进入 / 退出全屏 |
| 断开连接 | 结束当前远程会话 |
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
### 在会话中调整画面
连接成功后,点击工具栏中的 **画面设置**(滑杆图标),直接选择低 / 中 / 高画质、**30 / 60 fps**,以及帧率优先 / 画质优先 / 平衡。选项会发送给当前被控端,无需断开重连;实际画面效果取决于网络、设备与编码器能力。旧版被控端不支持时,菜单会提示并禁用选项。
<p align="center">
<img src="docs/images/desktop-stream-video-settings-zh.png" width="800" alt="当前远程会话的画面设置菜单:画面质量、采集帧率与画面偏好;背景为公开页面演示图" />
</p>
<a id="settings"></a> <a id="settings"></a>
## 客户端设置与更新 ## 会话操作与设置
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。画质、帧率和画面偏好在远程会话工具栏中调整。 远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置编码、中继、自托管与桌面墙画面预算。
<p align="center"> - [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前客户端设置:编码格式、硬件编解码、中继、强制中继与自动隐私屏" /> - [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
</p> - [桌面墙技术说明](docs/deskhub.md):预览订阅、带宽分配、协议与兼容性。
| 设置 | 说明 | <a id="mobile"></a>
| --- | --- |
| 语言 | 中文、English、Русский |
| 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 |
| 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 |
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
| 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 |
| 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 |
| 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 |
| 自托管配置 | 填写服务器地址和信令服务端口,并勾选启用 |
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
| 文件保存路径 | 选择接收文件的目录 |
### 检查更新 ## 移动端与鸿蒙
客户端启动后会在后台检查版本,发现新版本时自动弹出更新提示,可查看更新说明并前往下载页面。关闭提示后,本次运行中的后台检查不会再次弹窗,主窗口菜单入口仍会显示更新提示;重新启动客户端后,如仍有新版本则再次提示。打开 **☰ → 关于** 可查看版本;有新版本时,点击版本信息下方的 **更新日志** 可再次打开更新提示,也可点击 **检查更新** 手动检查。
<p align="center">
<img src="docs/images/desktop-about-zh.png" width="720" alt="关于窗口与检查更新按钮,版本号和检查结果为演示数据" />
</p>
<a id="ios"></a> <a id="ios"></a>
## 原生 iOS / iPadOS 客户端 ### iOS / iPadOS
仓库的 [`apps/ios`](apps/ios/README.md) 提供原生控制端,与桌面端使用同一套 MiniRTC 协议。它可以控制远端电脑;当前不提供将 iPhone / iPad 作为被控桌面的功能。 原生控制端支持触控与虚拟鼠标、多显示器、远端音频、文件收发和文本剪贴板。悬浮菜单的“剪贴板”按钮可主动发送本机文本,单次上限为 128 KiB。iPhone / iPad 当前不能作为被控桌面。
1. 按 [iOS 开发说明](apps/ios/README.md) 使用 Xcode 构建并签名,安装到 iOS 16+ 的真机。 [使用说明](docs/USER_GUIDE.md#ios) · [构建与签名](apps/ios/README.md)
2. 在首页输入对端 ID,点击连接并填写密码;成功连接后可从“最近连接”中重连。
3. 在设置中选择鼠标模式:**相对位置**像触控板一样移动光标,**绝对位置**将触摸位置直接映射到远端画面。
4. 点击悬浮图标展开菜单,可切换显示器、显示键盘、控制声音或发送文件。收到的文件位于应用的 `Documents/Received`,可通过传输状态旁的分享按钮导出。
当前支持接收远端文本到 iOS 剪贴板;界面暂未提供主动发送本机剪贴板的按钮。
当前支持单指点击、双指右键、长按拖动与捏合缩放;缩放后的双指平移用于移动视图。原生端与浏览器端分别维护,使用入口和手势可能不同。
<a id="android"></a> <a id="android"></a>
## 原生 Android 控制端 ### Android
[`apps/android`](apps/android/README.md) 提供独立 Android Studio / Gradle 工程,复用 MiniRTC 和桌面键鼠协议。支持连接电脑、H.264 / AV1 画面、声音、触控/外接键鼠、多屏切换和文本剪贴板。当前面向 Android 8.0+ arm64 设备,使用软件解码;尚未提供手机被控、文件传输和 iOS 全部附加功能。 原生控制端支持触控与外接键鼠、多屏切换、文件收发、文本剪贴板和会话画面设置。H.264 / AV1 优先使用硬件解码,不支持或运行失败时回退到软件解码;切换应用时优先通过画中画保持会话,画中画不可用时尝试前台服务保活。当前不提供手机被控或隐私屏功能。
按 [Android 开发说明](apps/android/README.md) 构建并安装 APK,在首页填写电脑端的设备 ID 和密码即可连接。长按右键、双指滚动,横屏可扩大画面;切换到后台会结束会话。 [使用说明](docs/USER_GUIDE.md#android) · [源码构建](apps/android/README.md)
<a id="harmonyos"></a>
### 鸿蒙 HarmonyOS(开发版)
PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板使用原生 ArkUI 控制端,支持触控、外接键鼠、多屏、文本剪贴板和文件收发。PC 录屏与输入控制需要系统授权,真机和长期会话验收仍在进行。
[使用与限制](docs/USER_GUIDE.md#harmonyos) · [构建与签名](apps/harmony/README.md) · [PC 说明](apps/harmony/desktop-port/README.md) · [移动端功能](apps/harmony/MOBILE_PARITY.md)
<a id="windows-service"></a> <a id="windows-service"></a>
## Windows 锁屏与登录界面 ## 无人值守与无头运行
**CrossDesk Service**(服务名 `CrossDeskService`)提供锁屏、登录、凭据输入及安全桌面的状态上报、`Ctrl+Alt+Del` 发送和键鼠输入转发。 - **Windows:** CrossDesk Service 支持锁屏、登录和安全桌面控制。安装版可额外启用登录前远控,使用独立设备 ID 和密码;普通“开机自启”仅在用户登录后启动客户端。功能限制及启停命令见 [Windows 无人值守指南](docs/USER_GUIDE.md#windows-service)。
- **Linux:** 可复用现有桌面,也可创建独立 Xvfb / Xfce 会话,操作方法见 [Linux 无头运行](docs/HEADLESS.md)。
安装版默认注册按需启动的服务,客户端启动时尝试启动它;未启用无人值守时,本机没有 CrossDesk 客户端进程后服务会自动退出。便携版可在提示窗口或“设置 → 锁屏控制服务”中安装服务,需要管理员权限。
Windows 的“开机自启”在当前用户登录后启动客户端。安装版使用以最高权限运行的登录计划任务,后续登录无需再次确认 UAC;便携版和 Debug 构建使用当前用户的普通启动项。升级安装版或手动启动新版时,会自动迁移指向该程序的旧启动项。关闭自启会删除相应注册。登录前远控需要另外启用下面的无人值守模式。
### 开机后尚未登录时远控
安装版支持独立的无人值守被控端。在完整安装目录中打开**管理员 PowerShell**,一次性启用:
```powershell
.\CrossDesk.exe --unattended-enable
# 等待联网注册后,查看独立设备 ID、密码和 online 状态
.\CrossDesk.exe --unattended-status
```
使用返回的 `device_id` 和 `password` 从另一台设备连接。首次启用时复制当前客户端的服务器和编码设置,单独生成无人值守凭据;它与普通客户端的设备 ID 不同,重启、注销和重新启用时保留。后续执行启用命令会复用机器配置,不会覆盖它。自建服务器的根证书需要安装到本地计算机的信任存储中。
启用后,`CrossDeskService` 改为系统启动时自动运行,无需 Windows 自动登录,也不需要保存 Windows 登录密码。服务以 SYSTEM 身份在当前控制台会话中启动无界面的被控进程,复用登录界面抓屏、键鼠及 `Ctrl+Alt+Del` 支持。用户登录、锁屏和关闭普通客户端不会停止它;注销、切换用户或进程崩溃后服务会重新拉起,期间连接可能中断,需要重新连接同一设备 ID。启动时网络尚未就绪会继续重试。
无人值守模式提供画面、键鼠、多屏切换和安全桌面控制;不提供声音、文件传输、剪贴板或隐私遮罩。便携版和 Debug 构建不能启用此模式。设置和凭据位于 `%ProgramData%\CrossDeskUnattended`,只允许 SYSTEM 和管理员访问;日志位于其 `logs` 子目录。
```powershell
# 停止无人值守被控端,并恢复服务按需启动
.\CrossDesk.exe --unattended-disable
```
关闭普通“开机自启”不会关闭无人值守模式。禁用或卸载服务会取消无人值守启动,但保留受保护的机器配置;若需彻底清除凭据,在服务停止后由管理员删除该目录。
<details>
<summary>手动部署与服务管理命令</summary>
保留完整安装目录;自行部署构建产物时,将 `CrossDesk.exe`、`crossdesk_service.exe`、`crossdesk_session_helper.exe` 及同次构建输出的全部 `.dll` 放在同一目录。在该目录打开管理员 PowerShell,按需执行:
```powershell
.\CrossDesk.exe --service-install
.\CrossDesk.exe --service-start
.\CrossDesk.exe --service-status
.\CrossDesk.exe --service-ping
# 停止或卸载服务
.\CrossDesk.exe --service-stop
.\CrossDesk.exe --service-uninstall
```
</details>
出现“远端 Windows 服务不可用”时,检查被控电脑的安装和服务状态;普通桌面连接仍可使用,受保护界面的控制能力会受限。
<a id="self-hosting"></a> <a id="self-hosting"></a>
## 自托管服务器 ## 自托管
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。 可自行部署信令与 TURN 中继服务,并在客户端“设置 → 常规 → 自托管配置”中启用。控制端与被控端需要接入同一服务。
- [自托管指南](docs/SELF_HOSTING.md):当前 Compose 部署入口、客户端设置、证书信任与排查。 [部署指南](docs/SELF_HOSTING.md) · [客户端配置](docs/USER_GUIDE.md#self-hosting) · [服务端源码](https://github.com/kunkundi/crossdesk-server) · [Web 客户端部署](https://github.com/kunkundi/crossdesk-web-client)
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server):服务端源码与发布配置。
- [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client):浏览器端源码与自托管说明。
## 开发与反馈 <a id="development"></a>
- [源码构建](docs/BUILD.md):Windows、macOS、Linux、可选编译参数与打包。 ## 构建与反馈
- [iOS 开发说明](apps/ios/README.md):原生端构建和真机验证。
- [Android 开发说明](apps/android/README.md):APK 构建、安装与模拟器验证。 - [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
- [GUI 架构](docs/gui-architecture.md):桌面 UI、平台实现与工程结构。 - [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
- [常见问题](docs/FAQ.md):连接失败、黑屏、文件位置与编译问题。 - [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
- [提交问题](https://github.com/kunkundi/crossdesk/issues):请附上两端系统、客户端版本、连接方式和可复现步骤。 - [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
<a id="sponsor"></a>
## 支持 CrossDesk
如果 CrossDesk 对你有帮助,欢迎通过以下平台赞助,支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!
[爱发电](https://afdian.com/a/kunkundi) · [Ko-fi](https://ko-fi.com/kunkundi) · [GitHub Sponsors](https://github.com/sponsors/kunkundi)
## 致谢与许可 ## 致谢与许可
感谢 [HelloGitHub](https://hellogithub.com/)、[阮一峰的科技爱好者周刊](https://github.com/ruanyf/weekly) 与 [LinuxDo](https://linux.do) 社区的推荐、交流与支持。 感谢 [HelloGitHub](https://hellogithub.com/)、[阮一峰的科技爱好者周刊](https://github.com/ruanyf/weekly) 与 [LinuxDo](https://linux.do) 社区的推荐、交流与支持。
CrossDesk 使用 [GPL-3.0](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。 Copyright (c) 2026 JUNKUN DI.
CrossDesk 使用 [GPL-3.0-only](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
### Code signing policy ### Code signing policy
+110 -213
View File
@@ -1,25 +1,71 @@
<div align="center"> <div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" /> <img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
<h1>CrossDesk</h1> <h1>CrossDesk</h1>
<p>A lightweight, cross-platform remote desktop for computers, browsers, and iPhone / iPad.</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>
<div align="center"> <div align="center">
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android-blue)](#download) [![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml) [![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) [![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers) [![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF6433?logo=kofi&logoColor=white)](https://ko-fi.com/kunkundi)
[![GitHub Sponsors](https://img.shields.io/badge/GitHub_Sponsors-C93C45?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/kunkundi)
[![AFDIAN](https://img.shields.io/badge/AFDIAN-946CE6?logo=afdian&logoColor=white)](https://afdian.com/a/kunkundi)
</div> </div>
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS / Android clients for controlling computers. Built on [MiniRTC](https://github.com/kunkundi/minirtc), it supports real-time video and audio, keyboard and mouse control, file transfer, and self-hosting. The project is under active development. 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.
> This guide follows the current repository source. Desktop screenshots render the current Slint UI with sample IDs, passwords, online statuses, and recent devices; thumbnails show public pages. Check each release's notes for the features and requirements of its binaries. Native iOS build and signing instructions are below. [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)
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english) <a id="features"></a>
## Features
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution.
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
- **Deployment:** direct P2P connections, TURN relay, SRTP media encryption, self-hosting, Windows access before sign-in, and Linux headless operation.
<a id="download"></a>
## Download and platform support
Get a package matching your OS and CPU architecture from the [website](https://www.crossdesk.cn/) or [GitHub Releases](https://github.com/kunkundi/crossdesk/releases), or open the [Web Client](https://web.crossdesk.cn/) directly.
| Platform | Support and installation |
| --- | --- |
| Windows | **Control + host**. Windows 10+, x64; `.exe` installer, with portable builds also supported |
| macOS | **Control + host**. macOS 14.0+; x64 / arm64 `.pkg` for Intel / Apple Silicon |
| Linux | **Control + host**. Ubuntu 20.04+, glibc 2.31 baseline; amd64 / arm64 `.deb` |
| iOS / iPadOS | **Control only**. iOS 16.0+; sign the unsigned release app yourself, or build and install with Xcode |
| Android | **Control only**. Android 8.0+, arm64; install the signed APK or build from source |
| HarmonyOS PC (development) | **Control + host**. API 26, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing |
| HarmonyOS phones / tablets (development) | **Control only**. API 20+, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing |
| Web | **Control only**. A WebRTC-capable browser; no client installation on the controlling device |
Requirements follow the current source and build configuration. Check the release notes for the downloaded package. Client capabilities are summarized under [Mobile and HarmonyOS](#mobile).
- **Linux:** use `crossdesk-linux-…deb` for an existing desktop, or `crossdesk-virtual-desktop-xfce-…deb` for a server needing a separate Xfce desktop. Choose one. See [installation details](docs/USER_GUIDE_EN.md#installation) and [headless operation](docs/HEADLESS_EN.md).
- **macOS:** grant Screen Recording and Accessibility when prompted on first launch. These enable desktop capture and remote keyboard/mouse input. See [permissions](docs/USER_GUIDE_EN.md#installation).
- **iOS / HarmonyOS:** follow the [iOS](apps/ios/README.md) or [HarmonyOS](apps/harmony/README.md) (Chinese) signing and build guide. Unsigned artifacts cannot be installed directly.
<a id="quick-start"></a>
## 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**.
2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect.
3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems.
<a id="preview"></a> <a id="preview"></a>
@@ -27,247 +73,102 @@ CrossDesk connects Windows, macOS, and Linux desktops, with browser and native i
### Desktop client ### Desktop client
The main window brings together your device ID, connection password, remote connection entry, and recent devices. Local credentials, remote connections, and recent devices share the main window. Open DeskHub from the top-left corner.
<p align="center"> <p align="center">
<img src="docs/images/desktop-main-en.png" width="800" alt="Current desktop UI example showing a connected server and three online devices with page thumbnails" /> <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" />
</p> </p>
### Web client <a id="deskhub"></a>
Enter a remote device ID and password in your browser without installing the desktop app on the controlling device. ### DeskHub
View devices by group, click a preview to enlarge it, and take control when needed. Batch actions include connecting, muting, and sending files.
<p align="center"> <p align="center">
<img src="docs/images/web-client.png" width="800" alt="Current CrossDesk Web Client connection page in Chinese" /> <img src="docs/images/desktop-deskhub-en.png" width="960" alt="DeskHub with six demonstration remote desktops and its management toolbar" />
</p> </p>
See the [screenshot notes](docs/images/README.md) for image sources and refresh instructions. Group control sends input to all connected devices; closing DeskHub disconnects all remote connections. See the [DeskHub user guide](docs/USER_GUIDE_EN.md#deskhub) for exact scopes and controls. Screenshots use demonstration data; more views and sources are in the [user guide](docs/USER_GUIDE_EN.md) and [screenshot notes](docs/images/README.md).
## Features
| Capability | Current support |
| --- | --- |
| Cross-platform control | Windows / macOS / Linux desktop control; browser and native iOS / Android controllers |
| Live video and audio | H.264 / AV1, hardware codecs, and remote audio; adjust quality, 30 / 60 fps, and video preference during a session |
| Devices and displays | Recent connections, device aliases, session tabs, and remote display switching |
| Input and sharing | Keyboard/mouse input, remote cursor synchronization, shortcuts, text clipboard synchronization, and file transfer |
| Networking and deployment | Direct P2P connections, TURN relay and forced relay, SRTP media encryption, and self-hosted signaling/relay services |
| Privacy screen | Enable automatically when connected to, or toggle from the session toolbar; availability depends on the host OS and capture method |
| Windows protected desktops | CrossDesk Service forwards input on lock screens, sign-in screens, and secure desktops |
| Linux headless operation | Reuse existing desktops and applications or create separate Xvfb desktops; use the terminal console to check status and change passwords or settings; see [setup](docs/HEADLESS_EN.md) |
Controls and capabilities vary by client. Desktop controls are described below; see the [iOS guide](apps/ios/README.md) for native mobile features.
<a id="download"></a>
## Download and install
Choose a package matching your OS and CPU architecture from [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) or the [website](https://www.crossdesk.cn/).
| Platform | Current source / CI baseline | Installation |
| --- | --- | --- |
| Windows | Windows 10+, x64 | `.exe` installer; portable builds are also supported |
| macOS | macOS 14.0+, Intel / Apple Silicon | x64 or arm64 `.pkg` |
| Linux | Ubuntu 20.04+, amd64 / arm64, glibc 2.31 baseline | Standard / Xfce virtual desktop `.deb` |
| iOS / iPadOS | iOS 16.0+, arm64 physical device | Native controller; build and sign with Xcode, or sign the unsigned CI app |
| Android | Android 8.0+, arm64 | Native controller; build an APK with Gradle ([guide](apps/android/README.md)) |
| Web | A WebRTC-capable browser | Open [web.crossdesk.cn](https://web.crossdesk.cn/) |
Linux provides two standalone packages per architecture. **Choose one**:
| Package | Intended use |
| --- | --- |
| `crossdesk-linux-<arch>-<version>.deb` (recommended) | Computers with an existing desktop; does not install Xfce or Xvfb |
| `crossdesk-virtual-desktop-xfce-linux-<arch>-<version>.deb` | Servers needing an independent desktop; APT additionally installs **Xfce, Xvfb and D-Bus session tools** |
To install the standard package, replace the example filename with your download (use arm64 for that architecture):
```bash
sudo apt install "./crossdesk-linux-amd64-<version>.deb"
```
For the virtual desktop package, install this file instead:
```bash
sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
```
Both contain the same complete application, replace each other and preserve user settings. The standard package can also create virtual desktops after you install the optional dependencies. Installing the virtual desktop package does not start a session or change the default desktop; see [Linux headless operation](docs/HEADLESS_EN.md) to start one. The app's update download button selects the installed package type. Upgrading an existing installation does not automatically remove previously installed Xfce components.
**First launch on macOS:** follow the app prompts to grant **Screen Recording** (called **Screen & System Audio Recording** on newer systems) and **Accessibility** under **System Settings → Privacy & Security**. Reopen the app if prompted. These permissions allow desktop capture and remote keyboard/mouse input respectively.
<a id="quick-start"></a>
## Quick start
### Connect from another computer
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the bottom status bar to show that the server is connected. Share the **Local ID** and **Password** shown under **Local Desktop** with the controller.
2. **Enter the remote ID.** On the controlling computer, enter the host's ID in **Remote Desktop → Remote ID**, then click **→**.
3. **Verify the password.** Enter the host's current connection password when prompted and confirm. Select **Remember password** if you want to save it.
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, edit its alias, or remove its record.
The eye button shows or hides the local password, the refresh button generates a new random password, and the pencil button lets you edit it. Passwords use **6 ASCII letters or digits**. Stay connected to the server when editing or refreshing a password. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
### Connect from a browser
1. Keep CrossDesk running and connected to the server on the host computer.
2. Open the [Web Client](https://web.crossdesk.cn/), enter the **Remote Device ID** and **Password**, and click **Connect**.
3. Use the page's display, mouse-mode, and keyboard controls during the session. Phone and tablet browsers can also connect.
For self-hosting, connect both sides to the same service. Browser deployment is documented in [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client).
<a id="session"></a> <a id="session"></a>
## Session controls
Switch devices using session tabs. Expand the control bar if it is collapsed, and hover over icons to see their tooltips.
| Control | Action |
| --- | --- |
| Display | Switch the remote monitor |
| Keyboard | Send shortcuts; Windows `Ctrl+Alt+Del` requires the remote service |
| Mouse | Enable / release remote mouse control |
| Speaker | Play / mute remote audio |
| Folder | Select a file to send and view transfer progress |
| Video settings | Adjust video quality, capture frame rate, and video preference during the current session |
| Network statistics | Inspect traffic, packet loss, frame rate, resolution, direct / relay mode, and encryption status |
| Privacy screen | Enable or disable the host's privacy screen; hover to see its status and available actions |
| Fullscreen | Enter / exit fullscreen |
| Disconnect | End the current remote session |
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
### Adjust video during a session
After connecting, open **Video settings** (the sliders icon) in the toolbar. Choose low / medium / high quality, **30 / 60 fps**, and frame-rate priority / quality priority / balanced adaptation. Changes are sent to the current host without reconnecting; actual results depend on the network, device, and encoder. For older hosts that do not support live video settings, the menu explains the limitation and disables the options.
<p align="center">
<img src="docs/images/desktop-stream-video-settings-en.png" width="800" alt="Current session video menu with quality, capture frame rate, and video preference; the background is a public page used for demonstration" />
</p>
<a id="settings"></a> <a id="settings"></a>
## Client settings and updates ## Session controls and settings
Open **☰ → Settings** in the top-right corner and click **OK** to save. Configure codecs and connections before connecting; some settings are disabled during an active session. Scroll down for self-hosting, startup behavior, and the file save path. Adjust quality, frame rate, and video preference from the remote session toolbar. 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.
<p align="center"> - [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens.
<img src="docs/images/desktop-settings-en.png" width="720" alt="Current client settings with codec, hardware codecs, TURN, forced relay, and automatic privacy screen options" /> - [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
</p> - [DeskHub technical guide](docs/deskhub.md) (Chinese): preview subscriptions, bandwidth allocation, protocol, and compatibility.
| Setting | Purpose | <a id="mobile"></a>
| --- | --- |
| Language | 中文, English, Русский |
| Capture Method (Windows) | Auto / DXGI / WGC / GDI; choose Auto for automatic headless adaptation |
| Video Encode Format | H.264 / AV1; hardware codec availability depends on the platform, device, and build options |
| Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails |
| Force Relay | Require relay connections when TURN is enabled; turning TURN off also disables this option |
| Save remote screen previews | On by default. When disabled, the thumbnail area shows the remote device’s platform and existing previews are cleared; connection records and passwords are preserved |
| Privacy screen on connect | Automatically enable privacy when this computer is controlled remotely; the controller can toggle it from the session toolbar |
| Self-Hosted Config | Set the server address and signaling port, then enable the checkbox |
| Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
| File Save Path | Choose where received files are saved |
### Check for updates ## Mobile and HarmonyOS
The client checks for updates in the background after launch and automatically opens the update dialog when a new version is found, showing release notes and a link to the download page. After you dismiss the dialog, background checks will not reopen it during the current run, while the main window menu keeps its update indicator. Restarting the client shows the dialog again if an update is still available. Open **☰ → About** to view the version. When an update is available, click **Release notes** below the new version to reopen the update dialog, or click **Check for updates** to check manually.
<p align="center">
<img src="docs/images/desktop-about-en.png" width="720" alt="About dialog with the Check for updates button; version and check result are demonstration data" />
</p>
<a id="ios"></a> <a id="ios"></a>
## Native iOS / iPadOS client ### iOS / iPadOS
[`apps/ios`](apps/ios/README.md) contains a native controller using the same MiniRTC protocol as the desktop client. It controls remote computers; it does not expose an iPhone or iPad as a remotely controlled desktop. The native controller supports touch and a virtual mouse, multiple monitors, remote audio, file transfers, and text clipboard. Tap **剪贴板** (Clipboard) in the floating menu to send local text, up to 128 KiB per transfer. iPhone and iPad cannot currently act as remote hosts. The native interface uses Chinese labels.
1. Follow the [iOS guide](apps/ios/README.md) to build and sign the app with Xcode, then install it on an iOS 16+ physical device. [User guide](docs/USER_GUIDE_EN.md#ios) · [Build and signing](apps/ios/README.md)
2. Enter a remote ID on the home screen, connect, and enter its password. Successful connections become available in the recent devices list.
3. Choose a mouse mode in Settings: **Relative Position** works like a trackpad; **Absolute Position** maps touches directly to the remote screen.
4. Tap the floating icon to expand the menu, then switch displays, open the keyboard, toggle audio, or send files. Received files are stored in `Documents/Received` and can be exported using the share button beside the transfer status.
The app currently receives remote text into the iOS clipboard. Its UI does not yet expose a button to send the local clipboard. <a id="android"></a>
Gestures include one-finger click, two-finger right-click, long-press drag, and pinch-to-zoom. When zoomed in, two-finger panning moves the viewport. The native and browser clients are maintained separately, so their controls and gestures may differ. The current native UI uses Chinese labels. ### 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.
[User guide](docs/USER_GUIDE_EN.md#android) · [Build from source](apps/android/README.md) (Chinese)
<a id="harmonyos"></a>
### HarmonyOS (development)
The PC client acts as both controller and host, sharing the desktop UI and DeskHub. Phones and tablets use a native ArkUI controller with touch, external input devices, monitors, text clipboard, and file transfers. PC screen capture and input control require system authorization. Physical-device and long-session validation are ongoing.
[Usage and limits](docs/USER_GUIDE_EN.md#harmonyos) · [Build and signing](apps/harmony/README.md) · [PC guide](apps/harmony/desktop-port/README.md) · [Mobile features](apps/harmony/MOBILE_PARITY.md) (platform guides in Chinese)
<a id="windows-service"></a> <a id="windows-service"></a>
## Windows lock screens and sign-in ## Unattended and headless access
**CrossDesk Service** (`CrossDeskService`) reports protected desktop states and forwards `Ctrl+Alt+Del`, keyboard, and mouse input on lock screens, sign-in screens, credential prompts, and secure desktops. - **Windows:** CrossDesk Service supports lock screens, sign-in, and secure desktops. Installed builds can enable access before sign-in with a separate device ID and password; ordinary **Auto Start** runs after a user signs in. See the [Windows unattended guide](docs/USER_GUIDE_EN.md#windows-service) for capabilities, limits, and commands.
- **Linux:** reuse an existing desktop or create an independent Xvfb / Xfce session. See [Linux headless operation](docs/HEADLESS_EN.md).
The installer registers an on-demand service by default. CrossDesk tries to start it on launch. Unless unattended mode is enabled, the service exits when no local CrossDesk client is running. Portable builds offer service installation in the prompt or **Settings → Lock Screen Service**, with administrator privileges.
On Windows, **Auto Start** launches the client after the current user signs in. Installed release builds use a logon scheduled task with highest privileges, so subsequent logons need no UAC confirmation; portable and Debug builds use a normal per-user startup entry. Upgrading the installed build or launching the new version manually migrates a legacy startup entry pointing to that executable. Turning Auto Start off removes its registration. Remote access before sign-in requires the separate unattended mode below.
### Remote access before Windows sign-in
The installed release supports a separate unattended host. Open an **administrator PowerShell** in the complete installation directory and enable it once:
```powershell
.\CrossDesk.exe --unattended-enable
# After network registration, show its separate device ID, password and online state
.\CrossDesk.exe --unattended-status
```
Connect from another device using the returned `device_id` and `password`. First-time setup copies the current client's server and codec preferences and creates separate credentials. This ID differs from the GUI client's ID and persists across reboots, logoffs and re-enabling. Subsequent enable commands retain the machine configuration. Self-hosted server root certificates must be trusted in the Local Computer certificate store.
Once enabled, `CrossDeskService` starts automatically at system boot. Windows automatic login and saved Windows login passwords are unnecessary. The service launches a windowless SYSTEM host in the active console session and reuses secure-desktop capture, keyboard/mouse input and `Ctrl+Alt+Del`. Signing in, locking Windows or closing the ordinary client does not stop it. Logoff, user switching or a host crash causes the service to restart the host; the connection may drop, and you can reconnect using the same ID. Network connections retry if networking is unavailable during boot.
Unattended mode supports screen viewing, keyboard/mouse input, monitor switching and secure desktops. Audio, file transfer, clipboard and privacy screens are unavailable. Portable and Debug builds cannot enable it. Configuration and credentials reside in `%ProgramData%\CrossDeskUnattended`, accessible only to SYSTEM and administrators; logs are in its `logs` subdirectory.
```powershell
# Stop the unattended host and restore on-demand service startup
.\CrossDesk.exe --unattended-disable
```
Turning off ordinary Auto Start does not disable unattended mode. Disabling or uninstalling the service disarms unattended startup but retains the protected machine profile. To erase its credentials, stop the service and delete that directory as an administrator.
<details>
<summary>Manual deployment and service commands</summary>
Keep the complete installation directory. For manual deployment, place `CrossDesk.exe`, `crossdesk_service.exe`, `crossdesk_session_helper.exe`, and all `.dll` files from the same build together. Open an administrator PowerShell in that directory and run the commands you need:
```powershell
.\CrossDesk.exe --service-install
.\CrossDesk.exe --service-start
.\CrossDesk.exe --service-status
.\CrossDesk.exe --service-ping
# Stop or uninstall the service
.\CrossDesk.exe --service-stop
.\CrossDesk.exe --service-uninstall
```
</details>
If **Remote Windows service unavailable** appears, check installation and service status on the host. Ordinary desktop connections remain available, while control of protected screens is limited.
<a id="self-hosting"></a> <a id="self-hosting"></a>
## Self-hosting ## Self-hosting
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → Self-Hosted Config**. Enable the same server configuration on both the controller and host. Host your own signaling and TURN services, then enable them under **Settings → General → Self-Hosted Config**. Both the controller and host must use the same service.
- [Self-hosting guide](docs/SELF_HOSTING_EN.md): current Compose deployment, client settings, certificate trust, and troubleshooting. [Deployment guide](docs/SELF_HOSTING_EN.md) · [Client setup](docs/USER_GUIDE_EN.md#self-hosting) · [Server source](https://github.com/kunkundi/crossdesk-server) · [Web client deployment](https://github.com/kunkundi/crossdesk-web-client)
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server): server source and release configuration.
- [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client): browser source and deployment instructions.
## Development and feedback <a id="development"></a>
- [Build from source](docs/BUILD_EN.md): Windows, macOS, Linux, optional flags, and packaging. ## Build and feedback
- [iOS development](apps/ios/README.md): native build and physical-device checks.
- [Android development](apps/android/README.md): APK builds, installation, and emulator checks. - [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): desktop UI and platform organization. - [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
- [FAQ](docs/FAQ.md#english): connection failures, blank video, received files, and build issues. - [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
- [Report an issue](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.
<a id="sponsor"></a>
## Support CrossDesk
If CrossDesk is useful to you, consider sponsoring its continued development, cross-platform testing, and maintenance through one of the platforms below. Thank you for your support!
[Ko-fi](https://ko-fi.com/kunkundi) · [GitHub Sponsors](https://github.com/sponsors/kunkundi) · [AFDIAN](https://afdian.com/a/kunkundi)
## Acknowledgements and license ## Acknowledgements and license
Thanks to [HelloGitHub](https://hellogithub.com/), [Ruanyf Weekly](https://github.com/ruanyf/weekly), and the [LinuxDo](https://linux.do) community for featuring CrossDesk and contributing feedback. Thanks to [HelloGitHub](https://hellogithub.com/), [Ruanyf Weekly](https://github.com/ruanyf/weekly), and the [LinuxDo](https://linux.do) community for featuring CrossDesk and contributing feedback.
CrossDesk is licensed under [GPL-3.0](LICENSE). See the [Privacy Policy](PRIVACY.md#english). Copyright (c) 2026 JUNKUN DI.
CrossDesk is licensed under [GPL-3.0-only](LICENSE). See the [Privacy Policy](PRIVACY.md#english).
### Code signing policy ### Code signing policy
@@ -277,7 +178,3 @@ CrossDesk uses SignPath.io to sign official Windows releases built from this rep
- **Committers and reviewers:** [kunkundi](https://github.com/kunkundi) - **Committers and reviewers:** [kunkundi](https://github.com/kunkundi)
- **Approvers:** [kunkundi](https://github.com/kunkundi) - **Approvers:** [kunkundi](https://github.com/kunkundi)
## Native Android controller
The independent [Android project](apps/android/README.md) builds an APK for Android 8.0+ arm64 devices. It reuses MiniRTC and the desktop input protocol for remote video/audio, touch and external keyboard/mouse input, display switching, and text clipboard transfer. H.264 and AV1 currently use software decoding. Phone-as-host, file transfer, and full iOS feature parity are not included. See the [Android build guide](apps/android/README.md) for setup and validation.
+3 -3
View File
@@ -7,11 +7,11 @@
- 官方或自托管 WSS 信令服务器;授权后进入前台即自动注册或登录本机身份,首页实时显示连接、重连及失败状态。发起桌面连接时复用该身份;切换服务器会重新登录,撤回授权会关闭连接。已有远程会话优先通过系统画中画保持连接,画中画不可用时由带连接通知的前台服务兜底;首页空闲信令在后台关闭。 - 官方或自托管 WSS 信令服务器;授权后进入前台即自动注册或登录本机身份,首页实时显示连接、重连及失败状态。发起桌面连接时复用该身份;切换服务器会重新登录,撤回授权会关闭连接。已有远程会话优先通过系统画中画保持连接,画中画不可用时由带连接通知的前台服务兜底;首页空闲信令在后台关闭。
- 优先使用 MediaCodec 硬件解码 H.264 / AV1,硬件缓冲区直接提交 Surface;设备无对应硬件、分辨率超限或运行失败时,按原编码格式回退到 OpenH264 / dav1d。软件输出由独立显示线程将 NV12 转 RGBA;只保留最新待显示帧。MiniRTC 原有的通用压缩帧解码队列保持不变。 - 优先使用 MediaCodec 硬件解码 H.264 / AV1,硬件缓冲区直接提交 Surface;设备无对应硬件、分辨率超限或运行失败时,按原编码格式回退到 OpenH264 / dav1d。软件输出由独立显示线程将 NV12 转 RGBA;只保留最新待显示帧。MiniRTC 原有的通用压缩帧解码队列保持不变。
- 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。 - 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指捏合以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。 - 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。 - 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。 - 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。 - 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。 - 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。 - 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。 - 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。
@@ -151,7 +151,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。 身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。浮动键盘切换“电脑”后的“复制 / 粘贴 / 全选”根据远端平台选择 Ctrl 或 macOS Command。 会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。 “发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
@@ -124,6 +124,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
else motion=navigatingBack||nextPage.equals("home")?PageHost.Motion.POP:PageHost.Motion.PUSH; else motion=navigatingBack||nextPage.equals("home")?PageHost.Motion.POP:PageHost.Motion.PUSH;
} }
if(root!=null)hideKeyboard();page=nextPage; if(root!=null)hideKeyboard();page=nextPage;
// Remote controls own the IME inset. Android 11–14 otherwise also resize
// the content, unlike Android 15's enforced edge-to-edge layout.
if(android.os.Build.VERSION.SDK_INT>=30&&android.os.Build.VERSION.SDK_INT<35)getWindow().setDecorFitsSystemWindows(!dark);
signalBadge=null;identitySummary=null;announcementBadge=null;announcementView=null;announcementRefresh=null; signalBadge=null;identitySummary=null;announcementBadge=null;announcementView=null;announcementRefresh=null;
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null; settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null; previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
@@ -132,7 +135,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720); root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720);
safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL)); safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL));
safeArea.setOnApplyWindowInsetsListener((view,insets)->{ safeArea.setOnApplyWindowInsetsListener((view,insets)->{
view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets; if(dark&&android.os.Build.VERSION.SDK_INT>=30){
android.graphics.Insets bars=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout());
view.setPadding(bars.left,bars.top,bars.right,bars.bottom);
if(controls!=null)controls.keyboardInsets(Math.max(0,insets.getInsets(WindowInsets.Type.ime()).bottom-bars.bottom),insets.isVisible(WindowInsets.Type.ime()),bars.bottom);
}else view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;
}); });
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion); root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
applySystemBars(dark); applySystemBars(dark);
@@ -320,12 +327,15 @@ public final class MainActivity extends Activity implements NativeSession.Listen
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 key(int code){tapKey(code);} public void key(int code){tapKey(code);}
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);}
public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);} public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);}
public void mouse(){boolean relative=!preferences.getBoolean("relativeMouse",true);preferences.edit().putBoolean("relativeMouse",relative).apply();video.setRelative(relative);controls.setRelative(relative);} public void mouse(){boolean relative=!preferences.getBoolean("relativeMouse",true);preferences.edit().putBoolean("relativeMouse",relative).apply();video.setRelative(relative);controls.setRelative(relative);}
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));
video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();};
controls.requestApplyInsets();
video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{ video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
if(!pictureInPictureUi&&!isInPictureInPictureMode())updatePictureInPicture(); if(!pictureInPictureUi&&!isInPictureInPictureMode())updatePictureInPicture();
}); });
@@ -463,7 +473,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private void about(){ private void about(){
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings,appUpdates);aboutPages.show(); if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings,appUpdates);aboutPages.show();
} }
private void sendText(String value){if(session==null)return;if(value.length()>2048){toast("单次最多 2048 个字符");return;}for(char ch:value.toCharArray())if(KeyMap.ascii(ch)==0){toast("中文等文本请通过剪贴板发送,再点击粘贴");return;}for(char ch:value.toCharArray()){int code=KeyMap.ascii(ch);boolean shift=(code&0x100)!=0;if(shift)session.key(0x10,true);tapKey(code&0xFF);if(shift)session.key(0x10,false);}} private void sendText(String value){sendTextEdit(0,value);}
private boolean sendTextEdit(int count,String value){
if(session==null)return false;
if(session.textEdit(count,value))return true;
toast("当前远端不支持此文本,中文或表情需更新 macOS 被控端,或通过剪贴板发送");return false;
}
private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}} private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}}
private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}} private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}}
private void selectDisplay(){ private void selectDisplay(){
@@ -635,11 +650,20 @@ public final class MainActivity extends Activity implements NativeSession.Listen
@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();}
@Override public boolean dispatchKeyEvent(KeyEvent event){ @Override public boolean dispatchKeyEvent(KeyEvent event){
if(ready&&session!=null&&!pictureInPictureUi&&hasWindowFocus()&&!(getCurrentFocus() instanceof EditText)&&event.getKeyCode()!=KeyEvent.KEYCODE_BACK){int code=KeyMap.windowsCode(event.getKeyCode());if(code!=0){boolean down=event.getAction()==KeyEvent.ACTION_DOWN;if(down)pressed.add(code);else pressed.remove(code);session.key(code,down);return true;}}return super.dispatchKeyEvent(event); int code=KeyMap.windowsCode(event.getKeyCode());View focus=getCurrentFocus();
boolean remoteEditor=focus instanceof RemoteKeyboardInput;
boolean desktopKey=event.isCtrlPressed()||event.isAltPressed()||event.isMetaPressed()||pressed.contains(code)
||code==17||code==18||code==91||code==27||code==9||code>=33&&code<=40||code==45||code==46||code>=112&&code<=123;
if(ready&&session!=null&&!pictureInPictureUi&&hasWindowFocus()&&(!(focus instanceof EditText)||remoteEditor&&(!((RemoteKeyboardInput)focus).isAcceptingInput()||desktopKey))&&event.getKeyCode()!=KeyEvent.KEYCODE_BACK&&code!=0){
boolean down=event.getAction()==KeyEvent.ACTION_DOWN;
if(down){pressed.add(code);if(remoteEditor)controls.resetKeyboardContext();}else pressed.remove(code);
session.key(code,down);return true;
}
return super.dispatchKeyEvent(event);
} }
private void releaseKeys(){if(session!=null)for(int code:pressed)session.key(code,false);pressed.clear();} private void releaseKeys(){if(session!=null)for(int code:pressed)session.key(code,false);pressed.clear();}
private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}} private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}}
private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();} private void back(){if(controls!=null&&controls.closeKeyboard())return;pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);} @Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();} @android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
@Override public void onConfigurationChanged(Configuration config){ @Override public void onConfigurationChanged(Configuration config){
@@ -751,7 +775,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
// 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(){releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();} @Override protected void onPause(){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();}
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish(); @Override protected void onStop(){if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
if(connectionDialog!=null)connectionDialog.dismiss(); if(connectionDialog!=null)connectionDialog.dismiss();
// Closing native PiP is an explicit end, not a request for invisible // Closing native PiP is an explicit end, not a request for invisible
// fallback. An unavailable/failed PiP can hand its live session off. // fallback. An unavailable/failed PiP can hand its live session off.
@@ -118,6 +118,8 @@ final class MobileUi {
case "refresh":c.drawArc(4,4,20,20,35,310,false,paint);path(c,20,3,20,9,14,9);break; case "refresh":c.drawArc(4,4,20,20,35,310,false,paint);path(c,20,3,20,9,14,9);break;
case "close":path(c,6,6,18,18);path(c,18,6,6,18);break; case "close":path(c,6,6,18,18);path(c,18,6,6,18);break;
case "arrow":path(c,4,12,20,12);path(c,14,6,20,12,14,18);break; case "arrow":path(c,4,12,20,12);path(c,14,6,20,12,14,18);break;
case "keyboard_hide":
c.drawRoundRect(2,2,22,16,2,2,paint);for(int y=6;y<=9;y+=3)for(int x=6;x<=18;x+=4)c.drawPoint(x,y,paint);c.drawLine(7,13,17,13,paint);path(c,7,20,12,23,17,20);break;
case "keyboard": case "keyboard":
c.drawRoundRect(2,5,22,19,3,3,paint);for(int y=9;y<=12;y+=3)for(int x=6;x<=18;x+=4)c.drawPoint(x,y,paint);c.drawLine(7,16,17,16,paint);break; c.drawRoundRect(2,5,22,19,3,3,paint);for(int y=9;y<=12;y+=3)for(int x=6;x<=18;x+=4)c.drawPoint(x,y,paint);c.drawLine(7,16,17,16,paint);break;
case "display":c.drawRoundRect(2,3,22,17,2,2,paint);path(c,12,17,12,21);path(c,7,21,17,21);break; case "display":c.drawRoundRect(2,3,22,17,2,2,paint);path(c,12,17,12,21);path(c,7,21,17,21);break;
@@ -47,7 +47,8 @@ final class NativeSession implements AutoCloseable {
private String password, identity = ""; private String password, identity = "";
private final int port; private final int port;
private long handle; private long handle;
private boolean signalReady, joining, connected, started; private boolean signalReady, joining, started;
private volatile boolean connected, supportsTextInput;
private int signalState; private int signalState;
private volatile long signalGeneration; private volatile long signalGeneration;
private boolean presenceRequested; private boolean presenceRequested;
@@ -189,7 +190,11 @@ final class NativeSession implements AutoCloseable {
"远端密码错误", "找不到该设备 ID", "远端当前不可用"}; "远端密码错误", "找不到该设备 ID", "远端当前不可用"};
fail(status >= 0 && status < reasons.length ? reasons[status] : "连接已结束"); fail(status >= 0 && status < reasons.length ? reasons[status] : "连接已结束");
} }
} else if (type == 4) ui(() -> listener.data(event)); } else if (type == 4) {
JSONObject info=event.optJSONObject("host_info");
if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false);
ui(() -> 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");
ui(() -> listener.videoSize(width, height)); ui(() -> listener.videoSize(width, height));
@@ -239,6 +244,23 @@ final class NativeSession implements AutoCloseable {
void surface(Surface value) { execute(() -> { surface = value; if (handle != 0) nSurface(handle, value); }); } void surface(Surface value) { execute(() -> { surface = value; if (handle != 0) nSurface(handle, value); }); }
void pointer(float x, float y, int flag, int wheel) { execute(() -> { if (connected) nPointer(handle, x, y, flag, wheel); }); } void pointer(float x, float y, int flag, int wheel) { execute(() -> { if (connected) nPointer(handle, x, y, flag, wheel); }); }
void key(int code, boolean down) { execute(() -> { if (connected) nKey(handle, code, down); }); } void key(int code, boolean down) { execute(() -> { if (connected) nKey(handle, code, down); }); }
boolean textEdit(int deletions,String text) {
byte[] bytes=text.getBytes(StandardCharsets.UTF_8);
boolean unicode=supportsTextInput;
if(closed||!connected||deletions<0||deletions>4096||bytes.length>16*1024||text.indexOf('\0')>=0)return false;
if(!unicode)for(char ch:text.toCharArray())if(KeyMap.ascii(ch)==0)return false;
// Validate the entire edit before deleting anything. One RTC task preserves order.
execute(()->{
if(!connected)return;
for(int i=0;i<deletions;i++){nKey(handle,8,true);nKey(handle,8,false);}
if(unicode){if(bytes.length>0)nText(handle,bytes);}
else for(char ch:text.toCharArray()){
int code=KeyMap.ascii(ch);boolean shift=(code&0x100)!=0;
if(shift)nKey(handle,16,true);nKey(handle,code&255,true);nKey(handle,code&255,false);if(shift)nKey(handle,16,false);
}
});
return true;
}
void control(int type, int value) { void control(int type, int value) {
AudioPlayer player = audio; AudioPlayer player = audio;
if (type == 2 && player != null) player.setEnabled(value != 0); if (type == 2 && player != null) player.setEnabled(value != 0);
@@ -290,6 +312,7 @@ final class NativeSession implements AutoCloseable {
private static native void nReady(long handle); private static native void nReady(long handle);
private static native void nPointer(long handle, float x, float y, int flag, int wheel); private static native void nPointer(long handle, float x, float y, int flag, int wheel);
private static native void nKey(long handle, int code, boolean down); private static native void nKey(long handle, int code, boolean down);
private static native void nText(long handle,byte[] text);
private static native void nControl(long handle, int type, int value); private static native void nControl(long handle, int type, int value);
private static native boolean nVideoSettings(long handle,int quality,int frameRate,int preference,long requestId); private static native boolean nVideoSettings(long handle,int quality,int frameRate,int preference,long requestId);
private static native boolean nAnnouncementRequest(long handle,byte[] data); private static native boolean nAnnouncementRequest(long handle,byte[] data);
@@ -0,0 +1,223 @@
package cn.crossdesk.mobile;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.drawable.StateListDrawable;
import android.os.Build;
import android.os.CancellationSignal;
import android.util.SparseIntArray;
import android.view.*;
import android.view.inputmethod.InputMethodManager;
import android.widget.*;
import java.util.LinkedHashSet;
import java.util.Set;
/** Docked native IME accessory and two-page desktop keyboard share one toolbar. */
@android.annotation.SuppressLint("ViewConstructor")
final class RemoteKeyboard extends LinearLayout {
private final MobileUi ui;
private final SessionControls.Actions actions;
final RemoteKeyboardInput editor;
private final Runnable changed;
private final LinearLayout toolbar,keys;
private final TextView switchButton,lettersButton,functionsButton;
private final Set<Integer> modifiers=new LinkedHashSet<>();
private final SparseIntArray heights=new SparseIntArray();
private boolean opened,computer,functions,imeVisible,measuring,switching,targetComputer;
private int imeHeight,navigationBottom,layoutWidth,layoutHeight,revision,lastInsetsHeight=-1,lastInsetsKey;
private CancellationSignal transition;
RemoteKeyboard(Context context,MobileUi ui,SessionControls.Actions actions,Runnable changed) {
super(context);this.ui=ui;this.actions=actions;this.changed=changed;setOrientation(VERTICAL);
setVisibility(GONE);setClickable(true);
editor=new RemoteKeyboardInput(context,new RemoteKeyboardInput.Sender(){
public boolean edit(int count,String text){return actions.textEdit(count,text);}
public void key(int code){actions.key(code);}
});
FrameLayout header=new FrameLayout(context);addView(header,new LayoutParams(-1,ui.dp(44)));
header.addView(editor,new FrameLayout.LayoutParams(1,1,Gravity.BOTTOM));
toolbar=ui.row();toolbar.setGravity(Gravity.CENTER_VERTICAL);toolbar.setPadding(ui.dp(6),0,ui.dp(6),0);
header.addView(toolbar,new FrameLayout.LayoutParams(-1,-1));
switchButton=button("电脑键盘",this::switchKeyboard);switchButton.setPadding(ui.dp(8),0,ui.dp(8),0);
toolbar.addView(switchButton,new LayoutParams(-2,-1));
LinearLayout pages=ui.row();pages.setGravity(Gravity.CENTER);toolbar.addView(pages,new LayoutParams(0,-1,1));
lettersButton=button("字母数字",()->page(false));functionsButton=button("符号功能",()->page(true));
pages.addView(lettersButton,new LayoutParams(-2,ui.dp(32)));pages.addView(functionsButton,new LayoutParams(-2,ui.dp(32)));
FrameLayout hide=new FrameLayout(context);hide.setContentDescription("收起键盘");hide.setClickable(true);hide.setFocusable(true);
hide.addView(ui.icon("keyboard_hide",foreground()),new FrameLayout.LayoutParams(ui.dp(24),ui.dp(24),Gravity.CENTER));
hide.setOnClickListener(v->{feedback(v);close();});toolbar.addView(hide,new LayoutParams(ui.dp(44),ui.dp(44)));
keys=ui.column();keys.setPadding(ui.dp(3),ui.dp(4),ui.dp(3),ui.dp(4));addView(keys,new LayoutParams(-1,0,1));
render();
}
private boolean dark(){return (getResources().getConfiguration().uiMode&Configuration.UI_MODE_NIGHT_MASK)==Configuration.UI_MODE_NIGHT_YES;}
private int foreground(){return dark()?Color.WHITE:0xFF202124;}
private TextView button(String label,Runnable action){
TextView view=ui.action(label,foreground(),action);view.setTypeface(android.graphics.Typeface.DEFAULT);
view.setTextSize(13);view.setMinHeight(0);view.setPadding(ui.dp(8),0,ui.dp(8),0);
view.setOnClickListener(v->{feedback(v);action.run();});return view;
}
private void feedback(View view){view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);}
boolean isOpen(){return opened;}
boolean isComputer(){return computer;}
int bottomInset(){return opened&&!computer?imeHeight:0;}
private int sizeKey(){Configuration c=getResources().getConfiguration();return c.screenWidthDp*10000+c.screenHeightDp;}
private int keyAreaHeight(){
int height=heights.get(sizeKey(),0);
if(height==0){Configuration c=getResources().getConfiguration();height=ui.dp(c.screenWidthDp>c.screenHeightDp?200:280);}
int available=getParent() instanceof View?((View)getParent()).getMeasuredHeight()-ui.dp(44):height;
return available>0?Math.min(height,available):height;
}
int preferredHeight(){return ui.dp(44)+(computer?keyAreaHeight():0);}
void toggle(){if(opened)close();else open();}
void open(){
opened=true;computer=false;modifiers.clear();editor.start();setVisibility(VISIBLE);render();changed.run();
post(()->{if(opened&&!computer)changeIme(true,false,false);});
}
boolean close(){
if(!opened)return false;opened=false;measuring=switching=false;cancelTransition();modifiers.clear();editor.stop();
getContext().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(getWindowToken(),0);
setVisibility(GONE);changed.run();return true;
}
void resetContext(){editor.resetContext();}
private void page(boolean next){functions=next;render();}
private void switchKeyboard(){
if(!opened||switching)return;
targetComputer=!computer;switching=true;modifiers.clear();
if(targetComputer){
editor.pause();computer=true;render();changed.run();
}else editor.start();
// Lay out computer keys behind the still-visible IME before onReady,
// which runs after layout. Keep them until the native IME is ready too.
if(targetComputer&&Build.VERSION.SDK_INT>=30&&isHardwareAccelerated()){
int token=revision;
// Insets surfaces can update ahead of the app's render thread.
// Submit the replacement frame before releasing the IME surface.
getViewTreeObserver().registerFrameCommitCallback(()->post(()->{
if(opened&&switching&&targetComputer&&token==revision)changeIme(false,true,false);
}));
invalidate();
}else changeIme(!targetComputer,true,false);
}
private void finishSwitch(){
if(!switching)return;switching=false;
if(computer!=targetComputer){computer=targetComputer;render();}
changed.run();
}
void updateInsets(int height,boolean visible,int navigation){
int key=sizeKey();
if(lastInsetsHeight==height&&lastInsetsKey==key&&imeVisible==visible&&navigationBottom==navigation)return;
lastInsetsHeight=height;lastInsetsKey=key;
navigationBottom=navigation;imeHeight=Build.VERSION.SDK_INT>=30?Math.max(0,height):0;
boolean wasVisible=imeVisible;imeVisible=visible;
if(visible&&height>ui.dp(80)){
heights.put(sizeKey(),height);
if(measuring){measuring=false;post(()->{if(opened&&computer)changeIme(false,true,false);});}
}
if(switching&&visible!=targetComputer&&(!visible||height>ui.dp(80)))finishSwitch();
if(opened&&!computer&&!switching&&wasVisible&&!visible&&transition==null){close();return;}
if(opened){renderKeysIfNeeded(getMeasuredWidth());changed.run();}
}
private void cancelTransition(){revision++;if(transition!=null){CancellationSignal old=transition;transition=null;old.cancel();}}
private void changeIme(boolean show,boolean immediate,boolean measureOnly){
cancelTransition();if(!opened)return;
measuring=measureOnly;
if(Build.VERSION.SDK_INT>=30&&immediate&&getWindowInsetsController()!=null){
// Request control directly: showSoftInput first starts a competing
// system animation before we can replace the computer keyboard.
if(show)editor.start();else if(measureOnly)editor.requestFocus();
int token=revision;transition=new CancellationSignal();
getWindowInsetsController().controlWindowInsetsAnimation(WindowInsets.Type.ime(),0,null,transition,new WindowInsetsAnimationControlListener(){
public void onReady(WindowInsetsAnimationController controller,int types){
if(token!=revision||!opened){controller.finish(false);return;}
int height=controller.getShownStateInsets().bottom-navigationBottom;
if(height>ui.dp(80)){
heights.put(sizeKey(),height);measuring=false;
if(!measureOnly){imeHeight=show?height:0;finishSwitch();}
controller.finish(show&&!measureOnly);
}else if(measureOnly){
// Some IMEs report zero until their new input view is laid out.
// The first nonzero inset completes measurement and hides it.
controller.finish(true);
}else controller.finish(show);
renderKeysIfNeeded(getMeasuredWidth());changed.run();
}
public void onFinished(WindowInsetsAnimationController controller){if(token==revision)transition=null;}
public void onCancelled(WindowInsetsAnimationController controller){
if(token!=revision)return;transition=null;
if(opened)normalIme(show||measureOnly);
}
});
}else normalIme(show||measureOnly);
}
private void normalIme(boolean show){
InputMethodManager manager=getContext().getSystemService(InputMethodManager.class);
if(show){if(computer)editor.requestFocus();else editor.start();manager.showSoftInput(editor,InputMethodManager.SHOW_IMPLICIT);}
else manager.hideSoftInputFromWindow(editor.getWindowToken(),0);
}
private void render(){
setBackgroundColor(dark()?0xFF202124:0xFFE8EAED);
switchButton.setText(computer?"系统键盘":"电脑键盘");switchButton.setTextColor(foreground());
lettersButton.setVisibility(computer?VISIBLE:GONE);functionsButton.setVisibility(computer?VISIBLE:GONE);
for(TextView view:new TextView[]{lettersButton,functionsButton}){
boolean selected=(view==functionsButton)==functions;view.setSelected(selected);
view.setTextColor(selected?(dark()?0xFFA8C7FA:0xFF185ABC):foreground());
view.setBackground(ui.background(selected?(dark()?0xFF394457:0xFFD2E3FC):Color.TRANSPARENT,6));
}
keys.setVisibility(computer?VISIBLE:GONE);layoutWidth=layoutHeight=0;renderKeysIfNeeded(getMeasuredWidth());
}
private void renderKeysIfNeeded(int width){
if(!computer)return;int height=keyAreaHeight();
if(layoutWidth==width&&layoutHeight==height)return;layoutWidth=width;layoutHeight=height;keys.removeAllViews();
boolean landscape=getResources().getConfiguration().screenWidthDp>getResources().getConfiguration().screenHeightDp;
String[][] upper;
if(!functions)upper=new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"}};
else if(landscape)upper=new String[][]{{"`","-","=","[","]","\\",";","'",",",".","/"},{"F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"},{"Esc","Tab","Caps","Ins","Del","Home","End"}};
else upper=new String[][]{{"`","-","=","[","]","\\",";","'",",",".","/"},{"F1","F2","F3","F4","F5","F6"},{"F7","F8","F9","F10","F11","F12"},{"Esc","Tab","Caps","Ins","Del","Home","End"}};
LinearLayout upperKeys=ui.column();keys.addView(upperKeys,new LayoutParams(-1,0,1));
for(String[] row:upper)upperKeys.addView(keyRow(row),new LayoutParams(-1,0,1));
// The same bottom two rows keep their geometry on both pages.
int rowHeight=Math.max(1,(height-ui.dp(8))/5);
keys.addView(keyRow(functions?new String[]{"⇧","←","↓","↑","→","PgUp","PgDn","⌫"}:new String[]{"⇧","z","x","c","v","b","n","m","⌫"}),new LayoutParams(-1,rowHeight));
keys.addView(keyRow(new String[]{"Ctrl","Win","Alt","空格","Enter"}),new LayoutParams(-1,rowHeight));
}
private LinearLayout keyRow(String[] values){
LinearLayout line=ui.row();line.setGravity(Gravity.CENTER);
for(String value:values){
boolean shift=modifiers.contains(16);String shown=shift&&value.length()==1?shifted(value):value;
TextView key=button(shown,()->key(value));key.setPadding(0,0,0,0);
key.setTextSize(value.length()>1?12:18);key.setSingleLine(true);
int modifier=modifier(value);boolean selected=modifier!=0&&modifiers.contains(modifier);key.setSelected(selected);
int color=selected?(dark()?0xFF596D8C:0xFFA8C7FA):value.length()>1||value.equals("⇧")||value.equals("⌫")?(dark()?0xFF30343B:0xFFD0D5DC):(dark()?0xFF3C4043:Color.WHITE);
StateListDrawable background=new StateListDrawable();background.addState(new int[]{android.R.attr.state_pressed},ui.background(dark()?0xFF68717D:0xFFBDC9D8,7));background.addState(new int[]{},ui.background(color,7));key.setBackground(background);
if(value.equals("⇧"))key.setContentDescription("Shift");else if(value.equals("⌫"))key.setContentDescription("退格");
// Both penultimate rows allocate exactly 1/8 to Shift and Backspace.
boolean penultimate=values[0].equals("⇧");float weight=value.equals("空格")?4:1;
if(penultimate)weight=value.equals("⇧")||value.equals("⌫")?1:(6f/(values.length-2));
boolean edge=penultimate&&(value.equals("⇧")||value.equals("⌫"));
int edgeWidth=Math.max(1,(layoutWidth-ui.dp(6))/8-ui.dp(5));
LayoutParams p=new LayoutParams(edge?edgeWidth:0,-1,edge?0:weight);p.setMargins(ui.dp(2.5f),ui.dp(3),ui.dp(2.5f),ui.dp(3));line.addView(key,p);
}
return line;
}
private static int modifier(String value){return switch(value){case "⇧"->16;case "Ctrl"->17;case "Alt"->18;case "Win"->91;default->0;};}
private static String shifted(String value){
String plain="1234567890`-=[]\\;',./",shifted="!@#$%^&*()~_+{}|:\"<>?";int index=plain.indexOf(value);
return index>=0?shifted.substring(index,index+1):value.toUpperCase(java.util.Locale.ROOT);
}
private void key(String value){
int modifier=modifier(value);if(modifier!=0){if(!modifiers.add(modifier))modifiers.remove(modifier);render();return;}
int code=switch(value){case "空格"->32;case "Enter"->13;case "⌫"->8;case "Esc"->27;case "Tab"->9;case "Caps"->20;case "Ins"->45;case "Del"->46;case "Home"->36;case "End"->35;case "PgUp"->33;case "PgDn"->34;case "←"->37;case "↑"->38;case "→"->39;case "↓"->40;default->value.startsWith("F")?111+Integer.parseInt(value.substring(1)):KeyMap.ascii(value.charAt(0))&255;};
actions.chord(code,modifiers.stream().mapToInt(Integer::intValue).toArray());
}
@Override protected void onMeasure(int widthSpec,int heightSpec){
renderKeysIfNeeded(MeasureSpec.getSize(widthSpec));super.onMeasure(widthSpec,heightSpec);
}
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){
super.onSizeChanged(width,height,oldWidth,oldHeight);
if(opened&&computer&&(width!=oldWidth||height!=oldHeight)&&heights.get(sizeKey(),0)==0)post(()->{
if(opened&&computer&&!switching&&!measuring&&transition==null&&heights.get(sizeKey(),0)==0)changeIme(false,true,true);
});
}
@Override protected void onDetachedFromWindow(){close();super.onDetachedFromWindow();}
}
@@ -0,0 +1,137 @@
package cn.crossdesk.mobile;
import android.content.Context;
import android.icu.text.BreakIterator;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputConnectionWrapper;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import java.util.ArrayList;
import java.util.List;
/** A real editor keeps IME candidates, corrections and dictation local until committed. */
@android.annotation.SuppressLint("ViewConstructor")
final class RemoteKeyboardInput extends EditText {
interface Sender {
boolean edit(int deletions, String text);
void key(int code);
}
private final Sender sender;
private final android.os.Handler main=new android.os.Handler(android.os.Looper.getMainLooper());
private String sent = "";
private boolean accepting, resetting, enterPending;
private int batches, generation;
private final Runnable synchronize = this::flush;
private void flush(){if(synchronize()&&enterPending){sender.key(13);resetContext();}}
RemoteKeyboardInput(Context context, Sender sender) {
super(context);this.sender=sender;
setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN | EditorInfo.IME_ACTION_NONE);
setCursorVisible(false);setBackground(null);setPadding(0,0,0,0);setAlpha(0);
setImportantForAutofill(IMPORTANT_FOR_AUTOFILL_NO);
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
addTextChangedListener(new TextWatcher() {
public void beforeTextChanged(CharSequence s,int start,int count,int after) { }
public void onTextChanged(CharSequence s,int start,int before,int count) { }
public void afterTextChanged(Editable value) { schedule(); }
});
}
void start() {
boolean resume=!accepting&&hasFocus();accepting=true;requestFocus();
if(resume)getContext().getSystemService(InputMethodManager.class).restartInput(this);
}
// Keep focus while replacing the IME, but reject its late edits without
// restarting it. A restart here can show it again after the controlled hide.
void pause() { accepting=false;resetContext(); }
void stop() { pause();clearFocus(); }
boolean isAcceptingInput() { return accepting; }
void resetContext() {
generation++;batches=0;main.removeCallbacks(synchronize);resetting=true;
// EditText.setText replaces the buffer and internally restarts the IME,
// even while paused. Clear in place so only the explicit restart below
// can renew the input connection.
enterPending=false;sent="";BaseInputConnection.removeComposingSpans(getText());getText().clear();setSelection(0);resetting=false;
if(accepting&&hasFocus())getContext().getSystemService(InputMethodManager.class).restartInput(this);
}
private void schedule() { if(!resetting&&accepting){main.removeCallbacks(synchronize);main.post(synchronize);} }
private boolean composing() { return BaseInputConnection.getComposingSpanStart(getText())>=0; }
private static List<String> characters(String text) {
BreakIterator iterator=BreakIterator.getCharacterInstance();iterator.setText(text);
List<String> result=new ArrayList<>();int start=iterator.first();
for(int end=iterator.next();end!=BreakIterator.DONE;start=end,end=iterator.next())result.add(text.substring(start,end));
return result;
}
private boolean synchronize() {
if(!accepting||resetting||batches>0||composing())return false;
String current=getText().toString();if(current.equals(sent))return true;
List<String> old=characters(sent),next=characters(current);int prefix=0,offset=0;
while(prefix<old.size()&&prefix<next.size()&&old.get(prefix).equals(next.get(prefix))){offset+=next.get(prefix).length();prefix++;}
if(!sender.edit(old.size()-prefix,current.substring(offset))){resetContext();return false;}
sent=current;
// Bound the correction context without changing anything on the remote desktop.
if(current.length()>2048||current.indexOf('\n')>=0)resetContext();
return true;
}
private void enter() {
if(!accepting)return;
BaseInputConnection.removeComposingSpans(getText());
enterPending=true;schedule();
}
@Override public boolean onKeyDown(int code,KeyEvent event) {
if(accepting&&code==KeyEvent.KEYCODE_ENTER){enter();return true;}
if(accepting&&code==KeyEvent.KEYCODE_DEL&&length()==0){sender.key(8);return true;}
return super.onKeyDown(code,event);
}
@Override public boolean onKeyUp(int code,KeyEvent event) {
if(accepting&&(code==KeyEvent.KEYCODE_ENTER||code==KeyEvent.KEYCODE_DEL))return true;
return super.onKeyUp(code,event);
}
@Override public InputConnection onCreateInputConnection(EditorInfo info) {
InputConnection base=super.onCreateInputConnection(info);if(base==null)return null;
int revision=++generation;batches=0;
return new InputConnectionWrapper(base,false) {
private boolean active(){return accepting&&generation==revision;}
@Override public boolean beginBatchEdit(){if(!active())return false;batches++;return super.beginBatchEdit();}
@Override public boolean endBatchEdit(){if(!active())return false;boolean result=super.endBatchEdit();batches=Math.max(0,batches-1);schedule();return result;}
@Override public boolean setComposingText(CharSequence text,int cursor){if(!active())return false;boolean result=super.setComposingText(text,cursor);schedule();return result;}
@Override public boolean setComposingRegion(int start,int end){return active()&&super.setComposingRegion(start,end);}
@Override public boolean commitText(CharSequence text,int cursor){if(!active())return false;boolean result=super.commitText(text,cursor);schedule();return result;}
@Override public boolean finishComposingText(){if(!active())return false;boolean result=super.finishComposingText();schedule();return result;}
@Override public boolean deleteSurroundingText(int before,int after){
if(!active())return false;
if(length()==0&&before>0&&after==0){for(int i=0;i<Math.min(before,2048);i++)sender.key(8);return true;}
boolean result=super.deleteSurroundingText(before,after);schedule();return result;
}
@Override public boolean deleteSurroundingTextInCodePoints(int before,int after){
if(!active())return false;
if(length()==0&&before>0&&after==0){for(int i=0;i<Math.min(before,2048);i++)sender.key(8);return true;}
boolean result=super.deleteSurroundingTextInCodePoints(before,after);schedule();return result;
}
@Override public boolean performEditorAction(int action){if(!active())return false;enter();return true;}
@Override public boolean sendKeyEvent(KeyEvent event){return active()&&super.sendKeyEvent(event);}
@Override public boolean setSelection(int start,int end){return active()&&super.setSelection(start,end);}
@Override public void closeConnection(){if(active())super.closeConnection();}
@android.annotation.SuppressLint("NewApi") // Framework calls these overloads only on API 33+.
@Override public boolean commitText(CharSequence text,int cursor,android.view.inputmethod.TextAttribute attributes){
if(!active())return false;boolean result=super.commitText(text,cursor,attributes);schedule();return result;
}
@android.annotation.SuppressLint("NewApi") // Framework calls these overloads only on API 33+.
@Override public boolean setComposingText(CharSequence text,int cursor,android.view.inputmethod.TextAttribute attributes){
if(!active())return false;boolean result=super.setComposingText(text,cursor,attributes);schedule();return result;
}
@android.annotation.SuppressLint("NewApi") // Framework calls these overloads only on API 33+.
@Override public boolean setComposingRegion(int start,int end,android.view.inputmethod.TextAttribute attributes){return active()&&super.setComposingRegion(start,end,attributes);}
@android.annotation.SuppressLint("NewApi") // Framework calls this overload only on API 34+.
@Override public boolean replaceText(int start,int end,CharSequence text,int cursor,android.view.inputmethod.TextAttribute attributes){
if(!active())return false;boolean result=super.replaceText(start,end,text,cursor,attributes);schedule();return result;
}
};
}
}
@@ -3,6 +3,8 @@ package cn.crossdesk.mobile;
/** Shared button ownership and cursor position for the video surface and mouse bar. */ /** Shared button ownership and cursor position for the video surface and mouse bar. */
final class RemoteMouseInput { final class RemoteMouseInput {
interface Sender { void send(float x, float y, int flag, int wheel); } interface Sender { void send(float x, float y, int flag, int wheel); }
interface RelativeMotion { void move(float dx, float dy); }
RelativeMotion relativeMotion;
private final Sender sender; private final Sender sender;
private float x = .5f, y = .5f; private float x = .5f, y = .5f;
private boolean gestureLeft, virtualLeft, virtualRight; private boolean gestureLeft, virtualLeft, virtualRight;
@@ -19,7 +21,8 @@ final class RemoteMouseInput {
} }
void drag(float dx, float dy) { void drag(float dx, float dy) {
if (!hasVirtualButton() || viewportWidth <= 0 || viewportHeight <= 0) return; if (!hasVirtualButton() || viewportWidth <= 0 || viewportHeight <= 0) return;
move(x + dx / viewportWidth, y + dy / viewportHeight); if (relativeMotion != null) relativeMotion.move(dx, dy);
else move(x + dx / viewportWidth, y + dy / viewportHeight);
} }
void gestureLeft(boolean down) { void gestureLeft(boolean down) {
if (down && hasVirtualButton()) return; if (down && hasVirtualButton()) return;
@@ -24,6 +24,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private final Cursor cursor; private final Cursor cursor;
private final NativeSession session; private final NativeSession session;
final RemoteMouseInput mouseInput; final RemoteMouseInput mouseInput;
Runnable remoteFocusChanged;
private final GestureDetector gestures; private final GestureDetector gestures;
private final ScaleGestureDetector scales; private final ScaleGestureDetector scales;
private final RemoteViewport viewport = new RemoteViewport(); private final RemoteViewport viewport = new RemoteViewport();
@@ -50,6 +51,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
RemoteVideoView(Context context, NativeSession session) { RemoteVideoView(Context context, NativeSession session) {
super(context); this.session = session; super(context); this.session = session;
mouseInput = new RemoteMouseInput(this::sendMouse); mouseInput = new RemoteMouseInput(this::sendMouse);
mouseInput.relativeMotion = this::moveRelative;
setBackgroundColor(Color.BLACK); setBackgroundColor(Color.BLACK);
video = new SurfaceView(context); video.getHolder().addCallback(this); video = new SurfaceView(context); video.getHolder().addCallback(this);
addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER)); addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER));
@@ -76,7 +78,8 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override public boolean onScaleBegin(ScaleGestureDetector detector) { @Override public boolean onScaleBegin(ScaleGestureDetector detector) {
if (mouseInput.hasVirtualButton()) return false; if (mouseInput.hasVirtualButton()) return false;
if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false; // Pinches can start anywhere on the touch surface, including letterboxing.
if (viewport.width() <= 0 || viewport.height() <= 0) return false;
viewportGesture = true; viewportGesture = true;
viewportPanActive = true; viewportPanActive = true;
return true; return true;
@@ -141,18 +144,26 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
} }
private void sendMouse(float x, float y, int flag, int wheel) { private void sendMouse(float x, float y, int flag, int wheel) {
this.x = x; this.y = y; this.x = x; this.y = y;
if((flag==1||flag==3||flag==5)&&remoteFocusChanged!=null)remoteFocusChanged.run();
session.pointer(x,y,flag,wheel); session.pointer(x,y,flag,wheel);
cursor.invalidate(); cursor.invalidate();
} }
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); } @Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); }
private void moveRelative(float dx, float dy) {
if (pictureInPicture || viewport.width() <= 0 || viewport.height() <= 0) return;
viewport.panForPointer(y, dy);
applyViewport();
mouseInput.move(x + dx / viewport.width(), y + dy / viewport.height());
}
private boolean point(MotionEvent e) { private boolean point(MotionEvent e) {
boolean touchpad = relative && e.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE; boolean touchpad = relative && e.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE;
float px=e.getX(),py=e.getY(); float px=e.getX(),py=e.getY();
if (!touchpad && !viewport.contains(px,py)) return false; if (!touchpad && !viewport.contains(px,py)) return false;
if(touchpad){ if(touchpad){
if(e.getActionMasked()==MotionEvent.ACTION_MOVE){ if(e.getActionMasked()==MotionEvent.ACTION_MOVE){
x=Math.max(0,Math.min(1,x+(px-lastX)/Math.max(1,viewport.width()))); moveRelative(px-lastX,py-lastY);
y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,viewport.height()))); lastX=px;lastY=py;
return true;
} }
}else{ }else{
x=viewport.normalizedX(px); x=viewport.normalizedX(px);
@@ -3,11 +3,14 @@ package cn.crossdesk.mobile;
/** Geometry shared by the video Surface, cursor and remote pointer input. */ /** Geometry shared by the video Surface, cursor and remote pointer input. */
final class RemoteViewport { final class RemoteViewport {
private static final float MAXIMUM_SCALE = 10; private static final float MAXIMUM_SCALE = 10;
private int baseWidth, baseHeight, baseLeft, baseTop; private static final float MINIMUM_VISIBLE_HEIGHT_FRACTION = .2f;
private int baseWidth, baseHeight, baseLeft, baseTop, containerHeight;
private boolean edgePanned;
private float scale = 1, offsetX, offsetY; private float scale = 1, offsetX, offsetY;
void fit(int width, int height, int videoWidth, int videoHeight) { void fit(int width, int height, int videoWidth, int videoHeight) {
if (width <= 0 || height <= 0 || videoWidth <= 0 || videoHeight <= 0) return; if (width <= 0 || height <= 0 || videoWidth <= 0 || videoHeight <= 0) return;
containerHeight = height;
double fitScale = Math.min((double) width / videoWidth, (double) height / videoHeight); double fitScale = Math.min((double) width / videoWidth, (double) height / videoHeight);
baseWidth = Math.max(1, (int) (videoWidth * fitScale)); baseWidth = Math.max(1, (int) (videoWidth * fitScale));
baseHeight = Math.max(1, (int) (videoHeight * fitScale)); baseHeight = Math.max(1, (int) (videoHeight * fitScale));
@@ -54,18 +57,46 @@ final class RemoteViewport {
constrainOffset(); constrainOffset();
} }
void finishGesture() { void panForPointer(float y, float dy) {
if (!isZoomed()) reset(); if (baseHeight == 0 || !Float.isFinite(dy)) return;
float overflowY = pointerOverflow(top() + y * height(), dy,
Math.max(0, top()), Math.min(containerHeight, top() + height()));
if (overflowY == 0) return;
edgePanned = true;
panBy(0, -overflowY);
} }
void reset() { scale = 1; offsetX = offsetY = 0; } // Only the outward portion of this movement pans; an offscreen cursor must
// not make a tiny movement jump the viewport by its entire hidden distance.
private static float pointerOverflow(float position, float delta, float low, float high) {
if (delta > 0) return Math.min(delta, Math.max(0, position + delta - high));
if (delta < 0) return Math.max(delta, Math.min(0, position + delta - low));
return 0;
}
void finishGesture() {
if (!isZoomed()) {
scale = 1;
constrainOffset();
}
}
void reset() { scale = 1; offsetX = offsetY = 0; edgePanned = false; }
private void constrainOffset() { private void constrainOffset() {
// Keep the original fitted area covered, as on iOS, even with letterboxing.
float maximumX = baseWidth * (scale - 1) / 2; float maximumX = baseWidth * (scale - 1) / 2;
float maximumY = baseHeight * (scale - 1) / 2; float minimumY = -baseHeight * (scale - 1) / 2;
float maximumY = -minimumY;
if (edgePanned) {
// Reserve 20% of the viewport height regardless of zoom. If the
// whole image is shorter than that, keep its full height visible.
float visibleHeight = Math.min(height(), containerHeight * MINIMUM_VISIBLE_HEIGHT_FRACTION);
float centerY = baseTop + baseHeight / 2f;
minimumY = visibleHeight - centerY - height() / 2;
maximumY = containerHeight - visibleHeight - centerY + height() / 2;
}
offsetX = clamp(offsetX, -maximumX, maximumX); offsetX = clamp(offsetX, -maximumX, maximumX);
offsetY = clamp(offsetY, -maximumY, maximumY); offsetY = clamp(offsetY, minimumY, maximumY);
} }
private static float clamp(float value, float minimum, float maximum) { private static float clamp(float value, float minimum, float maximum) {
@@ -6,12 +6,13 @@ import android.view.*;
import android.widget.*; import android.widget.*;
import static cn.crossdesk.mobile.MobileUi.*; import static cn.crossdesk.mobile.MobileUi.*;
/** Safe-area overlay: a draggable 54 dp orb, an adaptive menu and a floating keyboard. */ /** Safe-area overlay: a draggable 54 dp orb, an adaptive menu and a docked keyboard. */
@android.annotation.SuppressLint("ViewConstructor") @android.annotation.SuppressLint("ViewConstructor")
final class SessionControls extends FrameLayout { final class SessionControls extends FrameLayout {
interface Actions { interface Actions {
void disconnect(); void display(); void clipboard(); void key(int code); void disconnect(); void display(); void clipboard(); void key(int code);
void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse(); void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse();
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();
} }
@@ -20,14 +21,20 @@ final class SessionControls extends FrameLayout {
private final View orb; private final View orb;
private final LinearLayout waiting; private final LinearLayout waiting;
private View shield; private View shield;
private LinearLayout menu,keyboard; private LinearLayout menu;
private final RemoteKeyboard keyboard;
private VirtualMouseBar mouseBar; private VirtualMouseBar mouseBar;
private FrameLayout mouseHost; private FrameLayout mouseHost;
private boolean mouseVisible,pictureInPicture; private boolean mouseVisible,pictureInPicture;
private ScrollView keyboardKeys;
private ScrollView menuScroll; private ScrollView menuScroll;
private int keyboardPreferredHeight;
private int menuColumns=3; private int menuColumns=3;
private final ViewTreeObserver.OnGlobalLayoutListener legacyKeyboardLayout=this::legacyKeyboardLayout;
private void legacyKeyboardLayout(){
if(android.os.Build.VERSION.SDK_INT>=30)return;
android.graphics.Rect visible=new android.graphics.Rect();getWindowVisibleDisplayFrame(visible);
int height=Math.max(0,getRootView().getHeight()-visible.bottom);
keyboardInsets(height,height>ui.dp(80),0);
}
private TextView networkLabel,videoFeedback; private TextView networkLabel,videoFeedback;
private TextView transferLabel,saveFileButton; private TextView transferLabel,saveFileButton;
private String transferStatus=""; private String transferStatus="";
@@ -40,10 +47,11 @@ final class SessionControls extends FrameLayout {
private LinearLayout[] trafficRows; private LinearLayout[] trafficRows;
private TextView[] networkDetails; private TextView[] networkDetails;
private View encryptionLock,encryptionOpen; private View encryptionLock,encryptionOpen;
private boolean muted,relative=true,shift,computerKeyboard,orbPositioned; private boolean muted,relative=true,orbPositioned;
private final java.util.Set<Integer> modifiers=new java.util.LinkedHashSet<>();
SessionControls(Context context,MobileUi ui,Actions actions){ SessionControls(Context context,MobileUi ui,Actions actions){
super(context);this.ui=ui;this.actions=actions; super(context);this.ui=ui;this.actions=actions;
keyboard=new RemoteKeyboard(context,ui,actions,()->{requestLayout();positionMouse();});
addView(keyboard,new LayoutParams(-1,ui.dp(44),Gravity.BOTTOM));
// This container must remain transparent to remote touch outside its controls. // This container must remain transparent to remote touch outside its controls.
waiting=ui.column();waiting.setMinimumWidth(ui.dp(200));waiting.setGravity(Gravity.CENTER);waiting.setPadding(ui.dp(20),ui.dp(20),ui.dp(20),ui.dp(20));waiting.setBackground(ui.background(0xB0202024,14)); waiting=ui.column();waiting.setMinimumWidth(ui.dp(200));waiting.setGravity(Gravity.CENTER);waiting.setPadding(ui.dp(20),ui.dp(20),ui.dp(20),ui.dp(20));waiting.setBackground(ui.background(0xB0202024,14));
ProgressBar progress=new ProgressBar(context);progress.setIndeterminateTintList(android.content.res.ColorStateList.valueOf(Color.WHITE));waiting.addView(progress,ui.size(28,28));ui.gap(waiting,12);TextView message=ui.text("等待远端画面…",14,false);message.setTextColor(Color.WHITE);message.setGravity(Gravity.CENTER);waiting.addView(message);addView(waiting,new LayoutParams(-2,-2,Gravity.CENTER)); ProgressBar progress=new ProgressBar(context);progress.setIndeterminateTintList(android.content.res.ColorStateList.valueOf(Color.WHITE));waiting.addView(progress,ui.size(28,28));ui.gap(waiting,12);TextView message=ui.text("等待远端画面…",14,false);message.setTextColor(Color.WHITE);message.setGravity(Gravity.CENTER);waiting.addView(message);addView(waiting,new LayoutParams(-2,-2,Gravity.CENTER));
@@ -65,7 +73,7 @@ final class SessionControls extends FrameLayout {
void cancelMouseInput(){if(mouseBar!=null)mouseBar.cancelInput();} void cancelMouseInput(){if(mouseBar!=null)mouseBar.cancelInput();}
void setPictureInPicture(boolean value){ void setPictureInPicture(boolean value){
pictureInPicture=value; pictureInPicture=value;
if(value){cancelMouseInput();closeMenu();closeKeyboard();modifiers.clear();} if(value){cancelMouseInput();closeMenu();closeKeyboard();}
setVisibility(value?GONE:VISIBLE);positionMouse(); setVisibility(value?GONE:VISIBLE);positionMouse();
} }
private void toggleMouse(){cancelMouseInput();mouseVisible=!mouseVisible;closeMenu();positionMouse();} private void toggleMouse(){cancelMouseInput();mouseVisible=!mouseVisible;closeMenu();positionMouse();}
@@ -80,7 +88,7 @@ final class SessionControls extends FrameLayout {
// When the orb is near the middle of a narrow phone, use the adjacent // When the orb is near the middle of a narrow phone, use the adjacent
// row rather than letting the two controls overlap. // row rather than letting the two controls overlap.
if(x+width>getWidth()-margin){x=bound(orb.getX(),margin,getWidth()-width-margin);y=orb.getY()+orb.getHeight()+gap;if(y+height>getHeight()-margin)y=orb.getY()-gap-height;} if(x+width>getWidth()-margin){x=bound(orb.getX(),margin,getWidth()-width-margin);y=orb.getY()+orb.getHeight()+gap;if(y+height>getHeight()-margin)y=orb.getY()-gap-height;}
if(keyboard!=null&&x<keyboard.getX()+keyboard.getWidth()&&x+width>keyboard.getX()&&y<keyboard.getY()+keyboard.getHeight()&&y+height>keyboard.getY()){ if(keyboard.isOpen()&&x<keyboard.getX()+keyboard.getWidth()&&x+width>keyboard.getX()&&y<keyboard.getY()+keyboard.getHeight()&&y+height>keyboard.getY()){
y=keyboard.getY()-gap-height;if(y<margin)y=keyboard.getY()+keyboard.getHeight()+gap; y=keyboard.getY()-gap-height;if(y<margin)y=keyboard.getY()+keyboard.getHeight()+gap;
} }
mouseBar.setX(location[0]-hostLocation[0]+bound(x,margin,getWidth()-width-margin)); mouseBar.setX(location[0]-hostLocation[0]+bound(x,margin,getWidth()-width-margin));
@@ -113,9 +121,9 @@ 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={"键盘",relative?"相对鼠标":"绝对鼠标",mouseVisible?"收起鼠标":"虚拟鼠标","显示器","画面设置",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"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::mouse,this::toggleMouse,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};
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());
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);
@@ -201,38 +209,11 @@ final class SessionControls extends FrameLayout {
return Math.min(contentHeight,Math.max(1,height-ui.dp(16))); return Math.min(contentHeight,Math.max(1,height-ui.dp(16)));
} }
private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));} private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));}
private int keyboardWidth(int width){return Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,width-ui.dp(16)));} private void positionKeyboard(){positionMouse();}
private int keyboardHeight(int height){return Math.min(keyboardPreferredHeight,Math.max(1,height-ui.dp(16)));} boolean closeKeyboard(){return keyboard.close();}
private void positionKeyboard(){if(keyboard!=null){keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-keyboard.getWidth()-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}positionMouse();} void resetKeyboardContext(){keyboard.resetContext();}
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;keyboardKeys=null;}} void keyboardInsets(int height,boolean visible,int navigation){keyboard.updateInsets(height,visible,navigation);}
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();} private void toggleKeyboard(){keyboard.bringToFront();keyboard.toggle();}
private void showKeyboard(){
float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();int oldScroll=keyboardKeys==null?0:keyboardKeys.getScrollY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10));
LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{});
// Keep the drag/close row fixed while short split windows can scroll every key into view.
keyboardKeys=new ScrollView(getContext());keyboard.addView(keyboardKeys,new LinearLayout.LayoutParams(-1,0,1));LinearLayout keys=ui.column();keyboardKeys.addView(keys);
String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}};
for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keys.addView(line);ui.gap(keys,3);}
keyboardPreferredHeight=ui.dp(5)*2+ui.dp(28)+rows.length*(ui.dp(31)+ui.dp(3));
addView(keyboard,new LayoutParams(keyboardWidth(getWidth()),keyboardHeight(getHeight())));LinearLayout shownKeyboard=keyboard;ScrollView shownKeys=keyboardKeys;
keyboard.post(()->{if(keyboard==shownKeyboard){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);shownKeys.scrollTo(0,oldScroll);constrainPanels();}});
}
private void keyboardKey(String value){
switch(value){
case "⇧":shift=!shift;showKeyboard();return;
case "电脑":computerKeyboard=true;showKeyboard();return;
case "ABC":computerKeyboard=false;showKeyboard();return;
case "剪贴板":actions.clipboard();return;
case "复制":actions.shortcut(0x43);return;case "粘贴":actions.shortcut(0x56);return;case "全选":actions.shortcut(0x41);return;
case "空格":sendKey(32);return;case "⌫":sendKey(8);return;case "Enter":sendKey(13);return;case "Esc":sendKey(27);return;case "Tab":sendKey(9);return;
case "↑":sendKey(38);return;case "↓":sendKey(40);return;case "←":sendKey(37);return;case "→":sendKey(39);return;
// Latch locally; the next key sends a complete chord without leaving remote modifiers down.
case "Ctrl":toggleModifier(17);return;case "Alt":toggleModifier(18);return;case "Win":toggleModifier(91);return;
default:if(value.matches("F[1-6]")){sendKey(111+Integer.parseInt(value.substring(1)));return;}if(!modifiers.isEmpty())sendKey(KeyMap.ascii(value.charAt(0))&255);else actions.text(shift?value.toUpperCase(java.util.Locale.ROOT):value);
}
}
private void toggleModifier(int code){if(!modifiers.add(code))modifiers.remove(code);showKeyboard();}
private void sendKey(int code){if(modifiers.isEmpty())actions.key(code);else{actions.chord(code,modifiers.stream().mapToInt(Integer::intValue).toArray());modifiers.clear();showKeyboard();}}
@android.annotation.SuppressLint("ClickableViewAccessibility") // Tap dispatches performClick; dragging is an additional action. @android.annotation.SuppressLint("ClickableViewAccessibility") // Tap dispatches performClick; dragging is an additional action.
private void drag(View handle,View target,Runnable tap){ private void drag(View handle,View target,Runnable tap){
handle.setOnClickListener(v->tap.run());handle.setOnTouchListener(new View.OnTouchListener(){float startX,startY,originX,originY;boolean moved; handle.setOnClickListener(v->tap.run());handle.setOnTouchListener(new View.OnTouchListener(){float startX,startY,originX,originY;boolean moved;
@@ -251,7 +232,7 @@ final class SessionControls extends FrameLayout {
cancelMouseInput(); cancelMouseInput();
if(menu!=null&&menuPage.equals("controls")&&menuColumns!=controlColumns())openMenu("controls"); if(menu!=null&&menuPage.equals("controls")&&menuColumns!=controlColumns())openMenu("controls");
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8))); orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
if(menu!=null||keyboard!=null)requestLayout(); if(menu!=null||keyboard.isOpen())requestLayout();
positionKeyboard(); positionKeyboard();
positionMouse(); positionMouse();
} }
@@ -265,9 +246,10 @@ final class SessionControls extends FrameLayout {
// measure again with the final cap, even when it did not change. // measure again with the final cap, even when it did not change.
p.width=width;p.height=height;resized=true; p.width=width;p.height=height;resized=true;
} }
if(keyboard!=null){ if(keyboard.isOpen()){
ViewGroup.LayoutParams p=keyboard.getLayoutParams();int width=keyboardWidth(getMeasuredWidth()),height=keyboardHeight(getMeasuredHeight()); LayoutParams p=(LayoutParams)keyboard.getLayoutParams();
if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;} int height=keyboard.preferredHeight(),inset=keyboard.bottomInset();
if(p.height!=height||p.bottomMargin!=inset){p.height=height;p.bottomMargin=inset;resized=true;}
} }
if(resized)super.onMeasure(widthSpec,heightSpec); if(resized)super.onMeasure(widthSpec,heightSpec);
} }
@@ -280,7 +262,8 @@ final class SessionControls extends FrameLayout {
positionKeyboard(); positionKeyboard();
positionMouse(); positionMouse();
} }
@Override protected void onDetachedFromWindow(){cancelMouseInput();super.onDetachedFromWindow();} @Override protected void onAttachedToWindow(){super.onAttachedToWindow();if(android.os.Build.VERSION.SDK_INT<30)getViewTreeObserver().addOnGlobalLayoutListener(legacyKeyboardLayout);}
@Override protected void onDetachedFromWindow(){getViewTreeObserver().removeOnGlobalLayoutListener(legacyKeyboardLayout);cancelMouseInput();super.onDetachedFromWindow();}
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);} @Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);}
private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));} private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));}
} }
+4 -28
View File
@@ -7,35 +7,11 @@
#ifndef _CONTROLLER_PROTOCOL_H_ #ifndef _CONTROLLER_PROTOCOL_H_
#define _CONTROLLER_PROTOCOL_H_ #define _CONTROLLER_PROTOCOL_H_
#include <minirtc.h> #include "../../../libs/controller/include/mobile_controller_protocol.h"
#include <nlohmann/json.hpp>
#include <remote_action.h>
namespace crossdesk::android_controller { namespace crossdesk::android_controller {
inline std::string SecureAttentionMessage() { using mobile_controller::SecureAttentionMessage;
RemoteAction action{}; using mobile_controller::VideoSettingsMessage;
action.type = service_command; using mobile_controller::NetworkReport;
action.c.flag = send_sas;
return action.to_json();
} }
inline std::string VideoSettingsMessage(int quality, int frame_rate, int preference,
uint32_t request_id) {
RemoteAction action{};
action.type = video_settings;
action.vs = {quality, frame_rate, preference, request_id, false};
return action.to_json();
}
inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, TraversalMode mode) {
const auto traffic = [](const MiniRtcInboundStats& in, const MiniRtcOutboundStats& out) {
return nlohmann::json{{"inbound", in.bitrate}, {"outbound", out.bitrate}, {"loss", in.loss_rate}};
};
return {{"rtt", stats.rtt_ms}, {"mode", mode}, {"srtp", stats.srtp_active},
{"video", traffic(stats.video_inbound_stats, stats.video_outbound_stats)},
{"audio", traffic(stats.audio_inbound_stats, stats.audio_outbound_stats)},
{"data", traffic(stats.data_inbound_stats, stats.data_outbound_stats)},
{"total", traffic(stats.total_inbound_stats, stats.total_outbound_stats)}};
}
} // namespace crossdesk::android_controller
#endif #endif
+4 -260
View File
@@ -7,265 +7,9 @@
#ifndef _FILE_TRANSFER_SESSION_H_ #ifndef _FILE_TRANSFER_SESSION_H_
#define _FILE_TRANSFER_SESSION_H_ #define _FILE_TRANSFER_SESSION_H_
#include <file_transfer_format.h> #include "../../../libs/controller/include/mobile_file_transfer_session.h"
#include <stream_names.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <mutex>
#include <optional>
namespace crossdesk::android_controller { namespace crossdesk::android_controller {
using mobile_controller::FileTransferSession;
}
// File I/O is bounded to one chunk per pump. Feedback is delivered by MiniRTC #endif
// callback threads; no callback waits for the RTC executor or for a remote ACK.
class FileTransferSession {
public:
using Clock = std::chrono::steady_clock;
using Send = std::function<int(const char*, const char*, size_t)>;
using Progress = std::function<void(const std::string&, double, bool,
const std::string&)>;
FileTransferSession(Send send, Progress progress)
: send_(std::move(send)), progress_(std::move(progress)) {}
~FileTransferSession() {
if (outgoing_) {
outgoing_->input.close();
std::error_code ec;
std::filesystem::remove(outgoing_->path, ec);
}
for (auto& entry : incoming_) Remove(entry.second);
}
void SetDirectory(std::filesystem::path directory) {
std::lock_guard<std::mutex> lock(mutex_);
directory_ = std::move(directory);
}
bool Start(const std::filesystem::path& path, const std::string& name,
Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
if (outgoing_ || name.empty() || name.size() > UINT16_MAX) return false;
std::error_code ec;
if (!std::filesystem::is_regular_file(path, ec)) return false;
const auto size = std::filesystem::file_size(path, ec);
if (ec) return false;
Outgoing file;
file.input.open(path, std::ios::binary);
if (!file.input) return false;
file.id = ++next_id_;
file.name = name;
file.path = path;
file.total = size;
file.updated = now;
outgoing_ = std::move(file);
progress_(name, 0, true, "");
return true;
}
void Pump(Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
for (auto it = incoming_.begin(); it != incoming_.end();) {
if (now - it->second.updated < timeout_) { ++it; continue; }
Reject(it->first, it->second.total, it->second.received);
progress_(it->second.name, -1, false, "");
Remove(it->second);
it = incoming_.erase(it);
}
if (!outgoing_) return;
auto& file = *outgoing_;
if (now - file.updated >= timeout_) { FinishSend(false); return; }
if (file.last || file.sent - file.acked >= 1024 * 1024) return;
std::array<char, kFileChunkSize> payload{};
const auto size = static_cast<uint32_t>(std::min<uint64_t>(payload.size(), file.total - file.sent));
if (size) {
file.input.read(payload.data(), size);
if (file.input.gcount() != size) { FinishSend(false); return; }
}
const bool first = file.sent == 0;
const bool last = file.sent + size == file.total;
const auto chunk = EncodeFileChunk(file.id, file.sent, file.total,
payload.data(), size, first ? &file.name : nullptr, first, last);
if (chunk.empty() || send_(kFileStream, chunk.data(), chunk.size()) != 0) {
FinishSend(false); return;
}
file.sent += size;
file.last = last;
}
void Ack(const char* data, size_t size, Clock::time_point now = Clock::now()) {
FileTransferAck ack{};
if (!DecodeFileTransferAck(data, size, &ack)) return;
std::lock_guard<std::mutex> lock(mutex_);
if (!outgoing_ || ack.file_id != outgoing_->id) return;
auto& file = *outgoing_;
if (ack.total_size != file.total || ack.acked_offset > file.sent ||
(ack.flags & ~3u)) return;
if (ack.flags & 2) { FinishSend(false); return; }
if (ack.flags & 1) {
if (file.last && ack.acked_offset == file.total) FinishSend(true);
return;
}
if (ack.acked_offset <= file.acked) return;
file.acked = ack.acked_offset;
file.updated = now;
Report(file.name, file.acked, file.total, true, file.percent);
}
void Receive(const char* data, size_t size, Clock::time_point now = Clock::now()) {
FileChunkView chunk;
if (!DecodeFileChunk(data, size, &chunk)) return;
const auto& h = chunk.header;
std::lock_guard<std::mutex> lock(mutex_);
auto it = incoming_.find(h.file_id);
const bool first = h.flags & 1, last = h.flags & 2;
const bool invalid = (h.flags & ~3u) || h.total_size > INT64_MAX ||
(first && h.offset != 0) || chunk.payload_size > kFileChunkSize ||
size != sizeof(FileChunkHeader) + h.name_len + chunk.payload_size ||
(!first && h.name_len) || (!chunk.payload_size && !last) ||
last != (h.offset + chunk.payload_size == h.total_size);
if (invalid || (it == incoming_.end() && incoming_.size() >= 4)) {
Reject(h.file_id, h.total_size, h.offset);
if (it != incoming_.end()) { progress_(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it); }
return;
}
if (it == incoming_.end()) {
Incoming file;
file.name = SafeName(chunk.file_name);
file.total = h.total_size;
std::error_code ec;
if (!directory_.empty()) std::filesystem::create_directories(directory_, ec);
// A per-transfer directory avoids overwrites, even for duplicate names.
auto folder = directory_ / (std::to_string(h.file_id) + "-" + std::to_string(++receive_id_));
if (directory_.empty() || ec || !std::filesystem::create_directory(folder, ec)) {
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); return;
}
file.path = folder / file.name;
file.partial = folder / ".partial";
file.output.open(file.partial, std::ios::binary | std::ios::trunc);
if (!file.output) {
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); Remove(file); return;
}
it = incoming_.emplace(h.file_id, std::move(file)).first;
}
auto& file = it->second;
if (h.total_size != file.total || (first && file.first) ||
!AddRange(file, h.offset, h.offset + chunk.payload_size)) {
Reject(h.file_id, h.total_size, h.offset);
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
}
if (first) {
file.first = true;
file.name = SafeName(chunk.file_name);
file.path = file.path.parent_path() / file.name;
}
file.last |= last;
// KCP removes messages in order, but delivers batches outside its lock on
// both RTP and timer threads. Track coverage instead of assuming callbacks
// arrive in order; ACK only the contiguous prefix and never expose holes.
file.received = !file.ranges.empty() && file.ranges.begin()->first == 0
? file.ranges.begin()->second : 0;
const bool complete = file.first && file.last && file.received == file.total;
file.output.seekp(static_cast<std::streamoff>(h.offset));
file.output.write(chunk.payload, chunk.payload_size);
if (complete) { file.output.flush(); file.output.close(); }
std::error_code ec;
if (complete && !file.output.fail()) std::filesystem::rename(file.partial, file.path, ec);
if (file.output.fail() || ec) {
Reject(h.file_id, h.total_size, h.offset);
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
}
file.updated = now;
const FileTransferAck ack{kFileAckMagic, h.file_id, file.received, file.total, complete ? 1u : 0u};
const auto packet = EncodeFileTransferAck(ack);
send_(kFileFeedbackStream, packet.data(), packet.size());
if (complete) {
progress_(file.name, 1, false, file.path.string());
incoming_.erase(it);
} else Report(file.name, file.received, file.total, false, file.percent);
}
bool Sending() {
std::lock_guard<std::mutex> lock(mutex_);
return outgoing_.has_value();
}
private:
struct Outgoing {
uint32_t id = 0;
std::string name;
std::filesystem::path path;
std::ifstream input;
uint64_t total = 0, sent = 0, acked = 0;
bool last = false;
int percent = -1;
Clock::time_point updated;
};
struct Incoming {
std::string name;
std::filesystem::path path, partial;
std::ofstream output;
uint64_t total = 0, received = 0;
bool first = false, last = false;
std::map<uint64_t, uint64_t> ranges;
int percent = -1;
Clock::time_point updated;
};
static bool AddRange(Incoming& file, uint64_t start, uint64_t end) {
if (start == end) return true;
auto next = file.ranges.lower_bound(start);
if (next != file.ranges.end() && next->first < end) return false;
if (next != file.ranges.begin()) {
auto previous = std::prev(next);
if (previous->second > start) return false;
if (previous->second == start) { start = previous->first; file.ranges.erase(previous); }
}
if (next != file.ranges.end() && next->first == end) { end = next->second; file.ranges.erase(next); }
file.ranges.emplace(start, end);
return file.ranges.size() <= 1024;
}
static std::string SafeName(std::string name) {
const auto slash = name.find_last_of("/\\");
if (slash != std::string::npos) name.erase(0, slash + 1);
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == ':') ch = '_';
if (name.empty() || name == "." || name == ".." || name == ".partial" || name.size() > 240) return "received_file";
return name;
}
static void Remove(Incoming& file) {
file.output.close();
std::error_code ec;
std::filesystem::remove(file.partial, ec);
std::filesystem::remove(file.path.parent_path(), ec);
}
void Reject(uint32_t id, uint64_t total, uint64_t offset) {
const auto packet = EncodeFileTransferAck({kFileAckMagic, id, offset, total, 2});
send_(kFileFeedbackStream, packet.data(), packet.size());
}
void Report(const std::string& name, uint64_t offset, uint64_t total, bool sending, int& previous) {
const double progress = total ? static_cast<double>(offset) / total : 0;
const int percent = static_cast<int>(progress * 100);
if (percent != previous) { previous = percent; progress_(name, std::min(progress, .99), sending, ""); }
}
void FinishSend(bool success) {
progress_(outgoing_->name, success ? 1 : -1, true, "");
outgoing_->input.close();
std::error_code ec;
std::filesystem::remove(outgoing_->path, ec);
outgoing_.reset();
}
const std::chrono::seconds timeout_{30};
std::mutex mutex_;
Send send_;
Progress progress_;
std::filesystem::path directory_;
std::optional<Outgoing> outgoing_;
std::map<uint32_t, Incoming> incoming_;
uint32_t next_id_ = 0;
uint64_t receive_id_ = 0;
};
} // namespace crossdesk::android_controller
#endif
+11
View File
@@ -392,6 +392,17 @@ extern "C" JNIEXPORT void JNICALL JNI_METHOD(nKey)(JNIEnv*, jclass, jlong handle
if (down) s->pressed_keys.insert(key); else s->pressed_keys.erase(key); if (down) s->pressed_keys.insert(key); else s->pressed_keys.erase(key);
s->Send(kKeyboardStream, action.to_json()); s->Send(kKeyboardStream, action.to_json());
} }
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nText)(JNIEnv* env, jclass, jlong handle, jbyteArray bytes) {
auto* s = From(handle); if (!s || !bytes) return;
const auto size = env->GetArrayLength(bytes);
if (size <= 0 || static_cast<size_t>(size) > kMaxTextInputBytes) return;
RemoteAction action{}; action.type = text_input;
action.text.resize(size);
env->GetByteArrayRegion(bytes, 0, size, reinterpret_cast<jbyte*>(action.text.data()));
if (env->ExceptionCheck() || action.text.find('\0') != std::string::npos) return;
// Java supplies standard UTF-8, including supplementary Unicode characters.
s->Send(kKeyboardStream, action.to_json());
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nControl)( extern "C" JNIEXPORT void JNICALL JNI_METHOD(nControl)(
JNIEnv*, jclass, jlong handle, jint type, jint value) { JNIEnv*, jclass, jlong handle, jint type, jint value) {
auto* s = From(handle); if (!s) return; auto* s = From(handle); if (!s) return;
+3 -15
View File
@@ -7,21 +7,9 @@
#ifndef _VIDEO_TIMING_H_ #ifndef _VIDEO_TIMING_H_
#define _VIDEO_TIMING_H_ #define _VIDEO_TIMING_H_
#include <cstdint> #include "../../../libs/controller/include/mobile_video_timing.h"
namespace crossdesk::android_controller { namespace crossdesk::android_controller {
inline double VideoLatencyMilliseconds(uint64_t captured_us, using mobile_controller::VideoLatencyMilliseconds;
uint64_t received_us,
int64_t submitted_us) {
// MiniRTC calibrates capture time when the frame is reassembled. Validate
// that mapping at reception, not after decoding/Surface waits: a real local
// backlog over five seconds must remain visible in the latency measurement.
if (!captured_us || received_us < captured_us || submitted_us <= 0 ||
received_us > static_cast<uint64_t>(submitted_us) ||
received_us - captured_us > 5'000'000)
return -1;
return (static_cast<uint64_t>(submitted_us) - captured_us) / 1000.0;
} }
} // namespace crossdesk::android_controller
#endif #endif
+28 -2
View File
@@ -26,6 +26,7 @@
#include "config_center.h" #include "config_center.h"
#include "daemon.h" #include "daemon.h"
#include "path_manager.h" #include "path_manager.h"
#include "platform/instance_activation.h"
#include "platform/single_instance.h" #include "platform/single_instance.h"
#include "rd_log.h" #include "rd_log.h"
#include "render.h" #include "render.h"
@@ -307,6 +308,25 @@ static crossdesk::platform::InstanceResult AcquireInstance(
return result; return result;
} }
static int ExitForExistingInstance(
crossdesk::platform::InstanceResult result, bool interactive_launch) {
if (interactive_launch &&
result == crossdesk::platform::InstanceResult::kAlreadyRunning) {
std::string error;
if (!crossdesk::platform::RequestInstanceActivation(error)) {
std::cerr << error << '\n';
#ifdef _WIN32
MessageBoxW(nullptr,
L"CrossDesk is already running, but its window could not be "
L"activated.\n\nIt may still be starting or be running in "
L"another Windows session. Please check the system tray.",
L"CrossDesk", MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND);
#endif
}
}
return result == crossdesk::platform::InstanceResult::kAlreadyRunning ? 0 : 1;
}
static int RunApplication( static int RunApplication(
int argc, char* argv[], int argc, char* argv[],
crossdesk::platform::SingleInstanceGuard& client_instance) { crossdesk::platform::SingleInstanceGuard& client_instance) {
@@ -314,6 +334,12 @@ static int RunApplication(
auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk"); auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk");
crossdesk::InitLogger(path_manager->GetLogPath().string()); crossdesk::InitLogger(path_manager->GetLogPath().string());
#ifdef _WIN32
crossdesk::get_logger()->info(
"CrossDesk executable: {}",
std::filesystem::path(GetCurrentExecutablePathW()).u8string());
#endif
if (IsDaemonChild(argc, argv)) { if (IsDaemonChild(argc, argv)) {
// child process: run render directly // child process: run render directly
crossdesk::Render render; crossdesk::Render render;
@@ -452,11 +478,11 @@ int main(int argc, char* argv[]) {
if (!IsDaemonChild(argc, argv)) { if (!IsDaemonChild(argc, argv)) {
const auto result = AcquireInstance(launcher_instance, InstanceRole::kLauncher); const auto result = AcquireInstance(launcher_instance, InstanceRole::kLauncher);
if (result != InstanceResult::kAcquired) if (result != InstanceResult::kAcquired)
return result == InstanceResult::kAlreadyRunning ? 0 : 1; return ExitForExistingInstance(result, true);
} }
const auto result = AcquireInstance(client_instance, InstanceRole::kClient); const auto result = AcquireInstance(client_instance, InstanceRole::kClient);
if (result != InstanceResult::kAcquired) if (result != InstanceResult::kAcquired)
return result == InstanceResult::kAlreadyRunning ? 0 : 1; return ExitForExistingInstance(result, !IsDaemonChild(argc, argv));
#ifdef __linux__ #ifdef __linux__
const int exit_code = crossdesk::RunWithLinuxDisplay( const int exit_code = crossdesk::RunWithLinuxDisplay(
@@ -52,6 +52,10 @@ int ConfigCenter::Load() {
ini_->Delete(section_, "enable_desktop_quality_optimization"); ini_->Delete(section_, "enable_desktop_quality_optimization");
persist_config_migration |= persist_config_migration |=
ini_->Delete(section_, "enable_minimize_to_tray"); ini_->Delete(section_, "enable_minimize_to_tray");
// The old global flag has no version information. Prompt once for the
// current release, then remember its version when the user dismisses it.
persist_config_migration |=
ini_->Delete(section_, "sponsorship_prompt_dismissed");
const long language_value = const long language_value =
ini_->GetLongValue(section_, "language", static_cast<long>(language_)); ini_->GetLongValue(section_, "language", static_cast<long>(language_));
@@ -131,6 +135,8 @@ int ConfigCenter::Load() {
portable_service_prompt_suppressed_ = portable_service_prompt_suppressed_ =
ini_->GetBoolValue(section_, "portable_service_prompt_suppressed", ini_->GetBoolValue(section_, "portable_service_prompt_suppressed",
portable_service_prompt_suppressed_); portable_service_prompt_suppressed_);
sponsorship_prompt_dismissed_version_ =
ini_->GetValue(section_, "sponsorship_prompt_dismissed_version", "");
const char* file_transfer_save_path_value = const char* file_transfer_save_path_value =
ini_->GetValue(section_, "file_transfer_save_path", nullptr); ini_->GetValue(section_, "file_transfer_save_path", nullptr);
@@ -221,6 +227,8 @@ int ConfigCenter::Save() {
save_remote_previews_.load()); save_remote_previews_.load());
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
portable_service_prompt_suppressed_); portable_service_prompt_suppressed_);
ini_->SetValue(section_, "sponsorship_prompt_dismissed_version",
sponsorship_prompt_dismissed_version_.c_str());
ini_->SetValue(section_, "file_transfer_save_path", ini_->SetValue(section_, "file_transfer_save_path",
file_transfer_save_path_.c_str()); file_transfer_save_path_.c_str());
@@ -395,6 +403,16 @@ int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
return 0; return 0;
} }
int ConfigCenter::SetSponsorshipPromptDismissedVersion(
const std::string& version) {
if (version.empty() ||
StoreValues({{"sponsorship_prompt_dismissed_version", version}}) != 0) {
return -1;
}
sponsorship_prompt_dismissed_version_ = version;
return 0;
}
// getters // getters
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; } ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; }
@@ -463,6 +481,10 @@ bool ConfigCenter::IsPortableServicePromptSuppressed() const {
return portable_service_prompt_suppressed_; return portable_service_prompt_suppressed_;
} }
std::string ConfigCenter::GetSponsorshipPromptDismissedVersion() const {
return sponsorship_prompt_dismissed_version_;
}
int ConfigCenter::SetFileTransferSavePath(const std::string& path) { int ConfigCenter::SetFileTransferSavePath(const std::string& path) {
if (path.size() >= 512 || std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32 || c == 127; })) return -1; if (path.size() >= 512 || std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32 || c == 127; })) return -1;
std::lock_guard<std::mutex> lock(file_path_mutex_); std::lock_guard<std::mutex> lock(file_path_mutex_);
@@ -1,7 +1,7 @@
/* /*
* @Author: DI JUNKUN * @Author: DI JUNKUN
* @Date: 2024-05-29 * @Date: 2024-05-29
* Copyright (c) 2024 by DI JUNKUN, All Rights Reserved. * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/ */
#ifndef _CONFIG_CENTER_H_ #ifndef _CONFIG_CENTER_H_
@@ -49,6 +49,7 @@ class ConfigCenter {
int SetPrivacyScreen(bool enable_privacy_screen); int SetPrivacyScreen(bool enable_privacy_screen);
int SetSaveRemotePreviews(bool enabled); int SetSaveRemotePreviews(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed); int SetPortableServicePromptSuppressed(bool suppressed);
int SetSponsorshipPromptDismissedVersion(const std::string& version);
int SetFileTransferSavePath(const std::string& path); int SetFileTransferSavePath(const std::string& path);
// read config // read config
@@ -73,6 +74,7 @@ class ConfigCenter {
bool IsEnablePrivacyScreen() const; bool IsEnablePrivacyScreen() const;
bool IsSaveRemotePreviews() const; bool IsSaveRemotePreviews() const;
bool IsPortableServicePromptSuppressed() const; bool IsPortableServicePromptSuppressed() const;
std::string GetSponsorshipPromptDismissedVersion() const;
std::string GetFileTransferSavePath() const; std::string GetFileTransferSavePath() const;
int Load(); int Load();
@@ -102,6 +104,7 @@ class ConfigCenter {
std::atomic<bool> enable_privacy_screen_{true}; std::atomic<bool> enable_privacy_screen_{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 file_transfer_save_path_ = ""; std::string file_transfer_save_path_ = "";
}; };
} // namespace crossdesk } // namespace crossdesk
@@ -1,7 +1,7 @@
/* /*
* @Author: DI JUNKUN * @Author: DI JUNKUN
* @Date: 2023-12-14 * @Date: 2023-12-14
* Copyright (c) 2023 by DI JUNKUN, All Rights Reserved. * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/ */
#ifndef _DEVICE_CONTROLLER_H_ #ifndef _DEVICE_CONTROLLER_H_
@@ -45,6 +45,11 @@ class KeyboardCapturer : public DeviceController {
// Backends with an asynchronous capture loop override this so the UI can // Backends with an asynchronous capture loop override this so the UI can
// fall back to window events if that loop stops unexpectedly. // fall back to window events if that loop stops unexpectedly.
virtual bool IsHookActive() const { return true; } virtual bool IsHookActive() const { return true; }
// Permission-denied backends can wait for an OS grant notification rather
// than repeatedly releasing held fallback keys to retry an impossible hook.
virtual bool ShouldRetryHook() const { return true; }
virtual bool SupportsTextInput() const { return false; }
virtual int SendTextCommand(const std::string&) { return -1; }
virtual int SendKeyboardCommand(int key_code, bool is_down, virtual int SendKeyboardCommand(int key_code, bool is_down,
uint32_t scan_code = 0, uint32_t scan_code = 0,
bool extended = false) = 0; bool extended = false) = 0;
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@
#define _GUI_APPLICATION_H_ #define _GUI_APPLICATION_H_
#include <chrono> #include <chrono>
#include <functional>
#include <memory> #include <memory>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
@@ -49,8 +50,9 @@ private:
void InitializeModules(); void InitializeModules();
void InitializeUi(); void InitializeUi();
void InitializeSystemTray(); void InitializeSystemTray();
void ShowMainWindow(const std::string& activation_token = {});
bool MinimizeMainWindowToTray(); bool MinimizeMainWindowToTray();
void BindMainCallbacks(); void BindMainCallbacks(bool gallery_window = false);
void BindStreamCallbacks(); void BindStreamCallbacks();
void BindServerCallbacks(); void BindServerCallbacks();
void Tick(); void Tick();
@@ -60,6 +62,25 @@ private:
void HandlePasswordChangeResult(); void HandlePasswordChangeResult();
void HandleCredentialRecovery(); void HandleCredentialRecovery();
void SyncMainWindow(); void SyncMainWindow();
void ShowGalleryWindow();
void CloseGalleryWindow();
void SyncWindowGeometry(bool gallery_window);
void ToggleGalleryFullscreen();
void RestoreWindowGeometry(bool before_show = false,
bool gallery_window = false);
void SyncGallery();
void GalleryAction(const std::string& action, const std::string& value,
bool gallery_window = true);
bool AddGalleryDevice(const std::string& id, const std::string& password,
const std::string& group, bool remember_password);
bool ImportGalleryRecent(const std::string& group);
void DeviceGroupAction(const std::string& action, const std::string& value,
bool gallery_window);
bool SaveDeviceGroups(const std::string& new_group, bool gallery_window);
void ReleasePressedInput();
void ReleaseControl();
void SyncAudioPlayback();
void SaveGalleryWindowState();
void SyncAnnouncements(); void SyncAnnouncements();
void SyncConnectionDialog(); void SyncConnectionDialog();
void SyncPlatformDialogs(); void SyncPlatformDialogs();
@@ -70,15 +91,17 @@ private:
void SyncStreamKeyboardFocus(); void SyncStreamKeyboardFocus();
void SetStreamKeyboardFocus(bool focused); void SetStreamKeyboardFocus(bool focused);
void SyncServerWindow(); void SyncServerWindow();
void SyncFileTransferNotifications();
void CancelFileTransfer(uint32_t file_id);
void SetServerPanelExpanded(bool expanded); void SetServerPanelExpanded(bool expanded);
void PositionServerWindows(); void PositionServerWindows();
#if defined(__linux__) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
void SyncXWaylandWindowActivation(); void SyncXWaylandWindowActivation();
#endif #endif
void UpdateLocalization(); void UpdateLocalization();
void ResetSettingsUi(); void ResetSettingsUi(bool gallery_window = false);
bool ValidateSelfHostedSettingsFromUi(); bool ValidateSelfHostedSettingsFromUi(bool gallery_window = false);
bool SaveSettingsFromUi(); bool SaveSettingsFromUi(bool gallery_window = false);
void ApplySettingsFromConfig(bool reconnect); void ApplySettingsFromConfig(bool reconnect);
#if _WIN32 && CROSSDESK_PORTABLE #if _WIN32 && CROSSDESK_PORTABLE
void CheckPortableWindowsService(); void CheckPortableWindowsService();
@@ -97,7 +120,8 @@ private:
void Cleanup(); void Cleanup();
std::shared_ptr<RemoteSession> SelectedSession(); std::shared_ptr<RemoteSession> SelectedSession();
std::string OpenFileDialog(const std::string &title); void OpenFileDialog(const std::string& title,
std::function<void(std::string)> completion);
bool OpenUrl(const std::string &url); bool OpenUrl(const std::string &url);
std::unique_ptr<SlintUi> ui_; std::unique_ptr<SlintUi> ui_;
@@ -108,7 +132,7 @@ private:
uint32_t cursor_state_sequence_ = 0; uint32_t cursor_state_sequence_ = 0;
std::chrono::steady_clock::time_point next_video_frame_time_{}; std::chrono::steady_clock::time_point next_video_frame_time_{};
SignalConnectionIndicator signal_connection_indicator_; SignalConnectionIndicator signal_connection_indicator_;
#if defined(__linux__) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
bool use_xwayland_gui_ = false; bool use_xwayland_gui_ = false;
bool use_x11_custom_titlebar_ = false; bool use_x11_custom_titlebar_ = false;
#endif #endif
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Regenerate message_spacing.ttf with fontTools (only needed for regeneration).
Slint 1.17 pins line height to the requested font's ascent, descent and leading,
but StyledText does not expose line-height. This metrics-only font supplies 1.5em
line boxes without replacing any visible glyphs: Slint's normal system fallback
fonts still render the text and links. Use this family for message and release-note
bodies without changing the typography of other controls.
"""
from pathlib import Path
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
def main():
font = FontBuilder(1000, isTTF=True)
glyphs = [".notdef", "zeroWidthSpace"]
font.setupGlyphOrder(glyphs)
font.setupCharacterMap({0x200B: "zeroWidthSpace"})
font.setupGlyf({name: TTGlyphPen(None).glyph() for name in glyphs})
font.setupHorizontalMetrics({name: (0, 0) for name in glyphs})
font.setupHorizontalHeader(ascent=1000, descent=-250, lineGap=250)
font.setupOS2(
version=4,
sTypoAscender=1000,
sTypoDescender=-250,
sTypoLineGap=250,
usWinAscent=1000,
usWinDescent=250,
fsSelection=0xC0, # Regular, use typographic metrics.
)
font.setupNameTable({
"familyName": "CrossDesk Message Spacing",
"styleName": "Regular",
"uniqueFontIdentifier": "CrossDesk-Message-Spacing-1.0",
"fullName": "CrossDesk Message Spacing",
"psName": "CrossDeskMessageSpacing-Regular",
"version": "Version 1.0",
"copyright": "Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.",
})
font.setupPost()
# Fixed timestamps keep the generated asset reproducible.
font.font["head"].created = font.font["head"].modified = 3874867200
font.font.recalcTimestamp = False
font.save(Path(__file__).with_name("message_spacing.ttf"))
if __name__ == "__main__":
main()
@@ -1,8 +1,9 @@
/* /*
* @Author: DI JUNKUN * @Author: DI JUNKUN
* @Date: 2024-05-29 * @Date: 2024-05-29
* Copyright (c) 2024 by DI JUNKUN, All Rights Reserved. * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/ */
#ifndef _LOCALIZATION_DATA_H_ #ifndef _LOCALIZATION_DATA_H_
#define _LOCALIZATION_DATA_H_ #define _LOCALIZATION_DATA_H_
@@ -23,10 +24,11 @@ struct TranslationRow {
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_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. Open the announcement again to retry.", 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"Удалить объявление") \
@@ -205,8 +207,13 @@ 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 device needs an update", u8"Версия на удалённом устройстве устарела") \ X(remote_update_title, u8"建议升级被控端", "Remote update recommended", \
X(remote_update, u8"请升级到最新版本。", "Please update to the latest version.", u8"Обновите её до последней версии.") \ u8"Рекомендуется обновление") \
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"Не удалось применить настройки. Повторите попытку.") \
@@ -282,6 +289,14 @@ struct TranslationRow {
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_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
"If CrossDesk is useful to you, consider supporting its continued development, cross-platform testing, and maintenance. Thank you for your support!", \
u8"Если CrossDesk вам полезен, поддержите его дальнейшую разработку, тестирование на разных платформах и сопровождение. Спасибо за вашу поддержку!") \
X(sponsorship_dismiss_hint, u8"关闭后本版本不再提示,升级后再次显示。赞助入口仍在「关于」中。", \
"Once closed, this prompt stays hidden until the version changes. Sponsorship links remain available in About.", \
u8"После закрытия это окно появится только при смене версии. Ссылки для поддержки останутся в разделе «О программе».") \
X(sponsor_via_afdian, u8"爱发电", "AFDIAN", "AFDIAN") \
X(notification, u8"通知", "Notification", u8"Уведомление") \ X(notification, u8"通知", "Notification", u8"Уведомление") \
X(new_version, u8"新版本", "New version", u8"Новая версия") \ X(new_version, u8"新版本", "New version", u8"Новая версия") \
X(new_version_available, u8"新版本可用", "New Version Available", \ X(new_version_available, u8"新版本可用", "New Version Available", \
@@ -322,10 +337,17 @@ struct TranslationRow {
u8"Выбрать файл для отправки") \ u8"Выбрать файл для отправки") \
X(file_transfer_progress, u8"文件传输进度", "File Transfer Progress", \ X(file_transfer_progress, u8"文件传输进度", "File Transfer Progress", \
u8"Прогресс передачи файлов") \ u8"Прогресс передачи файлов") \
X(file_sending_progress, u8"文件发送进度", "File Sending Progress", \
u8"Отправка файла") \
X(file_receiving_progress, u8"文件接收进度", "File Receiving Progress", \
u8"Получение файла") \
X(queued, u8"队列中", "Queued", u8"В очереди") \ X(queued, u8"队列中", "Queued", u8"В очереди") \
X(sending, u8"正在传输", "Sending", u8"Передача") \ X(sending, u8"正在传输", "Sending", u8"Передача") \
X(receiving, u8"正在接收", "Receiving", u8"Получение") \
X(completed, u8"已完成", "Completed", u8"Завершено") \ X(completed, u8"已完成", "Completed", u8"Завершено") \
X(failed, u8"失败", "Failed", u8"Ошибка") \ X(failed, u8"失败", "Failed", u8"Ошибка") \
X(cancelling, u8"正在取消", "Cancelling", u8"Отмена…") \
X(cancelled, u8"已取消", "Cancelled", u8"Отменено") \
X(controller, u8"控制端:", "Controller:", u8"Контроллер:") \ X(controller, u8"控制端:", "Controller:", u8"Контроллер:") \
X(server_controlled, u8"被控中", "Controlled", u8"Под управлением") \ X(server_controlled, u8"被控中", "Controlled", u8"Под управлением") \
X(server_heading, u8"此设备被控中", "This device is controlled", u8"Устройство под управлением") \ X(server_heading, u8"此设备被控中", "This device is controlled", u8"Устройство под управлением") \
@@ -46,7 +46,8 @@ void ClipboardController::Shutdown() {
pending_remote_text_.reset(); pending_remote_text_.reset();
} }
void ClipboardController::QueueRemoteText(const char *data, size_t size) { void ClipboardController::QueueRemoteText(const char* data, size_t size,
const std::string& remote_id) {
if (!events_enabled_.load(std::memory_order_acquire)) { if (!events_enabled_.load(std::memory_order_acquire)) {
return; return;
} }
@@ -62,6 +63,11 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
return; return;
} }
const auto session =
remote_id.empty() ? nullptr : owner_.FindRemoteSession(remote_id);
if (!remote_id.empty() && (!session || !session->clipboard_allowed_.load()))
return;
const auto generation = session ? session->input_generation_.load() : 0;
{ {
std::lock_guard<std::mutex> lock(pending_mutex_); std::lock_guard<std::mutex> lock(pending_mutex_);
if (!events_enabled_.load(std::memory_order_relaxed)) { if (!events_enabled_.load(std::memory_order_relaxed)) {
@@ -70,6 +76,9 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
// Only the newest clipboard value matters. Replacing it also bounds the // Only the newest clipboard value matters. Replacing it also bounds the
// amount of memory a busy or malicious peer can queue. // amount of memory a busy or malicious peer can queue.
pending_remote_text_ = std::string(data, size); pending_remote_text_ = std::string(data, size);
pending_remote_id_ = remote_id;
pending_session_ = session;
pending_generation_ = generation;
} }
SDL_Event event{}; SDL_Event event{};
@@ -87,9 +96,22 @@ void ClipboardController::ApplyPendingRemoteText() {
} }
std::optional<std::string> pending_text; std::optional<std::string> pending_text;
std::string source_id;
std::weak_ptr<gui_detail::RemoteSession> source_session;
uint64_t generation = 0;
{ {
std::lock_guard<std::mutex> lock(pending_mutex_); std::lock_guard<std::mutex> lock(pending_mutex_);
pending_text.swap(pending_remote_text_); pending_text.swap(pending_remote_text_);
source_id.swap(pending_remote_id_);
source_session.swap(pending_session_);
generation = pending_generation_;
}
if (!source_id.empty()) {
auto props = owner_.FindRemoteSession(source_id);
if (!props || props != source_session.lock() ||
!props->clipboard_allowed_.load() ||
props->input_generation_.load() != generation)
return;
} }
if (!pending_text || *pending_text == last_text_) { if (!pending_text || *pending_text == last_text_) {
return; return;
@@ -145,7 +167,7 @@ int ClipboardController::SendToPeers(const std::string &text) {
std::shared_lock lock(owner_.remote_sessions_mutex_); std::shared_lock lock(owner_.remote_sessions_mutex_);
for (const auto &[remote_id, props] : owner_.remote_sessions_) { for (const auto &[remote_id, props] : owner_.remote_sessions_) {
if (!props || !props->peer_ || !props->connection_established_ || if (!props || !props->peer_ || !props->connection_established_ ||
!props->enable_mouse_control_) { !props->clipboard_allowed_.load()) {
continue; continue;
} }
@@ -10,10 +10,15 @@
#include <atomic> #include <atomic>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <memory>
#include <mutex> #include <mutex>
#include <optional> #include <optional>
#include <string> #include <string>
namespace crossdesk::gui_detail {
struct RemoteSession;
}
namespace crossdesk { namespace crossdesk {
class GuiRuntime; class GuiRuntime;
@@ -29,7 +34,8 @@ public:
void Initialize(); void Initialize();
void Shutdown(); void Shutdown();
void QueueRemoteText(const char *data, size_t size); void QueueRemoteText(const char* data, size_t size,
const std::string& remote_id = {});
void ApplyPendingRemoteText(); void ApplyPendingRemoteText();
void HandleLocalUpdate(); void HandleLocalUpdate();
@@ -41,6 +47,9 @@ private:
std::atomic<bool> events_enabled_{false}; std::atomic<bool> events_enabled_{false};
std::mutex pending_mutex_; std::mutex pending_mutex_;
std::optional<std::string> pending_remote_text_; std::optional<std::string> pending_remote_text_;
std::string pending_remote_id_;
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
uint64_t pending_generation_ = 0;
std::string last_text_; std::string last_text_;
}; };
@@ -30,7 +30,7 @@ namespace {
constexpr uint64_t kCaptureResumeKeyFrameGapMs = 500; constexpr uint64_t kCaptureResumeKeyFrameGapMs = 500;
constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1); constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1);
#ifdef _WIN32 #if defined(_WIN32) || defined(__OHOS__)
constexpr auto kKeyboardCaptureRetryDelay = std::chrono::seconds(1); constexpr auto kKeyboardCaptureRetryDelay = std::chrono::seconds(1);
#endif #endif
@@ -49,7 +49,13 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
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_); frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
SendAudioFrame(owner_.peer_, &frame, owner_.audio_label_.c_str()); std::shared_lock lock(owner_.connection_status_mutex_);
for (const auto& [id, status] : owner_.connection_status_) {
if (status == ConnectionStatus::Connected)
SendAudioFrameToPeer(owner_.peer_, &frame,
owner_.audio_label_.c_str(), id.data(),
id.size());
}
}) {} }) {}
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); } SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
@@ -245,6 +251,8 @@ int SessionDeviceManager::InitializeScreenCapturer() {
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,
stream_id)) continue;
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(), if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
remote_id.data(), remote_id.size()) != 0) remote_id.data(), remote_id.size()) != 0)
++send_errors; ++send_errors;
@@ -411,7 +419,7 @@ int SessionDeviceManager::StartMouseController() {
return -1; return -1;
} }
#if defined(__linux__) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
if (IsWaylandSession()) { if (IsWaylandSession()) {
if (!screen_capturer_) { if (!screen_capturer_) {
return 1; return 1;
@@ -459,7 +467,7 @@ int SessionDeviceManager::StopMouseController() {
int SessionDeviceManager::StartKeyboardCapturer() { int SessionDeviceManager::StartKeyboardCapturer() {
captured_keyboard_inputs_.Clear(); captured_keyboard_inputs_.Clear();
owner_.keyboard_capturer_uses_window_events_ = false; owner_.keyboard_capturer_uses_window_events_ = false;
#ifdef _WIN32 #if defined(_WIN32) || defined(__OHOS__)
next_keyboard_capture_retry_ = next_keyboard_capture_retry_ =
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay; std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
#endif #endif
@@ -471,7 +479,7 @@ int SessionDeviceManager::StartKeyboardCapturer() {
} }
#endif #endif
#if defined(__linux__) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
if (IsWaylandSession()) { if (IsWaylandSession()) {
owner_.keyboard_capturer_uses_window_events_ = true; owner_.keyboard_capturer_uses_window_events_ = true;
LOG_INFO("Start keyboard capturer with Slint Wayland backend"); LOG_INFO("Start keyboard capturer with Slint Wayland backend");
@@ -531,14 +539,14 @@ int SessionDeviceManager::InitializeAudioOutput() {
desired_out.format = SDL_AUDIO_S16; desired_out.format = SDL_AUDIO_S16;
desired_out.channels = 1; desired_out.channels = 1;
auto open_stream = [&]() { auto open_device = [&]() {
output_stream_ = SDL_OpenAudioDeviceStream( output_device_ = SDL_OpenAudioDevice(
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired_out, nullptr, nullptr); SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired_out);
return output_stream_ != nullptr; return output_device_ != 0;
}; };
if (!open_stream()) { if (!open_device()) {
#if defined(__linux__) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
LOG_WARN("Failed to open output stream with driver [{}]: {}", LOG_WARN("Failed to open output stream with driver [{}]: {}",
getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER") getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER")
: "(default)", : "(default)",
@@ -551,7 +559,7 @@ int SessionDeviceManager::InitializeAudioOutput() {
SDL_GetError()); SDL_GetError());
return -1; return -1;
} }
if (!open_stream()) { if (!open_device()) {
LOG_ERROR("Failed to open output stream with dummy driver: {}", LOG_ERROR("Failed to open output stream with dummy driver: {}",
SDL_GetError()); SDL_GetError());
return -1; return -1;
@@ -563,27 +571,50 @@ int SessionDeviceManager::InitializeAudioOutput() {
#endif #endif
} }
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(output_stream_)); SDL_ResumeAudioDevice(output_device_);
return 0; return 0;
} }
int SessionDeviceManager::DestroyAudioOutput() { int SessionDeviceManager::DestroyAudioOutput() {
if (output_stream_) { for (const auto& [_, stream] : output_streams_)
SDL_CloseAudioDevice(SDL_GetAudioStreamDevice(output_stream_)); SDL_DestroyAudioStream(stream);
SDL_DestroyAudioStream(output_stream_); output_streams_.clear();
output_stream_ = nullptr; if (output_device_) {
SDL_CloseAudioDevice(output_device_);
output_device_ = 0;
} }
return 0; return 0;
} }
void SessionDeviceManager::PushAudio(const char *data, size_t size) { void SessionDeviceManager::ClearAudio(const std::string& remote_id) {
if (!output_stream_) { const auto found = output_streams_.find(remote_id);
return; if (found == output_streams_.end()) return;
SDL_DestroyAudioStream(found->second);
output_streams_.erase(found);
}
void SessionDeviceManager::PushAudio(const std::string& remote_id,
const char *data, size_t size) {
if (!output_device_) return;
auto found = output_streams_.find(remote_id);
if (found == output_streams_.end()) {
SDL_AudioSpec spec{};
spec.freq = 48000;
spec.format = SDL_AUDIO_S16;
spec.channels = 1;
auto* stream = SDL_CreateAudioStream(&spec, &spec);
if (!stream) {
LOG_ERROR("Failed to create output stream: {}", SDL_GetError());
return;
}
if (!SDL_BindAudioStream(output_device_, stream)) {
LOG_ERROR("Failed to bind output stream: {}", SDL_GetError());
SDL_DestroyAudioStream(stream);
return;
}
found = output_streams_.emplace(remote_id, stream).first;
} }
const int pushed = SDL_PutAudioStreamData( if (!SDL_PutAudioStreamData(found->second, data, static_cast<int>(size))) {
output_stream_, reinterpret_cast<const Uint8 *>(data),
static_cast<int>(size));
if (pushed < 0) {
LOG_ERROR("Failed to push audio data: {}", SDL_GetError()); LOG_ERROR("Failed to push audio data: {}", SDL_GetError());
} }
} }
@@ -598,7 +629,7 @@ void SessionDeviceManager::UpdateInteractions() {
} }
} }
#endif #endif
#if defined(__linux__) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
const bool is_wayland_session = IsWaylandSession(); const bool is_wayland_session = IsWaylandSession();
const bool stop_wayland_mouse_before_screen = const bool stop_wayland_mouse_before_screen =
is_wayland_session && !owner_.start_screen_capturer_ && is_wayland_session && !owner_.start_screen_capturer_ &&
@@ -633,24 +664,27 @@ void SessionDeviceManager::UpdateInteractions() {
} }
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) { if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
#ifdef _WIN32 #if defined(_WIN32) || defined(__OHOS__)
const int active_display = screen_capturer_->GetCurrentMonitorIndex(); const int active_display = screen_capturer_->GetCurrentMonitorIndex();
const auto displays = display_info_list(); const auto displays = display_info_list();
if (active_display >= 0 && if (active_display >= 0 &&
active_display < static_cast<int>(displays.size())) { active_display < static_cast<int>(displays.size())) {
owner_.selected_display_ = active_display; owner_.selected_display_ = active_display;
#ifdef _WIN32
if (displays[active_display].width <= 0 || if (displays[active_display].width <= 0 ||
displays[active_display].height <= 0) displays[active_display].height <= 0)
ReleaseRemoteMouseButtons(); ReleaseRemoteMouseButtons();
#endif
} }
#endif #endif
} }
if (owner_.start_keyboard_capturer_ && owner_.focus_on_stream_window_ && if (owner_.start_keyboard_capturer_ && owner_.focus_on_stream_window_ &&
!owner_.privacy_.Snapshot().input_blocked) { !owner_.privacy_.Snapshot().input_blocked) {
#ifdef _WIN32 #if defined(_WIN32) || defined(__OHOS__)
if (owner_.keyboard_capturer_is_started_ && if (owner_.keyboard_capturer_is_started_ &&
owner_.keyboard_capturer_uses_window_events_ && keyboard_capturer_ && owner_.keyboard_capturer_uses_window_events_ && keyboard_capturer_ &&
keyboard_capturer_->ShouldRetryHook() &&
std::chrono::steady_clock::now() >= next_keyboard_capture_retry_) { std::chrono::steady_clock::now() >= next_keyboard_capture_retry_) {
// A transient installation failure or stalled hook must not leave // A transient installation failure or stalled hook must not leave
// system shortcuts in the window-event fallback until focus changes. // system shortcuts in the window-event fallback until focus changes.
@@ -668,7 +702,7 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.keyboard_.ForceReleasePressedKeys(); owner_.keyboard_.ForceReleasePressedKeys();
owner_.keyboard_capturer_uses_window_events_ = true; owner_.keyboard_capturer_uses_window_events_ = true;
owner_.keyboard_capturer_is_started_ = true; owner_.keyboard_capturer_is_started_ = true;
#ifdef _WIN32 #if defined(_WIN32) || defined(__OHOS__)
next_keyboard_capture_retry_ = next_keyboard_capture_retry_ =
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay; std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
#endif #endif
@@ -699,6 +733,14 @@ void SessionDeviceManager::DrainCapturedKeyboardInput() {
} }
} }
bool SessionDeviceManager::SupportsTextInput() const {
return keyboard_capturer_ && keyboard_capturer_->SupportsTextInput();
}
bool SessionDeviceManager::SendTextCommand(const std::string& text) {
return keyboard_capturer_ && keyboard_capturer_->SendTextCommand(text) == 0;
}
bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down, bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
uint32_t scan_code, uint32_t scan_code,
bool extended) { bool extended) {
@@ -13,6 +13,7 @@
#include <cstdint> #include <cstdint>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_map>
#include <vector> #include <vector>
#include <remote_action.h> #include <remote_action.h>
@@ -67,8 +68,13 @@ public:
int InitializeAudioOutput(); int InitializeAudioOutput();
int DestroyAudioOutput(); int DestroyAudioOutput();
void PushAudio(const char *data, size_t size); // Serialized by GuiRuntime::audio_delivery_mutex_. Each peer has its own
// stream so SDL mixes simultaneous playback and muting clears only that peer.
void ClearAudio(const std::string& remote_id);
void PushAudio(const std::string& remote_id, const char *data, size_t size);
bool SupportsTextInput() const;
bool SendTextCommand(const std::string& text);
bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code, bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code,
bool extended); bool extended);
bool SendMouseCommand(const RemoteAction &action, int selected_display); bool SendMouseCommand(const RemoteAction &action, int selected_display);
@@ -93,7 +99,8 @@ private:
bool from_secure_desktop); bool from_secure_desktop);
GuiRuntime &owner_; GuiRuntime &owner_;
SDL_AudioStream *output_stream_ = nullptr; uint32_t output_device_ = 0;
std::unordered_map<std::string, SDL_AudioStream*> output_streams_;
ScreenCapturerFactory *screen_capturer_factory_ = nullptr; ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
ScreenCapturer *screen_capturer_ = nullptr; ScreenCapturer *screen_capturer_ = nullptr;
std::atomic<bool> cursor_capture_dirty_{true}; std::atomic<bool> cursor_capture_dirty_{true};
@@ -106,8 +113,10 @@ private:
std::vector<DisplayInfo> display_info_list_; std::vector<DisplayInfo> display_info_list_;
size_t registered_display_stream_count_ = 0; size_t registered_display_stream_count_ = 0;
CapturedKeyboardQueue captured_keyboard_inputs_; CapturedKeyboardQueue captured_keyboard_inputs_;
#ifdef _WIN32 #if defined(_WIN32) || defined(__OHOS__)
std::chrono::steady_clock::time_point next_keyboard_capture_retry_{}; std::chrono::steady_clock::time_point next_keyboard_capture_retry_{};
#endif
#ifdef _WIN32
std::mutex service_mouse_mutex_; std::mutex service_mouse_mutex_;
unsigned service_mouse_buttons_ = 0; unsigned service_mouse_buttons_ = 0;
int service_mouse_x_ = 0, service_mouse_y_ = 0; int service_mouse_x_ = 0, service_mouse_y_ = 0;
@@ -1,370 +1,400 @@
#include "features/file_transfer/file_transfer_manager.h" #include "features/file_transfer/file_transfer_manager.h"
#include <algorithm> #include <algorithm>
#include <chrono>
#include <filesystem>
#include <limits> #include <limits>
#include <memory>
#include <mutex>
#include <shared_mutex>
#include <string>
#include <thread> #include <thread>
#include <utility>
#include "file_transfer.h"
#include "filesystem_utf8.h" #include "filesystem_utf8.h"
#include "rd_log.h" #include "rd_log.h"
#include "runtime/gui_runtime.h" #include "runtime/gui_runtime.h"
namespace crossdesk { namespace crossdesk {
FileTransferManager::FileTransferManager(GuiRuntime &owner) : owner_(owner) {} FileTransferManager::FileTransferManager(GuiRuntime& owner) : owner_(owner) {}
FileTransferManager::~FileTransferManager() { Shutdown(); }
FileTransferManager::~FileTransferManager() { void FileTransferManager::Shutdown() {
std::vector<std::future<void>> workers; std::vector<std::future<void>> workers;
{ {
std::lock_guard lock(workers_mutex_); std::lock_guard lock(workers_mutex_);
stopping_ = true; stopping_ = true;
workers.swap(workers_); workers.swap(workers_);
} }
{
std::lock_guard lock(sessions_mutex_);
for (auto& [id, sender] : senders_) sender.session->Cancel();
for (auto& [handler, peers] : receivers_)
for (auto& [id, receiver] : peers) receiver.session->Cancel();
receivers_.clear();
}
wake_.notify_all();
for (auto& worker : workers) worker.wait(); for (auto& worker : workers) worker.wait();
} }
FileTransferManager::FileTransferState &FileTransferManager::global_state() { FileTransferManager::FileTransferState& FileTransferManager::global_state() { return global_state_; }
return global_state_; FileTransferManager::FileTransferState& FileTransferManager::received_state() { return received_state_; }
} FileTransferManager::FileTransferState& FileTransferManager::state_for(const std::shared_ptr<RemoteSession>& props) {
FileTransferManager::FileTransferState &FileTransferManager::state_for(
const std::shared_ptr<RemoteSession> &props) {
return props ? props->file_transfer_ : global_state_; return props ? props->file_transfer_ : global_state_;
} }
void FileTransferManager::ProcessSelectedFile( bool FileTransferManager::ProcessSelectedFile(const std::string& path,
const std::string &path, const std::shared_ptr<RemoteSession>& props, const std::string& file_label,
const std::shared_ptr<RemoteSession> &props, const std::string& remote_id, uint32_t file_id) {
const std::string &file_label, const std::string &remote_id) { if (path.empty() || stopping_ || (props && props->closing_)) return false;
if (path.empty()) { const auto file_path = PathFromUtf8(path);
return;
}
FileTransferState &state = state_for(props);
LOG_INFO("Selected file: {}", path.c_str());
const std::filesystem::path file_path = PathFromUtf8(path);
std::error_code ec; std::error_code ec;
if (!std::filesystem::is_regular_file(file_path, ec)) { if (!std::filesystem::is_regular_file(file_path, ec)) return false;
LOG_ERROR("Selected path is not a regular file: {}", path); const auto size = std::filesystem::file_size(file_path, ec);
return; if (ec) return false;
} auto& state = state_for(props);
const uint64_t file_size = std::filesystem::file_size(file_path, ec); // Allocate once at enqueue time so queued and active notifications keep the
if (ec) { // same identity, including repeated sends of the same path.
LOG_ERROR("Failed to get file size: {}", ec.message()); const auto id = file_id ? file_id : file_transfer::Session::NextFileId();
return;
}
{ {
std::lock_guard<std::mutex> lock(state.file_transfer_list_mutex_); std::lock_guard lock(state.file_transfer_list_mutex_);
FileTransferState::FileTransferInfo info; FileTransferState::FileTransferInfo info;
const auto utf8_name = file_path.filename().u8string(); const auto name = file_path.filename().u8string();
info.file_name.assign(reinterpret_cast<const char *>(utf8_name.data()), info.file_name.assign(name.begin(), name.end());
utf8_name.size()); info.remote_id = props ? props->remote_id_ : remote_id;
info.file_path = file_path; info.file_path = file_path; info.file_size = size;
info.file_size = file_size; info.file_id = id;
info.status = FileTransferState::FileTransferStatus::Queued; info.status = FileTransferState::FileTransferStatus::Queued;
state.file_transfer_list_.push_back(std::move(info)); state.file_transfer_list_.push_back(std::move(info));
} }
state.file_transfer_window_visible_ = true; state.file_transfer_window_visible_ = true;
{
auto enqueue = [&]() { std::lock_guard lock(state.file_queue_mutex_);
size_t queue_size = 0; state.file_send_queue_.push({file_path, file_label, props ? props->remote_id_ : remote_id, id});
{
std::lock_guard<std::mutex> lock(state.file_queue_mutex_);
state.file_send_queue_.push(
FileTransferState::QueuedFile{file_path, file_label, remote_id});
queue_size = state.file_send_queue_.size();
}
LOG_INFO("File added to queue: {} ({} files in queue)",
file_path.filename().string().c_str(), queue_size);
};
if (state.file_sending_.load()) {
enqueue();
return;
}
Start(props, file_path, file_label, remote_id);
if (!state.file_sending_.load()) {
enqueue();
} }
ProcessQueue(props);
return true;
} }
void FileTransferManager::Start( void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
std::shared_ptr<RemoteSession> props,
const std::filesystem::path &file_path, const std::string &file_label,
const std::string &remote_id) {
if (props && props->closing_) return;
const bool is_global = !props;
PeerPtr* peer = nullptr;
std::shared_ptr<PeerEventHandler> peer_events;
{
std::shared_lock lock(owner_.remote_sessions_mutex_);
if (props && props->closing_) return;
peer = is_global ? owner_.peer_ : props->peer_;
peer_events = is_global ? owner_.peer_events_ : props->peer_events_;
}
if (!peer) {
LOG_ERROR("StartFileTransfer: invalid peer");
return;
}
FileTransferState &initial_state = state_for(props);
bool expected = false;
if (!initial_state.file_sending_.compare_exchange_strong(expected, true)) {
LOG_WARN("StartFileTransfer called while another file is active: {}",
file_path.filename().string().c_str());
return;
}
const auto props_weak = std::weak_ptr<RemoteSession>(props);
std::lock_guard workers_lock(workers_mutex_); std::lock_guard workers_lock(workers_mutex_);
if (stopping_) return; auto& state = state_for(props);
// Reap finished senders without blocking the UI. if (stopping_ || state.file_sending_) return;
FileTransferState::QueuedFile file;
{
std::lock_guard lock(state.file_queue_mutex_);
if (state.file_send_queue_.empty()) return;
file = state.file_send_queue_.front(); state.file_send_queue_.pop();
state.file_sending_ = true;
}
std::erase_if(workers_, [](auto& worker) { std::erase_if(workers_, [](auto& worker) {
return worker.wait_for(std::chrono::seconds(0)) == std::future_status::ready; return worker.wait_for(std::chrono::seconds(0)) == std::future_status::ready;
}); });
workers_.push_back(std::async(std::launch::async, workers_.push_back(std::async(std::launch::async, [this, props, file] {
[this, peer, peer_events, file_path, file_label, props_weak, remote_id, auto& state = state_for(props);
is_global]() { const auto id = file.file_id;
auto props_locked = props_weak.lock(); PeerPtr* peer;
FileTransferState *state = nullptr; std::shared_ptr<PeerEventHandler> events;
if (props_locked) {
state = &props_locked->file_transfer_;
} else if (is_global) {
state = &global_state_;
} else {
return;
}
std::error_code ec;
const uint64_t total_size = std::filesystem::file_size(file_path, ec);
if (ec) {
LOG_ERROR("Failed to get file size: {}", ec.message().c_str());
state->file_sending_ = false;
return;
}
state->file_sent_bytes_ = 0;
state->file_total_bytes_ = total_size;
state->file_send_rate_bps_ = 0;
state->file_transfer_window_visible_ = true;
{ {
std::lock_guard<std::mutex> lock(state->file_transfer_mutex_); std::shared_lock lock(owner_.remote_sessions_mutex_);
state->file_send_start_time_ = std::chrono::steady_clock::now(); peer = props ? props->peer_ : owner_.peer_;
state->file_send_last_update_time_ = state->file_send_start_time_; events = props ? props->peer_events_ : owner_.peer_events_;
state->file_send_last_bytes_ = 0;
} }
auto session = std::make_shared<file_transfer::Session>(
FileSender sender; [peer, events, file, props](const char*, const char* data, size_t size) {
const uint32_t file_id = FileSender::NextFileId(); if (!peer || !events || (props && props->closing_)) return -1;
if (props_locked) { auto callback = events->EnterCallback();
std::lock_guard<std::shared_mutex> lock(file_id_to_props_mutex_);
file_id_to_props_[file_id] = props_weak;
} else {
std::lock_guard<std::shared_mutex> lock(file_id_to_state_mutex_);
file_id_to_state_[file_id] = state;
}
state->current_file_id_ = file_id;
{
std::lock_guard<std::mutex> lock(state->file_transfer_list_mutex_);
for (auto &info : state->file_transfer_list_) {
if (info.file_path == file_path &&
info.status == FileTransferState::FileTransferStatus::Queued) {
info.status = FileTransferState::FileTransferStatus::Sending;
info.file_id = file_id;
info.file_size = total_size;
info.sent_bytes = 0;
break;
}
}
}
const int ret = sender.SendFile(
file_path, file_path.filename().string(),
[peer, peer_events, file_label, remote_id, props_locked](const char *buffer, size_t size) {
// Close drains each in-flight send before detaching the peer.
if (props_locked && props_locked->closing_) return -1;
auto callback = peer_events->EnterCallback();
if (!callback) return -1; if (!callback) return -1;
if (remote_id.empty()) { return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(),
return SendReliableDataFrame(peer, buffer, size, file.remote_id.c_str(), file.remote_id.size());
file_label.c_str()); }, file_transfer::Session::Progress{});
} state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0;
return SendReliableDataFrameToPeer( state.current_file_id_ = id;
peer, buffer, size, file_label.c_str(), remote_id.c_str(), const auto started_at = std::chrono::steady_clock::now();
remote_id.size()); auto next_rate_update = started_at + std::chrono::seconds(1);
},
64 * 1024, file_id);
if (ret == 0) {
return;
}
state->file_sending_ = false;
state->file_transfer_window_visible_ = false;
state->file_sent_bytes_ = 0;
state->file_total_bytes_ = 0;
state->file_send_rate_bps_ = 0;
state->current_file_id_ = 0;
Unregister(file_id, props_locked != nullptr);
{ {
std::lock_guard<std::mutex> lock(state->file_transfer_list_mutex_); std::lock_guard lock(state.file_transfer_mutex_);
for (auto &info : state->file_transfer_list_) { state.file_send_start_time_ = state.file_send_last_update_time_ = started_at;
if (info.file_id == file_id) { state.file_send_last_bytes_ = 0;
info.status = FileTransferState::FileTransferStatus::Failed; }
bool cancelled = false;
{
std::lock_guard lock(state.file_transfer_list_mutex_);
for (auto& info : state.file_transfer_list_) {
if (info.file_id == id) {
cancelled = info.status == FileTransferState::FileTransferStatus::Cancelled;
if (!cancelled) info.status = FileTransferState::FileTransferStatus::Sending;
info.file_id = id; info.sent_bytes = 0;
break; break;
} }
} }
} }
LOG_ERROR("FileSender::SendFile failed for [{}], ret={}", const auto name = file.file_path.filename().u8string();
file_path.string().c_str(), ret); bool started = false;
if (peer_events->IsActive()) ProcessQueue(props_locked);
}));
}
void FileTransferManager::ProcessQueue(
std::shared_ptr<RemoteSession> props) {
if (props && props->closing_) return;
FileTransferState &state = state_for(props);
if (state.file_sending_.load()) {
return;
}
FileTransferState::QueuedFile queued_file;
{
std::lock_guard<std::mutex> lock(state.file_queue_mutex_);
if (state.file_send_queue_.empty()) {
return;
}
queued_file = state.file_send_queue_.front();
state.file_send_queue_.pop();
}
Start(props, queued_file.file_path, queued_file.file_label,
queued_file.remote_id);
}
void FileTransferManager::Unregister(uint32_t file_id, bool per_peer) {
if (per_peer) {
std::lock_guard<std::shared_mutex> lock(file_id_to_props_mutex_);
file_id_to_props_.erase(file_id);
} else {
std::lock_guard<std::shared_mutex> lock(file_id_to_state_mutex_);
file_id_to_state_.erase(file_id);
}
}
void FileTransferManager::HandleAck(const char *data, size_t size) {
FileTransferAck ack{};
if (!DecodeFileTransferAck(data, size, &ack)) {
LOG_ERROR("FileTransferAck: invalid payload, size={}", size);
return;
}
std::shared_ptr<RemoteSession> props;
{
std::shared_lock lock(file_id_to_props_mutex_);
const auto it = file_id_to_props_.find(ack.file_id);
if (it != file_id_to_props_.end()) {
props = it->second.lock();
}
}
FileTransferState *state = props ? &props->file_transfer_ : nullptr;
if (!state) {
std::shared_lock lock(file_id_to_state_mutex_);
const auto it = file_id_to_state_.find(ack.file_id);
if (it != file_id_to_state_.end()) {
state = it->second;
}
}
if (!state) {
LOG_WARN("FileTransferAck: no state found for file_id={}", ack.file_id);
return;
}
state->file_sent_bytes_ = ack.acked_offset;
state->file_total_bytes_ = ack.total_size;
uint32_t rate_bps = 0;
if (props) {
const uint32_t bitrate =
props->net_traffic_stats_.DataOutboundBitrate();
if (bitrate > 0 && state->file_sending_.load()) {
rate_bps = static_cast<uint32_t>(bitrate * 0.99f);
const uint32_t current_rate = state->file_send_rate_bps_.load();
if (current_rate > 0) {
rate_bps = static_cast<uint32_t>(current_rate * 0.7 + rate_bps * 0.3);
}
} else {
rate_bps = state->file_send_rate_bps_.load();
}
} else {
const uint32_t current_rate = state->file_send_rate_bps_.load();
uint32_t estimated_rate = 0;
const auto now = std::chrono::steady_clock::now();
uint64_t last_bytes = 0;
std::chrono::steady_clock::time_point last_time;
{ {
std::lock_guard<std::mutex> lock(state->file_transfer_mutex_); std::lock_guard lock(sessions_mutex_);
last_bytes = state->file_send_last_bytes_; const auto closed = disconnected_.find(events.get());
last_time = state->file_send_last_update_time_; const bool disconnected = closed != disconnected_.end() &&
} (closed->second.count("") || closed->second.count(file.remote_id));
if (state->file_sending_.load() && ack.acked_offset >= last_bytes) { if (!cancelled && !stopping_ && peer && events && events->IsActive() && !disconnected &&
const auto delta_bytes = ack.acked_offset - last_bytes; (!props || !props->closing_) && !file.remote_id.empty()) {
const double seconds = started = session->Start(file.file_path, std::string(name.begin(), name.end()),
std::chrono::duration<double>(now - last_time).count(); file_transfer::Session::Clock::now(), id);
if (seconds > 0.0 && delta_bytes > 0) { if (started) senders_.emplace(id, Sender{session, events, file.remote_id});
const double bits_per_second = delta_bytes * 8.0 / seconds;
estimated_rate = static_cast<uint32_t>((std::min)(
bits_per_second,
static_cast<double>((std::numeric_limits<uint32_t>::max)())));
} }
} }
rate_bps = uint64_t previous = UINT64_MAX;
estimated_rate > 0 && current_rate > 0 auto progress = file_transfer::SendState{id, 0, 0,
? static_cast<uint32_t>(current_rate * 0.7 + estimated_rate * 0.3) cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed};
: estimated_rate > 0 ? estimated_rate if (started) {
: current_rate; do {
} {
std::lock_guard lock(state.file_transfer_list_mutex_);
state->file_send_rate_bps_ = rate_bps; for (const auto& info : state.file_transfer_list_) if (info.file_id == id) {
state->file_send_last_bytes_ = ack.acked_offset; cancelled = info.status == FileTransferState::FileTransferStatus::Cancelling;
state->file_send_last_update_time_ = std::chrono::steady_clock::now(); break;
{ }
std::lock_guard<std::mutex> lock(state->file_transfer_list_mutex_);
for (auto &info : state->file_transfer_list_) {
if (info.file_id == ack.file_id) {
info.sent_bytes = ack.acked_offset;
info.file_size = ack.total_size;
info.rate_bps = rate_bps;
if ((ack.flags & 0x01) != 0) {
info.status = FileTransferState::FileTransferStatus::Completed;
info.sent_bytes = ack.total_size;
} }
if (cancelled) session->CancelSend();
else if (stopping_ || !events->IsActive() || (props && props->closing_)) session->Cancel();
const bool pumped = session->Pump();
progress = session->Snapshot();
const auto now = std::chrono::steady_clock::now();
const bool rate_due = now >= next_rate_update;
if (progress.acknowledged != previous || rate_due ||
progress.status != file_transfer::Status::Sending) {
UpdateProgress(state, progress, now);
previous = progress.acknowledged;
if (rate_due) next_rate_update = now + std::chrono::seconds(1);
}
if (progress.status != file_transfer::Status::Sending) break;
if (!pumped) {
std::unique_lock lock(wake_mutex_);
wake_.wait_for(lock, std::chrono::milliseconds(5));
}
} while (true);
}
{
std::lock_guard lock(sessions_mutex_);
senders_.erase(id);
}
{
std::lock_guard lock(state.file_transfer_list_mutex_);
for (auto& info : state.file_transfer_list_) if (info.file_id == id) {
const bool cancelled = progress.status == file_transfer::Status::Cancelled ||
info.status == FileTransferState::FileTransferStatus::Cancelled ||
info.status == FileTransferState::FileTransferStatus::Cancelling;
info.status = progress.status == file_transfer::Status::Completed
? FileTransferState::FileTransferStatus::Completed
: cancelled ? FileTransferState::FileTransferStatus::Cancelled
: FileTransferState::FileTransferStatus::Failed;
info.rate_bps = 0;
if (info.status == FileTransferState::FileTransferStatus::Completed) info.sent_bytes = info.file_size;
break; break;
} }
} }
} if (progress.status != file_transfer::Status::Completed) {
state.file_transfer_window_visible_ = false;
if ((ack.flags & 0x01) == 0) { state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0;
return; state.file_send_rate_bps_ = 0; state.current_file_id_ = 0;
} } else state.file_transfer_window_visible_ = true;
LOG_INFO("File transfer completed: file_id={}, bytes={}", ack.file_id, state.file_sending_ = false;
ack.total_size); // Only the worker advances the queue, after all sends for this file drained.
state->file_transfer_window_visible_ = true; ProcessQueue(props);
state->file_sending_ = false; }));
Unregister(ack.file_id, props != nullptr);
ProcessQueue(props);
} }
} // namespace crossdesk void FileTransferManager::UpdateProgress(FileTransferState& state,
const file_transfer::SendState& progress,
std::chrono::steady_clock::time_point now) {
state.file_sent_bytes_ = progress.acknowledged; state.file_total_bytes_ = progress.total;
uint32_t rate = state.file_send_rate_bps_.load();
{
std::lock_guard lock(state.file_transfer_mutex_);
const auto elapsed = now - state.file_send_last_update_time_;
// Progress follows every ACK; speed uses one full sample per second for
// this file. Sample even without new ACKs so a stalled transfer reads zero.
if (elapsed >= std::chrono::seconds(1)) {
const double seconds = std::chrono::duration<double>(elapsed).count();
const auto bytes = progress.acknowledged >= state.file_send_last_bytes_
? progress.acknowledged - state.file_send_last_bytes_ : 0;
const double measured = bytes * 8.0 / seconds;
rate = static_cast<uint32_t>((std::min)(measured,
static_cast<double>((std::numeric_limits<uint32_t>::max)())));
state.file_send_rate_bps_ = rate;
state.file_send_last_bytes_ = progress.acknowledged;
state.file_send_last_update_time_ = now;
}
}
std::lock_guard lock(state.file_transfer_list_mutex_);
for (auto& info : state.file_transfer_list_) if (info.file_id == progress.id) {
info.sent_bytes = progress.acknowledged; info.file_size = progress.total; info.rate_bps = rate;
break;
}
}
bool FileTransferManager::CancelSend(uint32_t file_id) {
using Status = FileTransferState::FileTransferStatus;
const auto cancel = [file_id](FileTransferState& state) {
std::lock_guard lock(state.file_transfer_list_mutex_);
for (auto& info : state.file_transfer_list_) {
if (info.file_id != file_id || info.receiving) continue;
if (info.status != Status::Queued && info.status != Status::Sending) return false;
info.status = info.status == Status::Queued ? Status::Cancelled : Status::Cancelling;
info.rate_bps = 0;
return true;
}
return false;
};
bool cancelled = cancel(global_state_);
if (!cancelled) {
std::shared_lock lock(owner_.remote_sessions_mutex_);
for (const auto& [_, props] : owner_.remote_sessions_) {
if (props && cancel(props->file_transfer_)) { cancelled = true; break; }
}
}
if (cancelled) wake_.notify_all();
return cancelled;
}
void FileTransferManager::HandleAck(const char* data, size_t size,
PeerEventHandler* handler, const std::string& remote) {
FileTransferAck ack{};
if (!DecodeFileTransferAck(data, size, &ack)) return;
std::shared_ptr<file_transfer::Session> session;
{
std::lock_guard lock(sessions_mutex_);
const auto it = senders_.find(ack.file_id);
if (it == senders_.end() || it->second.handler.get() != handler || it->second.remote != remote) return;
session = it->second.session;
}
session->Ack(data, size);
wake_.notify_all();
}
void FileTransferManager::UpdateReceiveProgress(
const file_transfer::ReceiveState& progress,
std::unordered_map<uint32_t, size_t>& notifications,
const std::string& remote_id) {
using Status = FileTransferState::FileTransferStatus;
std::lock_guard lock(received_state_.file_transfer_list_mutex_);
auto& files = received_state_.file_transfer_list_;
auto found = notifications.find(progress.id);
if (found == notifications.end()) {
FileTransferState::FileTransferInfo info;
// Wire IDs can collide between peers or with outgoing transfers. Give each
// reception a local notification identity from the same allocator as sends.
info.file_id = file_transfer::Session::NextFileId();
info.remote_id = remote_id;
info.receiving = true;
receive_rate_samples_.emplace(info.file_id,
ReceiveRateSample{std::chrono::steady_clock::now(), 0});
found = notifications.emplace(progress.id, files.size()).first;
files.push_back(std::move(info));
}
auto& info = files[found->second];
info.file_name = progress.name;
info.file_size = progress.total;
info.sent_bytes = progress.received;
switch (progress.status) {
case file_transfer::ReceiveStatus::Receiving: info.status = Status::Receiving; break;
case file_transfer::ReceiveStatus::Completed: info.status = Status::Completed; break;
case file_transfer::ReceiveStatus::Failed: info.status = Status::Failed; break;
case file_transfer::ReceiveStatus::Cancelled: info.status = Status::Cancelled; break;
}
if (info.status != Status::Receiving) {
info.rate_bps = 0;
receive_rate_samples_.erase(info.file_id);
notifications.erase(found);
}
}
void FileTransferManager::Receive(const char* data, size_t size,
PeerEventHandler* handler, const std::string& remote) {
const auto props = owner_.FindRemoteSession(remote);
PeerPtr* peer;
std::shared_ptr<PeerEventHandler> events;
{
std::shared_lock lock(owner_.remote_sessions_mutex_);
peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_;
}
if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
std::shared_ptr<FileReceiver> receiver;
std::filesystem::path default_directory;
{
std::lock_guard lock(sessions_mutex_);
const auto closed = disconnected_.find(handler);
if (stopping_ || (closed != disconnected_.end() &&
(closed->second.count("") || closed->second.count(remote)))) return;
auto& entry = receivers_[handler][remote];
if (!entry.session) {
entry.session = std::make_shared<FileReceiver>(); entry.handler = events;
entry.default_directory = entry.session->OutputDir();
// The transfer engine serializes progress callbacks for this receiver.
entry.session->SetOnReceiveProgress(
[this, remote, notifications = std::unordered_map<uint32_t, size_t>{}]
(const file_transfer::ReceiveState& progress) mutable {
UpdateReceiveProgress(progress, notifications, remote);
});
entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) {
auto callback = events->EnterCallback();
if (!callback) return -1;
const auto bytes = EncodeFileTransferAck(ack);
return SendReliableDataFrameToPeer(peer, bytes.data(), bytes.size(), kFileFeedbackStream,
remote.c_str(), remote.size());
});
}
receiver = entry.session;
default_directory = entry.default_directory;
}
const auto configured = owner_.config_center_->GetFileTransferSavePath();
receiver->SetOutputDir(configured.empty() ? default_directory : PathFromUtf8(configured));
receiver->OnData(data, size);
}
void FileTransferManager::Connected(PeerEventHandler* handler, const std::string& remote) {
std::lock_guard lock(sessions_mutex_);
if (remote.empty()) disconnected_.erase(handler);
else if (const auto it = disconnected_.find(handler); it != disconnected_.end()) {
it->second.erase(remote);
if (it->second.empty()) disconnected_.erase(it);
}
}
void FileTransferManager::Cancel(PeerEventHandler* handler, const std::string& remote) {
std::lock_guard lock(sessions_mutex_);
disconnected_[handler].insert(remote);
for (auto& [id, sender] : senders_)
if (sender.handler.get() == handler && (remote.empty() || sender.remote == remote)) sender.session->Cancel();
const auto it = receivers_.find(handler);
if (it != receivers_.end()) {
for (auto entry = it->second.begin(); entry != it->second.end();) {
if (remote.empty() || entry->first == remote) {
entry->second.session->Cancel(); entry = it->second.erase(entry);
} else ++entry;
}
if (it->second.empty()) receivers_.erase(it);
}
wake_.notify_all();
}
void FileTransferManager::Poll() {
std::vector<std::shared_ptr<FileReceiver>> receivers;
{
std::lock_guard lock(sessions_mutex_);
for (const auto& [handler, peers] : receivers_)
for (const auto& [remote, receiver] : peers) receivers.push_back(receiver.session);
}
for (const auto& receiver : receivers) receiver->Poll();
const auto now = std::chrono::steady_clock::now();
std::lock_guard lock(received_state_.file_transfer_list_mutex_);
for (auto& info : received_state_.file_transfer_list_) {
const auto sample = receive_rate_samples_.find(info.file_id);
if (sample == receive_rate_samples_.end()) continue;
const auto elapsed = now - sample->second.updated;
if (elapsed < std::chrono::seconds(1)) continue;
const double seconds = std::chrono::duration<double>(elapsed).count();
const auto bytes = info.sent_bytes >= sample->second.bytes
? info.sent_bytes - sample->second.bytes : 0;
info.rate_bps = static_cast<uint32_t>((std::min)(bytes * 8.0 / seconds,
static_cast<double>((std::numeric_limits<uint32_t>::max)())));
sample->second = {now, info.sent_bytes};
}
}
} // namespace crossdesk
@@ -1,61 +1,83 @@
#ifndef CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_ #ifndef CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
#define CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_ #define CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
#include <cstddef> #include <atomic>
#include <cstdint> #include <chrono>
#include <filesystem> #include <condition_variable>
#include <future> #include <future>
#include <mutex>
#include <vector>
#include <memory> #include <memory>
#include <shared_mutex> #include <mutex>
#include <set>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector>
#include "file_transfer.h"
#include "runtime/remote_session.h" #include "runtime/remote_session.h"
namespace crossdesk { namespace crossdesk {
class GuiRuntime; class GuiRuntime;
class PeerEventHandler;
class FileTransferManager { class FileTransferManager {
public: public:
using FileTransferState = gui_detail::FileTransferState; using FileTransferState = gui_detail::FileTransferState;
using RemoteSession = gui_detail::RemoteSession; using RemoteSession = gui_detail::RemoteSession;
explicit FileTransferManager(GuiRuntime& owner);
explicit FileTransferManager(GuiRuntime &owner);
~FileTransferManager(); ~FileTransferManager();
FileTransferState& global_state();
FileTransferState& received_state();
FileTransferState& state_for(const std::shared_ptr<RemoteSession>& props);
// A reserved ID keeps a DeskHub notification stable while waiting for a slot.
bool ProcessSelectedFile(const std::string& path, const std::shared_ptr<RemoteSession>& props,
const std::string& file_label, const std::string& remote_id = "",
uint32_t file_id = 0);
void HandleAck(const char* data, size_t size, PeerEventHandler* handler, const std::string& remote);
void Receive(const char* data, size_t size, PeerEventHandler* handler, const std::string& remote);
void Connected(PeerEventHandler* handler, const std::string& remote);
void Cancel(PeerEventHandler* handler, const std::string& remote = "");
bool CancelSend(uint32_t file_id);
void Poll();
void Shutdown();
FileTransferState &global_state(); private:
FileTransferState & struct Sender {
state_for(const std::shared_ptr<RemoteSession> &props); std::shared_ptr<file_transfer::Session> session;
std::shared_ptr<PeerEventHandler> handler;
void std::string remote;
ProcessSelectedFile(const std::string &path, };
const std::shared_ptr<RemoteSession> &props, struct Receiver {
const std::string &file_label, std::shared_ptr<FileReceiver> session;
const std::string &remote_id = ""); std::shared_ptr<PeerEventHandler> handler;
void HandleAck(const char *data, size_t size); std::filesystem::path default_directory;
};
private:
void Start(std::shared_ptr<RemoteSession> props,
const std::filesystem::path &file_path,
const std::string &file_label, const std::string &remote_id = "");
void ProcessQueue(std::shared_ptr<RemoteSession> props); void ProcessQueue(std::shared_ptr<RemoteSession> props);
void Unregister(uint32_t file_id, bool per_peer); void UpdateProgress(FileTransferState& state,
const file_transfer::SendState& progress,
std::mutex workers_mutex_; std::chrono::steady_clock::time_point now);
void UpdateReceiveProgress(const file_transfer::ReceiveState& progress,
std::unordered_map<uint32_t, size_t>& notifications,
const std::string& remote_id);
struct ReceiveRateSample {
std::chrono::steady_clock::time_point updated;
uint64_t bytes = 0;
};
std::mutex workers_mutex_, sessions_mutex_, wake_mutex_;
std::condition_variable wake_;
std::vector<std::future<void>> workers_; std::vector<std::future<void>> workers_;
bool stopping_ = false; std::atomic<bool> stopping_{false};
GuiRuntime &owner_; std::unordered_map<uint32_t, Sender> senders_;
std::unordered_map<PeerEventHandler*, std::unordered_map<std::string, Receiver>> receivers_;
std::unordered_map<PeerEventHandler*, std::set<std::string>> disconnected_;
GuiRuntime& owner_;
FileTransferState global_state_; FileTransferState global_state_;
std::unordered_map<uint32_t, std::weak_ptr<RemoteSession>> // Keep received files separate from outgoing queues.
file_id_to_props_; FileTransferState received_state_;
std::shared_mutex file_id_to_props_mutex_; // Guarded by received_state_.file_transfer_list_mutex_.
std::unordered_map<uint32_t, FileTransferState *> file_id_to_state_; std::unordered_map<uint32_t, ReceiveRateSample> receive_rate_samples_;
std::shared_mutex file_id_to_state_mutex_;
}; };
} // namespace crossdesk } // namespace crossdesk
#endif // CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_ #endif
@@ -0,0 +1,48 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-19
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _TRANSFER_NOTIFICATION_LAYOUT_H_
#define _TRANSFER_NOTIFICATION_LAYOUT_H_
#include <algorithm>
#include <cstddef>
namespace crossdesk::transfer_notification {
constexpr float kWidth = 360;
constexpr float kHeight = 132;
constexpr float kMargin = 16;
constexpr float kGap = 10;
struct Layout {
float width, height;
size_t rows, columns;
size_t capacity() const { return rows * columns; }
};
inline Layout Fit(float width, float height) {
const float card_width = std::min(kWidth, std::max(1.0f, width - 2 * kMargin));
const float card_height = std::min(kHeight, std::max(1.0f, height - 2 * kMargin));
const auto count = [](float available, float card) {
return static_cast<size_t>(std::max(1.0f, (available - 2 * kMargin + kGap) / (card + kGap)));
};
return {card_width, card_height, count(height, card_height), count(width, card_width)};
}
struct Position { float x, y; };
inline Position Place(const Layout& layout, float x, float y,
float width, float height, size_t index) {
// Fill upward from the bottom-right, then continue in a column to the left.
return {x + std::max(0.0f, width - kMargin - layout.width) -
(index / layout.rows) * (layout.width + kGap),
y + std::max(0.0f, height - kMargin - layout.height) -
(index % layout.rows) * (layout.height + kGap)};
}
} // namespace crossdesk::transfer_notification
#endif
@@ -0,0 +1,92 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_FULLSCREEN_TRANSITION_H_
#define _GALLERY_FULLSCREEN_TRANSITION_H_
#include <chrono>
#include <cstdint>
namespace crossdesk::gallery {
// Guard input while native geometry settles, without hiding the live content.
// Driven by the existing UI tick; never pumps events or creates nested timers.
class FullscreenTransition {
public:
using Clock = std::chrono::steady_clock;
enum class Action { Idle, Apply, Finished };
bool Begin(bool fullscreen, Clock::time_point now) {
if (Active())
return false;
target_ = fullscreen;
phase_ = Phase::Pending;
phase_at_ = now;
width_ = height_ = 0;
scale_ = 0;
geometry_changed_ = false;
return true;
}
Action Advance(Clock::time_point now, uint32_t width, uint32_t height,
float scale, bool fullscreen, bool restoring) {
using namespace std::chrono_literals;
if (phase_ == Phase::Pending) {
if (restoring && now - phase_at_ >= 1500ms) {
Cancel();
return Action::Finished;
}
// Apply on the next UI tick as soon as window restoration is complete.
if (restoring)
return Action::Idle;
phase_ = Phase::Settling;
phase_at_ = stable_at_ = now;
width_ = width;
height_ = height;
scale_ = scale;
return Action::Apply;
}
if (phase_ == Phase::Settling) {
const bool changed =
width != width_ || height != height_ || scale != scale_;
if (changed && width > 0 && height > 0)
geometry_changed_ = true;
if (changed || fullscreen != target_ || restoring) {
width_ = width;
height_ = height;
scale_ = scale;
stable_at_ = now;
}
// Some backends report the requested flag before delivering new bounds.
// Also allow fullscreen modes whose client dimensions genuinely match.
if ((width > 0 && height > 0 && fullscreen == target_ && !restoring &&
(geometry_changed_ || now - phase_at_ >= 300ms) &&
now - stable_at_ >= 32ms) ||
now - phase_at_ >= 1500ms) {
Cancel();
return Action::Finished;
}
}
return Action::Idle;
}
void Cancel() { phase_ = Phase::Idle; }
bool Active() const { return phase_ != Phase::Idle; }
bool Target() const { return target_; }
private:
enum class Phase { Idle, Pending, Settling };
Phase phase_ = Phase::Idle;
bool target_ = false;
bool geometry_changed_ = false;
uint32_t width_ = 0, height_ = 0;
float scale_ = 0;
Clock::time_point phase_at_{}, stable_at_{};
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,37 @@
#include "gallery_storage.h"
#include <fstream>
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
namespace crossdesk::gallery {
bool WriteAtomically(const std::filesystem::path &path,
const std::string &text) {
auto temporary = path;
temporary += ".tmp";
try {
{
std::ofstream output;
output.exceptions(std::ios::failbit | std::ios::badbit);
output.open(temporary, std::ios::binary | std::ios::trunc);
output.write(text.data(), text.size());
output.close();
}
#ifdef _WIN32
if (MoveFileExW(temporary.c_str(), path.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
return true;
#else
std::filesystem::rename(temporary, path);
return true;
#endif
} catch (...) {
}
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
return false;
}
} // namespace crossdesk::gallery
@@ -0,0 +1,18 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_STORAGE_H_
#define _GALLERY_STORAGE_H_
#include <filesystem>
#include <string>
namespace crossdesk::gallery {
bool WriteAtomically(const std::filesystem::path &path,
const std::string &text);
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,93 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_STREAM_SETTINGS_H_
#define _GALLERY_STREAM_SETTINGS_H_
#include <algorithm>
#include <array>
#include <cstdint>
#include <nlohmann/json.hpp>
#include <optional>
#include <preview_subscription.h>
#include "preview_resolution.h"
namespace crossdesk::gallery {
struct StreamSettings {
// Persisted preset order: automatic, fixed bounds, then original size.
static constexpr std::array<PreviewResolution, 8> kResolutions{{
{}, {320, 180}, {640, 360}, {1280, 720}, {1920, 1080},
{2560, 1440}, {3840, 2160}, {}}};
static constexpr std::array<int, 4> kFrameRates{0, 15, 30, 60};
static constexpr std::array<int, 6> kBandwidthMbps{2, 5, 10, 20, 50, 100};
int resolution = 0;
int frame_rate = 0; // 0 follows the session's existing 30/60 FPS preference.
int bandwidth_mbps = 20;
static std::optional<StreamSettings> FromIndices(int resolution, int fps,
int bandwidth = 3) {
if (resolution < 0 || resolution >= int(kResolutions.size()) ||
fps < 0 || fps >= int(kFrameRates.size()) ||
bandwidth < 0 || bandwidth >= int(kBandwidthMbps.size()))
return {};
return StreamSettings{resolution, kFrameRates[fps], kBandwidthMbps[bandwidth]};
}
int FrameRateIndex() const {
return int(std::find(kFrameRates.begin(), kFrameRates.end(), frame_rate) -
kFrameRates.begin());
}
int BandwidthIndex() const {
return int(std::find(kBandwidthMbps.begin(), kBandwidthMbps.end(), bandwidth_mbps) -
kBandwidthMbps.begin());
}
bool Valid() const {
return resolution >= 0 && resolution < int(kResolutions.size()) &&
FrameRateIndex() < int(kFrameRates.size()) &&
BandwidthIndex() < int(kBandwidthMbps.size());
}
static StreamSettings FromJson(const nlohmann::json& json) {
try {
if (!json.at("resolution").is_number_integer() ||
!json.at("frame_rate").is_number_integer()) return {};
const auto resolution = json.at("resolution").get<int64_t>();
const auto fps = json.at("frame_rate").get<int64_t>();
if (resolution < 0 || resolution >= int(kResolutions.size()) ||
fps < 0 || fps > 60) return {};
// Retired sparse presets migrate to the lowest current setting.
StreamSettings result{int(resolution), fps == 1 || fps == 8 ? 15 : int(fps)};
if (json.contains("bandwidth_mbps") && json.at("bandwidth_mbps").is_number_integer()) {
const auto bandwidth = json.at("bandwidth_mbps").get<int64_t>();
if (std::find(kBandwidthMbps.begin(), kBandwidthMbps.end(), bandwidth) !=
kBandwidthMbps.end()) result.bandwidth_mbps = int(bandwidth);
}
return result.Valid() ? result : StreamSettings{};
} catch (...) {
return {};
}
}
nlohmann::json ToJson() const {
return {{"resolution", resolution}, {"frame_rate", frame_rate},
{"bandwidth_mbps", bandwidth_mbps}};
}
PreviewResolution Resolution(bool wall_managed, PreviewResolution wall_view,
PreviewResolution stream_view) const {
if (!wall_managed) return {};
if (resolution != 0 && Valid()) return kResolutions[resolution];
// Keep a bounded fallback until a rendered viewport is available.
if (!wall_view.width && !stream_view.width) return {320, 320};
return {std::max(wall_view.width, stream_view.width),
std::max(wall_view.height, stream_view.height)};
}
void ApplyFrameRate(PreviewSubscription& demand, bool supports_fps15) const {
if (!Valid() || !frame_rate) return;
// Older peers reject fps=15. Keep their existing interactive wire format.
demand.fps = frame_rate == 15 && supports_fps15 ? 15 : 60;
demand.frame_rate = frame_rate <= 30 ? 30 : 60;
}
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,178 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_WINDOW_STATE_H_
#define _GALLERY_WINDOW_STATE_H_
#include <slint.h>
#include <algorithm>
#include <cmath>
#include <optional>
namespace crossdesk::gallery {
struct WindowState {
bool wall_fullscreen = false;
bool wall_maximized = false;
// Store client dimensions in logical pixels, independent of the display DPI.
std::optional<slint::LogicalSize> wall_size;
std::optional<slint::PhysicalPosition> wall_position, connection_position;
void Observe(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool fullscreen,
bool maximized, float compact_height = 486,
float compact_width = 640) {
if (Restoring())
return;
if (!wall) {
connection_position = position;
return;
}
if (!fullscreen && !maximized &&
IsUndersized(size, scale, compact_height, compact_width))
return;
scale = std::max(scale, 1.0f);
wall_fullscreen = fullscreen;
wall_maximized = !fullscreen && maximized;
if (!fullscreen && !maximized) {
wall_size = slint::LogicalSize(
slint::Size<float>{size.width / scale, size.height / scale});
wall_position = position;
}
}
bool RecoverUndersized(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool visible,
bool minimized, bool fullscreen, bool maximized,
float compact_height = 486,
float compact_width = 640) {
// Hidden/minimized/uninitialized windows can report temporary tiny bounds.
// Leave those alone, and let an in-flight restoration retain its target.
if (Restoring() || !visible || minimized || fullscreen || maximized ||
size.width == 0 || size.height == 0 ||
!IsUndersized(size, scale, compact_height, compact_width))
return false;
if (wall) {
wall_position = position;
wall_fullscreen = false;
wall_maximized = false;
} else {
connection_position = position;
}
// Keep the last valid logical size; only the current anchor is captured.
BeginRestore();
return true;
}
slint::PhysicalSize RestoreSize(bool wall, float scale,
float compact_height = 486,
float compact_width = 640) const {
const float width =
wall && wall_size ? std::max(compact_width, wall_size->width)
: compact_width;
const float height = wall && wall_size
? std::max(compact_height, wall_size->height)
: compact_height;
scale = std::max(scale, 1.0f);
return slint::PhysicalSize(
slint::Size<uint32_t>{uint32_t(std::lround(width * scale)),
uint32_t(std::lround(height * scale))});
}
void BeginRestore() {
phase_ = Phase::Normal;
target_.reset();
last_size_.reset();
matches_ = 0;
}
void PrepareForShow(slint::Window &window, bool wall,
slint::PhysicalSize size,
std::optional<slint::PhysicalPosition> position,
float initial_y_offset = 0) {
const float scale = std::max(window.scale_factor(), 1.0f);
// The actual display scale is resolved when the native window is created.
// Give the backend logical geometry so its first visible frame is correct.
window.set_size(slint::LogicalSize(
slint::Size<float>{size.width / scale, size.height / scale}));
if (position) {
(wall ? wall_position : connection_position) = position;
window.set_position(slint::LogicalPosition(slint::Point<float>{
position->x / scale, position->y / scale + initial_y_offset}));
}
}
bool Restoring() const { return phase_ != Phase::Idle; }
bool RestoringNormal() const { return phase_ == Phase::Normal; }
// A set_size request can complete asynchronously, after layout constraints
// and the backing scale have changed. Acknowledge observations on later
// ticks.
bool NormalSizeRestored(slint::PhysicalSize actual,
slint::PhysicalSize target) {
if (!RestoringNormal())
return false;
if (target_ && SameSize(*target_, target) && SameSize(actual, target))
++matches_;
else
matches_ = 0;
target_ = target;
if (matches_ < 2)
return false;
phase_ = Phase::Presentation;
matches_ = 0;
last_size_.reset();
return true;
}
bool FinishRestore(bool wall, slint::PhysicalSize actual, bool fullscreen,
bool maximized) {
if (phase_ != Phase::Presentation)
return false;
const bool want_fullscreen = wall && wall_fullscreen;
const bool want_maximized = wall && !want_fullscreen && wall_maximized;
const bool normal = !want_fullscreen && !want_maximized;
if (normal && !fullscreen && !maximized && target_ &&
!SameSize(actual, *target_)) {
// A late constraint/DPI update can invalidate an earlier acknowledgement.
// Reissue the normal size instead of getting stuck or saving that size.
BeginRestore();
return false;
}
const bool ready = fullscreen == want_fullscreen &&
(want_fullscreen || maximized == want_maximized) &&
(!normal || (target_ && SameSize(actual, *target_)));
if (ready && last_size_ && SameSize(actual, *last_size_))
++matches_;
else
matches_ = 0;
last_size_ = actual;
if (matches_ < 2)
return false;
phase_ = Phase::Idle;
return true;
}
private:
enum class Phase { Idle, Normal, Presentation };
Phase phase_ = Phase::Idle;
std::optional<slint::PhysicalSize> target_, last_size_;
int matches_ = 0;
static bool IsUndersized(slint::PhysicalSize size, float scale,
float compact_height, float compact_width) {
scale = std::max(scale, 1.0f);
return double(size.width) + 1 < compact_width * scale ||
double(size.height) + 1 < compact_height * scale;
}
static bool SameSize(slint::PhysicalSize a, slint::PhysicalSize b) {
return std::abs(double(a.width) - b.width) <= 1 &&
std::abs(double(a.height) - b.height) <= 1;
}
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,306 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_WORKSPACE_H_
#define _GALLERY_WORKSPACE_H_
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <nlohmann/json.hpp>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include "gallery_storage.h"
#include "gallery_stream_settings.h"
namespace crossdesk::gallery {
struct Member {
std::string id;
std::string group;
};
// Layout contains no credentials and never reconnects devices when loaded.
struct Workspace {
// Group assignments are shared by history and the wall. Wall enrollment is
// independent, so assigning a group alone must never add a device to it.
std::vector<Member> members;
std::vector<std::string> groups;
std::set<std::string> selected;
int page = 0;
int capacity = 6;
StreamSettings stream_settings;
static std::string NormalizeId(const std::string &input) {
std::string result;
for (size_t i = 0; i < input.size();) {
const auto ch = static_cast<unsigned char>(input[i]);
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
++i;
continue;
}
if (i + 2 < input.size() && ch == 0xEF &&
static_cast<unsigned char>(input[i + 1]) == 0xBC &&
static_cast<unsigned char>(input[i + 2]) >= 0x90 &&
static_cast<unsigned char>(input[i + 2]) <= 0x99) {
result += char('0' + static_cast<unsigned char>(input[i + 2]) - 0x90);
i += 3;
} else if (input.compare(i, 3, "\xe3\x80\x80") == 0)
i += 3;
else
result += input[i++];
}
return result;
}
static bool ValidId(const std::string &id) {
return id.size() == 9 &&
std::all_of(id.begin(), id.end(),
[](unsigned char c) { return c >= '0' && c <= '9'; });
}
bool Contains(const std::string &id) const {
return wall_ids_.count(id) != 0;
}
bool Add(const std::string &id, const std::string &group = {}) {
if (!ValidId(id))
return false;
const bool joined = wall_ids_.insert(id).second;
// A device grouped earlier in history joins at the end when explicitly
// added, while retaining its existing group assignments.
if (joined)
std::stable_partition(members.begin(), members.end(),
[&](const auto &m) { return m.id != id; });
if (group.empty() && HasAssignments(id))
return joined;
return AddAssignment(id, group) || joined;
}
std::vector<std::string> Ids(const std::string &group = {}) const {
std::vector<std::string> ids;
std::set<std::string> seen;
for (const auto &m : members)
if (wall_ids_.count(m.id) && (group.empty() || m.group == group) &&
seen.insert(m.id).second)
ids.push_back(m.id);
return OrderIds(ids);
}
// Order is independent of membership, group assignments and live sessions.
std::vector<std::string> OrderIds(const std::vector<std::string> &ids) const {
if (device_order_.empty())
return ids;
std::set<std::string> remaining(ids.begin(), ids.end());
std::vector<std::string> ordered;
ordered.reserve(ids.size());
for (const auto &id : device_order_)
if (remaining.erase(id)) ordered.push_back(id);
for (const auto &id : ids)
if (remaining.erase(id)) ordered.push_back(id);
return ordered;
}
bool SwapPositions(const std::string &source, const std::string &target,
const std::vector<std::string> &visible_ids) {
if (source == target || !ValidId(source) || !ValidId(target) ||
std::find(visible_ids.begin(), visible_ids.end(), source) == visible_ids.end() ||
std::find(visible_ids.begin(), visible_ids.end(), target) == visible_ids.end())
return false;
auto ordered = device_order_;
std::set<std::string> known(ordered.begin(), ordered.end());
// Include hidden groups before applying a swap in a filtered view.
for (const auto &id : Ids())
if (known.insert(id).second) ordered.push_back(id);
// Active-only devices can be arranged without enrolling them in the wall.
for (const auto &id : visible_ids)
if (ValidId(id) && known.insert(id).second) ordered.push_back(id);
std::iter_swap(std::find(ordered.begin(), ordered.end(), source),
std::find(ordered.begin(), ordered.end(), target));
device_order_ = std::move(ordered);
return true;
}
// History filtering uses group assignments, independently of wall enrollment.
// nullopt means all devices; an empty name means no named group.
bool MatchesGroup(const std::string &id,
const std::optional<std::string> &group) const {
if (!group)
return true;
if (group->empty())
return std::none_of(members.begin(), members.end(), [&](const auto &m) {
return m.id == id && !m.group.empty();
});
return std::any_of(members.begin(), members.end(), [&](const auto &m) {
return m.id == id && m.group == *group;
});
}
size_t AddMany(const std::vector<std::string> &ids,
const std::string &group = {}) {
size_t added = 0;
for (const auto &id : ids) {
// Without a target group, an existing membership already puts the device
// on the wall. An explicit group may reuse that same device/session.
if (group.empty() && wall_ids_.count(id))
continue;
if (Add(id, group))
++added;
}
return added;
}
// Both editors replace the same group assignments without changing wall
// enrollment. Keep the position of devices already on the wall untouched.
bool SetGroups(const std::string &id, const std::vector<std::string> &names) {
if (!ValidId(id))
return false;
std::vector<Member> replacements;
std::set<std::string> seen;
for (const auto &name : names)
if (!name.empty() && seen.insert(name).second)
replacements.push_back({id, name});
if (replacements.empty())
replacements.push_back({id, ""});
std::vector<Member> updated;
bool inserted = false;
for (const auto &member : members) {
if (member.id != id) {
updated.push_back(member);
} else if (!inserted) {
updated.insert(updated.end(), replacements.begin(), replacements.end());
inserted = true;
}
}
if (!inserted)
updated.insert(updated.end(), replacements.begin(), replacements.end());
members = std::move(updated);
for (const auto &member : replacements)
if (!member.group.empty() &&
std::find(groups.begin(), groups.end(), member.group) == groups.end())
groups.push_back(member.group);
PruneGroups();
return true;
}
void Remove(const std::string &id, const std::string &group = {}) {
if (group.empty()) {
// Removing from the wall leaves history's group assignments intact.
wall_ids_.erase(id);
std::erase(device_order_, id);
} else if (MatchesGroup(id, group)) {
// Removing the last named group leaves an ungrouped wall member.
// Reuse the editor path so enrollment and card order stay unchanged.
std::vector<std::string> remaining;
for (const auto &member : members)
if (member.id == id && member.group != group)
remaining.push_back(member.group);
SetGroups(id, remaining);
}
selected.erase(id);
}
void ResizePage(int new_capacity, int count) {
const int first = page * capacity;
capacity = std::max(1, new_capacity);
page = std::clamp(first / capacity, 0, std::max(0, (count - 1) / capacity));
}
// The caller supplies every ID in the current filter, across all pages.
void ToggleSelectAll(const std::vector<std::string> &ids) {
std::set<std::string> targets(ids.begin(), ids.end());
if (selected == targets)
selected.clear();
else
selected = std::move(targets);
}
bool Load(const std::filesystem::path &path) {
try {
if (!std::filesystem::exists(path))
return true;
std::ifstream file(path);
const auto doc = nlohmann::json::parse(file);
const int version = doc.at("version").get<int>();
if ((version != 1 && version != 2) || !doc.at("members").is_array())
return false;
if (version == 2 && !doc.at("wall_ids").is_array())
return false;
Workspace loaded;
if (doc.contains("stream_settings"))
loaded.stream_settings = StreamSettings::FromJson(doc["stream_settings"]);
for (const auto &member : doc.at("members")) {
const auto id = member.at("id").get<std::string>();
loaded.AddAssignment(id, member.value("group", ""));
// Every saved member in the old format was already on the wall.
if (version == 1 && ValidId(id))
loaded.wall_ids_.insert(id);
}
if (version == 2)
for (const auto &value : doc.at("wall_ids")) {
const auto id = value.get<std::string>();
if (ValidId(id) && loaded.HasAssignments(id))
loaded.wall_ids_.insert(id);
}
if (doc.contains("device_order") && doc["device_order"].is_array()) {
std::set<std::string> seen;
for (const auto &value : doc["device_order"]) {
if (!value.is_string()) continue;
const auto id = value.get<std::string>();
if (ValidId(id) && seen.insert(id).second)
loaded.device_order_.push_back(id);
}
}
members = std::move(loaded.members);
groups = std::move(loaded.groups);
wall_ids_ = std::move(loaded.wall_ids_);
device_order_ = std::move(loaded.device_order_);
stream_settings = loaded.stream_settings;
return true;
} catch (...) {
return false;
}
}
bool Save(const std::filesystem::path &path) const {
try {
nlohmann::json doc{{"version", 2},
{"members", nlohmann::json::array()},
{"wall_ids", wall_ids_},
{"device_order", device_order_},
{"stream_settings", stream_settings.ToJson()}};
for (const auto &m : members)
doc["members"].push_back({{"id", m.id}, {"group", m.group}});
return WriteAtomically(path, doc.dump(2));
} catch (...) {
return false;
}
}
private:
std::set<std::string> wall_ids_;
std::vector<std::string> device_order_;
bool HasAssignments(const std::string &id) const {
return std::any_of(members.begin(), members.end(),
[&](const auto &m) { return m.id == id; });
}
bool AddAssignment(const std::string &id, const std::string &group) {
if (!ValidId(id))
return false;
if (std::any_of(members.begin(), members.end(), [&](const auto &m) {
return m.id == id && m.group == group;
}))
return false;
if (!group.empty() &&
std::find(groups.begin(), groups.end(), group) == groups.end())
groups.push_back(group);
members.push_back({id, group});
return true;
}
void PruneGroups() {
groups.erase(std::remove_if(groups.begin(), groups.end(),
[&](const auto &name) {
return std::none_of(members.begin(),
members.end(),
[&](const auto &m) {
return m.group == name;
});
}),
groups.end());
}
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,99 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PREVIEW_BANDWIDTH_H_
#define _PREVIEW_BANDWIDTH_H_
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <vector>
namespace crossdesk::gallery {
struct PreviewBandwidthDemand {
int desired_bps = 0;
bool foreground = false;
bool operator==(const PreviewBandwidthDemand& other) const {
return desired_bps == other.desired_bps && foreground == other.foreground;
}
static int Estimate(int width, int height, int fps) {
// A demand ceiling, not a throughput measurement. Include room for video
// repair traffic; the sender's congestion control may allocate less.
const int64_t pixels = width > 0 && height > 0
? int64_t(width) * height : 3840LL * 2160;
const int64_t estimate = pixels * std::clamp(fps, 1, 60) * 15 / 100;
return int(std::clamp<int64_t>((estimate + 49'999) / 50'000 * 50'000,
150'000, 20'000'000));
}
};
inline std::vector<int> AllocatePreviewBandwidth(
const std::vector<PreviewBandwidthDemand>& demands, int budget_bps) {
std::vector<int> result(demands.size(), 0);
int remaining = std::max(0, budget_bps);
int active = 0;
int64_t desired_total = 0;
for (const auto& demand : demands) {
active += demand.desired_bps > 0;
desired_total += std::max(0, demand.desired_bps);
}
if (!active || !remaining) return result;
if (desired_total <= remaining) {
for (std::size_t i = 0; i < demands.size(); ++i)
result[i] = std::max(0, demands[i].desired_bps);
return result;
}
// First preserve a small fair share for every visible device, then perform
// weighted max-min allocation. Satisfied devices return unused capacity.
const int baseline = std::min(100'000, remaining / active);
for (std::size_t i = 0; i < demands.size(); ++i) {
result[i] = std::min(std::max(0, demands[i].desired_bps), baseline);
remaining -= result[i];
}
while (remaining > 0) {
int weights = 0;
for (std::size_t i = 0; i < demands.size(); ++i)
if (result[i] < demands[i].desired_bps)
weights += demands[i].foreground ? 4 : 1;
if (!weights) break;
const int available = remaining;
for (std::size_t i = 0; i < demands.size() && remaining > 0; ++i) {
if (result[i] >= demands[i].desired_bps) continue;
const int share = int(int64_t(available) *
(demands[i].foreground ? 4 : 1) / weights);
const int extra = std::min({remaining, demands[i].desired_bps - result[i],
std::max(1, share)});
result[i] += extra;
remaining -= extra;
}
}
return result;
}
// UI refreshes much more often than video demand changes. Keep the previous
// result while membership, dimensions, cadence, priority and budget are stable.
class PreviewBandwidthAllocator {
public:
const std::vector<int>& Allocate(
const std::vector<PreviewBandwidthDemand>& demands, int budget_bps) {
if (budget_bps_ != budget_bps || demands_ != demands) {
demands_ = demands;
budget_bps_ = budget_bps;
allocations_ = AllocatePreviewBandwidth(demands, budget_bps);
}
return allocations_;
}
private:
int budget_bps_ = -1;
std::vector<PreviewBandwidthDemand> demands_;
std::vector<int> allocations_;
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,55 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PREVIEW_RESOLUTION_H_
#define _PREVIEW_RESOLUTION_H_
#include <algorithm>
#include <chrono>
#include <cmath>
namespace crossdesk::gallery {
struct PreviewResolution {
int width = 0;
int height = 0;
bool operator==(const PreviewResolution& other) const {
return width == other.width && height == other.height;
}
static PreviewResolution Viewport(float width, float height, float scale) {
if (!std::isfinite(width) || !std::isfinite(height) ||
!std::isfinite(scale) || width <= 0 || height <= 0 || scale <= 0)
return {};
return {int(std::clamp(std::ceil(double(width) * scale), 2.0, 16384.0)),
int(std::clamp(std::ceil(double(height) * scale), 2.0, 16384.0))};
}
};
class PreviewResolutionDebouncer {
public:
using Clock = std::chrono::steady_clock;
PreviewResolution Update(PreviewResolution desired, Clock::time_point now,
bool reset = false) {
if (!initialized_ || reset) {
initialized_ = true;
applied_ = candidate_ = desired;
changed_at_ = now;
} else if (!(candidate_ == desired)) {
candidate_ = desired;
changed_at_ = now;
} else if (now - changed_at_ >= std::chrono::milliseconds(200)) {
applied_ = candidate_;
}
return applied_;
}
private:
bool initialized_ = false;
PreviewResolution applied_, candidate_;
Clock::time_point changed_at_{};
};
} // namespace crossdesk::gallery
#endif
@@ -160,27 +160,7 @@ void KeyboardController::SendHeartbeat(bool force) {
action.ks.pressed_count = index; action.ks.pressed_count = index;
} }
const std::string target_id = owner_.controlled_remote_id_.empty() owner_.SendRemoteInput(action);
? owner_.focused_remote_id_
: owner_.controlled_remote_id_;
std::shared_lock sessions_lock(owner_.remote_sessions_mutex_);
const auto props_it = owner_.remote_sessions_.find(target_id);
if (target_id.empty() || props_it == owner_.remote_sessions_.end() ||
props_it->second->connection_status_.load() !=
ConnectionStatus::Connected ||
!props_it->second->peer_) {
last_heartbeat_tick_ = now;
return;
}
const std::string message = action.to_json();
const int result = SendReliableDataFrame(
props_it->second->peer_, message.c_str(), message.size(),
props_it->second->keyboard_label_.c_str());
if (result != 0) {
LOG_WARN("Send keyboard heartbeat failed, remote_id={}, ret={}", target_id,
result);
}
last_heartbeat_tick_ = now; last_heartbeat_tick_ = now;
} }
@@ -200,24 +180,7 @@ int KeyboardController::SendKeyCommand(int key_code, bool is_down,
action.k.scan_code = scan_code; action.k.scan_code = scan_code;
action.k.extended = extended; action.k.extended = extended;
const std::string target_id = owner_.controlled_remote_id_.empty() owner_.SendRemoteInput(action);
? owner_.focused_remote_id_
: owner_.controlled_remote_id_;
std::shared_lock sessions_lock(owner_.remote_sessions_mutex_);
const auto props_it = owner_.remote_sessions_.find(target_id);
if (!target_id.empty() && props_it != owner_.remote_sessions_.end() &&
props_it->second->connection_status_.load() ==
ConnectionStatus::Connected &&
props_it->second->peer_) {
const std::string message = action.to_json();
const int result = SendReliableDataFrame(
props_it->second->peer_, message.c_str(), message.size(),
props_it->second->keyboard_label_.c_str());
if (result != 0) {
LOG_WARN("Send keyboard command failed, remote_id={}, ret={}", target_id,
result);
}
}
TrackPressedKey(key_code, is_down, scan_code, extended); TrackPressedKey(key_code, is_down, scan_code, extended);
return 0; return 0;
@@ -305,6 +268,15 @@ void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
} }
} }
void KeyboardController::ApplyRemoteText(const std::string& remote_id,
const std::string& text) {
std::lock_guard input_lock(remote_input_mutex_);
if (!owner_.IsAuthorizedController(remote_id) ||
!owner_.devices_.SupportsTextInput()) return;
if (!owner_.devices_.SendTextCommand(text))
LOG_WARN("Failed to inject committed remote text");
}
void KeyboardController::ApplyRemoteState(const std::string& remote_id, void KeyboardController::ApplyRemoteState(const std::string& remote_id,
const RemoteAction& action) { const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_); std::lock_guard input_lock(remote_input_mutex_);
@@ -27,6 +27,7 @@ public:
void SendHeartbeat(bool force); void SendHeartbeat(bool force);
void ApplyRemoteEvent(const std::string &remote_id, void ApplyRemoteEvent(const std::string &remote_id,
const RemoteAction &remote_action); const RemoteAction &remote_action);
void ApplyRemoteText(const std::string& remote_id, const std::string& text);
void ApplyRemoteState(const std::string &remote_id, void ApplyRemoteState(const std::string &remote_id,
const RemoteAction &remote_action); const RemoteAction &remote_action);
void ReleaseRemotePressedKeys(const std::string &remote_id, void ReleaseRemotePressedKeys(const std::string &remote_id,
@@ -16,7 +16,7 @@
#if defined(__APPLE__) #if defined(__APPLE__)
#include "platform/macos/gui/metal_video_renderer.h" #include "platform/macos/gui/metal_video_renderer.h"
#else #elif !defined(__OHOS__)
#if defined(_WIN32) #if defined(_WIN32)
#include <windows.h> #include <windows.h>
#endif #endif
@@ -28,10 +28,10 @@
namespace crossdesk { namespace crossdesk {
namespace { namespace {
#if !defined(__APPLE__) #if !defined(__APPLE__) && !defined(__OHOS__)
constexpr GLint kGlClampToEdge = 0x812F; constexpr GLint kGlClampToEdge = 0x812F;
constexpr float kStreamWindowCornerRadius = 12.0f; constexpr float kStreamWindowCornerRadius = 12.0f;
#else #elif defined(__APPLE__)
constexpr int kMetalAttachmentAttemptLimit = 30; constexpr int kMetalAttachmentAttemptLimit = 30;
#endif #endif
@@ -78,7 +78,7 @@ struct SlintVideoPresenter::Impl {
#if defined(__APPLE__) #if defined(__APPLE__)
int attachment_attempts = 0; int attachment_attempts = 0;
bool skip_attachment_once = false; bool skip_attachment_once = false;
#else #elif !defined(__OHOS__)
std::mutex gl_mutex; std::mutex gl_mutex;
std::vector<uint8_t> gl_conversion_frame; std::vector<uint8_t> gl_conversion_frame;
std::vector<uint8_t> gl_pending_frame; std::vector<uint8_t> gl_pending_frame;
@@ -109,7 +109,7 @@ struct SlintVideoPresenter::Impl {
return true; return true;
} }
#if !defined(__APPLE__) #if !defined(__APPLE__) && !defined(__OHOS__)
void ConfigureOpenGlNotifier() { void ConfigureOpenGlNotifier() {
if (!stream) { if (!stream) {
return; return;
@@ -238,7 +238,7 @@ void SlintVideoPresenter::PrepareWindow(
stream->set_native_video_enabled(false); stream->set_native_video_enabled(false);
impl_->attachment_attempts = kMetalAttachmentAttemptLimit; impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
impl_->skip_attachment_once = true; impl_->skip_attachment_once = true;
#else #elif !defined(__OHOS__)
impl_->ConfigureOpenGlNotifier(); impl_->ConfigureOpenGlNotifier();
#endif #endif
} }
@@ -351,7 +351,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
} }
return result; return result;
} }
#else #elif !defined(__OHOS__)
auto* opengl_renderer = auto* opengl_renderer =
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer); dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
if (opengl_renderer && opengl_renderer->IsReady()) { if (opengl_renderer && opengl_renderer->IsReady()) {
@@ -419,7 +419,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
} }
} }
#if !defined(__APPLE__) #if !defined(__APPLE__) && !defined(__OHOS__)
uint32_t texture_id = 0; uint32_t texture_id = 0;
{ {
std::lock_guard lock(impl_->gl_mutex); std::lock_guard lock(impl_->gl_mutex);
@@ -154,6 +154,29 @@ bool AnnouncementInbox::Read(int64_t id, int64_t revision) {
} }
return false; return false;
} }
bool AnnouncementInbox::ReadAll() {
std::lock_guard<std::mutex> lock(mutex_);
if (catalog_revision_ < 0) return false;
auto updated = reads_;
// Use the complete catalog, including announcements whose bodies have not
// been paged in. Persist once before changing any visible reading state.
for (const auto& [id, revision] : catalog_) {
if (!IsDismissed(id, revision)) updated[id] = revision;
}
if (updated == reads_) return true;
if (!SaveLocalState(updated, dismissed_)) {
state_.read_save_failed = true;
++state_.version;
return false;
}
reads_ = std::move(updated);
state_.read_save_failed = false;
state_.delete_failed = false;
UpdateReadingState();
++state_.version;
return true;
}
bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const { bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const {
const auto found = dismissed_.find(id); const auto found = dismissed_.find(id);
return found != dismissed_.end() && found->second == revision; return found != dismissed_.end() && found->second == revision;
@@ -38,6 +38,7 @@ class AnnouncementInbox {
void Refresh(); void Refresh();
void LoadMore(); void LoadMore();
bool Read(int64_t id, int64_t revision); bool Read(int64_t id, int64_t revision);
bool ReadAll();
bool Dismiss(int64_t id, int64_t revision); bool Dismiss(int64_t id, int64_t revision);
nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now = nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now =
std::chrono::steady_clock::now()); std::chrono::steady_clock::now());
@@ -35,6 +35,30 @@ bool GuiRuntime::HasActiveSession() {
}); });
} }
void GuiRuntime::MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
if (!props) return;
std::lock_guard delivery_lock(audio_delivery_mutex_);
props->audio_capture_button_pressed_ = false;
props->audio_allowed_ = false;
devices_.ClearAudio(props->remote_id_);
// Capable peers stop sending to this controller on the next subscription;
// old peers remain muted by the local receive gate above.
props->preview_needs_sync_ = true;
}
void GuiRuntime::UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
if (!props || props->closing_) return;
props->audio_capture_button_pressed_ = true;
props->preview_needs_sync_ = true;
// Also resume capture on old peers, or after a legacy controller stopped it.
RemoteAction action{};
action.type = ControlType::audio_capture;
action.a = true;
const auto message = action.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(),
props->control_data_label_.c_str());
}
void GuiRuntime::HandleConnectionStatusChange() { void GuiRuntime::HandleConnectionStatusChange() {
if (!signal_connected_ || !peer_) { if (!signal_connected_ || !peer_) {
return; return;
@@ -51,7 +75,8 @@ void GuiRuntime::HandleConnectionStatusChange() {
} }
last_presence_request_attempt_at_ = now; last_presence_request_attempt_at_ = now;
const auto message = const auto message =
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true).dump(); BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true, {},
gallery_presence_ids_).dump();
if (SendSignalMessage(peer_, message.data(), message.size()) == 0) { if (SendSignalMessage(peer_, message.data(), message.size()) == 0) {
next_presence_refresh_at_ = now + kPresenceRefreshInterval; next_presence_refresh_at_ = now + kPresenceRefreshInterval;
} else { } else {
@@ -59,6 +84,39 @@ void GuiRuntime::HandleConnectionStatusChange() {
} }
} }
void GuiRuntime::HandlePendingRejoins() {
need_to_rejoin_ = false;
std::vector<std::shared_ptr<RemoteSession>> pending;
{
std::shared_lock lock(remote_sessions_mutex_);
for (const auto& [_, props] : remote_sessions_) {
if (props && props->rejoin_ && !props->closing_)
pending.push_back(props);
}
}
for (const auto& props : pending) {
// Password submission already entered the validating state. Let Join
// report its result even when this device is absent from the presence
// cache (for example, a newly added DeskHub device). A failed presence
// probe has no auth callback and would leave the prompt validating forever.
ConnectTo(props->remote_id_, props->remote_password_,
props->remember_password_, true, props->wall_managed_.load());
}
}
void GuiRuntime::CancelPendingConnection(const std::string& remote_id) {
pending_reconnects_.erase(remote_id);
if (auto props = FindRemoteSession(remote_id)) props->rejoin_ = false;
std::lock_guard lock(pending_presence_probe_mutex_);
if (pending_presence_remote_id_ != remote_id) return;
pending_presence_probe_ = false;
pending_presence_result_ready_ = false;
pending_presence_online_ = false;
pending_presence_remote_id_.clear();
pending_presence_password_.clear();
pending_presence_remember_password_ = false;
}
void GuiRuntime::HandlePendingPresenceProbe() { void GuiRuntime::HandlePendingPresenceProbe() {
bool has_action = false; bool has_action = false;
bool should_connect = false; bool should_connect = false;
@@ -130,6 +188,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);
preview_subscriptions_.Remove(remote_id);
keyboard_.ReleaseRemotePressedKeys(remote_id, reason); keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
#ifdef _WIN32 #ifdef _WIN32
devices_.ReleaseRemoteMouseButtons(); devices_.ReleaseRemoteMouseButtons();
@@ -156,6 +216,10 @@ void GuiRuntime::HandleServerControllerDisconnected(
remaining_controller_id = id; remaining_controller_id = id;
} }
} }
#if _WIN32
windows_rdp_handoff_connected_->store(has_connected_controller,
std::memory_order_relaxed);
#endif
} }
if (has_connected_controller) { if (has_connected_controller) {
remote_client_id_ = remaining_controller_id; remote_client_id_ = remaining_controller_id;
@@ -169,7 +233,7 @@ void GuiRuntime::HandleServerControllerDisconnected(
need_to_create_server_window_.store(false, std::memory_order_release); need_to_create_server_window_.store(false, std::memory_order_release);
need_to_destroy_server_window_.store(true, std::memory_order_release); need_to_destroy_server_window_.store(true, std::memory_order_release);
is_server_mode_ = false; is_server_mode_ = false;
#if defined(__linux__) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
if (IsWaylandSession()) { if (IsWaylandSession()) {
// Keep Wayland capture session warm to avoid black screen on subsequent // Keep Wayland capture session warm to avoid black screen on subsequent
// reconnects. // reconnects.
@@ -210,7 +274,8 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
} }
const auto s = BuildDevicePresenceRequest( const auto s = BuildDevicePresenceRequest(
client_id_, recent_connection_ids_, false, remote_id) client_id_, recent_connection_ids_, false, remote_id,
gallery_presence_ids_)
.dump(); .dump();
int ret = SendSignalMessage(peer_, s.data(), s.size()); int ret = SendSignalMessage(peer_, s.data(), s.size());
@@ -237,7 +302,11 @@ 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 || props->closing_.exchange(true)) return;
if (props->peer_events_) props->peer_events_->Deactivate(); MuteRemoteAudio(props);
if (props->peer_events_) {
props->peer_events_->Deactivate();
transfers_.Cancel(props->peer_events_.get());
}
props->connection_established_ = false; props->connection_established_ = false;
props->streaming_ = false; props->streaming_ = false;
props->connection_status_.store(ConnectionStatus::Closed); props->connection_status_.store(ConnectionStatus::Closed);
@@ -331,6 +400,7 @@ void GuiRuntime::CloseConnectionPeer() {
return; return;
} }
peer_events_->Deactivate(); peer_events_->Deactivate();
transfers_.Cancel(peer_events_.get());
announcements_.Reset(); announcements_.Reset();
std::vector<std::string> controllers; std::vector<std::string> controllers;
{ {
@@ -378,6 +448,7 @@ void GuiRuntime::CloseAllRemoteSessions() {
} }
void GuiRuntime::HandleSessionCleanup() { void GuiRuntime::HandleSessionCleanup() {
transfers_.Poll();
auto completed = [](std::future<void>& task) { auto completed = [](std::future<void>& task) {
if (!task.valid() || task.wait_for(std::chrono::seconds(0)) != if (!task.valid() || task.wait_for(std::chrono::seconds(0)) !=
std::future_status::ready) return false; std::future_status::ready) return false;
@@ -417,7 +488,8 @@ void GuiRuntime::HandleSessionCleanup() {
recent_connection_image_save_time_ = 0; recent_connection_image_save_time_ = 0;
} }
for (const auto& [id, request] : reconnects) { for (const auto& [id, request] : reconnects) {
ConnectTo(id, request.password.c_str(), request.remember_password); ConnectTo(id, request.password.c_str(), request.remember_password, false,
request.background);
} }
} }
@@ -447,6 +519,12 @@ void GuiRuntime::WaitForSessionCleanup() {
void GuiRuntime::ResetRemoteSessionResources( void GuiRuntime::ResetRemoteSessionResources(
std::shared_ptr<RemoteSession> props) { std::shared_ptr<RemoteSession> props) {
props->net_traffic_stats_.Reset(); props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
{
std::lock_guard lock(props->gallery_frame_mutex_);
props->gallery_video_latency_->Reset();
props->gallery_frame_timing_ = {};
}
{ {
std::lock_guard<std::mutex> lock(props->video_frame_mutex_); std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
props->video_latency_->Reset(); props->video_latency_->Reset();
@@ -486,23 +564,28 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
} }
int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password, int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check) { bool remember_password, bool bypass_presence_check,
bool background) {
// Do not recreate a failed peer or send another probe during a server cooldown. // Do not recreate a failed peer or send another probe during a server cooldown.
if (const auto current = FindRemoteSession(remote_id); if (const auto current = FindRemoteSession(remote_id);
current && current->connection_auth_.Get().retry_after > 0) { current && current->connection_auth_.Get().retry_after > 0) {
focused_remote_id_ = remote_id; if (!background) focused_remote_id_ = remote_id;
show_connection_status_window_ = true; if (!background) show_connection_status_window_ = true;
current->rejoin_ = false; current->rejoin_ = false;
return 0; return 0;
} }
if (session_cleanup_tasks_.count(remote_id)) { if (session_cleanup_tasks_.count(remote_id)) {
pending_reconnects_[remote_id] = {password ? password : "", remember_password}; pending_reconnects_[remote_id] = {password ? password : "",
remember_password, background};
LOG_INFO("[{}] Reconnect queued until previous peer is destroyed", remote_id); LOG_INFO("[{}] Reconnect queued until previous peer is destroyed", remote_id);
return 0; return 0;
} }
if (!bypass_presence_check && !device_presence_cache_.IsOnline(remote_id)) { // Wall requests, including deferred reconnects, must not replace the single
int ret = // interactive presence probe. Join reports availability for each session.
RequestSingleDevicePresence(remote_id, password, remember_password); if (!background && !bypass_presence_check &&
!device_presence_cache_.IsOnline(remote_id)) {
int ret = RequestSingleDevicePresence(remote_id, password,
remember_password);
if (ret != 0) { if (ret != 0) {
offline_warning_text_ = offline_warning_text_ =
localization::device_offline[localization_language_index_]; localization::device_offline[localization_language_index_];
@@ -515,7 +598,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
} }
LOG_INFO("Connect to [{}]", remote_id); LOG_INFO("Connect to [{}]", remote_id);
focused_remote_id_ = remote_id; if (!background) focused_remote_id_ = remote_id;
auto stale = FindRemoteSession(remote_id); auto stale = FindRemoteSession(remote_id);
bool exists = stale != nullptr; bool exists = stale != nullptr;
@@ -545,7 +628,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
pending_reconnects_[remote_id] = { pending_reconnects_[remote_id] = {
keep_stored_password ? std::string(stale->remote_password_) keep_stored_password ? std::string(stale->remote_password_)
: std::string(password), : std::string(password),
remember_password}; remember_password, background};
CloseRemoteSession(stale); CloseRemoteSession(stale);
LOG_INFO("[{}] Reconnecting after terminal peer status {}", remote_id, LOG_INFO("[{}] Reconnecting after terminal peer status {}", remote_id,
static_cast<int>(status)); static_cast<int>(status));
@@ -565,6 +648,11 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
auto props = remote_sessions_[remote_id]; auto props = remote_sessions_[remote_id];
props->local_id_ = "C-" + std::string(client_id_); props->local_id_ = "C-" + std::string(client_id_);
props->remote_id_ = remote_id; props->remote_id_ = remote_id;
props->wall_managed_ = background;
props->tab_opened_ = !background;
props->control_mouse_ = !background;
props->enable_mouse_control_ = !background;
props->audio_capture_button_pressed_ = !background;
memcpy(&props->params_, &params_, sizeof(Params)); memcpy(&props->params_, &params_, sizeof(Params));
props->params_.user_id = props->local_id_.c_str(); props->params_.user_id = props->local_id_.c_str();
props->peer_events_ = std::make_shared<PeerEventHandler>(*this); props->peer_events_ = std::make_shared<PeerEventHandler>(*this);
@@ -572,7 +660,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
props->peer_ = CreatePeer(&props->params_); props->peer_ = CreatePeer(&props->params_);
props->connection_status_.store(ConnectionStatus::Connecting); props->connection_status_.store(ConnectionStatus::Connecting);
show_connection_status_window_ = true; if (!background) show_connection_status_window_ = true;
if (!props->peer_) { if (!props->peer_) {
LOG_INFO("Create peer [{}] instance failed", props->local_id_); LOG_INFO("Create peer [{}] instance failed", props->local_id_);
@@ -610,8 +698,13 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
int ret = -1; int ret = -1;
auto props = FindRemoteSession(remote_id); auto props = FindRemoteSession(remote_id);
if (!props || props->closing_) return -1; if (!props || props->closing_) return -1;
if (background) {
// This also covers reusing a foreground connection that is awaiting auth.
props->wall_managed_ = true;
MuteRemoteAudio(props);
}
if (!props->connection_established_) { if (!props->connection_established_) {
show_connection_status_window_ = true; if (!background) show_connection_status_window_ = true;
props->remember_password_ = remember_password; props->remember_password_ = remember_password;
if (password && *password && if (password && *password &&
@@ -33,6 +33,15 @@ class CursorPresentation {
const bool separate = const bool separate =
state.render_mode == CursorRenderMode::separate || state.render_mode == CursorRenderMode::separate ||
(state.render_mode == CursorRenderMode::legacy && state.visible); (state.render_mode == CursorRenderMode::legacy && state.visible);
system_hidden_ = state.render_mode == CursorRenderMode::hidden &&
state.hidden_reason == CursorHiddenReason::system_hidden;
if (separate) {
mac_locator_allowed_ = state.shape != RemoteCursorShape::none;
} else if (!system_hidden_) {
// Embedded cursors and other hiding policies end the separate-cursor
// history. A later system_hidden sample must not revive that overlay.
mac_locator_allowed_ = false;
}
// Old senders cannot distinguish embedded from hidden. Only explicit // Old senders cannot distinguish embedded from hidden. Only explicit
// metadata from a new sender can establish the no-device compatibility // metadata from a new sender can establish the no-device compatibility
// case. // case.
@@ -47,9 +56,21 @@ class CursorPresentation {
void NoteInput(uint64_t now) { last_input_ = now; } void NoteInput(uint64_t now) { last_input_ = now; }
RemoteCursorShape Resolve(uint64_t now) { RemoteCursorShape Resolve(uint64_t now, HostPlatform remote_platform) {
if (!stale_at_) return RemoteCursorShape::default_cursor; if (!stale_at_) return RemoteCursorShape::default_cursor;
// macOS can keep its system cursor hidden after typing even while remote
// mouse events move it. Keep a previously separate desktop locator across
// those heartbeats; the video has no pointer to fall back to. On a new
// connection, use an arrow until the first visible shape arrives. The
// protocol cannot distinguish typing from other system_hidden cases;
// restrict this policy to identified Mac hosts and stop on any explicit
// embedded cursor or other hiding policy.
if (remote_platform == HostPlatform::MacOS && system_hidden_ &&
mac_locator_allowed_) {
return last_visible_shape_;
}
const bool recent_input = const bool recent_input =
last_input_ && now - *last_input_ < kInputActivityMs; last_input_ && now - *last_input_ < kInputActivityMs;
const bool can_recover = const bool can_recover =
@@ -64,6 +85,8 @@ class CursorPresentation {
private: private:
bool sample_unavailable_ = false; bool sample_unavailable_ = false;
bool system_hidden_ = false;
bool mac_locator_allowed_ = true;
bool no_device_ = false; bool no_device_ = false;
bool fallback_engaged_ = false; bool fallback_engaged_ = false;
std::optional<uint64_t> stale_at_; std::optional<uint64_t> stale_at_;
@@ -74,4 +97,4 @@ class CursorPresentation {
} // namespace crossdesk } // namespace crossdesk
#endif #endif
@@ -63,7 +63,7 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
} // namespace crossdesk } // namespace crossdesk
#elif defined(__linux__) && !defined(__APPLE__) #elif (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
#include <X11/Xlib.h> #include <X11/Xlib.h>
#include <X11/extensions/Xfixes.h> #include <X11/extensions/Xfixes.h>
@@ -9,6 +9,7 @@
#include <chrono> #include <chrono>
#include <mutex> #include <mutex>
#include <optional>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
@@ -33,12 +34,19 @@ public:
} }
} }
bool IsOnline(const std::string &device_id, std::optional<bool> GetOnline(const std::string &device_id,
Clock::time_point now = Clock::now()) const { Clock::time_point now = Clock::now()) const {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
const auto it = cache_.find(device_id); const auto it = cache_.find(device_id);
return signal_connected_ && it != cache_.end() && it->second.online && if (!signal_connected_ || it == cache_.end() ||
now - it->second.updated_at < kMaxAge; now - it->second.updated_at >= kMaxAge)
return std::nullopt;
return it->second.online;
}
bool IsOnline(const std::string &device_id,
Clock::time_point now = Clock::now()) const {
return GetOnline(device_id, now).value_or(false);
} }
void Clear() { void Clear() {
@@ -58,4 +66,4 @@ private:
} // namespace crossdesk } // namespace crossdesk
#endif #endif
@@ -16,22 +16,25 @@ namespace crossdesk {
inline nlohmann::json BuildDevicePresenceRequest( inline nlohmann::json BuildDevicePresenceRequest(
const std::string& client_id, const std::vector<std::string>& recent_ids, const std::string& client_id, const std::vector<std::string>& recent_ids,
bool subscribe, const std::string& probe_id = {}) { bool subscribe, const std::string& probe_id = {},
const std::vector<std::string>& additional_ids = {}) {
std::vector<std::string> devices; std::vector<std::string> devices;
for (const auto& id : recent_ids) { 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.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);
} }
} };
for (const auto& id : recent_ids) append(id);
for (const auto& id : additional_ids) append(id);
if (!probe_id.empty() && if (!probe_id.empty() &&
std::find(devices.begin(), devices.end(), probe_id) == devices.end()) { std::find(devices.begin(), devices.end(), probe_id) == devices.end()) {
devices.push_back(probe_id); devices.push_back(probe_id);
} }
// Include recent devices even in a one-shot probe: older servers ignore // Keep recent and wall devices even in a one-shot probe: older servers
// subscribe=false and replace their subscriptions with this device list. // ignore subscribe=false and replace subscriptions with this device list.
return {{"type", "recent_connections_presence"}, return {{"type", "recent_connections_presence"},
{"user_id", client_id}, {"user_id", client_id},
{"devices", devices}, {"devices", devices},
@@ -40,4 +43,4 @@ inline nlohmann::json BuildDevicePresenceRequest(
} // namespace crossdesk } // namespace crossdesk
#endif #endif
+84 -4
View File
@@ -16,7 +16,7 @@
#include "localization.h" #include "localization.h"
#include "platform/video_renderer.h" #include "platform/video_renderer.h"
#include "rd_log.h" #include "rd_log.h"
#ifdef __linux__ #if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h" #include "platform/linux/headless/headless_console.h"
#endif #endif
@@ -26,7 +26,58 @@ GuiRuntime::GuiRuntime()
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this), : clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this),
keyboard_(*this) {} keyboard_(*this) {}
GuiRuntime::~GuiRuntime() { WaitForSessionCleanup(); } GuiRuntime::~GuiRuntime() {
#if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
if (windows_service_worker_) windows_service_worker_->Reset();
#endif
transfers_.Shutdown();
WaitForSessionCleanup();
}
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
const bool mouse = action.type == ControlType::mouse;
if (!mouse && action.type != ControlType::keyboard &&
action.type != ControlType::keyboard_state)
return;
const std::string& target_id = controlled_remote_id_.empty()
? focused_remote_id_ : controlled_remote_id_;
if (target_id.empty()) return;
const bool group = gallery_group_control_id_ == target_id;
const bool releasing = mouse
? action.m.flag == MouseFlag::left_up ||
action.m.flag == MouseFlag::right_up ||
action.m.flag == MouseFlag::middle_up
: action.type == ControlType::keyboard
? action.k.flag == KeyFlag::key_up : action.ks.pressed_count == 0;
const auto available = [](const auto& props) {
return props && props->peer_ && !props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected;
};
std::shared_lock lock(remote_sessions_mutex_);
const auto source = remote_sessions_.find(target_id);
// A lost leader must stop new input, but its followers still need key/button
// releases before group control is cleared by the UI.
if (!releasing &&
(source == remote_sessions_.end() || !available(source->second)))
return;
const auto message = action.to_json();
for (const auto& [id, props] : remote_sessions_) {
if ((!group && id != target_id) || !available(props)) continue;
if (mouse) {
std::lock_guard cursor_lock(props->remote_cursor_state_mutex_);
props->cursor_presentation_.NoteInput(SDL_GetTicks());
}
// Reuse exactly the leader's mapped coordinates and key metadata. Neither
// gallery filters nor the follower's preview dimensions affect input.
const int result = SendReliableDataFrame(
props->peer_, message.data(), message.size(),
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
if (result != 0)
LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result);
}
}
int GuiRuntime::CreateConnectionPeer() { int GuiRuntime::CreateConnectionPeer() {
// Tick retries after the old peer's leave notification and socket shutdown. // Tick retries after the old peer's leave notification and socket shutdown.
@@ -97,7 +148,7 @@ int GuiRuntime::CreateConnectionPeer() {
const char *active_identity = const char *active_identity =
self_hosted ? self_hosted_user_id_ : client_id_with_password_; self_hosted ? self_hosted_user_id_ : client_id_with_password_;
#ifdef __linux__ #if defined(__linux__) && !defined(__OHOS__)
// Publish the committed identity; an unconfirmed recovery credential must // Publish the committed identity; an unconfirmed recovery credential must
// not be advertised as the current connection password. // not be advertised as the current connection password.
HeadlessConsole::Instance().SetIdentity(active_identity); HeadlessConsole::Instance().SetIdentity(active_identity);
@@ -131,7 +182,7 @@ int GuiRuntime::CreateConnectionPeer() {
sizeof(params_.log_path) - 1); sizeof(params_.log_path) - 1);
params_.log_path[sizeof(params_.log_path) - 1] = '\0'; params_.log_path[sizeof(params_.log_path) - 1] = '\0';
params_.hardware_acceleration = config_center_->IsHardwareVideoCodec(); params_.hardware_acceleration = config_center_->IsHardwareVideoCodec();
#if defined(_WIN32) || defined(__linux__) || defined(__APPLE__) #if defined(_WIN32) || (defined(__linux__) && !defined(__OHOS__)) || defined(__APPLE__)
// Windows and Linux retain pooled CPU NV12 frames or CUDA device frames; // Windows and Linux retain pooled CPU NV12 frames or CUDA device frames;
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All // macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
// platforms fall back to a packed CPU copy when native upload is unavailable. // platforms fall back to a packed CPU copy when native upload is unavailable.
@@ -206,6 +257,35 @@ int GuiRuntime::CreateConnectionPeer() {
} }
void GuiRuntime::HandleRecentConnections() { void GuiRuntime::HandleRecentConnections() {
if (thumbnail_ && session_cleanup_tasks_.empty()) {
std::vector<std::shared_ptr<RemoteSession>> sessions;
{
std::shared_lock lock(remote_sessions_mutex_);
for (const auto& [_, props] : remote_sessions_)
if (props && !props->closing_ && props->was_connected_ &&
props->recent_connection_dirty_.exchange(false))
sessions.push_back(props);
}
for (const auto& props : sessions) {
std::string host_name;
HostPlatform platform;
{
std::lock_guard lock(props->remote_version_mutex_);
host_name = props->remote_host_name_;
platform = props->recent_platform_;
}
// Record a successful connection before a preview or host info arrives.
// The close path still updates its final thumbnail. Remember password
// controls only credential storage, not whether this device is listed.
if (thumbnail_->SaveToThumbnail(
nullptr, 0, 0, props->remote_id_, host_name,
props->remember_password_ ? props->remote_password_ : "",
platform) == 0)
reload_recent_connections_ = true;
else
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
}
}
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) {
+11 -1
View File
@@ -51,13 +51,19 @@ class GuiRuntime : protected gui_detail::GuiState {
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,
bool background = false);
// UI thread: mute this receiver without stopping audio for other controllers.
void MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
void UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
int RequestSingleDevicePresence(const std::string& remote_id, int RequestSingleDevicePresence(const std::string& remote_id,
const char* password, bool remember_password); const char* password, bool remember_password);
int ForceSignalReconnect(); int ForceSignalReconnect();
void HandleRecentConnections(); void HandleRecentConnections();
void HandleConnectionStatusChange(); void HandleConnectionStatusChange();
void HandlePendingRejoins();
void CancelPendingConnection(const std::string& remote_id);
void HandlePendingPresenceProbe(); void HandlePendingPresenceProbe();
void HandlePresenceProbeTimeout(); void HandlePresenceProbeTimeout();
bool HasActiveSession(); bool HasActiveSession();
@@ -81,6 +87,10 @@ class GuiRuntime : protected gui_detail::GuiState {
void WaitForSessionCleanup(); void WaitForSessionCleanup();
std::shared_ptr<RemoteSession> FindRemoteSession( std::shared_ptr<RemoteSession> FindRemoteSession(
const std::string& remote_id); const std::string& remote_id);
// UI thread: send mapped mouse/keyboard input to the current control target,
// or every connected session while that target leads DeskHub group control.
void SendRemoteInput(const RemoteAction& action);
std::string gallery_group_control_id_;
void ResetRemoteServiceStatus(RemoteSession& props); void ResetRemoteServiceStatus(RemoteSession& props);
void ApplyRemoteServiceStatus(RemoteSession& props, void ApplyRemoteServiceStatus(RemoteSession& props,
@@ -24,6 +24,8 @@ class NetTrafficStatsCache {
"Network statistics require lock-free scalar atomics"); "Network statistics require lock-free scalar atomics");
public: public:
using Clock = std::chrono::steady_clock;
void Store(const MiniRtcNetTrafficStats& stats) { void Store(const MiniRtcNetTrafficStats& stats) {
video_inbound_stats_.Store(stats.video_inbound_stats); video_inbound_stats_.Store(stats.video_inbound_stats);
video_outbound_stats_.Store(stats.video_outbound_stats); video_outbound_stats_.Store(stats.video_outbound_stats);
@@ -36,6 +38,10 @@ class NetTrafficStatsCache {
srtp_active_.store(stats.srtp_active, std::memory_order_relaxed); srtp_active_.store(stats.srtp_active, std::memory_order_relaxed);
rtt_ms_.store(stats.rtt_ms, std::memory_order_relaxed); rtt_ms_.store(stats.rtt_ms, std::memory_order_relaxed);
connection_latency_.Update(stats.rtt_ms); connection_latency_.Update(stats.rtt_ms);
last_report_ms_.store(
std::chrono::duration_cast<std::chrono::milliseconds>(
Clock::now().time_since_epoch()).count(),
std::memory_order_relaxed);
} }
MiniRtcNetTrafficStats Load() const { MiniRtcNetTrafficStats Load() const {
@@ -65,9 +71,17 @@ class NetTrafficStatsCache {
void ResetLatency() { connection_latency_.Reset(); } void ResetLatency() { connection_latency_.Reset(); }
bool HasRecentReport(Clock::time_point now = Clock::now()) const {
const auto last_report = last_report_ms_.load(std::memory_order_relaxed);
const auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch()).count();
return last_report > 0 && now_ms - last_report < 3000;
}
void Reset() { void Reset() {
Store(MiniRtcNetTrafficStats{}); Store(MiniRtcNetTrafficStats{});
ResetLatency(); ResetLatency();
last_report_ms_.store(0, std::memory_order_relaxed);
} }
private: private:
@@ -114,6 +128,7 @@ class NetTrafficStatsCache {
OutboundStats total_outbound_stats_; OutboundStats total_outbound_stats_;
std::atomic<bool> srtp_active_{false}; std::atomic<bool> srtp_active_{false};
std::atomic<double> rtt_ms_{-1}; std::atomic<double> rtt_ms_{-1};
std::atomic<int64_t> last_report_ms_{0};
ConnectionLatencyStats connection_latency_; ConnectionLatencyStats connection_latency_;
}; };
@@ -4,6 +4,7 @@
#include <app_version.h> #include <app_version.h>
#include <remote_action.h> #include <remote_action.h>
#include <stream_names.h>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <algorithm> #include <algorithm>
@@ -24,6 +25,9 @@
#include "platform.h" #include "platform.h"
#include "rd_log.h" #include "rd_log.h"
#include "runtime/gui_runtime.h" #include "runtime/gui_runtime.h"
#ifdef __OHOS__
#include "platform/harmony/input/system_shortcuts.h"
#endif
#if _WIN32 #if _WIN32
#include "desktop_transition_policy.h" #include "desktop_transition_policy.h"
@@ -101,64 +105,110 @@ 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_) {
std::string remote_user_id = std::string(user_id, user_id_size); runtime->transfers_.Receive(data, size, handler, std::string(user_id, user_id_size));
static FileReceiver receiver;
// Update output directory from config
std::string configured_path =
runtime->config_center_->GetFileTransferSavePath();
if (!configured_path.empty()) {
receiver.SetOutputDir(PathFromUtf8(configured_path));
} else if (receiver.OutputDir().empty()) {
receiver = FileReceiver(); // re-init with default desktop path
}
receiver.SetOnSendAck([runtime,
remote_user_id](const FileTransferAck &ack) -> int {
const auto encoded_ack = EncodeFileTransferAck(ack);
bool is_server_sending = remote_user_id.rfind("C-", 0) != 0;
if (is_server_sending) {
auto props =
runtime->FindRemoteSession(remote_user_id);
if (props) {
PeerPtr *peer = props->peer_;
return SendReliableDataFrame(
peer, encoded_ack.data(), encoded_ack.size(),
runtime->file_feedback_label_.c_str());
}
}
return SendReliableDataFrame(
runtime->peer_, encoded_ack.data(), encoded_ack.size(),
runtime->file_feedback_label_.c_str());
});
receiver.OnData(data, 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(user_id, user_id_size);
auto props = auto props =
runtime->FindRemoteSession(remote_user_id); runtime->FindRemoteSession(remote_user_id);
if (props && !props->enable_mouse_control_) { if (props && !props->clipboard_allowed_.load()) {
return; return;
} }
runtime->clipboard_.QueueRemoteText(data, size); runtime->clipboard_.QueueRemoteText(
data, size, props ? remote_user_id : std::string{});
} }
return; return;
} else if (source_id == runtime->file_feedback_label_) { } else if (source_id == runtime->file_feedback_label_) {
runtime->transfers_.HandleAck(data, size); runtime->transfers_.HandleAck(data, size, handler, std::string(user_id, user_id_size));
return; return;
} }
const std::string remote_id(user_id, user_id_size);
std::string json_str(data, size); std::string json_str(data, size);
const auto preview_json =
json_str.find("\"deskhub_preview") != std::string::npos
? nlohmann::json::parse(json_str, nullptr, false)
: nlohmann::json{};
if (preview_json.is_object() &&
preview_json.value("type", nlohmann::json{}) == "deskhub_preview") {
if (source_id != runtime->control_data_label_ ||
!runtime->IsAuthorizedController(remote_id))
return;
const auto subscription = PreviewSubscription::Parse(preview_json);
if (!subscription) return;
const auto previous = runtime->preview_subscriptions_.Get(remote_id);
bool accepted = true;
// Apply encoder quality to this controller only; other controllers retain
// their own demand. MiniRTC owns per-peer video/audio pause state.
if (!previous || subscription->IsNewerThan(*previous)) {
if (subscription->fps > 0)
accepted = UpdateVideoSettings(
runtime->peer_, remote_id.data(), remote_id.size(),
static_cast<VideoQuality>(subscription->quality),
subscription->frame_rate,
static_cast<VideoDegradationPreference>(
subscription->EncoderPreference())) == 0;
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
subscription->max_bitrate_bps.value_or(0)))
accepted = UpdateVideoBitrateLimit(
runtime->peer_, remote_id.data(), remote_id.size(),
subscription->max_bitrate_bps.value_or(0)) == 0;
// Rebalancing bandwidth alone must not force every device to send a
// keyframe. The pause API requests its own keyframes on resume.
if (accepted && (!previous || previous->width != subscription->width ||
previous->height != subscription->height))
accepted = UpdateVideoResolution(
runtime->peer_, remote_id.data(), remote_id.size(),
subscription->width, subscription->height) == 0;
if (accepted)
accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), subscription->fps == 0) == 0;
if (accepted)
accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), !subscription->audio) == 0;
if (accepted && subscription->audio)
runtime->devices_.StartSpeakerCapturer();
}
if (accepted) runtime->preview_subscriptions_.Set(remote_id, *subscription);
const auto reply = nlohmann::json{
{"type", "deskhub_preview_status"},
{"version", subscription->version},
{"request_id", subscription->request_id},
{"accepted",
accepted}}.dump();
SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(),
runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size());
return;
}
if (preview_json.is_object() &&
preview_json.value("type", nlohmann::json{}) ==
"deskhub_preview_status") {
if (auto props = runtime->FindRemoteSession(remote_id);
props && source_id == props->control_data_label_) {
try {
if ((preview_json.at("version") == 1 ||
preview_json.at("version") == 2) &&
preview_json.at("request_id").is_number_unsigned() &&
preview_json.at("request_id").get<uint64_t>() > 0 &&
preview_json.at("request_id").get<uint64_t>() <= UINT32_MAX &&
preview_json.at("accepted").is_boolean()) {
props->preview_accepted_ = preview_json.at("accepted").get<bool>();
props->preview_ack_ = preview_json.at("request_id").get<uint32_t>();
}
} catch (...) {
}
}
return;
}
RemoteAction remote_action{}; RemoteAction remote_action{};
if (!remote_action.from_json(json_str)) { if (!remote_action.from_json(json_str)) {
LOG_ERROR("Failed to parse RemoteAction JSON payload"); LOG_ERROR("Failed to parse RemoteAction JSON payload");
return; return;
} }
std::string remote_id(user_id, user_id_size);
if (remote_action.type == ControlType::video_settings_status) { if (remote_action.type == ControlType::video_settings_status) {
if (auto props = runtime->FindRemoteSession(remote_id)) { if (auto props = runtime->FindRemoteSession(remote_id)) {
if (source_id != props->control_data_label_) return; if (source_id != props->control_data_label_) return;
@@ -184,7 +234,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
return; return;
} }
if (remote_action.type == ControlType::video_settings) { if (remote_action.type == ControlType::video_settings) {
if (source_id != runtime->control_data_label_ || // Web multiplexes its reliable control actions on a single DataChannel.
// Accept that transport's source alias while retaining controller admission.
if ((source_id != runtime->control_data_label_ &&
source_id != kWebControlStream) ||
!runtime->IsAuthorizedController(remote_id)) !runtime->IsAuthorizedController(remote_id))
return; return;
const auto& settings = remote_action.vs; const auto& settings = remote_action.vs;
@@ -193,6 +246,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->peer_, remote_id.data(), remote_id.size(), runtime->peer_, remote_id.data(), remote_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={} "
"preference={} accepted={}",
remote_id, source_id, settings.request_id, settings.quality,
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(runtime->peer_, message.data(), message.size(),
@@ -223,6 +280,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->QueuePrivacyCommand(remote_id, remote_action.pc); runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
return; return;
} }
if (remote_action.type == ControlType::text_input) {
// Share the ordered keyboard stream with delete/return and shortcuts.
if (source_id == runtime->keyboard_label_ &&
runtime->IsAuthorizedController(remote_id))
runtime->keyboard_.ApplyRemoteText(remote_id, remote_action.text);
return;
}
const bool operates_host = remote_action.type == ControlType::mouse || const bool operates_host = remote_action.type == ControlType::mouse ||
remote_action.type == ControlType::keyboard || remote_action.type == ControlType::keyboard_state || remote_action.type == ControlType::keyboard || remote_action.type == ControlType::keyboard_state ||
remote_action.type == ControlType::service_command || remote_action.type == ControlType::display_id || remote_action.type == ControlType::service_command || remote_action.type == ControlType::display_id ||
@@ -262,6 +326,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
GetLastError()); GetLastError());
} }
} }
#elif defined(__OHOS__)
if (!SendHarmonySystemShortcut(remote_action.c.flag))
LOG_WARN("Harmony system shortcut unsupported or unauthorized, command={}",
static_cast<int>(remote_action.c.flag));
#endif #endif
return; return;
} }
@@ -342,6 +410,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
if (props && source_id == props->control_data_label_) { if (props && source_id == props->control_data_label_) {
std::lock_guard lock(props->video_settings_mutex_); std::lock_guard lock(props->video_settings_mutex_);
const bool first_advertisement = !props->video_settings_supported_; const bool first_advertisement = !props->video_settings_supported_;
props->preview_subscription_supported_ =
remote_action.i.supports_deskhub_preview;
props->preview_resolution_supported_ =
remote_action.i.supports_deskhub_resolution;
props->preview_fps15_supported_ = remote_action.i.supports_deskhub_fps15;
props->preview_bandwidth_supported_ = remote_action.i.supports_deskhub_bandwidth;
props->video_settings_supported_ = props->video_settings_supported_ =
remote_action.i.supports_video_settings; remote_action.i.supports_video_settings;
if (first_advertisement && props->video_settings_supported_) { if (first_advertisement && props->video_settings_supported_) {
@@ -357,17 +431,21 @@ void PeerEventHandler::OnReceiveDataBuffer(
} }
} }
// client mode // client mode
if (props && props->remote_host_name_.empty()) { if (props) {
props->remote_host_name_ = std::string(remote_action.i.host_name, std::lock_guard lock(props->remote_version_mutex_);
remote_action.i.host_name_size); if (props->remote_host_name_.empty()) {
LOG_INFO("Remote hostname: [{}]", props->remote_host_name_); props->remote_host_name_ = std::string(remote_action.i.host_name,
remote_action.i.host_name_size);
LOG_INFO("Remote hostname: [{}]", props->remote_host_name_);
for (int i = 0; i < remote_action.i.display_num; i++) { for (int i = 0; i < remote_action.i.display_num; i++) {
props->display_info_list_.push_back( props->display_info_list_.push_back(
DisplayInfo(remote_action.i.display_list[i], DisplayInfo(remote_action.i.display_list[i],
remote_action.i.left[i], remote_action.i.top[i], remote_action.i.left[i], remote_action.i.top[i],
remote_action.i.right[i], remote_action.i.bottom[i])); remote_action.i.right[i], remote_action.i.bottom[i]));
}
} }
props->recent_connection_dirty_ = true;
} }
FreeRemoteAction(remote_action); FreeRemoteAction(remote_action);
} else { } else {
@@ -455,9 +533,16 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->devices_.SendMouseCommand(remote_action, runtime->devices_.SendMouseCommand(remote_action,
runtime->selected_display_); runtime->selected_display_);
} else if (remote_action.type == ControlType::audio_capture) { } else if (remote_action.type == ControlType::audio_capture) {
// A negotiated subscription remains authoritative if a legacy capture
// command arrives after it (e.g. when unmuting an inactive window).
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
const bool paused = subscription ? !subscription->audio : !remote_action.a;
const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), paused);
if (remote_action.a) if (remote_action.a)
runtime->devices_.StartSpeakerCapturer(); runtime->devices_.StartSpeakerCapturer();
else else if (ret != 0)
// Legacy/web transports without the per-peer API retain their fallback.
runtime->devices_.StopSpeakerCapturer(); runtime->devices_.StopSpeakerCapturer();
} else if (remote_action.type == ControlType::keyboard) { } else if (remote_action.type == ControlType::keyboard) {
runtime->keyboard_.ApplyRemoteEvent(remote_id, remote_action); runtime->keyboard_.ApplyRemoteEvent(remote_id, remote_action);
@@ -22,7 +22,7 @@
#include "platform/video_renderer.h" #include "platform/video_renderer.h"
#include "rd_log.h" #include "rd_log.h"
#include "runtime/gui_runtime.h" #include "runtime/gui_runtime.h"
#ifdef __linux__ #if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h" #include "platform/linux/headless/headless_console.h"
#endif #endif
@@ -38,7 +38,9 @@ namespace {
constexpr HostPlatform kClientPlatform = HostPlatform::Windows; constexpr HostPlatform kClientPlatform = HostPlatform::Windows;
#elif defined(__APPLE__) #elif defined(__APPLE__)
constexpr HostPlatform kClientPlatform = HostPlatform::MacOS; constexpr HostPlatform kClientPlatform = HostPlatform::MacOS;
#elif defined(__linux__) #elif defined(__OHOS__)
constexpr HostPlatform kClientPlatform = HostPlatform::HarmonyOS;
#elif (defined(__linux__) && !defined(__OHOS__))
constexpr HostPlatform kClientPlatform = HostPlatform::Linux; constexpr HostPlatform kClientPlatform = HostPlatform::Linux;
#else #else
constexpr HostPlatform kClientPlatform = HostPlatform::Unknown; constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
@@ -61,7 +63,9 @@ void PeerEventHandler::SendClientInfo(PeerPtr* peer,
} }
} }
PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {} PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {
owner_.transfers_.Connected(this, "");
}
void PeerEventHandler::OnSignalMessage(const char* message, size_t size, void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
void* user_data) { void* user_data) {
@@ -257,6 +261,9 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
if (!runtime) return; if (!runtime) return;
std::string remote_id(user_id, user_id_size); std::string remote_id(user_id, user_id_size);
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
std::shared_ptr<GuiRuntime::RemoteSession> props; std::shared_ptr<GuiRuntime::RemoteSession> props;
{ {
std::shared_lock lock(runtime->remote_sessions_mutex_); std::shared_lock lock(runtime->remote_sessions_mutex_);
@@ -268,7 +275,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
if (props) { if (props) {
runtime->is_client_mode_ = true; runtime->is_client_mode_ = true;
runtime->show_connection_status_window_ = true; if (!props->wall_managed_) runtime->show_connection_status_window_ = true;
props->connection_status_.store(status); props->connection_status_.store(status);
if (status != ConnectionStatus::Connecting && status != ConnectionStatus::Gathering) if (status != ConnectionStatus::Connecting && status != ConnectionStatus::Gathering)
props->connection_auth_.Complete(); props->connection_auth_.Complete();
@@ -288,6 +295,11 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_, /*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform); CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true; remote_action.i.supports_video_settings = true;
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true;
remote_action.i.supports_deskhub_bandwidth = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) { for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1, LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]); remote_action.i.display_list[i]);
@@ -303,12 +315,13 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
FreeRemoteAction(remote_action); FreeRemoteAction(remote_action);
} }
if (!runtime->need_to_create_stream_window_) { if (!props->wall_managed_ && !runtime->need_to_create_stream_window_) {
runtime->need_to_create_stream_window_ = true; runtime->need_to_create_stream_window_ = true;
} }
props->connection_established_ = true; props->connection_established_ = true;
props->was_connected_ = true; props->was_connected_ = true;
runtime->start_keyboard_capturer_ = true; props->recent_connection_dirty_ = true;
if (!props->wall_managed_) runtime->start_keyboard_capturer_ = true;
break; break;
} }
case ConnectionStatus::Disconnected: case ConnectionStatus::Disconnected:
@@ -325,7 +338,23 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
props->remote_update_dismissed_ = false; props->remote_update_dismissed_ = false;
} }
props->net_traffic_stats_.Reset(); props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
{
std::lock_guard lock(props->gallery_frame_mutex_);
props->gallery_video_latency_->Reset();
props->gallery_frame_timing_ = {};
}
props->traversal_mode_ = TraversalMode::UnknownMode;
props->enable_mouse_control_ = false; props->enable_mouse_control_ = false;
props->preview_subscription_supported_ = false;
props->preview_resolution_supported_ = false;
props->preview_fps15_supported_ = false;
props->preview_bandwidth_supported_ = false;
props->preview_needs_sync_ = true;
props->preview_ack_ = 0;
props->preview_accepted_ = false;
props->audio_allowed_ = false;
props->clipboard_allowed_ = false;
runtime->ResetRemoteServiceStatus(*props); runtime->ResetRemoteServiceStatus(*props);
{ {
std::lock_guard lock(props->video_settings_mutex_); std::lock_guard lock(props->video_settings_mutex_);
@@ -408,11 +437,18 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->ApplyPrivacyAdmission(automatic_enable); runtime->ApplyPrivacyAdmission(automatic_enable);
} }
runtime->connection_status_[remote_id] = status; runtime->connection_status_[remote_id] = status;
#if _WIN32
runtime->windows_rdp_handoff_connected_->store(std::any_of(
runtime->connection_status_.begin(), runtime->connection_status_.end(),
[](const auto& entry) { return entry.second == ConnectionStatus::Connected; }),
std::memory_order_relaxed);
#endif
runtime->devices_.OnControllerConnectionsChanged(); runtime->devices_.OnControllerConnectionsChanged();
} }
switch (status) { switch (status) {
case ConnectionStatus::Connected: { case ConnectionStatus::Connected: {
runtime->preview_subscriptions_.Remove(remote_id);
#if _WIN32 #if _WIN32
runtime->last_windows_service_status_tick_ = 0; runtime->last_windows_service_status_tick_ = 0;
#endif #endif
@@ -422,6 +458,11 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_, /*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform); CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true; remote_action.i.supports_video_settings = true;
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true;
remote_action.i.supports_deskhub_bandwidth = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) { for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1, LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]); remote_action.i.display_list[i]);
@@ -527,7 +568,7 @@ void PeerEventHandler::OnNetStatusReport(
LOG_INFO("Use client id [{}] and save id into cache file", id); LOG_INFO("Use client id [{}] and save id into cache file", id);
runtime->settings_.Save(); runtime->settings_.Save();
} }
#ifdef __linux__ #if defined(__linux__) && !defined(__OHOS__)
HeadlessConsole::Instance().SetIdentity(id + "@" + password); HeadlessConsole::Instance().SetIdentity(id + "@" + password);
#endif #endif
} }
@@ -538,7 +579,7 @@ void PeerEventHandler::OnNetStatusReport(
if (props->traversal_mode_ != mode) { if (props->traversal_mode_ != mode) {
props->net_traffic_stats_.ResetLatency(); props->net_traffic_stats_.ResetLatency();
props->traversal_mode_ = mode; props->traversal_mode_ = mode;
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_)); LOG_INFO("Net mode: [{}]", int(props->traversal_mode_.load()));
} }
if (!net_traffic_stats) { if (!net_traffic_stats) {
@@ -1,3 +1,6 @@
#include <libyuv.h>
#include <algorithm>
#include <chrono> #include <chrono>
#include <cstring> #include <cstring>
#include <memory> #include <memory>
@@ -5,6 +8,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "nv12_scaler.h"
#include "platform/video_renderer.h" #include "platform/video_renderer.h"
#include "runtime/gui_runtime.h" #include "runtime/gui_runtime.h"
#include "runtime/peer_event_handler.h" #include "runtime/peer_event_handler.h"
@@ -46,9 +50,80 @@ void PeerEventHandler::OnReceiveVideoBuffer(
if (props->connection_established_) { if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now(); const auto timing_now = VideoLatencyFrame::Clock::now();
const auto timing = props->video_latency_->Frame( // Pair the clocks before preview conversion so its cost is included.
video_frame->captured_timestamp, GetSystemTimeMicros(props->peer_), const auto local_now_us = GetSystemTimeMicros(props->peer_);
const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record(
received_native ? received_native->width : video_frame->width,
received_native ? received_native->height : video_frame->height,
timing_now); timing_now);
// Convert only visible thumbnails, off the UI thread. A session retains
// one latest RGBA image, not an unbounded queue of frames.
const int preview_width = props->gallery_preview_width_.load();
// Multiple video streams may decode concurrently for the same session.
// Serialize access to the shared conversion buffers. Visible previews
// consume incoming frames at the negotiated interactive cadence.
std::unique_lock conversion_lock(props->gallery_conversion_mutex_,
std::defer_lock);
if (preview_width > 0) conversion_lock.lock();
if (conversion_lock.owns_lock()) {
const auto gallery_timing = props->gallery_video_latency_->Frame(
video_frame->captured_timestamp, local_now_us, timing_now);
const auto* native = GetNativeVideoFrame(video_frame);
const int w = native ? native->width : video_frame->width;
const int h = native ? native->height : video_frame->height;
if (w > 0 && h > 0 && w <= 16384 && h <= 16384 && !(w % 2) && !(h % 2)) {
const size_t bytes = static_cast<size_t>(w) * h * 3 / 2;
const uint8_t* data =
reinterpret_cast<const uint8_t*>(video_frame->data);
if (native) {
props->gallery_nv12_.resize(bytes);
data = native->copy_to_nv12(native->owner,
props->gallery_nv12_.data(), bytes) == 0
? props->gallery_nv12_.data()
: nullptr;
} else if (video_frame->size < bytes)
data = nullptr;
if (data) {
const double scale = std::min(
{1.0, double(preview_width) / w, double(preview_width) / h});
const int dw = std::max(2, int(w * scale) & ~1);
const int dh = std::max(2, int(h * scale) & ~1);
auto& scaled = props->gallery_scaled_;
scaled.resize(static_cast<size_t>(dw) * dh * 3 / 2);
if (ScaleNv12ViaI420(data, w, data + w * h, w, w, h, scaled.data(),
dw, scaled.data() + dw * dh, dw, dw, dh,
libyuv::kFilterBilinear,
&props->gallery_scratch_) == 0) {
auto rgba = std::make_shared<std::vector<unsigned char>>(
static_cast<size_t>(dw) * dh * 4);
if (libyuv::NV12ToABGR(scaled.data(), dw, scaled.data() + dw * dh,
dw, rgba->data(), dw * 4, dw, dh) == 0) {
std::lock_guard lock(props->gallery_frame_mutex_);
props->gallery_rgba_ = std::move(rgba);
props->gallery_width_ = dw;
props->gallery_height_ = dh;
props->gallery_frame_at_ = timing_now;
props->gallery_frame_timing_ = gallery_timing;
++props->gallery_sequence_;
}
}
}
}
}
if (conversion_lock.owns_lock()) conversion_lock.unlock();
// A wall-only session does not need a second full-resolution CPU buffer.
if (props->wall_managed_ && !props->tab_opened_) {
{
std::lock_guard lock(props->video_frame_mutex_);
props->video_width_ = video_frame->width;
props->video_height_ = video_frame->height;
}
props->streaming_ = true;
return;
}
const auto timing = props->video_latency_->Frame(
video_frame->captured_timestamp, local_now_us, timing_now);
bool background_snapshot_only = false; bool background_snapshot_only = false;
auto* native_renderer = runtime->video_renderer_.get(); auto* native_renderer = runtime->video_renderer_.get();
if (native_renderer && native_renderer->IsActive()) { if (native_renderer && native_renderer->IsActive()) {
@@ -205,9 +280,13 @@ void PeerEventHandler::OnReceiveAudioBuffer(
return; return;
} }
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
const auto session =
runtime->FindRemoteSession(std::string(user_id, user_id_size));
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
runtime->audio_buffer_fresh_ = true; runtime->audio_buffer_fresh_ = true;
runtime->devices_.PushAudio(data, size); runtime->devices_.PushAudio(session->remote_id_, data, size);
} }
} // namespace crossdesk } // namespace crossdesk
@@ -0,0 +1,86 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PREVIEW_SUBSCRIPTIONS_H_
#define _PREVIEW_SUBSCRIPTIONS_H_
#include <preview_subscription.h>
#include <chrono>
#include <mutex>
#include <string>
#include <unordered_map>
namespace crossdesk {
// Accessed by the network callback and screen-capture callback. Legacy
// controllers have no entry and continue receiving their normal stream.
class PreviewSubscriptions {
public:
using Clock = std::chrono::steady_clock;
std::optional<PreviewSubscription> Get(const std::string &id) {
std::lock_guard lock(mutex_);
const auto found = states_.find(id);
return found == states_.end() ? std::nullopt
: std::optional(found->second.request);
}
bool Set(const std::string &id, const PreviewSubscription &request) {
std::lock_guard lock(mutex_);
const auto found = states_.find(id);
if (found != states_.end() &&
!request.IsNewerThan(found->second.request))
return false;
auto &state = states_[id];
if (state.request.fps != request.fps) state.next.clear();
state.request = request;
return true;
}
bool ShouldSend(const std::string &id, Clock::time_point now = Clock::now(),
const std::string &stream = {}) {
std::lock_guard lock(mutex_);
const auto found = states_.find(id);
if (found == states_.end())
return true;
auto &state = found->second;
// MiniRTC owns pause/resume. This gate only limits preview cadence, so
// paused input reaches its cheap send gate without dividing by zero here.
if (!state.request.fps)
return true;
// Interactive streams use the encoder's own 30/60 FPS cadence. A second
// time gate here can drop valid frames because capture timing varies.
if (state.request.fps == 60)
return true;
auto &next = state.next[stream];
if (state.request.fps == 15) {
// Keep a stable 15 FPS deadline across capture jitter. Rebasing every
// frame can turn small timing errors into extra dropped capture frames.
const auto interval = std::chrono::nanoseconds(1'000'000'000 / 15);
if (now + std::chrono::milliseconds(1) < next) return false;
next = next == Clock::time_point{} || now - next > interval
? now + interval : next + interval;
return true;
}
if (now < next)
return false;
next = now + std::chrono::milliseconds(1000 / state.request.fps);
return true;
}
void Remove(const std::string &id) {
std::lock_guard lock(mutex_);
states_.erase(id);
}
private:
struct State {
PreviewSubscription request;
// One display must not consume another display's preview frame budget.
std::unordered_map<std::string, Clock::time_point> next;
};
std::mutex mutex_;
std::unordered_map<std::string, State> states_;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,72 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _RECEIVED_VIDEO_STATS_H_
#define _RECEIVED_VIDEO_STATS_H_
#include <chrono>
#include <cmath>
#include <mutex>
#include <optional>
namespace crossdesk::gui_detail {
// Measured at decode delivery, including wall-only and background sessions.
// Independent of the selected window's presentation cadence and FPS setting.
class ReceivedVideoStats {
public:
using Clock = std::chrono::steady_clock;
struct Snapshot {
int width = 0;
int height = 0;
std::optional<int> fps;
};
void Record(int width, int height, Clock::time_point now = Clock::now()) {
if (width <= 0 || height <= 0) return;
std::lock_guard lock(mutex_);
const bool restart = snapshot_.width == 0 ||
now - last_frame_ >= std::chrono::seconds(3);
snapshot_.width = width;
snapshot_.height = height;
last_frame_ = now;
if (restart) {
started_ = now;
frames_ = 0;
snapshot_.fps.reset();
return;
}
++frames_;
const double elapsed = std::chrono::duration<double>(now - started_).count();
if (elapsed >= 1.0) {
snapshot_.fps = static_cast<int>(std::lround(frames_ / elapsed));
frames_ = 0;
started_ = now;
}
}
Snapshot Get(Clock::time_point now = Clock::now()) const {
std::lock_guard lock(mutex_);
auto result = snapshot_;
if (result.width > 0 && now - last_frame_ >= std::chrono::seconds(1))
result.fps = 0;
return result;
}
void Reset() {
std::lock_guard lock(mutex_);
snapshot_ = {};
frames_ = 0;
started_ = last_frame_ = {};
}
private:
mutable std::mutex mutex_;
Snapshot snapshot_;
unsigned frames_ = 0;
Clock::time_point started_{}, last_frame_{};
};
} // namespace crossdesk::gui_detail
#endif
+42 -4
View File
@@ -20,12 +20,15 @@
#include <vector> #include <vector>
#include <remote_action.h> #include <remote_action.h>
#include <preview_subscription.h>
#include <stream_names.h> #include <stream_names.h>
#include "display_info.h" #include "display_info.h"
#include "features/gallery/preview_resolution.h"
#include "cursor_presentation.h" #include "cursor_presentation.h"
#include "minirtc.h" #include "minirtc.h"
#include "rendering/video_latency.h" #include "rendering/video_latency.h"
#include "runtime/net_traffic_stats_cache.h" #include "runtime/net_traffic_stats_cache.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; }
@@ -41,27 +44,30 @@ struct FileTransferState {
std::chrono::steady_clock::time_point file_send_start_time_; std::chrono::steady_clock::time_point file_send_start_time_;
std::chrono::steady_clock::time_point file_send_last_update_time_; std::chrono::steady_clock::time_point file_send_last_update_time_;
uint64_t file_send_last_bytes_ = 0; uint64_t file_send_last_bytes_ = 0;
bool file_transfer_window_visible_ = false; std::atomic<bool> file_transfer_window_visible_ = false;
std::atomic<uint32_t> current_file_id_{0}; std::atomic<uint32_t> current_file_id_{0};
struct QueuedFile { struct QueuedFile {
std::filesystem::path file_path; std::filesystem::path file_path;
std::string file_label; std::string file_label;
std::string remote_id; std::string remote_id;
uint32_t file_id = 0;
}; };
std::queue<QueuedFile> file_send_queue_; std::queue<QueuedFile> file_send_queue_;
std::mutex file_queue_mutex_; std::mutex file_queue_mutex_;
enum class FileTransferStatus { Queued, Sending, Completed, Failed }; enum class FileTransferStatus { Queued, Sending, Completed, Failed, Receiving, Cancelling, Cancelled };
struct FileTransferInfo { struct FileTransferInfo {
std::string file_name; std::string file_name;
std::string remote_id;
std::filesystem::path file_path; std::filesystem::path file_path;
uint64_t file_size = 0; uint64_t file_size = 0;
FileTransferStatus status = FileTransferStatus::Queued; FileTransferStatus status = FileTransferStatus::Queued;
uint64_t sent_bytes = 0; uint64_t sent_bytes = 0;
uint32_t file_id = 0; uint32_t file_id = 0;
uint32_t rate_bps = 0; uint32_t rate_bps = 0;
bool receiving = false;
}; };
std::vector<FileTransferInfo> file_transfer_list_; std::vector<FileTransferInfo> file_transfer_list_;
std::mutex file_transfer_list_mutex_; std::mutex file_transfer_list_mutex_;
@@ -70,6 +76,35 @@ struct FileTransferState {
// Runtime state for one connected or connecting remote endpoint. It groups the // Runtime state for one connected or connecting remote endpoint. It groups the
// connection, media, input, window and transfer data that share one lifetime. // connection, media, input, window and transfer data that share one lifetime.
struct RemoteSession { struct RemoteSession {
// Wall membership, stream-window visibility and input ownership are separate.
std::atomic<bool> wall_managed_{false};
std::atomic<bool> preview_subscription_supported_{false};
std::atomic<bool> preview_resolution_supported_{false};
std::atomic<bool> preview_fps15_supported_{false};
std::atomic<bool> preview_bandwidth_supported_{false};
std::atomic<bool> preview_needs_sync_{true};
std::atomic<uint32_t> preview_ack_{0};
std::atomic<bool> preview_accepted_{false};
uint32_t preview_request_ = 0;
PreviewSubscription preview_requested_;
gallery::PreviewResolutionDebouncer preview_resolution_;
std::chrono::steady_clock::time_point preview_request_at_{};
std::atomic<bool> clipboard_allowed_{false};
std::atomic<uint64_t> input_generation_{0};
std::atomic<bool> audio_allowed_{false};
std::atomic<int> gallery_preview_width_{0};
std::mutex gallery_frame_mutex_;
std::shared_ptr<std::vector<unsigned char>> gallery_rgba_;
int gallery_width_ = 0, gallery_height_ = 0;
uint64_t gallery_sequence_ = 0;
std::chrono::steady_clock::time_point gallery_frame_at_{};
VideoLatencyFrame gallery_frame_timing_;
// Keep preview submission estimates separate from stream presentation times.
std::shared_ptr<VideoLatencyStats> gallery_video_latency_ =
std::make_shared<VideoLatencyStats>();
// Decoder-thread scratch memory, never shared with the UI.
std::mutex gallery_conversion_mutex_;
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
Params params_; Params params_;
PeerPtr* peer_ = nullptr; PeerPtr* peer_ = nullptr;
std::shared_ptr<PeerEventHandler> peer_events_; std::shared_ptr<PeerEventHandler> peer_events_;
@@ -91,6 +126,8 @@ struct RemoteSession {
SignalStatus signal_status_ = SignalStatus::SignalClosed; SignalStatus signal_status_ = SignalStatus::SignalClosed;
bool connection_established_ = false; bool connection_established_ = false;
std::atomic<bool> was_connected_{false}; std::atomic<bool> was_connected_{false};
// Transport callbacks publish changes; the UI thread persists history.
std::atomic<bool> recent_connection_dirty_{false};
bool rejoin_ = false; bool rejoin_ = false;
bool net_traffic_stats_button_pressed_ = false; bool net_traffic_stats_button_pressed_ = false;
bool enable_mouse_control_ = true; bool enable_mouse_control_ = true;
@@ -127,7 +164,7 @@ struct RemoteSession {
std::shared_ptr<VideoLatencyStats> video_latency_ = std::shared_ptr<VideoLatencyStats> video_latency_ =
std::make_shared<VideoLatencyStats>(); std::make_shared<VideoLatencyStats>();
bool tab_selected_ = false; bool tab_selected_ = false;
bool tab_opened_ = true; std::atomic<bool> tab_opened_{true};
std::string remote_host_name_; std::string remote_host_name_;
// Host metadata and dismissal state belong to this connection and are // Host metadata and dismissal state belong to this connection and are
// protected by remote_version_mutex_. // protected by remote_version_mutex_.
@@ -166,13 +203,14 @@ struct RemoteSession {
CursorPresentation cursor_presentation_; CursorPresentation cursor_presentation_;
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks. // Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed; std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed;
TraversalMode traversal_mode_ = TraversalMode::UnknownMode; std::atomic<TraversalMode> traversal_mode_{TraversalMode::UnknownMode};
int fps_ = 0; int fps_ = 0;
int frame_count_ = 0; int frame_count_ = 0;
std::chrono::steady_clock::time_point last_time_; std::chrono::steady_clock::time_point last_time_;
// UI-thread snapshot refreshed with FPS once per second. // UI-thread snapshot refreshed with FPS once per second.
std::optional<VideoLatencyStats::Snapshot> video_latency_snapshot_; std::optional<VideoLatencyStats::Snapshot> video_latency_snapshot_;
NetTrafficStatsCache net_traffic_stats_; NetTrafficStatsCache net_traffic_stats_;
ReceivedVideoStats received_video_stats_;
using QueuedFile = FileTransferState::QueuedFile; using QueuedFile = FileTransferState::QueuedFile;
using FileTransferStatus = FileTransferState::FileTransferStatus; using FileTransferStatus = FileTransferState::FileTransferStatus;
+15 -3
View File
@@ -7,6 +7,8 @@
#ifndef _RUNTIME_STATE_H_ #ifndef _RUNTIME_STATE_H_
#define _RUNTIME_STATE_H_ #define _RUNTIME_STATE_H_
#include <stream_names.h>
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include <cstdint> #include <cstdint>
@@ -19,12 +21,11 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include <stream_names.h>
#include "config_center.h"
#include "common/task_queue_lock_free.h" #include "common/task_queue_lock_free.h"
#include "config_center.h"
#include "path_manager.h" #include "path_manager.h"
#include "runtime/device_presence_cache.h" #include "runtime/device_presence_cache.h"
#include "runtime/preview_subscriptions.h"
#include "runtime/remote_session.h" #include "runtime/remote_session.h"
#include "runtime/session_lifecycle.h" #include "runtime/session_lifecycle.h"
#include "thumbnail.h" #include "thumbnail.h"
@@ -36,6 +37,8 @@
namespace crossdesk::gui_detail { namespace crossdesk::gui_detail {
struct InfrastructureState { struct InfrastructureState {
PreviewSubscriptions preview_subscriptions_;
std::mutex audio_delivery_mutex_;
std::unique_ptr<ConfigCenter> config_center_; std::unique_ptr<ConfigCenter> config_center_;
ConfigCenter::LANGUAGE localization_language_ = ConfigCenter::LANGUAGE localization_language_ =
ConfigCenter::LANGUAGE::CHINESE; ConfigCenter::LANGUAGE::CHINESE;
@@ -54,6 +57,7 @@ struct RecentConnectionsState {
std::vector<std::pair<std::string, Thumbnail::RecentConnection>> std::vector<std::pair<std::string, Thumbnail::RecentConnection>>
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_;
int recent_connection_image_width_ = 160; int recent_connection_image_width_ = 160;
int recent_connection_image_height_ = 90; int recent_connection_image_height_ = 90;
uint32_t recent_connection_image_save_time_ = 0; uint32_t recent_connection_image_save_time_ = 0;
@@ -88,6 +92,13 @@ struct PeerState {
struct PlatformIntegrationState { struct PlatformIntegrationState {
#if _WIN32 #if _WIN32
std::unique_ptr<ServiceQueryWorker> windows_service_worker_; std::unique_ptr<ServiceQueryWorker> windows_service_worker_;
// Lifecycle gate for interactive hosts; revoked before shutdown.
std::shared_ptr<std::atomic<bool>> windows_rdp_handoff_allowed_ =
std::make_shared<std::atomic<bool>>(false);
// Transport callbacks revoke this immediately; a stale UI snapshot cannot
// authorize a request after the last authenticated controller disconnects.
std::shared_ptr<std::atomic<bool>> windows_rdp_handoff_connected_ =
std::make_shared<std::atomic<bool>>(false);
// Published by the UI, read by transport callbacks without sharing a mutable string. // Published by the UI, read by transport callbacks without sharing a mutable string.
mutable std::mutex windows_input_stage_mutex_; mutable std::mutex windows_input_stage_mutex_;
std::string windows_input_stage_; std::string windows_input_stage_;
@@ -190,6 +201,7 @@ struct ConnectionState {
struct PendingReconnect { struct PendingReconnect {
std::string password; std::string password;
bool remember_password = false; bool remember_password = false;
bool background = false;
}; };
std::unordered_map<std::string, PendingReconnect> pending_reconnects_; std::unordered_map<std::string, PendingReconnect> pending_reconnects_;
+65 -35
View File
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line // The legacy client uses ImGui::StyleColorsLight(). Keep the structural line
// colors in one place so the Slint renderer produces the same visual weight. // colors in one place so the Slint renderer produces the same visual weight.
export global ImGuiLineStyle { export global ImGuiLineStyle {
@@ -8,7 +9,7 @@ export global ImGuiLineStyle {
} }
export global FloatingBarStyle { export global FloatingBarStyle {
out property <length> corner-radius: 6px; out property <length> corner-radius: UiRadius.menu;
} }
// 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
@@ -49,6 +50,7 @@ export global FontAwesomeIcons {
out property <string> signal: "\u{f012}"; out property <string> signal: "\u{f012}";
out property <string> shield-halved: "\u{f3ed}"; out property <string> shield-halved: "\u{f3ed}";
out property <string> square-full: "\u{f45c}"; out property <string> square-full: "\u{f45c}";
out property <string> tags: "\u{f02c}";
out property <string> trash-can: "\u{f2ed}"; out property <string> trash-can: "\u{f2ed}";
out property <string> user-secret: "\u{f21b}"; out property <string> user-secret: "\u{f21b}";
out property <string> volume-high: "\u{f028}"; out property <string> volume-high: "\u{f028}";
@@ -59,6 +61,7 @@ export global FontAwesomeIcons {
export component IconButton inherits Rectangle { export component IconButton inherits Rectangle {
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> 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;
@@ -74,7 +77,7 @@ export component IconButton inherits Rectangle {
background: touch.pressed ? (danger ? #d92d20 : root.pressed-background) background: touch.pressed ? (danger ? #d92d20 : root.pressed-background)
: touch.has-hover ? (danger ? #f04438 : root.hover-background) : touch.has-hover ? (danger ? #f04438 : root.hover-background)
: root.normal-background; : root.normal-background;
border-radius: 4px; border-radius: UiRadius.control;
opacity: root.enabled ? 1 : 0.4; opacity: root.enabled ? 1 : 0.4;
Text { Text {
@@ -103,11 +106,11 @@ export component IconButton inherits Rectangle {
if touch.has-hover && root.tooltip != "": Rectangle { if touch.has-hover && root.tooltip != "": Rectangle {
x: min(root.width - self.width, 0px); x: min(root.width - self.width, 0px);
y: root.height + 3px; y: root.tooltip-above ? -self.height - 3px : root.height + 3px;
width: tip.preferred-width + 12px; width: tip.preferred-width + 12px;
height: 24px; height: 24px;
background: #22252be8; background: #22252be8;
border-radius: 4px; border-radius: UiRadius.control;
z: 20; z: 20;
tip := Text { tip := Text {
text: root.tooltip; text: root.tooltip;
@@ -134,7 +137,7 @@ export component ImageButton inherits Rectangle {
background: touch.pressed ? (danger ? #d92d20 : #d9dde5) background: touch.pressed ? (danger ? #d92d20 : #d9dde5)
: touch.has-hover ? (danger ? #f04438 : #edf0f5) : touch.has-hover ? (danger ? #f04438 : #edf0f5)
: transparent; : transparent;
border-radius: 4px; border-radius: UiRadius.control;
Image { Image {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
@@ -155,7 +158,7 @@ export component ImageButton inherits Rectangle {
width: tip.preferred-width + 12px; width: tip.preferred-width + 12px;
height: 24px; height: 24px;
background: #22252be8; background: #22252be8;
border-radius: 4px; border-radius: UiRadius.control;
z: 20; z: 20;
tip := Text { tip := Text {
text: root.tooltip; text: root.tooltip;
@@ -171,10 +174,25 @@ export component CompactComboBox inherits Rectangle {
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> show-arrow: true;
in property <color> normal-border-color: root.subtle ? #dfe6ef : #bfc2c7;
in property <color> hover-border-color: root.normal-border-color;
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);
// Session state can disable a setting while its popup is already open.
// Remove the stale options and stop a pending reveal on every backend.
changed enabled => {
if !root.enabled {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0.0;
combo-popup.close();
}
}
animate popup-progress { animate popup-progress {
duration: 160ms; duration: 160ms;
easing: ease-out; easing: ease-out;
@@ -183,29 +201,31 @@ export component CompactComboBox inherits Rectangle {
width: 73px; width: 73px;
height: 24px; height: 24px;
background: root.enabled ? white : #f1f1f1; background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1;
border-width: 1px; border-width: 1px;
border-color: #bfc2c7; border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color;
border-radius: 3px; border-radius: UiRadius.control;
Text { Text {
x: 5px; x: root.subtle ? 10px : 5px;
width: parent.width - 24px; width: parent.width - (root.show-arrow ? (root.subtle ? 36px : 24px) : (root.subtle ? 20px : 10px));
text: root.model[root.current-index]; text: root.model[root.current-index];
color: root.enabled ? #202124 : #8b8f95; color: root.enabled ? #202124 : #8b8f95;
font-size: ImGuiFontStyle.body; font-size: root.subtle ? 12px : ImGuiFontStyle.body;
overflow: elide; overflow: elide;
vertical-alignment: center; vertical-alignment: center;
} }
Rectangle { if root.show-arrow: Rectangle {
x: parent.width - 21px; x: parent.width - (root.subtle ? 27px : 21px);
width: 20px; width: 20px;
height: parent.height - 2px; height: parent.height - 2px;
y: 1px; y: 1px;
background: root.enabled ? #9bc9f8 : #d7d9dc; background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc;
border-top-right-radius: UiRadius.control - 1px;
border-bottom-right-radius: UiRadius.control - 1px;
Text { Text {
text: FontAwesomeIcons.angle-down; text: FontAwesomeIcons.angle-down;
color: #202124; color: root.subtle ? #79879a : #202124;
font-family: "Font Awesome 6 Free"; font-family: "Font Awesome 6 Free";
font-weight: 900; font-weight: 900;
font-size: 10px; font-size: 10px;
@@ -219,8 +239,8 @@ export component CompactComboBox inherits Rectangle {
} }
} }
} }
TouchArea { combo-touch := TouchArea {
enabled: root.enabled; enabled: root.enabled; mouse-cursor: pointer;
clicked => { clicked => {
root.popup-animation-enabled = false; root.popup-animation-enabled = false;
root.popup-progress = 0.0; root.popup-progress = 0.0;
@@ -259,22 +279,25 @@ export component CompactComboBox inherits Rectangle {
opacity: root.popup-progress; opacity: root.popup-progress;
background: white; background: white;
border-width: 1px; border-width: 1px;
border-color: #bfc2c7; border-color: root.normal-border-color;
border-radius: 3px; 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; y: index * 23px;
width: parent.width; width: parent.width;
height: 23px; height: 23px;
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white; background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
option-touch := TouchArea { option-touch := TouchArea {
enabled: root.enabled;
clicked => { clicked => {
popup-open-timer.running = false; popup-open-timer.running = false;
root.popup-animation-enabled = false; root.popup-animation-enabled = false;
root.popup-progress = 0.0; root.popup-progress = 0.0;
root.current-index = index; if root.enabled && index >= 0 && index < root.model.length {
root.selected(index); root.current-index = index;
root.selected(index);
}
combo-popup.close(); combo-popup.close();
} }
} }
@@ -284,8 +307,8 @@ export component CompactComboBox inherits Rectangle {
height: parent.height; height: parent.height;
background: transparent; background: transparent;
border-width: 1px; border-width: 1px;
border-color: #bfc2c7; border-color: root.normal-border-color;
border-radius: 3px; border-radius: UiRadius.menu;
z: 2; z: 2;
} }
} }
@@ -345,18 +368,19 @@ export component CompactButton inherits Rectangle {
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;
in property <bool> quiet: false;
in property <bool> enabled: true; in property <bool> enabled: true;
callback clicked; callback clicked;
width: 34px; width: 34px;
height: 24px; height: 24px;
background: !root.enabled ? #eceef0 background: !root.enabled ? (root.quiet ? transparent : #eceef0)
: button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd) : button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd)
: button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2) : button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2)
: root.primary ? #a8d0fa : #e2e4e7; : root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7;
border-width: 1px; border-width: 1px;
border-color: root.primary ? #9bc3ed : #c8cbd0; border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0;
border-radius: 3px; border-radius: UiRadius.control;
Text { Text {
text: root.text; text: root.text;
color: root.enabled ? #24272d : #8e9298; color: root.enabled ? #24272d : #8e9298;
@@ -380,7 +404,7 @@ export component PrimaryButton inherits Rectangle {
: touch.pressed ? #1849a9 : touch.pressed ? #1849a9
: touch.has-hover ? #2970d6 : touch.has-hover ? #2970d6
: #2463c7; : #2463c7;
border-radius: 5px; border-radius: UiRadius.control;
Text { Text {
text: root.text; text: root.text;
@@ -398,6 +422,7 @@ export component PrimaryButton inherits Rectangle {
export component SecondaryButton inherits Rectangle { export component SecondaryButton inherits Rectangle {
in property <string> text; in property <string> text;
in property <length> text-size: ImGuiFontStyle.body;
in property <bool> enabled: true; in property <bool> enabled: true;
callback clicked; callback clicked;
@@ -408,13 +433,13 @@ export component SecondaryButton inherits Rectangle {
: touch.has-hover ? #f3f4f6 : white; : touch.has-hover ? #f3f4f6 : white;
border-width: 1px; border-width: 1px;
border-color: #c9ced7; border-color: #c9ced7;
border-radius: 5px; border-radius: UiRadius.control;
clip: true; clip: true;
Text { Text {
text: root.text; text: root.text;
color: root.enabled ? #24272d : #949aa5; color: root.enabled ? #24272d : #949aa5;
font-size: ImGuiFontStyle.body; font-size: root.text-size;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
} }
@@ -428,6 +453,8 @@ export component SecondaryButton inherits Rectangle {
export component ToggleSwitch inherits Rectangle { export component ToggleSwitch inherits Rectangle {
in-out property <bool> checked; in-out property <bool> checked;
in property <bool> enabled: true; in property <bool> enabled: true;
in property <bool> toggle-on-click: true;
callback clicked();
callback toggled(bool); callback toggled(bool);
width: 36px; width: 36px;
@@ -448,8 +475,11 @@ export component ToggleSwitch inherits Rectangle {
TouchArea { TouchArea {
enabled: root.enabled; enabled: root.enabled;
clicked => { clicked => {
root.checked = !root.checked; if root.toggle-on-click {
root.toggled(root.checked); root.checked = !root.checked;
root.toggled(root.checked);
}
root.clicked();
} }
} }
} }
@@ -465,5 +495,5 @@ export component DialogSurface inherits Rectangle {
background: white; background: white;
border-width: 1px; border-width: 1px;
border-color: ImGuiLineStyle.border; border-color: ImGuiLineStyle.border;
border-radius: 8px; border-radius: UiRadius.surface;
} }
+7 -1
View File
@@ -1,6 +1,12 @@
// Single AOT entry point. Generating one header for the entire UI avoids // Single AOT entry point. Generating one header for the entire UI avoids
// duplicate imported-component definitions when C++ uses all three windows. // duplicate imported-component definitions when C++ uses all three windows.
export { AnnouncementItem, MessageStrings } from "message_panel.slint"; export { AnnouncementItem, MessageStrings } from "message_panel.slint";
export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint"; export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint";
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint"; export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint"; export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
export { FileTransferNotification } from "file_transfer_notification.slint";
export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint";
export { GalleryStrings } from "device_strings.slint";
export { GalleryNetworkDevice } from "gallery_network_dialog.slint";
export { DeviceGroupOption } from "device_group_dialog.slint";
@@ -0,0 +1,61 @@
import { UiRadius } from "ui_radius.slint";
import { IconButton, FontAwesomeIcons } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
export global DeviceCardStyle {
out property <length> toolbar-width: 104px;
out property <color> offline-preview: #626870;
out property <color> offline-background: #e4e6e9;
out property <color> offline-border: #c9cdd2;
out property <color> offline-text: #68707a;
out property <color> offline-placeholder: #eceef1;
}
component CardAction inherits IconButton {
width: 26px; height: 23px;
border-radius: UiRadius.control;
icon-size: 12px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
tooltip-above: true;
}
// Shared by recent connections and DeskHub; callers supply only behavior.
export component DeviceCardActions inherits Rectangle {
in property <string> remove-label;
in property <bool> connected: false;
callback edit-alias();
callback edit-groups();
callback remove-device();
callback connect-device();
callback disconnect-device();
width: DeviceCardStyle.toolbar-width; height: 23px;
background: #4a4e55dc; border-radius: UiRadius.control;
HorizontalLayout {
spacing: 0px;
CardAction {
icon: FontAwesomeIcons.pen;
tooltip: GalleryStrings.tr("修改名称", "Rename", "Переименовать");
clicked => { root.edit-alias(); }
}
CardAction {
icon: FontAwesomeIcons.tags;
tooltip: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
clicked => { root.edit-groups(); }
}
CardAction {
icon: FontAwesomeIcons.trash-can;
tooltip: root.remove-label;
clicked => { root.remove-device(); }
}
CardAction {
icon: root.connected ? FontAwesomeIcons.xmark : FontAwesomeIcons.arrow-right-long;
icon-size: 13px;
tooltip: root.connected ? GalleryStrings.tr("断开", "Disconnect", "Отключить") : GalleryStrings.tr("连接", "Connect", "Подключить");
clicked => {
if root.connected { root.disconnect-device(); }
else { root.connect-device(); }
}
}
}
}
@@ -0,0 +1,103 @@
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint";
import { CompactButton, ImGuiCheckBox } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
export struct DeviceGroupOption {
name: string,
selected: bool,
}
export component DeviceGroupDialog inherits Rectangle {
in property <string> device-name;
in property <string> device-id;
in property <[DeviceGroupOption]> options;
in property <string> error;
in-out property <string> new-group;
callback toggle-group(string);
callback save(string);
callback cancel();
background: #00000040;
TouchArea {}
dialog := Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(360px, parent.width - 40px);
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #b2b2b2;
TouchArea {}
Text {
x: 18px; y: 16px; height: 24px;
text: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
color: #24272d; font-size: 17px; font-weight: 600;
}
Text {
x: 18px; y: 47px; width: parent.width - 36px; height: 18px;
text: root.device-name; color: #30343b; font-size: 12px; overflow: elide;
}
Text {
x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
}
group-list := Rectangle {
x: 18px; y: 95px; width: parent.width - 36px;
height: min(5, max(1, root.options.length)) * 32px + 2px;
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: UiRadius.surface;
clip: true;
ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width;
VerticalLayout {
spacing: 0px;
for option[index] in root.options: Rectangle {
height: 32px;
background: row-touch.has-hover ? #eef4fc : transparent;
ImGuiCheckBox {
x: 9px; y: 6px; width: 20px; height: 20px;
checked: option.selected && (option.name != "" || root.new-group == "");
}
Text {
x: 39px; width: parent.width - 48px; height: parent.height;
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name;
color: #30343b; font-size: 12px; overflow: elide; vertical-alignment: center;
}
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
}
}
}
// Keep row hover backgrounds inside the rounded list on every renderer.
Rectangle {
x: -UiRadius.surface; y: -UiRadius.surface;
width: parent.width + 2 * UiRadius.surface;
height: parent.height + 2 * UiRadius.surface;
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white;
}
Rectangle {
width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface;
border-color: #e1e5eb;
}
}
RoundedLineEdit {
x: 18px; y: group-list.y + group-list.height + 12px;
width: parent.width - 36px; height: 30px;
text <=> root.new-group;
placeholder-text: GalleryStrings.tr("新建分组(可选)", "New group (optional)", "Новая группа (необязательно)");
}
if root.error != "": Text {
x: 18px; y: group-list.y + group-list.height + 48px;
width: parent.width - 36px; height: 32px;
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
}
HorizontalLayout {
x: 18px; y: parent.height - 46px; width: parent.width - 36px; height: 28px;
spacing: 10px;
Rectangle { horizontal-stretch: 1; }
CompactButton { width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.cancel(); } }
CompactButton { width: 88px; height: 28px; primary: true; text: GalleryStrings.tr("保存", "Save", "Сохранить"); clicked => { root.save(root.new-group); } }
}
}
}
@@ -0,0 +1,6 @@
export global GalleryStrings {
in-out property <int> language: 0;
public pure function tr(zh: string, en: string, ru: string) -> string {
return self.language == 0 ? zh : self.language == 2 ? ru : en;
}
}
@@ -0,0 +1,104 @@
import { FileTransferEntry } from "stream_window.slint";
import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint";
import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint";
export component FileTransferNotification inherits Window {
title: "CrossDesk";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
preferred-width: 360px;
preferred-height: 132px;
no-frame: true;
always-on-top: true;
background: transparent;
default-font-size: ImGuiFontStyle.base;
in property <FileTransferEntry> entry;
in property <string> heading;
in property <string> remote-id;
in property <bool> completed;
in property <bool> failed;
in property <bool> transferring;
in property <bool> cancelled;
in property <bool> can-cancel;
in property <string> cancel-text;
callback dismissed;
callback cancel-requested;
DialogSurface {
width: parent.width; height: parent.height;
background: WorkbenchStyle.surface;
border-color: WorkbenchStyle.separator;
Text {
x: 16px; y: 8px;
width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px);
height: 28px;
text: root.heading; color: WorkbenchStyle.text;
font-size: ImGuiFontStyle.prominent; font-weight: 600;
vertical-alignment: center; overflow: elide;
}
if root.remote-id != "": Text {
x: parent.width - 148px; y: 8px; width: 100px; height: 28px;
text: root.remote-id;
color: WorkbenchStyle.muted;
font-size: ImGuiFontStyle.body;
horizontal-alignment: right; vertical-alignment: center; overflow: elide;
}
IconButton {
x: parent.width - 40px; y: 8px; width: 28px; height: 28px;
icon: FontAwesomeIcons.xmark; icon-size: 14px;
icon-color: WorkbenchStyle.muted;
hover-background: WorkbenchStyle.hover-surface;
pressed-background: WorkbenchStyle.pressed-surface;
clicked => { root.dismissed(); }
}
Rectangle {
x: 16px; y: 40px; width: parent.width - 32px; height: 1px;
background: WorkbenchStyle.separator;
}
Text {
x: 16px; y: 47px; width: parent.width - 32px; height: 18px;
text: root.entry.name; color: WorkbenchStyle.text;
font-size: ImGuiFontStyle.base; vertical-alignment: center; overflow: elide;
}
HorizontalLayout {
x: 16px; y: 68px; width: parent.width - 32px; height: 16px; spacing: 8px;
Text {
text: root.entry.status;
color: root.failed ? #d92d20 : root.completed ? WorkbenchStyle.success
: root.transferring ? WorkbenchStyle.accent : WorkbenchStyle.muted;
font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide;
}
Text {
text: root.transferring ? round(root.entry.progress * 100) + "% " + root.entry.speed : root.completed ? "100%" : "";
color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body;
horizontal-alignment: right; vertical-alignment: center;
}
}
Rectangle {
x: 16px; y: 90px; width: parent.width - 32px; height: 6px;
background: WorkbenchStyle.separator; border-radius: 3px;
Rectangle {
x: 0px; y: 0px;
width: parent.width * max(0, min(1, root.entry.progress)); height: parent.height;
background: root.failed ? #d92d20 : root.completed ? WorkbenchStyle.success
: root.cancelled ? WorkbenchStyle.muted : WorkbenchStyle.accent;
border-radius: 3px;
}
}
HorizontalLayout {
x: 16px; y: 100px; width: parent.width - 32px; height: 24px; spacing: 12px;
Text {
text: root.entry.size; color: WorkbenchStyle.muted;
font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide;
horizontal-stretch: 1;
}
if root.can-cancel: WorkbenchButton {
width: 72px; height: 24px; horizontal-stretch: 0;
text: root.cancel-text;
text-size: ImGuiFontStyle.base;
text-color: WorkbenchStyle.muted;
normal-border-color: WorkbenchStyle.border;
clicked => { root.cancel-requested(); }
}
}
}
}
@@ -0,0 +1,161 @@
import { UiRadius } from "ui_radius.slint";
import { CompactButton, FontAwesomeIcons } from "common.slint";
export struct GalleryMenuItem { label: string, enabled: bool }
export component GalleryMenuButton inherits Rectangle {
in property <string> text;
in property <bool> primary: false;
in property <bool> enabled: true;
in property <bool> opens-up: false;
in property <bool> compact-trigger: false;
in property <bool> compact-menu: false;
in property <length> menu-width: 200px;
in property <length> horizontal-padding: 12px;
out property <length> preferred-button-width: ceil(button-label.preferred-width / 1px) * 1px + root.horizontal-padding * 2 + 18px;
in property <[GalleryMenuItem]> items;
in property <[string]> item-icons;
in property <int> destructive-index: -1;
out property <bool> menu-open: popup.is-open;
private property <length> row-height: root.compact-menu ? 24px : 30px;
private property <length> menu-padding: root.compact-menu ? 3px : 4px;
private property <length> divider-spacing: root.compact-menu ? 5px : 7px;
private property <int> current-index: 0;
callback opened;
callback selected(int);
height: 24px;
forward-focus: trigger-focus;
accessible-role: button;
accessible-label: root.text;
accessible-enabled: root.enabled;
accessible-expandable: true;
accessible-expanded: popup.is-open;
accessible-action-default => { root.open-menu(); }
changed enabled => { if !root.enabled { popup.close(); } }
changed visible => { if !root.visible { popup.close(); } }
public function close-menu() { popup.close(); }
function open-menu() {
if !root.enabled || root.items.length == 0 { return; }
if popup.is-open { popup.close(); return; }
root.opened();
root.current-index = 0;
popup.show();
}
function choose(index: int) {
if !root.enabled || index < 0 || index >= root.items.length || !root.items[index].enabled { return; }
popup.close();
trigger-focus.focus();
root.selected(index);
}
function handle-key(event: KeyEvent) -> EventResult {
if !root.enabled { return reject; }
if event.text == Key.Escape && popup.is-open {
popup.close(); trigger-focus.focus(); return accept;
}
if event.text == Key.Return || event.text == " " {
if popup.is-open { root.choose(root.current-index); } else { root.open-menu(); }
return accept;
}
if event.text == Key.UpArrow || event.text == Key.DownArrow {
if !popup.is-open { root.open-menu(); }
else { root.current-index = clamp(root.current-index + (event.text == Key.UpArrow ? -1 : 1), 0, root.items.length - 1); }
return accept;
}
return reject;
}
trigger-focus := FocusScope {
enabled: root.enabled;
key-pressed(event) => { return root.handle-key(event); }
if !root.compact-trigger: CompactButton {
width: parent.width; height: parent.height;
border-radius: UiRadius.control;
enabled: root.enabled; primary: root.primary;
clicked => { trigger-focus.focus(); root.open-menu(); }
}
button-label := Text {
visible: !root.compact-trigger;
x: root.horizontal-padding; width: parent.width - root.horizontal-padding * 2 - 18px; height: parent.height;
text: root.text; color: root.enabled ? #24272d : #8e9298;
font-size: 11px; overflow: elide; horizontal-alignment: center; vertical-alignment: center;
}
Text {
visible: !root.compact-trigger;
x: parent.width - root.horizontal-padding - 12px; width: 12px; height: parent.height;
text: root.opens-up ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 9px;
color: root.enabled ? #62666d : #8e9298;
horizontal-alignment: center; vertical-alignment: center;
}
if root.compact-trigger: Rectangle {
background: more-touch.pressed || popup.is-open ? #e5edfc : more-touch.has-hover ? white : #fffffff0;
border-radius: UiRadius.menu;
border-width: 1px;
border-color: trigger-focus.has-focus ? #3564e3 : #ffffff80;
animate background, border-color { duration: 120ms; }
for dot in 3: Rectangle {
x: parent.width / 2 - 6px + dot * 5px;
y: (parent.height - self.height) / 2;
width: 2px; height: 2px; border-radius: 1px;
background: #35445e;
}
more-touch := TouchArea {
enabled: root.enabled;
mouse-cursor: pointer;
clicked => { trigger-focus.focus(); root.open-menu(); }
}
}
}
popup := PopupWindow {
x: root.width - self.width;
y: root.opens-up ? -self.height - 4px : root.height + 4px;
width: root.menu-width;
height: root.items.length * root.row-height + root.menu-padding * 2
+ (root.destructive-index > 0 ? root.divider-spacing : 0px);
close-policy: close-on-click-outside;
forward-focus: popup-focus;
popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); }
Rectangle {
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu;
if root.destructive-index > 0: Rectangle {
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;
}
for item[index] in root.items: Rectangle {
private property <bool> destructive: index == root.destructive-index;
private property <bool> has-icon: index < root.item-icons.length && root.item-icons[index] != "";
private property <color> foreground: !item.enabled ? #92979f : self.destructive ? #c43c43 : #30343b;
x: root.menu-padding;
y: root.menu-padding + index * root.row-height
+ (root.destructive-index > 0 && index >= root.destructive-index ? root.divider-spacing : 0px);
width: parent.width - root.menu-padding * 2; height: root.row-height;
border-radius: UiRadius.control;
background: item.enabled && index == root.current-index ? (self.destructive ? #fff0f0 : #edf4fc) : transparent;
accessible-role: button; accessible-label: item.label; accessible-enabled: item.enabled;
accessible-action-default => { root.choose(index); }
if self.has-icon: Text {
x: root.compact-menu ? 8px : 10px;
width: root.compact-menu ? 12px : 14px; height: parent.height;
text: root.item-icons[index]; color: parent.foreground;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: root.compact-menu ? 10px : 11px;
horizontal-alignment: center; vertical-alignment: center;
}
Text {
x: parent.has-icon ? (root.compact-menu ? 28px : 34px) : 10px;
width: parent.width - self.x - 10px; height: parent.height;
text: item.label; color: parent.foreground;
font-size: root.compact-menu ? 11px : 12px; vertical-alignment: center;
}
TouchArea {
enabled: item.enabled;
mouse-cursor: pointer;
changed has-hover => { if self.has-hover { root.current-index = index; } }
clicked => { root.choose(index); }
}
}
}
}
}
}
@@ -0,0 +1,225 @@
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { CompactButton } from "common.slint";
import { ScrollView } from "std-widgets.slint";
export struct GalleryNetworkDevice {
name: string,
id: string,
mode: string,
encryption-known: bool,
encryption-enabled: bool,
video-latency: string,
inbound: string,
outbound: string,
loss: string,
resolution: string,
fps: string,
}
component NetworkRow inherits Rectangle {
in property <GalleryNetworkDevice> device;
in property <bool> heading: false;
// Keep the same column geometry in the header and every row. Grouping
// related values leaves room for device names even in the 640px window.
HorizontalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height;
padding-left: 12px; padding-right: 12px; spacing: 10px;
identity := Rectangle {
horizontal-stretch: 1;
device-name := Text {
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width; text: root.device.name;
font-size: root.heading ? 11px : 12px; font-weight: 600;
color: root.heading ? #6b7785 : #293746;
vertical-alignment: center; overflow: elide;
}
name-hover := TouchArea {
y: 8px; height: 20px; width: parent.width;
enabled: !root.heading;
}
if !root.heading: HorizontalLayout {
x: 0px; y: 30px; width: parent.width; height: 17px; spacing: 4px;
id-text := Text {
text: root.device.id; font-size: 10px; color: #7a8490;
vertical-alignment: center; overflow: elide;
}
Rectangle {
width: max(0px, min(mode-text.preferred-width + 12px,
parent.width - id-text.preferred-width - encryption-badge.width - 12px));
background: #edf2f7; border-radius: 3px;
mode-text := Text {
x: 6px; width: parent.width - 12px; height: parent.height;
text: root.device.mode; font-size: 10px; color: #586f87;
horizontal-alignment: center; vertical-alignment: center; overflow: elide;
}
}
encryption-badge := Rectangle {
width: encryption-text.preferred-width + 12px;
background: !root.device.encryption-known ? #f0f2f5
: root.device.encryption-enabled ? #e8f5ec : #fff3df;
border-radius: 3px;
encryption-text := Text {
x: 6px; width: parent.width - 12px; height: parent.height;
text: !root.device.encryption-known
? GalleryStrings.tr("待确认", "Unknown", "Неизвестно")
: root.device.encryption-enabled
? GalleryStrings.tr("已加密", "Encrypted", "Защищено")
: GalleryStrings.tr("未加密", "Unencrypted", "Без шифр.");
color: !root.device.encryption-known ? #7a8490
: root.device.encryption-enabled ? #18794e : #8a6428;
font-size: 10px; horizontal-alignment: center;
vertical-alignment: center; overflow: elide;
}
}
Rectangle { horizontal-stretch: 1; }
}
}
Rectangle {
width: max(108px, root.width * 0.19);
Text {
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width;
text: root.heading ? GalleryStrings.tr("画面", "Video", "Видео") : root.device.resolution;
font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400;
color: root.heading ? #6b7785 : #293746;
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
y: 30px; height: 17px; width: parent.width;
text: root.device.fps; font-size: 11px; color: #7a8490;
vertical-alignment: center; overflow: elide;
}
}
Rectangle {
width: max(100px, root.width * 0.18);
if root.heading: Text {
x: 0px; width: parent.width;
text: GalleryStrings.tr("收发速率", "Transfer rate", "Скорость");
font-size: 11px; font-weight: 600; color: #6b7785;
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
x: 0px; y: 8px; height: 20px; width: 14px;
text: "↓"; color: #2676b9; font-size: 12px; vertical-alignment: center;
}
if !root.heading: Text {
x: 17px; y: 8px; height: 20px; width: parent.width - 17px;
text: root.device.inbound; color: #293746; font-size: 12px;
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
x: 0px; y: 30px; height: 17px; width: 14px;
text: "↑"; color: #7a8490; font-size: 11px; vertical-alignment: center;
}
if !root.heading: Text {
x: 17px; y: 30px; height: 17px; width: parent.width - 17px;
text: root.device.outbound; color: #7a8490; font-size: 11px;
vertical-alignment: center; overflow: elide;
}
}
Rectangle {
width: max(94px, root.width * 0.16);
Text {
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width;
text: root.heading ? GalleryStrings.tr("视频延时", "Video latency", "Задержка видео") : root.device.video-latency;
font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400;
color: root.heading ? #6b7785 : #293746;
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
y: 30px; height: 17px; width: parent.width;
text: GalleryStrings.tr("丢包 ", "Loss ", "Потери ") + root.device.loss;
font-size: 11px; color: #7a8490;
vertical-alignment: center; overflow: elide;
}
}
}
if !root.heading && name-hover.has-hover && device-name.preferred-width > identity.width: Rectangle {
x: 12px; y: 32px; width: min(name-tip.preferred-width + 20px, root.width - 24px);
height: 26px; background: #293746; border-radius: UiRadius.control; z: 10;
name-tip := Text {
x: 10px; width: parent.width - 20px; height: parent.height;
text: root.device.name; font-size: 11px; color: white;
vertical-alignment: center; overflow: elide;
}
}
}
component NetworkEmptyState inherits Rectangle {
VerticalLayout {
alignment: center; spacing: 8px;
Text {
text: GalleryStrings.tr("暂无已连接设备", "No connected devices", "Нет подключённых устройств");
font-size: 13px; color: #596878; horizontal-alignment: center;
}
Text {
text: GalleryStrings.tr("建立连接后,网络和画面信息将在此显示", "Network and video details appear here once connected", "После подключения здесь появятся данные сети и видео");
font-size: 11px; color: #8a95a1; horizontal-alignment: center;
}
}
}
export component GalleryNetworkDialog inherits Rectangle {
in property <[GalleryNetworkDevice]> devices;
private property <length> row-height: 56px;
private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
callback close();
background: #00000042;
TouchArea { clicked => { root.close(); } }
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(760px, parent.width - 32px);
height: min(120px + root.list-height, parent.height - 32px);
background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #c6cdd5;
TouchArea {}
FocusScope {
init => { self.focus(); }
key-pressed(event) => {
if event.text == Key.Escape { root.close(); }
accept
}
}
Text {
x: 20px; y: 17px; width: parent.width - 120px; height: 24px;
text: GalleryStrings.tr("设备网络状态", "Device network status", "Сеть устройств");
font-size: 17px; font-weight: 600; color: #293746;
}
CompactButton {
border-radius: UiRadius.control;
x: parent.width - 80px; y: 17px; width: 60px; height: 26px;
text: GalleryStrings.tr("关闭", "Close", "Закрыть"); clicked => { root.close(); }
}
Text {
x: 20px; y: 48px; width: parent.width - 40px; height: 18px;
text: GalleryStrings.tr("已连接设备 ", "Connected devices ", "Подключённые устройства ") + root.devices.length
+ GalleryStrings.tr(" 台", "", "");
font-size: 11px; color: #7a8490; overflow: elide;
}
NetworkRow {
x: 12px; y: 78px; width: parent.width - 38px; height: 30px;
heading: true; background: #f3f5f8; border-radius: UiRadius.control;
device: {
name: GalleryStrings.tr("设备 / ID", "Device / ID", "Устройство / ID"),
};
}
list := ScrollView {
x: 12px; y: 108px; width: parent.width - 24px;
height: parent.height - self.y - 12px;
viewport-width: self.width - 14px;
viewport-height: max(self.height, root.devices.length * root.row-height);
horizontal-scrollbar-policy: always-off;
for device[index] in root.devices: NetworkRow {
y: index * root.row-height; width: list.viewport-width; height: root.row-height;
device: device; background: mod(index, 2) == 0 ? white : #f8fafc;
Rectangle { y: parent.height - 1px; height: 1px; background: #edf0f4; }
}
}
if root.devices.length == 0: NetworkEmptyState {
x: list.x; y: list.y; width: list.width; height: list.height;
}
}
}
@@ -0,0 +1,50 @@
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { SettingsGroup, SettingsRow, SettingsSelect } from "settings_controls.slint";
export component GallerySettings inherits Rectangle {
in-out property <int> resolution-index: 0;
in-out property <int> frame-rate-index: 0;
in-out property <int> bandwidth-index: 3;
in property <string> error;
SettingsGroup {
y: 0px; width: parent.width;
title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3;
SettingsRow {
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение");
label-reserve: self.width - 112px;
SettingsSelect {
border-radius: UiRadius.control;
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.resolution-index;
model: [GalleryStrings.tr("自动", "Auto", "Авто"),
"320 × 180", "640 × 360", "1280 × 720", "1920 × 1080", "2560 × 1440", "3840 × 2160",
GalleryStrings.tr("原始分辨率", "Original resolution", "Исходное разрешение")];
}
}
SettingsRow {
label: GalleryStrings.tr("帧率", "Frame rate", "Частота кадров");
label-reserve: self.width - 112px;
SettingsSelect {
border-radius: UiRadius.control;
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.frame-rate-index;
model: [GalleryStrings.tr("跟随设备设置", "Follow device settings", "Настройки устройства"), "15 FPS", "30 FPS", "60 FPS"];
}
}
SettingsRow {
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео");
label-reserve: self.width - 112px; divider: false;
SettingsSelect {
border-radius: UiRadius.control;
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.bandwidth-index;
model: ["2 Mbps", "5 Mbps", "10 Mbps", "20 Mbps", "50 Mbps", "100 Mbps"];
}
}
}
if root.error != "": Text {
x: 2px; y: 144px; width: parent.width - 4px; height: 32px;
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
}
}
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons } from "common.slint";
// Shared by history and DeskHub; system choices precede the named groups.
export component GroupFilter inherits Rectangle {
in property <[string]> model;
in property <int> system-count: 2;
in property <bool> enabled: true;
in property <color> normal-border-color: #c9cdd2;
in property <color> hover-border-color: root.normal-border-color;
in property <color> focus-border-color: #8db5df;
in-out property <int> current-index: 0;
callback selected(int);
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
private property <length> visible-height: min(root.list-height, 162px);
private property <length> scroll-y: 0px;
private property <bool> popup-animation-enabled: false;
private property <float> popup-progress: 0;
animate popup-progress {
duration: 140ms; easing: ease-out;
enabled: root.popup-animation-enabled;
}
width: 155px; height: 28px;
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
border-width: 1px;
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color;
border-radius: UiRadius.control;
animate background, border-color { duration: 100ms; easing: ease-out; }
forward-focus: trigger-focus;
accessible-role: combobox;
accessible-enabled: root.enabled;
accessible-expandable: true;
accessible-expanded: popup.is-open;
accessible-value: root.model[root.current-index];
function reveal-selection() {
let top = root.current-index * 26px + (root.has-divider && root.current-index >= root.system-count ? 6px : 0px);
if top < -root.scroll-y { root.scroll-y = -top; }
if top + 26px > -root.scroll-y + root.visible-height {
root.scroll-y = root.visible-height - top - 26px;
}
root.scroll-y = clamp(root.scroll-y, min(0px, root.visible-height - root.list-height), 0px);
}
function choose(index: int) {
if !root.enabled || root.model.length == 0 { return; }
root.current-index = clamp(index, 0, root.model.length - 1);
root.reveal-selection();
root.selected(root.current-index);
}
function open-menu() {
if root.enabled && root.model.length > 0 {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0;
root.reveal-selection();
popup.show();
popup-open-timer.running = true;
}
}
// Defer until the popup has its initial frame; animate its contents only,
// keeping the native popup bounds and scrolling geometry stable.
popup-open-timer := Timer {
interval: 16ms;
running: false;
triggered => {
self.running = false;
if popup.is-open {
root.popup-animation-enabled = true;
root.popup-progress = 1;
}
}
}
function handle-key(event: KeyEvent) -> EventResult {
if !root.enabled { return reject; }
if event.text == Key.UpArrow { root.choose(root.current-index - 1); return accept; }
if event.text == Key.DownArrow { root.choose(root.current-index + 1); return accept; }
if event.text == Key.Return || event.text == " " {
if popup.is-open { popup.close(); } else { root.open-menu(); }
return accept;
}
if event.text == Key.Escape && popup.is-open { popup.close(); return accept; }
return reject;
}
Text {
x: 10px; width: parent.width - 36px; height: parent.height;
text: root.model[root.current-index];
color: root.enabled ? #30343b : #92979f;
font-size: 12px; overflow: elide; vertical-alignment: center;
accessible-role: none;
}
Text {
x: parent.width - 25px; width: 16px; height: parent.height;
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #858c96;
horizontal-alignment: center; vertical-alignment: center;
transform-rotation: popup.is-open ? 180deg : 0deg;
animate transform-rotation { duration: 140ms; easing: ease-out; }
accessible-role: none;
}
trigger-focus := FocusScope {
enabled: root.enabled;
key-pressed(event) => { return root.handle-key(event); }
trigger-touch := TouchArea {
enabled: root.enabled;
clicked => { trigger-focus.focus(); root.open-menu(); }
}
}
popup := PopupWindow {
x: 0px; y: root.height + 4px;
width: root.width; height: root.visible-height + 8px;
close-policy: close-on-click-outside;
forward-focus: popup-focus;
popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); }
Rectangle {
y: (1 - root.popup-progress) * -4px;
width: parent.width; height: parent.height;
opacity: root.popup-progress;
background: white; border-width: 1px;
border-color: root.normal-border-color; border-radius: UiRadius.menu;
ScrollView {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
viewport-width: self.width;
viewport-height: root.list-height;
viewport-y <=> root.scroll-y;
Rectangle {
width: parent.width; height: root.list-height;
if root.has-divider: Rectangle {
x: 6px; y: root.system-count * 26px + 3px;
width: parent.width - 12px; height: 1px; background: #e9ebee;
}
for choice[index] in root.model: Rectangle {
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
width: parent.width; height: 26px; border-radius: UiRadius.control;
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
animate background { duration: 100ms; easing: ease-out; }
accessible-role: list-item;
accessible-label: choice;
Text {
x: 8px; width: parent.width - 34px; height: parent.height;
text: choice; color: #30343b; font-size: 12px;
overflow: elide; vertical-alignment: center;
accessible-role: none;
}
if index == root.current-index: Text {
x: parent.width - 24px; width: 16px; height: parent.height;
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #4e87c0;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
option-touch := TouchArea {
clicked => { root.choose(index); popup.close(); }
}
}
}
}
}
}
}
}
@@ -0,0 +1,16 @@
# Sponsorship platform icons
These platform marks identify external sponsorship links. They remain the
property of their respective owners and are not covered by CrossDesk's own
artwork license. Retrieved on 2026-10-19.
- `afdian.png`: [AFDIAN](https://afdian.com/) navigation logo, downloaded from
[its official CDN](https://static.afdiancdn.com/static/img/logo/logo.png)
without modification (80 × 80).
- `kofi.png`: cup icon from [Ko-fi's brand assets](https://more.ko-fi.com/brand-assets),
downloaded from [its official asset host](https://cdn.prod.website-files.com/5c14e387dab576fe667689cf/670f5a01229bf8a18f97a3c1_favion.avif),
converted to PNG and proportionally resized to 64 × 64.
- `github.svg`: unmodified `GitHub_Invertocat_Black.svg` from the
[official logo archive](https://brand.github.com/GitHub_Logos.zip).
[GitHub's brand guidelines](https://brand.github.com/foundations/logo)
permit the logo to identify a link to GitHub.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,10 @@
<svg width="98" height="96" viewBox="0 0 98 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_730_27126)">
<path d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 6.69539e-07 48.9043 4.309e-07C21.8203 1.92261e-07 -1.9479e-07 22.1074 -4.3343e-07 49.1914C-6.20631e-07 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_730_27126">
<rect width="98" height="96" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

File diff suppressed because it is too large Load Diff
+163 -144
View File
@@ -1,5 +1,9 @@
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint"; import { IconButton, FontAwesomeIcons } from "common.slint";
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
import "../assets/fonts/message_spacing.ttf";
export struct AnnouncementItem { export struct AnnouncementItem {
id: string, id: string,
@@ -13,10 +17,11 @@ export global MessageStrings {
in-out property <string> messages: "Messages"; in-out property <string> messages: "Messages";
in-out property <string> unread: "Unread"; in-out property <string> unread: "Unread";
in-out property <string> read: "Read"; in-out property <string> read: "Read";
in-out property <string> read-all: "Mark all as read";
in-out property <string> empty: "No announcements"; in-out property <string> empty: "No announcements";
in-out property <string> select-message: "Select an announcement to read"; in-out property <string> select-message: "Select an announcement to read";
in-out property <string> loading: "Loading…"; in-out property <string> loading: "Loading…";
in-out property <string> read-save-error: "Unable to save reading status locally. Open the announcement again to retry."; in-out property <string> read-save-error: "Unable to save reading status locally. Please try again.";
in-out property <string> close: "Close"; in-out property <string> close: "Close";
in-out property <string> delete-message: "Delete"; in-out property <string> delete-message: "Delete";
in-out property <string> delete-title: "Delete announcement"; in-out property <string> delete-title: "Delete announcement";
@@ -25,7 +30,7 @@ export global MessageStrings {
in-out property <string> cancel: "Cancel"; in-out property <string> cancel: "Cancel";
} }
export component MessagePanel inherits DialogSurface { export component MessagePanel inherits SettingsDialog {
in property <[AnnouncementItem]> items; in property <[AnnouncementItem]> items;
in property <int> unread-count; in property <int> unread-count;
in property <int> total; in property <int> total;
@@ -36,6 +41,7 @@ export component MessagePanel inherits DialogSurface {
in property <bool> loaded; in property <bool> loaded;
in property <bool> connected; in property <bool> connected;
in property <string> selected-id; in property <string> selected-id;
in property <string> selected-revision;
in property <string> selected-title; in property <string> selected-title;
in property <styled-text> selected-body; in property <styled-text> selected-body;
in property <string> selected-date; in property <string> selected-date;
@@ -44,11 +50,9 @@ export component MessagePanel inherits DialogSurface {
callback open-link(string); callback open-link(string);
callback delete-message(string, string); callback delete-message(string, string);
callback load-more; callback load-more;
callback close; callback read-all;
private property <string> notice-text: root.delete-failed ? MessageStrings.delete-error private property <string> notice-text: root.delete-failed ? MessageStrings.delete-error
: root.read-save-failed ? MessageStrings.read-save-error : ""; : root.read-save-failed ? MessageStrings.read-save-error : "";
private property <length> content-top: title-bar.height + (root.notice-text == "" ? 14px : 50px);
private property <color> separator-color: #e6e8eb;
private property <string> pending-delete-id; private property <string> pending-delete-id;
private property <string> pending-delete-revision; private property <string> pending-delete-revision;
function request-delete(id: string, revision: string) { function request-delete(id: string, revision: string) {
@@ -67,137 +71,151 @@ export component MessagePanel inherits DialogSurface {
return reject; return reject;
} }
} }
background: white; title: MessageStrings.messages;
border-radius: 7px; close-label: MessageStrings.close;
TouchArea { } badge-count: root.unread-count;
title-bar := Rectangle { badge-label: MessageStrings.unread + " " + root.unread-count;
x: 0px; y: 0px; width: parent.width; height: 21px; header-status: root.loading ? MessageStrings.loading : "";
background: #d3d3d3; border-radius: 7px; action-text: MessageStrings.read-all;
Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } action-enabled: root.loaded && !root.loading && root.unread-count > 0;
message-heading := Text { action => { root.read-all(); }
x: 6px; width: parent.width - 100px; height: parent.height; notice := Rectangle {
text: MessageStrings.messages; x: 24px; y: root.content-top; width: parent.width - 48px;
font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; height: root.notice-text == "" ? 0px : notice-label.preferred-height + 20px;
vertical-alignment: center; overflow: elide; visible: root.notice-text != "";
} border-radius: UiRadius.control; background: #fef2f2;
if root.unread-count > 0: Rectangle { notice-label := Text {
x: message-heading.x + message-heading.preferred-width + 8px; x: 10px; y: 10px; width: parent.width - 20px;
y: (parent.height - self.height) / 2; height: 16px; height: self.preferred-height;
width: max(16px, unread-count-label.preferred-width + 8px); text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e;
border-radius: 8px; background: #eaf2ff;
accessible-role: text;
accessible-label: MessageStrings.unread + " " + root.unread-count;
unread-count-label := Text {
text: "" + root.unread-count; color: #2563eb;
font-size: ImGuiFontStyle.small; font-weight: 600;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
}
if root.loading: Text {
x: parent.width - 150px; width: 110px; height: parent.height;
text: MessageStrings.loading; color: #61656c; font-size: ImGuiFontStyle.small;
horizontal-alignment: right; vertical-alignment: center;
}
IconButton {
x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px;
border-radius: 3px;
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.base;
icon-color: #202124;
hover-background: #bfc2c7; pressed-background: #b2b2b2;
accessible-role: button;
accessible-label: MessageStrings.close;
accessible-action-default => { root.close(); }
clicked => { root.close(); }
} }
} }
if root.notice-text != "": Rectangle { message-content := Rectangle {
x: 16px; y: title-bar.height + 10px; width: parent.width - 32px; height: 28px; x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
border-radius: 4px; width: parent.width - 48px; height: parent.height - self.y - 24px;
background: #fef2f2; background: WorkbenchStyle.surface;
Text { border-radius: UiRadius.surface; border-width: 1px; border-color: WorkbenchStyle.border;
x: 8px; width: parent.width - 16px; height: parent.height; if root.items.length > 0 || root.selected-id != "": Rectangle {
text: root.notice-text; font-size: ImGuiFontStyle.body; overflow: elide; x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
color: #b3261e; background: WorkbenchStyle.field-surface;
vertical-alignment: center; border-top-left-radius: UiRadius.surface - 1px;
border-bottom-left-radius: UiRadius.surface - 1px;
} }
} announcement-list := Rectangle {
announcement-list := Rectangle { x: 8px; y: 8px; width: 176px; height: parent.height - 16px;
x: 16px; y: root.content-top; width: 168px; height: parent.height - self.y - 16px; announcements-scroll := ScrollView {
announcements-scroll := ScrollView { width: parent.width; height: parent.height;
width: parent.width; height: parent.height; viewport-width: self.width - 10px;
VerticalLayout { viewport-height: announcement-entries.preferred-height;
width: announcements-scroll.width; spacing: 0px; padding: 0px; horizontal-scrollbar-policy: always-off;
padding-right: 8px; vertical-scrollbar-policy: as-needed;
alignment: start; announcement-entries := VerticalLayout {
for item[index] in root.items: Rectangle { width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
height: 54px; alignment: start;
background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f0f1f2 : transparent; for item in root.items: Rectangle {
border-radius: 4px; height: 64px;
Text { x: 8px; y: 6px; width: parent.width - 32px; height: 18px; text: item.title; overflow: elide; font-size: ImGuiFontStyle.base; font-weight: item.read ? 400 : 600; color: #202124; vertical-alignment: center; } background: root.selected-id == item.id ? #e8efff
if !item.read: Rectangle { : entry-touch.pressed ? WorkbenchStyle.pressed-surface
x: parent.width - 17px; y: 13px; width: 5px; height: 5px; : entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: 2.5px; background: #2563eb; border-radius: UiRadius.control;
accessible-role: none; border-width: 1px;
} border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border;
Text { x: 8px; y: 28px; width: parent.width - 36px; height: 16px; text: item.date; color: #61656c; font-size: ImGuiFontStyle.small; overflow: elide; vertical-alignment: center; } Text {
accessible-role: button; x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread); text: item.title; overflow: elide; font-size: 13px;
accessible-action-default => { root.open-message(item.id, item.revision); } font-weight: item.read ? 400 : 600;
entry-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-message(item.id, item.revision); } } color: root.selected-id == item.id ? WorkbenchStyle.accent : WorkbenchStyle.text;
IconButton { vertical-alignment: center;
x: parent.width - 24px; y: 26px; width: 20px; height: 20px; }
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.small; if !item.read: Rectangle {
icon-color: #61656c; danger: true; x: parent.width - 15px; y: 16px; width: 6px; height: 6px;
border-radius: 3px; background: WorkbenchStyle.accent;
accessible-role: none;
}
Text {
x: 10px; y: 35px; width: parent.width - 20px; height: 18px;
text: item.date; color: WorkbenchStyle.muted; font-size: 11px;
overflow: elide; vertical-alignment: center;
}
accessible-role: button; accessible-role: button;
accessible-label: MessageStrings.delete-message + " " + item.title; accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
accessible-action-default => { root.request-delete(item.id, item.revision); } accessible-action-default => { root.open-message(item.id, item.revision); }
clicked => { root.request-delete(item.id, item.revision); } entry-touch := TouchArea {
} mouse-cursor: pointer;
if index < root.items.length - 1: Rectangle { clicked => { root.open-message(item.id, item.revision); }
x: 8px; y: parent.height - 1px; }
width: parent.width - 16px; height: 1px;
background: root.separator-color;
accessible-role: none;
} }
} }
} }
} }
} if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text { x: 24px; width: parent.width - 48px; height: parent.height;
x: 16px; y: root.content-top; width: parent.width - 32px; height: announcement-list.height; text: MessageStrings.empty; wrap: word-wrap;
text: MessageStrings.empty; wrap: word-wrap; color: WorkbenchStyle.muted; font-size: 13px;
color: #61656c; font-size: ImGuiFontStyle.body; horizontal-alignment: center; vertical-alignment: center;
horizontal-alignment: center; vertical-alignment: center; }
} content-divider := Rectangle {
content-divider := Rectangle { x: announcement-list.x + announcement-list.width + 8px;
x: announcement-list.x + announcement-list.width + 12px; y: 1px; width: 1px; height: parent.height - 2px;
y: root.content-top; width: 1px; height: announcement-list.height; background: WorkbenchStyle.separator;
background: root.separator-color; visible: root.items.length > 0 || root.selected-id != "";
visible: root.items.length > 0 || root.selected-id != ""; accessible-role: none;
accessible-role: none; }
} announcement-details := Rectangle {
announcement-details := Rectangle { x: content-divider.x + 17px; y: 16px;
x: content-divider.x + content-divider.width + 12px; width: parent.width - self.x - 16px; height: parent.height - 32px;
y: root.content-top; width: parent.width - self.x - 16px; height: announcement-list.height; clip: true;
if root.selected-id == "" && root.items.length > 0: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.select-message; color: #61656c; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; } if root.selected-id == "" && root.items.length > 0: Text {
if root.selected-id != "": details-scroll := ScrollView { width: parent.width; height: parent.height;
width: parent.width; height: parent.height; text: MessageStrings.select-message; color: WorkbenchStyle.muted; font-size: 13px;
VerticalLayout { wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center;
width: details-scroll.width; padding: 8px; padding-top: 6px; padding-right: 16px; }
spacing: 12px; alignment: start; if root.selected-id != "": details-scroll := ScrollView {
VerticalLayout { width: parent.width; height: parent.height;
vertical-stretch: 0; spacing: 4px; viewport-width: self.width - 12px;
Text { vertical-stretch: 0; min-height: 18px; text: root.selected-title; wrap: word-wrap; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } viewport-height: details-content.preferred-height;
Text { vertical-stretch: 0; min-height: 16px; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #61656c; font-size: ImGuiFontStyle.small; wrap: word-wrap; vertical-alignment: center; } horizontal-scrollbar-policy: always-off;
} vertical-scrollbar-policy: as-needed;
Rectangle { height: 1px; background: root.separator-color; } details-content := VerticalLayout {
StyledText { width: details-scroll.viewport-width; padding: 0px; padding-bottom: 4px;
vertical-stretch: 0; spacing: 14px; alignment: start;
text: root.selected-body; Rectangle {
default-color: #202124; default-font-size: ImGuiFontStyle.body; height: max(28px, selected-heading.preferred-height);
link-color: #2563eb; selected-heading := VerticalLayout {
link-clicked(url) => { root.open-link(url); } x: 0px; y: 0px;
width: parent.width - 40px; height: self.preferred-height;
padding: 0px; spacing: 6px;
Text {
vertical-stretch: 0; text: root.selected-title; wrap: word-wrap;
font-size: 16px; font-weight: 600; color: WorkbenchStyle.text;
}
Text {
vertical-stretch: 0; text: root.selected-date;
accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread);
color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
}
}
IconButton {
x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
icon: FontAwesomeIcons.trash-can; icon-size: 12px;
icon-color: WorkbenchStyle.muted; danger: true;
accessible-role: button;
accessible-label: MessageStrings.delete-message + " " + root.selected-title;
accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); }
clicked => { root.request-delete(root.selected-id, root.selected-revision); }
}
}
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
StyledText {
vertical-stretch: 0;
text: root.selected-body;
default-color: WorkbenchStyle.text; default-font-size: 13px;
// Metrics-only family provides 1.5em line height; visible
// glyphs continue to use Slint's normal font fallback.
default-font-family: "CrossDesk Message Spacing";
link-color: WorkbenchStyle.accent;
link-clicked(url) => { root.open-link(url); }
}
} }
} }
} }
@@ -211,30 +229,31 @@ export component MessagePanel inherits DialogSurface {
triggered => { root.load-more(); } triggered => { root.load-more(); }
} }
if root.pending-delete-id != "": Rectangle { if root.pending-delete-id != "": Rectangle {
background: #00000042; border-radius: root.border-radius; z: 30; background: #15223850; border-radius: root.border-radius; z: 30;
TouchArea { clicked => { confirmation-keys.focus(); } } TouchArea { clicked => { confirmation-keys.focus(); } }
DialogSurface { SettingsDialog {
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: 230px; height: confirmation-content.preferred-height; width: min(360px, root.width - 48px);
border-radius: root.border-radius; height: self.content-top + confirmation-content.preferred-height + 24px;
TouchArea { clicked => { confirmation-keys.focus(); } } title: MessageStrings.delete-title;
close-label: MessageStrings.cancel;
close => { root.pending-delete-id = ""; }
confirmation-content := VerticalLayout { confirmation-content := VerticalLayout {
padding: 16px; x: 24px; y: parent.content-top; width: parent.width - 48px;
padding-top: 26px; height: self.preferred-height;
padding-bottom: 26px; padding: 0px; spacing: 24px;
spacing: 20px; Text { text: MessageStrings.delete-confirmation; font-size: 13px; color: WorkbenchStyle.text; wrap: word-wrap; }
Text { text: MessageStrings.delete-confirmation; font-size: ImGuiFontStyle.base; color: #202124; wrap: word-wrap; horizontal-alignment: center; }
HorizontalLayout { HorizontalLayout {
alignment: center; spacing: 8px; alignment: end; spacing: 8px;
cancel-delete-button := CompactButton { cancel-delete-button := SettingsButton {
width: 72px; text: MessageStrings.cancel; width: 88px; text: MessageStrings.cancel;
accessible-role: button; accessible-label: MessageStrings.cancel; accessible-role: button; accessible-label: MessageStrings.cancel;
accessible-action-default => { cancel-delete-button.clicked(); } accessible-action-default => { cancel-delete-button.clicked(); }
clicked => { root.pending-delete-id = ""; } clicked => { root.pending-delete-id = ""; }
} }
confirm-delete-button := CompactButton { confirm-delete-button := SettingsButton {
width: 72px; text: MessageStrings.delete-message; primary: true; width: 88px; text: MessageStrings.delete-message; primary: true;
accessible-role: button; accessible-label: MessageStrings.delete-title; accessible-role: button; accessible-label: MessageStrings.delete-title;
accessible-action-default => { confirm-delete-button.clicked(); } accessible-action-default => { confirm-delete-button.clicked(); }
clicked => { clicked => {
+34
View File
@@ -0,0 +1,34 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _MODEL_UPDATE_H_
#define _MODEL_UPDATE_H_
#include <slint.h>
#include <utility>
#include <vector>
namespace crossdesk::ui {
// A reset invalidates row-count bindings even if the count is unchanged.
// Repeating it from a timer can continually restart dependent size animations.
template <typename Row>
void UpdateModelRows(slint::VectorModel<Row>& model, std::vector<Row> rows) {
if (model.row_count() != rows.size()) {
model.set_vector(std::move(rows));
return;
}
for (std::size_t i = 0; i < rows.size(); ++i) {
if (model.row_data(i) != rows[i]) {
model.set_row_data(i, rows[i]);
}
}
}
} // namespace crossdesk::ui
#endif
@@ -0,0 +1,90 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _RELEASE_NOTES_TEXT_H_
#define _RELEASE_NOTES_TEXT_H_
#include <slint.h>
#include <algorithm>
#include <cctype>
#include <string_view>
#include <utility>
#include <vector>
namespace crossdesk::release_notes_text {
struct Block {
slint::StyledText content;
bool section_gap = false;
};
inline bool IsThematicBreak(std::string_view line) {
const auto start = line.find_first_not_of(' ');
// Four leading spaces denote a code block, not a thematic break.
if (start == std::string_view::npos || start > 3) return false;
const char marker = line[start];
if (marker != '-' && marker != '*' && marker != '_') return false;
size_t count = 0;
for (const char ch : line.substr(start)) {
if (ch == marker)
++count;
else if (ch != ' ' && ch != '\t')
return false;
}
return count >= 3;
}
inline std::vector<Block> Parse(std::string_view markdown) {
std::vector<Block> blocks;
bool section_gap = false;
size_t line_start = 0;
while (line_start < markdown.size()) {
const size_t newline = markdown.find('\n', line_start);
const size_t line_end =
newline == std::string_view::npos ? markdown.size() : newline;
auto line = markdown.substr(line_start, line_end - line_start);
line_start = newline == std::string_view::npos ? markdown.size() : newline + 1;
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
// StyledText does not support thematic breaks. Represent them with the
// same spacing as blank lines instead of rendering raw Markdown markers.
if (std::all_of(line.begin(), line.end(), [](unsigned char ch) {
return std::isspace(ch) != 0;
}) || IsThematicBreak(line)) {
section_gap = !blocks.empty();
continue;
}
size_t heading_end = 0;
while (heading_end < line.size() && heading_end < 6 &&
line[heading_end] == '#') {
++heading_end;
}
// The dialog already supplies the release title.
if (heading_end > 0 && heading_end < line.size() &&
std::isspace(static_cast<unsigned char>(line[heading_end])) != 0) {
continue;
}
Block block;
if (const auto parsed = slint::StyledText::from_markdown(line)) {
block.content = *parsed;
} else {
// Unsupported syntax is valid release content, not a parsing failure.
// Preserve it literally when it is outside Slint's Markdown subset.
block.content = slint::StyledText::from_plain_text(line);
}
block.section_gap = section_gap;
blocks.push_back(std::move(block));
section_gap = false;
}
return blocks;
}
} // namespace crossdesk::release_notes_text
#endif
@@ -0,0 +1,34 @@
export global RemoteCursor {
// Numeric values mirror crossdesk::RemoteCursorShape on the wire.
public pure function shape(value: int) -> MouseCursor {
return value == 1 ? MouseCursor.none
: value == 2 ? MouseCursor.help
: value == 3 ? MouseCursor.pointer
: value == 4 ? MouseCursor.progress
: value == 5 ? MouseCursor.wait
: value == 6 ? MouseCursor.crosshair
: value == 7 ? MouseCursor.text
: value == 8 ? MouseCursor.alias
: value == 9 ? MouseCursor.copy
: value == 10 ? MouseCursor.move
: value == 11 ? MouseCursor.no-drop
: value == 12 ? MouseCursor.not-allowed
: value == 13 ? MouseCursor.grab
: value == 14 ? MouseCursor.grabbing
: value == 15 ? MouseCursor.col-resize
: value == 16 ? MouseCursor.row-resize
: value == 17 ? MouseCursor.n-resize
: value == 18 ? MouseCursor.e-resize
: value == 19 ? MouseCursor.s-resize
: value == 20 ? MouseCursor.w-resize
: value == 21 ? MouseCursor.ne-resize
: value == 22 ? MouseCursor.nw-resize
: value == 23 ? MouseCursor.se-resize
: value == 24 ? MouseCursor.sw-resize
: value == 25 ? MouseCursor.ew-resize
: value == 26 ? MouseCursor.ns-resize
: value == 27 ? MouseCursor.nesw-resize
: value == 28 ? MouseCursor.nwse-resize
: MouseCursor.default;
}
}
@@ -0,0 +1,50 @@
import { LineEdit } from "std-widgets.slint";
import { UiRadius } from "ui_radius.slint";
// Retain the standard editor's selection, clipboard, clear button and input
// method support. Only its outer frame is replaced by the shared shape.
export component RoundedLineEdit inherits Rectangle {
in-out property <string> text;
in property <string> placeholder-text;
in property <InputType> input-type: InputType.text;
in property <bool> enabled: true;
in property <length> font-size: 12px;
in property <color> normal-border-color: #dfe6ef;
in property <brush> surrounding-background: white;
callback accepted();
callback edited();
height: 30px;
border-radius: UiRadius.control;
background: white;
clip: true;
forward-focus: editor;
editor := LineEdit {
x: -1px; y: -1px;
width: parent.width + 2px; height: parent.height + 2px;
text <=> root.text;
placeholder-text: root.placeholder-text;
input-type: root.input-type;
enabled: root.enabled;
font-size: root.font-size;
accepted => { root.accepted(); }
edited => { root.edited(); }
}
// The software renderer clips children to rectangular bounds. Mask the
// standard editor's corners before drawing our rounded border on top.
Rectangle {
x: -root.border-radius; y: -root.border-radius;
width: parent.width + 2 * root.border-radius;
height: parent.height + 2 * root.border-radius;
border-width: root.border-radius;
border-radius: 2 * root.border-radius;
border-color: root.surrounding-background;
}
Rectangle {
width: parent.width; height: parent.height;
border-radius: root.border-radius;
border-width: editor.has-focus ? 2px : 1px;
border-color: editor.has-focus ? #3564e3 : root.normal-border-color;
background: transparent;
}
}
+7 -6
View File
@@ -1,3 +1,4 @@
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, ImGuiLineStyle, FloatingBarStyle } from "common.slint";
@@ -27,7 +28,7 @@ component SessionButton inherits Rectangle {
callback clicked; callback clicked;
width: label.preferred-width + 20px; width: label.preferred-width + 20px;
height: 28px; height: 28px;
border-radius: 5px; border-radius: UiRadius.control;
border-width: 1px; border-width: 1px;
border-color: root.danger ? #d92d20 : #c9ced7; border-color: root.danger ? #d92d20 : #c9ced7;
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)
@@ -73,7 +74,7 @@ component SessionButton inherits Rectangle {
} }
if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle { if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle {
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: 3px; border-width: 1px; border-color: root.danger ? white : #2463c7; border-radius: UiRadius.control - 2px; border-width: 1px; border-color: root.danger ? white : #2463c7;
} }
} }
@@ -172,7 +173,7 @@ export component ServerWindow inherits Window {
Rectangle { Rectangle {
width: root.width; height: root.height; width: root.width; height: root.height;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
border-radius: 8px; 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;
text: ServerStrings.heading + (root.controllers.length > 1 ? " · " + root.controllers.length : ""); text: ServerStrings.heading + (root.controllers.length > 1 ? " · " + root.controllers.length : "");
@@ -189,7 +190,7 @@ export component ServerWindow inherits Window {
select := Rectangle { select := Rectangle {
x: 0px; y: 0px; x: 0px; y: 0px;
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: 4px; 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: Rectangle {
x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px; x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px;
@@ -246,7 +247,7 @@ export component ServerWindow inherits Window {
x: root.footer-height > 28px ? parent.width - self.width : disconnect-all.x - self.width - 6px; x: root.footer-height > 28px ? parent.width - self.width : disconnect-all.x - self.width - 6px;
y: 0px; y: 0px;
text: root.confirming-disconnect ? ServerStrings.cancel : ServerStrings.send-file; text: root.confirming-disconnect ? ServerStrings.cancel : ServerStrings.send-file;
enabled: root.confirming-disconnect || !root.sending-file; // Additional files join the queue while the current transfer continues.
clicked => { if root.confirming-disconnect { root.confirming-disconnect = false; } else { root.select-file(); } } clicked => { if root.confirming-disconnect { root.confirming-disconnect = false; } else { root.select-file(); } }
} }
disconnect-all := SessionButton { disconnect-all := SessionButton {
@@ -274,7 +275,7 @@ export component ServerWindow inherits Window {
// Draw outside the scrolling list so its last row can reveal its name. // Draw outside the scrolling list so its last row can reveal its name.
if root.hovered-name != "": Rectangle { if root.hovered-name != "": Rectangle {
x: 10px; y: root.tooltip-y; width: 205px; height: tip.preferred-height + 12px; z: 20; x: 10px; y: root.tooltip-y; width: 205px; height: tip.preferred-height + 12px; z: 20;
background: #24272d; border-radius: 5px; background: #24272d; border-radius: UiRadius.control;
tip := Text { x: 6px; y: 6px; width: parent.width - 12px; text: root.hovered-name; color: white; font-size: 12px; wrap: word-wrap; } tip := Text { x: 6px; y: 6px; width: parent.width - 12px; text: root.hovered-name; color: white; font-size: 12px; wrap: word-wrap; }
} }
} }
@@ -0,0 +1,161 @@
import { UiRadius } from "ui_radius.slint";
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
// Shared visual treatment for the global settings pages.
export component SettingsButton inherits Rectangle {
in property <string> text;
in property <image> icon;
in property <bool> primary: false;
in property <bool> quiet: false;
in property <bool> align-left: false;
in property <bool> enabled: true;
private property <bool> has-icon: root.icon.width > 0;
private property <length> content-padding: root.has-icon ? 8px : 12px;
callback clicked();
width: 88px; height: 32px; border-radius: UiRadius.control;
background: !root.enabled ? #f0f3f7
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white;
border-width: root.quiet || root.primary ? 0px : 1px;
border-color: !root.enabled ? WorkbenchStyle.border
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border;
accessible-role: button; accessible-label: root.text;
accessible-action-default => { if root.enabled { root.clicked(); } }
Rectangle {
x: root.align-left ? root.content-padding : (parent.width - self.width) / 2;
width: root.has-icon
? min(parent.width - 2 * root.content-padding, button-label.preferred-width + 24px)
: parent.width - 2 * root.content-padding;
height: parent.height;
if root.has-icon: Image {
x: 0px; y: (parent.height - self.height) / 2;
width: 18px; height: 18px;
source: root.icon; image-fit: contain;
opacity: root.enabled ? 1 : 0.45;
accessible-role: none;
}
button-label := Text {
x: root.has-icon ? 24px : 0px;
width: parent.width - self.x; height: parent.height;
text: root.text; font-size: 12px; font-weight: root.primary ? 600 : 400;
color: !root.enabled ? #98a2b3 : root.primary ? white : root.quiet ? #65758b : #3c4d63;
horizontal-alignment: root.align-left ? left : center; vertical-alignment: center; overflow: elide;
}
}
touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } }
}
export component SettingsDialog inherits DialogSurface {
in property <string> title;
in property <string> close-label;
in property <bool> show-close-button: true;
in property <int> badge-count: 0;
in property <string> badge-label;
in property <string> header-status;
in property <string> action-text;
in property <bool> action-enabled: true;
out property <length> content-top: 64px;
callback action();
callback close();
background: #f8fafc;
border-color: WorkbenchStyle.separator;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
TouchArea { }
HorizontalLayout {
x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
spacing: 10px;
Text {
text: root.title; font-size: 20px; font-weight: 600;
color: WorkbenchStyle.text; vertical-alignment: center;
}
if root.badge-count > 0: Rectangle {
width: max(24px, count-label.preferred-width + 14px); height: 22px;
border-radius: 11px; background: #e8efff;
accessible-role: text; accessible-label: root.badge-label;
count-label := Text {
text: "" + root.badge-count; color: WorkbenchStyle.accent;
font-size: 11px; font-weight: 600;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
}
Rectangle { horizontal-stretch: 1; }
if root.header-status != "": Text {
text: root.header-status; color: WorkbenchStyle.muted;
font-size: 11px; vertical-alignment: center;
}
if root.action-text != "": SettingsButton {
width: action-label-metrics.preferred-width + 24px; height: 28px;
text: root.action-text; enabled: root.action-enabled;
clicked => { root.action(); }
action-label-metrics := Text { visible: false; text: root.action-text; font-size: 12px; }
}
if root.show-close-button: IconButton {
width: 28px; height: 28px;
icon: FontAwesomeIcons.xmark; icon-size: 14px;
icon-color: WorkbenchStyle.muted;
hover-background: WorkbenchStyle.hover-surface;
pressed-background: WorkbenchStyle.pressed-surface;
accessible-role: button; accessible-label: root.close-label;
accessible-action-default => { root.close(); }
clicked => { root.close(); }
}
}
@children
}
export component SettingsSelect inherits CompactComboBox {
subtle: true;
show-arrow: false;
normal-border-color: WorkbenchStyle.control-border;
hover-border-color: WorkbenchStyle.hover-border;
height: 28px;
}
export component SettingsGroup inherits Rectangle {
in property <string> title;
in property <int> rows: 1;
height: 24px + root.rows * 36px;
Text {
x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title;
font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center;
}
Rectangle {
y: 24px; width: parent.width; height: parent.height - self.y;
background: white; border-radius: UiRadius.surface; border-color: WorkbenchStyle.border; border-width: 1px;
VerticalLayout { padding: 0px; spacing: 0px; @children }
}
}
export component SettingsRow inherits Rectangle {
in property <string> label;
in property <bool> enabled: true;
in property <bool> divider: true;
in property <length> label-reserve: 128px;
height: 36px;
Text {
x: 12px; width: parent.width - root.label-reserve; height: parent.height;
text: root.label; color: root.enabled ? #34445a : #9ba5b3;
font-size: 12px; overflow: elide; vertical-alignment: center;
}
if root.divider: Rectangle {
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec;
}
@children
}
export component SettingsToggleRow inherits SettingsRow {
in-out property <bool> checked;
in property <bool> toggle-on-click: true;
callback clicked();
label-reserve: 64px;
ToggleSwitch {
x: parent.width - self.width - 12px; y: (parent.height - self.height) / 2;
checked <=> root.checked; enabled: root.enabled;
toggle-on-click: root.toggle-on-click;
clicked => { root.clicked(); }
}
}
+52 -66
View File
@@ -1,4 +1,6 @@
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 { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle, FloatingBarStyle } from "common.slint"; import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle, FloatingBarStyle } from "common.slint";
export struct StreamTab { export struct StreamTab {
@@ -23,8 +25,8 @@ export struct NetworkStatsRow {
} }
export global StreamStrings { export global StreamStrings {
in-out property <string> remote-update-title: "Remote device needs an update"; in-out property <string> remote-update-title: "Remote update recommended";
in-out property <string> remote-update: "Please update to the latest version."; 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";
in-out property <string> video-settings: "Video settings"; in-out property <string> video-settings: "Video settings";
in-out property <string> video-settings-unavailable: "The remote device does not support live video settings"; in-out property <string> video-settings-unavailable: "The remote device does not support live video settings";
@@ -97,7 +99,7 @@ component ControlBarButton inherits Rectangle {
background: root.muted ? #eef0f3 : touch.pressed && root.action-enabled ? #0f87fa background: root.muted ? #eef0f3 : touch.pressed && root.action-enabled ? #0f87fa
: touch.has-hover ? #4296fa : touch.has-hover ? #4296fa
: root.selected ? #4296fa : #4296fa66; : root.selected ? #4296fa : #4296fa66;
border-radius: 3px; border-radius: UiRadius.control;
Text { Text {
text: root.icon; text: root.icon;
@@ -158,7 +160,7 @@ component VideoSettingRow inherits VerticalLayout {
height: 27px; height: 27px;
background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff
: option-touch.has-hover ? #eaf3ff : #f5f6f8; : option-touch.has-hover ? #eaf3ff : #f5f6f8;
border-radius: 4px; border-radius: UiRadius.control;
border-width: 1px; border-width: 1px;
border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6; border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6;
Text { text: option; color: root.enabled ? #30343b : #8b8f95; Text { text: option; color: root.enabled ? #30343b : #8b8f95;
@@ -236,6 +238,11 @@ export component StreamWindow inherits Window {
callback reorder-tab(int, length, length); callback reorder-tab(int, length, length);
callback close-tab(string); callback close-tab(string);
callback switch-display(int); callback switch-display(int);
in property <bool> secure-attention-supported: true;
private property <[string]> secure-shortcut-items: ["Ctrl+Alt+Del", "Win+L"];
private property <[string]> ordinary-shortcut-items: ["Win+L"];
private property <[string]> shortcut-items: root.secure-attention-supported ? root.secure-shortcut-items : root.ordinary-shortcut-items;
private property <length> shortcut-menu-height: root.shortcut-items.length * 30px + 8px;
callback send-shortcut(string); callback send-shortcut(string);
callback toggle-mouse-control; callback toggle-mouse-control;
callback toggle-privacy; callback toggle-privacy;
@@ -263,6 +270,10 @@ export component StreamWindow inherits Window {
private property <length> stats-header-height: 18px; private property <length> stats-header-height: 18px;
private property <length> stats-content-height: private property <length> stats-content-height:
root.stats-header-height + root.stats-rows.length * root.stats-row-height + 65px; root.stats-header-height + root.stats-rows.length * root.stats-row-height + 65px;
private property <length> file-panel-x: 18px;
private property <length> file-panel-y: video.height - min(210px, video.height * 0.35);
private property <length> file-panel-width: min(430px, video.width * 0.6);
private property <length> file-panel-height: min(190px, video.height * 0.32);
private property <bool> display-menu-open: false; private property <bool> display-menu-open: false;
private property <bool> display-menu-animation-enabled: false; private property <bool> display-menu-animation-enabled: false;
private property <float> display-menu-progress: 0.0; private property <float> display-menu-progress: 0.0;
@@ -390,11 +401,14 @@ export component StreamWindow inherits Window {
&& pointer-x >= shortcut-menu-x && pointer-x >= shortcut-menu-x
&& pointer-x <= shortcut-menu-x + 180px && pointer-x <= shortcut-menu-x + 180px
&& pointer-y >= shortcut-menu-y && pointer-y >= shortcut-menu-y
&& pointer-y <= shortcut-menu-y + 68px; && pointer-y <= shortcut-menu-y + root.shortcut-menu-height;
let over-video-menu = root.video-menu-open let over-video-menu = root.video-menu-open
&& pointer-x >= root.video-menu-x && pointer-x <= root.video-menu-x + root.video-menu-width && pointer-x >= root.video-menu-x && pointer-x <= root.video-menu-x + root.video-menu-width
&& pointer-y >= root.video-menu-y && pointer-y <= root.video-menu-y + root.video-menu-height; && pointer-y >= root.video-menu-y && pointer-y <= root.video-menu-y + root.video-menu-height;
return over-control || over-display-menu || over-shortcut-menu || over-video-menu; let over-file-panel = root.file-transfer-visible
&& pointer-x >= root.file-panel-x && pointer-x <= root.file-panel-x + root.file-panel-width
&& pointer-y >= root.file-panel-y && pointer-y <= root.file-panel-y + root.file-panel-height;
return over-control || over-display-menu || over-shortcut-menu || over-video-menu || over-file-panel;
} }
begin-control-drag(press-x, press-y) => { begin-control-drag(press-x, press-y) => {
@@ -714,36 +728,8 @@ export component StreamWindow inherits Window {
accept accept
} }
pointer := TouchArea { pointer := TouchArea {
mouse-cursor: !root.remote-cursor-active ? MouseCursor.default mouse-cursor: root.remote-cursor-active
: root.remote-cursor-shape == 1 ? MouseCursor.none ? RemoteCursor.shape(root.remote-cursor-shape) : MouseCursor.default;
: root.remote-cursor-shape == 2 ? MouseCursor.help
: root.remote-cursor-shape == 3 ? MouseCursor.pointer
: root.remote-cursor-shape == 4 ? MouseCursor.progress
: root.remote-cursor-shape == 5 ? MouseCursor.wait
: root.remote-cursor-shape == 6 ? MouseCursor.crosshair
: root.remote-cursor-shape == 7 ? MouseCursor.text
: root.remote-cursor-shape == 8 ? MouseCursor.alias
: root.remote-cursor-shape == 9 ? MouseCursor.copy
: root.remote-cursor-shape == 10 ? MouseCursor.move
: root.remote-cursor-shape == 11 ? MouseCursor.no-drop
: root.remote-cursor-shape == 12 ? MouseCursor.not-allowed
: root.remote-cursor-shape == 13 ? MouseCursor.grab
: root.remote-cursor-shape == 14 ? MouseCursor.grabbing
: root.remote-cursor-shape == 15 ? MouseCursor.col-resize
: root.remote-cursor-shape == 16 ? MouseCursor.row-resize
: root.remote-cursor-shape == 17 ? MouseCursor.n-resize
: root.remote-cursor-shape == 18 ? MouseCursor.e-resize
: root.remote-cursor-shape == 19 ? MouseCursor.s-resize
: root.remote-cursor-shape == 20 ? MouseCursor.w-resize
: root.remote-cursor-shape == 21 ? MouseCursor.ne-resize
: root.remote-cursor-shape == 22 ? MouseCursor.nw-resize
: root.remote-cursor-shape == 23 ? MouseCursor.se-resize
: root.remote-cursor-shape == 24 ? MouseCursor.sw-resize
: root.remote-cursor-shape == 25 ? MouseCursor.ew-resize
: root.remote-cursor-shape == 26 ? MouseCursor.ns-resize
: root.remote-cursor-shape == 27 ? MouseCursor.nesw-resize
: root.remote-cursor-shape == 28 ? MouseCursor.nwse-resize
: MouseCursor.default;
pointer-event(event) => { pointer-event(event) => {
let over-control = self.mouse-x >= control.x let over-control = self.mouse-x >= control.x
&& self.mouse-x <= control.x + control.width && self.mouse-x <= control.x + control.width
@@ -994,7 +980,7 @@ export component StreamWindow inherits Window {
width: parent.width; width: parent.width;
height: root.stats-header-height; height: root.stats-header-height;
background: #f3f5f8; background: #f3f5f8;
border-radius: 4px; border-radius: UiRadius.control;
Text { Text {
x: stats-table.label-column-width + 4px; x: stats-table.label-column-width + 4px;
width: stats-table.value-column-width - 8px; width: stats-table.value-column-width - 8px;
@@ -1031,7 +1017,7 @@ export component StreamWindow inherits Window {
width: parent.width; width: parent.width;
height: stats-table.row-height; height: stats-table.row-height;
background: index == root.stats-rows.length - 1 ? #edf3fc : transparent; background: index == root.stats-rows.length - 1 ? #edf3fc : transparent;
border-radius: 4px; border-radius: UiRadius.control;
Text { Text {
x: 4px; x: 4px;
width: stats-table.label-column-width - 8px; width: stats-table.label-column-width - 8px;
@@ -1160,7 +1146,7 @@ export component StreamWindow inherits Window {
x: root.video-menu-x; y: root.video-menu-y; x: root.video-menu-x; y: root.video-menu-y;
width: root.video-menu-width; height: root.video-menu-height; width: root.video-menu-width; height: root.video-menu-height;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
border-radius: 5px; z: 11; border-radius: UiRadius.menu; z: 11;
TouchArea { TouchArea {
pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } } pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } }
scroll-event(event) => { accept } scroll-event(event) => { accept }
@@ -1210,7 +1196,7 @@ export component StreamWindow inherits Window {
background: white; background: white;
border-width: 1px; border-width: 1px;
border-color: ImGuiLineStyle.border; border-color: ImGuiLineStyle.border;
border-radius: 5px; border-radius: UiRadius.menu;
clip: true; clip: true;
z: 11; z: 11;
@@ -1222,7 +1208,7 @@ export component StreamWindow inherits Window {
: index == display-menu.hovered-index ? #d8e6f8 : index == display-menu.hovered-index ? #d8e6f8
: index == root.selected-display ? #e8eef9 : index == root.selected-display ? #e8eef9
: transparent; : transparent;
border-radius: 3px; border-radius: UiRadius.control;
Text { Text {
x: 8px; x: 8px;
width: parent.width - 34px; width: parent.width - 34px;
@@ -1272,11 +1258,11 @@ export component StreamWindow inherits Window {
} }
if root.shortcut-menu-open: Rectangle { if root.shortcut-menu-open: Rectangle {
x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: 68px; x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: root.shortcut-menu-height;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11; background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.menu; z: 11;
VerticalLayout { VerticalLayout {
padding: 4px; spacing: 1px; padding: 4px; spacing: 1px;
for shortcut in ["Ctrl+Alt+Del", "Win+L"]: Rectangle { for shortcut in root.shortcut-items: Rectangle {
height: 29px; background: shortcut-touch.has-hover ? #e8eef9 : transparent; height: 29px; background: shortcut-touch.has-hover ? #e8eef9 : 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 {
@@ -1307,7 +1293,7 @@ export component StreamWindow inherits Window {
// Anchor to the button row so expanded stats stay beneath the tooltip. // Anchor to the button row so expanded stats stay beneath the tooltip.
y: control.y + root.control-toolbar-height + self.height + 8px <= video.height y: control.y + root.control-toolbar-height + self.height + 8px <= video.height
? control.y + root.control-toolbar-height + 4px : max(4px, control.y - self.height - 4px); ? control.y + root.control-toolbar-height + 4px : max(4px, control.y - self.height - 4px);
background: #30343bf5; border-radius: 5px; z: 12; background: #30343bf5; border-radius: UiRadius.control; z: 12;
control-tip := Text { control-tip := Text {
x: 8px; y: 6px; width: parent.width - 16px; x: 8px; y: 6px; width: parent.width - 16px;
text: root.hovered-control.text; text: root.hovered-control.text;
@@ -1328,10 +1314,10 @@ export component StreamWindow inherits Window {
visible: root.remote-update-visible; visible: root.remote-update-visible;
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
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(520px, parent.width - 48px); width: min(400px, parent.width - 48px);
height: max(208px, remote-update-message.y + remote-update-message.height + 92px); height: remote-update-message.y + remote-update-message.height + 84px;
border-color: #e5e9f0; border-color: #e5e9f0;
border-radius: 14px; border-radius: UiRadius.surface;
drop-shadow-color: #07142b30; drop-shadow-color: #07142b30;
drop-shadow-blur: 32px; drop-shadow-blur: 32px;
drop-shadow-offset-y: 12px; drop-shadow-offset-y: 12px;
@@ -1345,19 +1331,8 @@ export component StreamWindow inherits Window {
} }
scroll-event(event) => { accept } scroll-event(event) => { accept }
} }
Rectangle {
x: 32px; y: 32px; width: 48px; height: 48px;
background: #edf4ff; border-radius: 12px;
Path {
x: 12px; y: 12px; width: 24px; height: 24px;
viewbox-width: 24; viewbox-height: 24;
commands: "M 12 16 L 12 3 M 6.5 8.5 L 12 3 L 17.5 8.5 M 4 15 L 4 20 Q 4 21 5 21 L 19 21 Q 20 21 20 20 L 20 15";
fill: transparent;
stroke: #2463c7; stroke-width: 1.8px;
}
}
remote-update-title := Text { remote-update-title := Text {
x: 100px; y: 34px; width: parent.width - 144px; x: 24px; y: 24px; width: parent.width - 80px;
height: max(28px, self.preferred-height); height: max(28px, self.preferred-height);
text: StreamStrings.remote-update-title; text: StreamStrings.remote-update-title;
color: #1b2433; font-size: 20px; font-weight: 600; color: #1b2433; font-size: 20px; font-weight: 600;
@@ -1366,7 +1341,7 @@ export component StreamWindow inherits Window {
remote-update-message := Text { remote-update-message := Text {
x: remote-update-title.x; x: remote-update-title.x;
y: remote-update-title.y + remote-update-title.height + 8px; y: remote-update-title.y + remote-update-title.height + 8px;
width: remote-update-title.width; width: parent.width - 48px;
height: max(22px, self.preferred-height); height: max(22px, self.preferred-height);
text: StreamStrings.remote-update; text: StreamStrings.remote-update;
color: #667085; font-size: 15px; color: #667085; font-size: 15px;
@@ -1380,9 +1355,9 @@ export component StreamWindow inherits Window {
clicked => { root.dismiss-remote-update(); } clicked => { root.dismiss-remote-update(); }
} }
PrimaryButton { PrimaryButton {
x: parent.width - 136px; y: parent.height - 68px; x: parent.width - 128px; y: parent.height - 60px;
width: 104px; height: 36px; width: 104px; height: 36px;
border-radius: 7px; border-radius: UiRadius.control;
text: StreamStrings.remote-update-dismiss; text: StreamStrings.remote-update-dismiss;
text-size: 14px; text-size: 14px;
clicked => { root.dismiss-remote-update(); } clicked => { root.dismiss-remote-update(); }
@@ -1391,10 +1366,21 @@ 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: Rectangle {
x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32); x: root.file-panel-x; y: root.file-panel-y;
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 6px; z: 7; 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;
// Header/padding and drags leaving the panel belong to local UI.
TouchArea {
pointer-event(event) => {
if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; }
}
scroll-event(event) => { accept }
}
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 { x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px; clicked => { root.close-file-transfer(); } } IconButton {
x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px;
clicked => { root.suppress-next-remote-pointer-release = true; root.close-file-transfer(); }
}
ScrollView { ScrollView {
x: 10px; y: 32px; width: parent.width - 20px; height: parent.height - 42px; x: 10px; y: 32px; width: parent.width - 20px; height: parent.height - 42px;
VerticalLayout { VerticalLayout {
@@ -40,11 +40,13 @@ inline int ApplyMainWindowStrings(
int language_index) { int language_index) {
const int language = const int language =
localization::detail::ClampLanguageIndex(language_index); localization::detail::ClampLanguageIndex(language_index);
window->global<ui::GalleryStrings>().set_language(language);
auto& strings = window->global<ui::UiStrings>(); auto& strings = window->global<ui::UiStrings>();
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]));
messages.set_read(Text(localization::message_read[language])); messages.set_read(Text(localization::message_read[language]));
messages.set_read_all(Text(localization::message_read_all[language]));
messages.set_empty(Text(localization::message_empty[language])); messages.set_empty(Text(localization::message_empty[language]));
messages.set_select_message(Text(localization::message_select[language])); messages.set_select_message(Text(localization::message_select[language]));
messages.set_loading(Text(localization::message_loading[language])); messages.set_loading(Text(localization::message_loading[language]));
@@ -72,6 +74,10 @@ inline int ApplyMainWindowStrings(
strings.set_connect(Text(localization::connect[language])); strings.set_connect(Text(localization::connect[language]));
strings.set_settings(Text(localization::settings[language])); strings.set_settings(Text(localization::settings[language]));
strings.set_about(Text(localization::about[language])); strings.set_about(Text(localization::about[language]));
strings.set_sponsorship_title(Text(localization::sponsorship_title[language]));
strings.set_sponsorship_message(Text(localization::sponsorship_message[language]));
strings.set_sponsorship_dismiss_hint(Text(localization::sponsorship_dismiss_hint[language]));
strings.set_sponsor_via_afdian(Text(localization::sponsor_via_afdian[language]));
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]));
+8
View File
@@ -0,0 +1,8 @@
// Logical pixels: compact controls, popup menus, and larger surfaces.
// Circular indicators, switches, progress bars, and native window chrome
// retain radii derived from their own geometry.
export global UiRadius {
out property <length> control: 6px;
out property <length> menu: 8px;
out property <length> surface: 12px;
}
@@ -0,0 +1,195 @@
import { UiRadius } from "ui_radius.slint";
// Colors and spacing for the connection workbench; shapes are shared app-wide.
export global WorkbenchStyle {
out property <color> canvas: #f2f5f9;
out property <color> surface: #ffffff;
out property <color> border: #c6cfdd;
out property <color> control-border: #b2bfd1;
out property <color> hover-border: #7f97b8;
out property <color> field-surface: #f5f7fb;
out property <color> hover-surface: #eef3fb;
out property <color> pressed-surface: #e4ebf6;
out property <color> separator: #e1e7ef;
out property <color> text: #1c2940;
out property <color> muted: #69788e;
out property <color> accent: #3564e3;
out property <color> success: #18835a;
}
export component WorkbenchButton inherits Rectangle {
in property <string> text;
in property <length> text-size: 13px;
in property <color> text-color: root.primary ? white : WorkbenchStyle.text;
in property <color> normal-border-color: WorkbenchStyle.control-border;
in property <string> icon;
in property <string> tooltip;
in property <bool> tooltip-above: false;
in property <bool> tooltip-suppressed: false;
in property <color> icon-color: root.primary
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
: WorkbenchStyle.muted;
in property <bool> primary: false;
in property <bool> soft: false;
in property <bool> quiet: false;
in property <bool> enabled: true;
out property <bool> has-focus: button-focus.has-focus;
private property <bool> keyboard-focus: false;
private property <bool> show-focus: button-focus.has-focus && root.keyboard-focus;
private property <bool> tooltip-active: root.enabled && root.tooltip != ""
&& !root.tooltip-suppressed && !touch.pressed
&& (touch.has-hover || root.show-focus);
private property <bool> tooltip-visible: false;
changed tooltip-active => { root.tooltip-visible = false; }
callback clicked;
width: 112px;
height: 36px;
border-radius: UiRadius.control;
border-width: root.show-focus ? 2px : root.soft ? 0px : 1px;
border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent)
: root.primary ? WorkbenchStyle.accent
: touch.has-hover ? WorkbenchStyle.hover-border
: root.quiet ? transparent : root.normal-border-color;
background: root.soft
? (root.primary
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #edf2ff)
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.field-surface))
: root.primary
? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent)
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : root.quiet ? transparent : white);
opacity: root.enabled ? 1 : 0.45;
animate background, border-color { duration: 120ms; }
forward-focus: button-focus;
accessible-role: button;
accessible-label: root.tooltip != "" ? root.tooltip : root.text;
accessible-enabled: root.enabled;
accessible-action-default => { if root.enabled { root.clicked(); } }
HorizontalLayout {
padding-left: root.text == "" ? 0px : 10px;
padding-right: root.text == "" ? 0px : 10px;
spacing: 8px;
alignment: center;
if root.icon != "": Text {
text: root.icon;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: root.soft ? 12px : 14px;
color: root.icon-color;
vertical-alignment: center;
accessible-role: none;
}
if root.text != "": Text {
text: root.text;
font-size: root.text-size;
font-weight: root.primary ? 600 : 500;
color: root.text-color;
vertical-alignment: center;
accessible-role: none;
}
}
button-focus := FocusScope {
enabled: root.enabled;
focus-gained(reason) => {
if reason == FocusReason.tab-navigation {
root.keyboard-focus = true;
} else if reason == FocusReason.pointer-click {
root.keyboard-focus = false;
}
}
key-pressed(event) => {
root.keyboard-focus = true;
if event.text == Key.Return || event.text == " " {
root.clicked();
return accept;
}
return reject;
}
touch := TouchArea {
enabled: root.enabled;
mouse-cursor: pointer;
pointer-event(event) => {
if event.kind == PointerEventKind.down {
root.keyboard-focus = false;
}
}
// Keep focus for subsequent keyboard activation without leaving a
// keyboard focus ring behind after a pointer click.
clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); }
}
}
tooltip-timer := Timer {
interval: 450ms;
running: root.tooltip-active && !root.tooltip-visible;
triggered => { root.tooltip-visible = true; }
}
if root.tooltip-active && root.tooltip-visible: Rectangle {
x: min(0px, root.width - self.width);
y: root.tooltip-above ? -self.height - 6px : root.height + 6px;
width: min(260px, tip.preferred-width + 20px);
height: max(28px, tip.preferred-height + 12px);
background: #24324a;
border-radius: UiRadius.control;
z: 20;
tip := Text {
x: 10px; y: 6px;
width: parent.width - 20px;
height: parent.height - 12px;
text: root.tooltip;
font-size: 12px;
color: white;
wrap: word-wrap;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// A raised surface keeps transient action feedback distinct from the workbench.
export component WorkbenchNotice inherits Rectangle {
in property <string> text;
in property <string> icon;
in property <bool> error: false;
in property <length> width-limit: 360px;
private property <color> foreground: root.error ? #b42318 : #146c49;
width: min(root.width-limit, max(280px, message.preferred-width + 64px));
height: 64px;
background: root.error ? #fff1f0 : #e9f6ef;
border-width: 1px;
border-color: root.error ? #f0c4bf : #b9dfc9;
border-radius: UiRadius.surface;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 6px;
accessible-role: AccessibleRole.text;
accessible-label: root.text;
Text {
x: message.x - 28px; width: 18px; height: parent.height;
text: root.icon;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 16px;
color: root.foreground;
horizontal-alignment: center;
vertical-alignment: center;
accessible-role: none;
}
message := Text {
x: (parent.width - self.width + 28px) / 2;
width: min(self.preferred-width, parent.width - 64px);
height: parent.height;
text: root.text;
font-size: 14px;
font-weight: 600;
color: root.foreground;
overflow: elide;
vertical-alignment: center;
accessible-role: none;
}
}
export component WorkbenchCard inherits Rectangle {
background: WorkbenchStyle.surface;
border-width: 1px;
border-color: WorkbenchStyle.border;
border-radius: UiRadius.surface;
}
+22
View File
@@ -6,11 +6,29 @@
#include "spdlog/sinks/rotating_file_sink.h" #include "spdlog/sinks/rotating_file_sink.h"
#include "spdlog/sinks/stdout_color_sinks.h" #include "spdlog/sinks/stdout_color_sinks.h"
#if defined(__OHOS__)
#include <hilog/log.h>
#include "spdlog/sinks/base_sink.h"
#endif
namespace crossdesk { namespace crossdesk {
namespace { namespace {
#if defined(__OHOS__)
class HarmonyLogSink final : public spdlog::sinks::base_sink<std::mutex> {
void sink_it_(const spdlog::details::log_msg& message) override {
spdlog::memory_buf_t formatted;
formatter_->format(message, formatted);
const std::string text(formatted.data(), formatted.size());
const auto level = message.level >= spdlog::level::err ? LOG_ERROR :
message.level >= spdlog::level::warn ? LOG_WARN : LOG_INFO;
OH_LOG_Print(LOG_APP, level, 0, "CrossDeskDesktop", "%{public}s", text.c_str());
}
void flush_() override {}
};
#endif
std::string g_log_dir = "logs"; std::string g_log_dir = "logs";
std::string g_logger_name = LOGGER_NAME; std::string g_logger_name = LOGGER_NAME;
std::once_flag g_logger_once_flag; std::once_flag g_logger_once_flag;
@@ -56,7 +74,11 @@ std::shared_ptr<spdlog::logger> get_logger() {
g_log_dir + "/" + g_logger_name + ".log", 5 * 1024 * 1024, 3); g_log_dir + "/" + g_logger_name + ".log", 5 * 1024 * 1024, 3);
std::vector<spdlog::sink_ptr> sinks; std::vector<spdlog::sink_ptr> sinks;
#if defined(__OHOS__)
sinks.push_back(std::make_shared<HarmonyLogSink>());
#else
sinks.push_back(std::make_shared<spdlog::sinks::stdout_color_sink_mt>()); sinks.push_back(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
#endif
sinks.push_back(file_sink); sinks.push_back(file_sink);
auto logger = std::make_shared<spdlog::logger>(g_logger_name, sinks.begin(), auto logger = std::make_shared<spdlog::logger>(g_logger_name, sinks.begin(),
@@ -0,0 +1,248 @@
#include "platform/instance_activation.h"
#include "platform/single_instance.h"
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <cstddef>
#include <cstdlib>
#include <cstring>
#include <string_view>
#include <thread>
#include <utility>
namespace crossdesk::platform {
namespace {
constexpr std::string_view kRequest = "CrossDesk.Activate.v1";
constexpr std::size_t kMaxActivationToken = 4096;
#if defined(__linux__)
std::string Environment(const char* name) {
const char* value = std::getenv(name);
return value ? value : "";
}
#endif
std::string ActivationDirectory() {
// Match the single-instance guard's user scope, independent of TMPDIR/HOME.
return "/tmp/crossdesk-activation-" + std::to_string(geteuid());
}
bool PrivateDirectory(bool create, std::string& error) {
const auto directory = ActivationDirectory();
if (create && mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST) {
error = "Cannot create activation directory: " + std::string(strerror(errno));
return false;
}
struct stat info{};
if (lstat(directory.c_str(), &info) != 0) {
if (errno != ENOENT)
error = "Cannot inspect activation directory: " + std::string(strerror(errno));
return false;
}
if (!S_ISDIR(info.st_mode) || info.st_uid != geteuid() ||
(info.st_mode & 0777) != 0700) {
error = "Activation directory must be private and owned by this user";
return false;
}
return true;
}
std::string ActivationPath(const std::string& application, std::string& error) {
if (!ValidInstanceApplication(application)) {
error = "Invalid activation application name";
return {};
}
std::string session = "macos";
#if defined(__linux__)
if (!Environment("CROSSDESK_HEADLESS_ACTIVE").empty()) {
error = "Window activation is unavailable in a headless session";
return {};
}
const auto wayland = Environment("WAYLAND_DISPLAY");
auto display = Environment("DISPLAY");
if (!wayland.empty()) {
session = "wayland:" + Environment("XDG_RUNTIME_DIR") + "/" + wayland;
} else if (!display.empty()) {
// :0 and :0.0 address the same server; a screen suffix is not a session.
const auto colon = display.rfind(':');
const auto screen = display.find('.', colon == std::string::npos ? 0 : colon);
if (screen != std::string::npos) display.resize(screen);
if (display.rfind("unix:", 0) == 0) display.erase(0, 4);
session = "x11:" + display;
} else {
error = "No graphical session is available to activate the existing window";
return {};
}
#endif
// A stable bounded name fits both Darwin and Linux sockaddr_un limits.
std::uint64_t hash = 14695981039346656037ull;
for (unsigned char byte : application + '\0' + session) {
hash ^= byte;
hash *= 1099511628211ull;
}
return ActivationDirectory() + "/" + std::to_string(hash) + ".sock";
}
int CreateSocket(std::string& error) {
const int fd = socket(AF_UNIX, SOCK_DGRAM, 0);
if (fd < 0) {
error = "Cannot create activation socket: " + std::string(strerror(errno));
return -1;
}
if (fcntl(fd, F_SETFD, FD_CLOEXEC) < 0 ||
fcntl(fd, F_SETFL, O_NONBLOCK) < 0) {
error = "Cannot configure activation socket: " + std::string(strerror(errno));
close(fd);
return -1;
}
return fd;
}
sockaddr_un Address(const std::string& path) {
sockaddr_un address{};
address.sun_family = AF_UNIX;
// ActivationPath always produces a name shorter than sun_path on both OSes.
std::memcpy(address.sun_path, path.c_str(), path.size() + 1);
return address;
}
socklen_t AddressSize(const std::string& path) {
return static_cast<socklen_t>(offsetof(sockaddr_un, sun_path) + path.size() + 1);
}
} // namespace
InstanceActivationReceiver::InstanceActivationReceiver(std::string application)
: application_(std::move(application)) {}
InstanceActivationReceiver::~InstanceActivationReceiver() {
if (socket_ < 0) return;
struct stat info{};
if (lstat(socket_path_.c_str(), &info) == 0 &&
static_cast<std::uint64_t>(info.st_dev) == socket_device_ &&
static_cast<std::uint64_t>(info.st_ino) == socket_inode_) {
unlink(socket_path_.c_str());
}
close(socket_);
}
bool InstanceActivationReceiver::Start(std::string& error) {
error.clear();
if (socket_ >= 0) return true;
const auto path = ActivationPath(application_, error);
if (path.empty() || !PrivateDirectory(true, error)) return false;
// The caller holds the client lease. Any socket left at this path is from a
// crashed GUI, and may be removed without disrupting another live instance.
struct stat info{};
if (lstat(path.c_str(), &info) == 0) {
if (!S_ISSOCK(info.st_mode) || info.st_uid != geteuid()) {
error = "Activation endpoint is not a socket owned by this user";
return false;
}
if (unlink(path.c_str()) != 0) {
error = "Cannot remove stale activation socket: " + std::string(strerror(errno));
return false;
}
} else if (errno != ENOENT) {
error = "Cannot inspect activation socket: " + std::string(strerror(errno));
return false;
}
const int fd = CreateSocket(error);
if (fd < 0) return false;
const auto address = Address(path);
if (bind(fd, reinterpret_cast<const sockaddr*>(&address), AddressSize(path)) != 0) {
error = "Cannot bind activation socket: " + std::string(strerror(errno));
close(fd);
return false;
}
if (lstat(path.c_str(), &info) != 0) {
error = "Cannot inspect bound activation socket: " + std::string(strerror(errno));
unlink(path.c_str());
close(fd);
return false;
}
socket_ = fd;
socket_path_ = path;
socket_device_ = info.st_dev;
socket_inode_ = info.st_ino;
return true;
}
bool InstanceActivationReceiver::TakeRequest(std::string* activation_token) {
if (activation_token) activation_token->clear();
if (socket_ < 0) return false;
bool requested = false;
// Bound each UI tick's work even if many duplicate launches arrive together.
for (int i = 0; i < 32; ++i) {
char buffer[kRequest.size() + 1 + kMaxActivationToken + 1];
const auto size = recv(socket_, buffer, sizeof(buffer), 0);
if (size < 0) {
if (errno == EINTR) continue;
break;
}
const std::string_view message(buffer, size);
if (message == kRequest) {
requested = true;
} else if (message.size() > kRequest.size() &&
message.size() <= kRequest.size() + 1 + kMaxActivationToken &&
message.substr(0, kRequest.size()) == kRequest &&
message[kRequest.size()] == '\0') {
const auto token = message.substr(kRequest.size() + 1);
if (token.find('\0') == std::string_view::npos) {
requested = true;
if (activation_token) *activation_token = token;
}
}
}
return requested;
}
bool RequestInstanceActivation(std::string& error, const std::string& application) {
error.clear();
const auto path = ActivationPath(application, error);
if (path.empty()) return false;
const int fd = CreateSocket(error);
if (fd < 0) return false;
const auto address = Address(path);
std::string request(kRequest);
#if defined(__linux__)
const auto token = Environment("XDG_ACTIVATION_TOKEN");
if (!token.empty() && token.size() <= kMaxActivationToken) {
request += '\0';
request += token;
}
#endif
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
int last_error = ENOENT;
do {
if (PrivateDirectory(false, error)) {
if (sendto(fd, request.data(), request.size(), 0,
reinterpret_cast<const sockaddr*>(&address), AddressSize(path)) ==
static_cast<ssize_t>(request.size())) {
close(fd);
return true;
}
last_error = errno;
if (last_error != ENOENT && last_error != ECONNREFUSED &&
last_error != EAGAIN && last_error != EINTR) break;
} else if (!error.empty()) {
close(fd);
return false;
}
if (std::chrono::steady_clock::now() >= deadline) break;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
} while (true);
close(fd);
error = "Cannot notify the existing CrossDesk window in this graphical session: " +
std::string(strerror(last_error));
return false;
}
} // namespace crossdesk::platform
@@ -0,0 +1,30 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _HARMONY_TRAY_H_
#define _HARMONY_TRAY_H_
#include <functional>
#include <string>
namespace crossdesk {
// Constructed and invoked on the shared Slint event thread.
class HarmonyTray {
public:
HarmonyTray(std::function<void()> show, std::function<void()> hide,
std::function<void()> settings, std::function<void()> exit,
const std::string& tooltip, int language);
~HarmonyTray();
bool MinimizeToTray();
void RemoveTrayIcon();
static void Dispatch(const std::string& action);
private:
std::function<void()> show_, hide_, settings_, exit_;
bool active_ = false;
};
}
#endif
@@ -0,0 +1,16 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOW_STATE_H_
#define _WINDOW_STATE_H_
namespace slint { class Window; }
namespace crossdesk {
// GUI-thread query of the native window focus delivered by Harmony ArkUI.
bool IsHarmonyWindowActive(const slint::Window& window);
}
#endif
@@ -0,0 +1,19 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _HARMONY_AUTOSTART_H_
#define _HARMONY_AUTOSTART_H_
namespace crossdesk {
enum class HarmonyAutostartStatus { checking, enabled, disabled, unsupported, failed };
// ArkTS publishes system state; the GUI reads it without waiting on a service.
HarmonyAutostartStatus GetHarmonyAutostartStatus();
void SetHarmonyAutostartStatus(HarmonyAutostartStatus status);
void RefreshHarmonyAutostartStatus();
}
#endif
@@ -0,0 +1,15 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SYSTEM_SHORTCUTS_H_
#define _SYSTEM_SHORTCUTS_H_
#include <remote_action.h>
namespace crossdesk {
bool SendHarmonySystemShortcut(ServiceCommandFlag command);
}
#endif

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