Compare 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
239 changed files with 17990 additions and 2726 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
Last updated: October 10, 2026
Last updated: October 15, 2026
[中文](#中文) | [English](#english)
## 中文
更新日期:2026 年 10 月 10 日
更新日期:2026 年 10 月 15 日
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
### 一、适用范围
本政策适用于 CrossDesk 桌面客户端、iOS 与 Android 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
本政策适用于 CrossDesk 桌面客户端、iOS、Android 与鸿蒙移动客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
@@ -50,11 +50,13 @@ Android 中的设备身份及您选择保存的访问密码使用 Android Keysto
iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。
Android 默认信令连接同样进行 TLS 证书校验。
Android 与鸿蒙移动端默认信令连接同样进行 TLS 证书校验。
鸿蒙移动端使用系统 Asset Store 加密保存设备身份及您选择保存的访问密码,明确禁止凭据跨设备同步。设置和连接记录按服务器隔离。远程预览默认关闭,单独确认后从下一次连接起保存一张 640×360 预览;预览保存在应用私有缓存中,不上传,可随时清除,也可能被系统回收。关闭预览或撤回联网授权会清除预览。接收文件保存在应用私有目录,您可在会话中通过系统文件选择器导出。
### 五、移动端权限与授权选择
**隐私授权。** iOS 与 Android 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
**隐私授权。** iOS、Android 与鸿蒙移动端 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。
@@ -64,6 +66,8 @@ Android 客户端同样作为控制端接收远程画面与音频,不采集本
Android 远程会话优先使用系统画中画在切换应用后继续显示画面。画中画不可用时,通过前台服务和连接通知保持您已建立的远程连接;后台暂停本地画面渲染和音频播放,不申请悬浮窗权限。通知权限用于提供返回及断开入口,CPU 唤醒锁用于会话后台期间维持连接,返回前台或会话结束时释放。您可以在应用或连接通知中断开,移除应用任务也会结束会话及保活服务。
鸿蒙手机和平板只作为控制端,不申请本机录屏、输入注入、摄像头或麦克风权限;文件选择和剪贴板操作由您主动触发。已建立会话在切入后台时优先使用系统画中画,提供静音控制;画中画不可用时申请数据传输后台任务并显示系统通知。若系统不允许或停止后台任务且画中画不可用,会话会断开。后台切换释放远端按键;空闲后台不维持信令连接。完成联网授权后,应用在前台检查鸿蒙本机版本,并在连接后检查远端版本;版本请求不携带设备身份或远端版本。
应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。
### 六、查询与删除请求
@@ -86,13 +90,13 @@ Android 远程会话优先使用系统画中画在切换应用后继续显示画
## 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.
### 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.
@@ -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.
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
**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.
@@ -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.
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.
### 6. Access and deletion requests
+105 -258
View File
@@ -7,19 +7,65 @@
<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)
[![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)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[![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?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![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)
[![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>
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS / Android 客户端控制电脑。桌面端新增 **桌面墙(DeskHub)**,可在独立窗口中同时查看多台远程桌面,按分组管理设备,并进行批量操作与群控。项目基于 [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) · [桌面墙](#deskhub) · [会话操作](#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>
@@ -27,301 +73,102 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
### 桌面客户端
主窗口以“本桌面”和“远程桌面”两张卡片展示本机凭据与连接入口,下方是可按分组筛选的近期连接。左上角打开桌面墙,右上角菜单进入设置、消息与关于。
本机凭据、远程连接和近期设备集中在主窗口,左上角进入桌面墙。
<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>
### 桌面墙(DeskHub)
在独立窗口中同时查看多台设备,支持分组、可调整大小的网格、精简模式、全屏与批量操作。点击画面可放大查看,再按需接管操控。
<p align="center">
<img src="docs/images/desktop-deskhub-zh.png" width="960" alt="桌面墙网格:六台演示设备、分组筛选、添加、选择、精简模式和网络状态入口" />
</p>
### Web 客户端
在浏览器中输入远程设备 ID 和密码,无需在控制端安装桌面客户端。
<p align="center">
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端连接首页(2026-09-25 截图)" />
</p>
图片来源与更新方式见 [截图说明](docs/images/README.md)。
## 主要功能
| 能力 | 当前支持 |
| --- | --- |
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS / Android 作为控制端 |
| 实时画面与声音 | H.264 / AV1、硬件编解码、远端声音播放;会话中可调整画质、30 / 60 fps 与画面偏好 |
| 多设备与多显示器 | 近期连接、设备别名与分组筛选、会话标签与远端显示器切换 |
| 桌面墙(DeskHub) | 多桌面实时预览、分组与拖动排序、放大接管、群控、批量连接 / 断开 / 静音 / 文件分发;可配置分辨率、帧率与视频总带宽 |
| 输入与协作 | 键鼠输入、远端光标同步、组合键、文本剪贴板同步、文件传输 |
| 网络与部署 | 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”输入被控电脑的 **9 位 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="deskhub"></a>
## 桌面墙:集中查看与操控多台设备
### 桌面墙(DeskHub)
点击主窗口左上角 **桌面墙**,打开独立窗口;连接窗口仍可用于普通远程连接和全局设置。桌面墙会保存设备成员、分组、排序与窗口布局,重启后需要手动连接设备。
1. **添加设备。** 点击 **添加 → 通过设备 ID 添加**,填写 ID、密码及可选分组,点击“添加并连接”;只有勾选“记住密码”才保存凭据。也可选择 **从近期连接添加**,多选已有记录并导入;导入后用“选择 → 连接”发起连接。
2. **查看与整理。** 从“全部设备”切换到指定分组;拖动网格分隔线统一调整画面大小,拖动设备到另一台设备上交换位置。精简模式隐藏名称和管理工具栏,全屏用于扩大查看区域;翻页保留连接。
3. **放大与操控。** 点击设备画面进入只读放大视图,上方缩略图可切换设备,拖动中间分隔条可调整上下区域。点击 **接管操控** 后才发送键鼠;点击“释放操控”或按 **Ctrl / ⌘ + Shift + Esc** 释放。切换设备或窗口失焦也会释放操控。
4. **批量操作。** 点击 **选择**,勾选设备或“全选”,执行连接、断开,或从“更多”中静音 / 取消静音、分发文件。“全选”覆盖当前分组的全部分页;切换分组会清空选择。
按分组查看多台设备,点击画面放大,再选择接管操控;支持批量连接、静音与文件分发。
<p align="center">
<img src="docs/images/desktop-deskhub-focus-zh.png" width="960" alt="桌面墙放大视图:上方设备缩略图、下方主画面,以及只读预览、开启群控和接管操控入口;画面为演示" />
<img src="docs/images/desktop-deskhub-zh.png" width="960" alt="DeskHub 桌面墙:六台演示设备的远程画面与管理工具栏" />
</p>
**群控:** 在放大视图点击“开启群控”,将当前设备的键鼠操作同步到 **所有已连接设备**,包含其他分组、分页及未显示在墙内的连接;范围不受批量勾选限制。各设备接收相同坐标与按键,屏幕尺寸不同时不会另做比例适配。释放操控会同时结束群控。
桌面墙连接默认静音,需要声音时通过“选择 → 更多 → 取消静音”开启。底部 **网络状态** 可查看所有已连接设备的直连 / 中继、加密状态、收发速率、视频延时、丢包率、分辨率与帧率。需要显示器切换、隐私屏等完整单会话工具时,可在主窗口“近期连接”中点击已连接设备,打开独立会话窗口。
**关闭桌面墙会断开全部远程设备连接,并关闭共用连接的独立会话窗口;最小化不会断开。** 分组、分页和选择范围不会限制关闭时的断开范围。更多操作、预览策略与版本兼容性见 [桌面墙说明](docs/deskhub.md)。
群控会向所有已连接设备发送输入;关闭桌面墙会断开全部远程连接。操作范围与详细用法见 [桌面墙使用指南](docs/USER_GUIDE.md#deskhub)。截图使用演示数据,更多界面及来源见 [使用指南](docs/USER_GUIDE.md) 与 [截图说明](docs/images/README.md)。
<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>
## 客户端设置与更新
## 会话操作与设置
点击主窗口右上角 **☰ → 设置**,顶部切换 **常规 / 桌面墙**,修改后点击 **保存**。切换选项卡会保留未保存的修改,点击“取消”放弃修改。“常规”按界面、视频、网络连接等类别组织选项,可向下滚动查看其余设置。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。普通会话的画质、帧率和画面偏好在远程会话工具栏中调整。
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置编码、中继、自托管与桌面墙画面预算。
<p align="center">
<img src="docs/images/desktop-settings-zh.png" width="720" alt="新版常规设置:常规与桌面墙选项卡、语言、视频编码、硬件编解码及网络连接设置" />
</p>
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
- [桌面墙技术说明](docs/deskhub.md):预览订阅、带宽分配、协议与兼容性。
| 设置 | 说明 |
| --- | --- |
| 语言 | 中文、English、Русский |
| 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 |
| 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 |
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
| 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 |
| 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 |
| 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 |
| 自托管配置 | 填写服务器地址和信令服务端口,并勾选启用 |
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
| 文件保存路径 | 选择接收文件的目录 |
<a id="mobile"></a>
### 桌面墙画面传输
在 **设置 → 桌面墙** 中统一配置墙内设备,保存后立即应用到当前桌面墙会话,并用于后续连接;普通连接不受影响。
| 设置 | 说明 |
| --- | --- |
| 分辨率上限 | 自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160 或原始分辨率;自动模式按网格 / 放大视图调整,固定上限保持比例且不放大源画面 |
| 帧率 | 跟随设备设置,或指定 15 / 30 / 60 FPS |
| 视频总带宽 | 默认 20 Mbps,可选 2 / 5 / 10 / 20 / 50 / 100 Mbps;由支持该能力的桌面墙会话共享,放大画面优先分配,不包含音频、文件传输与普通连接,也不是瞬时流量的硬上限 |
<p align="center">
<img src="docs/images/desktop-settings-deskhub-zh.png" width="720" alt="桌面墙传输设置:自动分辨率、跟随设备帧率和 20 Mbps 视频总带宽" />
</p>
支持预览订阅的设备在不可见时可暂停视频传输,重新可见时恢复;旧版被控端按其能力回退,不保证所有分辨率、帧率或带宽选项都生效。
### 检查更新
客户端启动后会在后台检查版本,发现新版本时自动弹出更新提示,可查看更新说明并前往下载页面。关闭提示后,本次运行中的后台检查不会再次弹窗,主窗口菜单入口仍会显示更新提示;重新启动客户端后,如仍有新版本则再次提示。打开 **☰ → 关于** 可查看版本;有新版本时,点击版本信息下方的 **更新日志** 可再次打开更新提示,也可点击 **检查更新** 手动检查。
<p align="center">
<img src="docs/images/desktop-about-zh.png" width="720" alt="关于窗口与检查更新按钮,版本号和检查结果为演示数据" />
</p>
## 移动端与鸿蒙
<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+ 的真机。
2. 在首页输入对端 ID,点击连接并填写密码;成功连接后可从“最近连接”中重连。
3. 在设置中选择鼠标模式:**相对位置**像触控板一样移动光标,**绝对位置**将触摸位置直接映射到远端画面。
4. 点击悬浮图标展开菜单,可切换显示器、显示键盘、控制声音或发送文件。收到的文件位于应用的 `Documents/Received`,可通过传输状态旁的分享按钮导出。
当前支持接收远端文本到 iOS 剪贴板;界面暂未提供主动发送本机剪贴板的按钮。
当前支持单指点击、双指右键、长按拖动与捏合缩放;缩放后的双指平移用于移动视图。原生端与浏览器端分别维护,使用入口和手势可能不同。
[使用说明](docs/USER_GUIDE.md#ios) · [构建与签名](apps/ios/README.md)
<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>
## Windows 锁屏与登录界面
## 无人值守与无头运行
**CrossDesk Service**(服务名 `CrossDeskService`)提供锁屏、登录、凭据输入及安全桌面的状态上报、`Ctrl+Alt+Del` 发送和键鼠输入转发。
安装版默认注册按需启动的服务,客户端启动时尝试启动它;未启用无人值守时,本机没有 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 服务不可用”时,检查被控电脑的安装和服务状态;普通桌面连接仍可使用,受保护界面的控制能力会受限。
- **Windows:** CrossDesk Service 支持锁屏、登录和安全桌面控制。安装版可额外启用登录前远控,使用独立设备 ID 和密码;普通“开机自启”仅在用户登录后启动客户端。功能限制及启停命令见 [Windows 无人值守指南](docs/USER_GUIDE.md#windows-service)。
- **Linux:** 可复用现有桌面,也可创建独立 Xvfb / Xfce 会话,操作方法见 [Linux 无头运行](docs/HEADLESS.md)。
<a id="self-hosting"></a>
## 自托管服务器
## 自托管
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 常规 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
可自行部署信令与 TURN 中继服务,并在客户端“设置 → 常规 → 自托管配置”中启用。控制端与被控端需要接入同一服务。
- [自托管指南](docs/SELF_HOSTING.md):当前 Compose 部署入口、客户端设置、证书信任与排查。
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server):服务端源码与发布配置。
- [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client):浏览器端源码与自托管说明。
[部署指南](docs/SELF_HOSTING.md) · [客户端配置](docs/USER_GUIDE.md#self-hosting) · [服务端源码](https://github.com/kunkundi/crossdesk-server) · [Web 客户端部署](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 构建、安装与模拟器验证。
- [GUI 架构](docs/gui-architecture.md):桌面 UI、平台实现与工程结构。
- [常见问题](docs/FAQ.md):连接失败、黑屏、文件位置与编译问题。
- [提交问题](https://github.com/kunkundi/crossdesk/issues):请附上两端系统、客户端版本、连接方式和可复现步骤。
## 构建与反馈
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
- [提交问题或建议](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) 社区的推荐、交流与支持。
CrossDesk 使用 [GPL-3.0](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
Copyright (c) 2026 JUNKUN DI.
CrossDesk 使用 [GPL-3.0-only](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
### Code signing policy
+107 -256
View File
@@ -7,19 +7,65 @@
<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)
[![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)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[![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?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![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)
[![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>
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS / Android clients for controlling computers. The desktop client now includes **DeskHub**, a separate window for viewing multiple remote desktops, organizing devices into groups, and performing batch actions or synchronized control. 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) · [DeskHub](#deskhub) · [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>
@@ -27,293 +73,102 @@ CrossDesk connects Windows, macOS, and Linux desktops, with browser and native i
### Desktop client
The main window presents your credentials and connection controls in Local Desktop and Remote Desktop cards, with recent devices and a group filter below. Open DeskHub at the top left, or use the top-right menu for settings, messages, and About.
Local credentials, remote connections, and recent devices share the main window. Open DeskHub from the top-left corner.
<p align="center">
<img src="docs/images/desktop-main-en.png" width="800" alt="Updated main window with DeskHub access, local credentials, remote connection controls, and recent-device group filtering; data is illustrative" />
<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>
### DeskHub
View multiple devices in a separate window with groups, a resizable grid, compact and fullscreen views, and batch actions. Click a preview to enlarge it, then take control when needed.
<p align="center">
<img src="docs/images/desktop-deskhub-en.png" width="960" alt="DeskHub grid with six demonstration devices, group filtering, Add, Select, Compact, and Network controls" />
</p>
### Web client
Enter a remote device ID and password in your browser without installing the desktop app on the controlling device.
<p align="center">
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web Client connection page in Chinese, captured on 2026-09-25" />
</p>
See the [screenshot notes](docs/images/README.md) for image sources and refresh instructions.
## 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 and group filtering, session tabs, and remote display switching |
| DeskHub | Live multi-desktop previews, groups and drag reordering, enlarged views with control, synchronized input, and batch connection / disconnection / audio / file actions; configurable resolution, frame rate, and shared video budget |
| 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 **9-digit ID** in **Remote Desktop → Remote ID**, then click **Connect**.
3. **Verify the password.** Enter the host's current connection password when prompted and confirm. Select **Remember password** if you want to save it.
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, or hover to rename it, edit its groups, remove its record, or connect / disconnect. Use the group filter beside the heading to narrow the list.
The eye button shows or hides the local password, the refresh button generates a new random password, and the pencil button lets you edit it. Passwords use **6 ASCII letters or digits**. Stay connected to the server when editing or refreshing a password. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
### 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="deskhub"></a>
## DeskHub: view and control multiple devices
### DeskHub
Click **DeskHub** at the top left of the main window to open a separate window. The connection window remains available for ordinary remote sessions and global settings. DeskHub saves its device membership, groups, order, and window layout; connections must be started manually after relaunching.
1. **Add devices.** Choose **Add → Add by device ID**, enter an ID, password, and optional group, then click **Add and connect**. Credentials are saved only when you select **Remember password**. Alternatively, choose **Add from recent** and import multiple records; use **Select → Connect** to connect after importing.
2. **Arrange the view.** Switch from **All devices** to a group, drag grid dividers to resize all previews, or drag a device onto another to swap positions. **Compact** hides device names and management controls; fullscreen provides more viewing space. Changing pages keeps connections open.
3. **Enlarge and control.** Click a preview to open a read-only enlarged view. Switch devices with the thumbnails above it, and drag the divider to resize the two areas. Click **Control** to send keyboard and mouse input. Click **Release** or press **Ctrl / ⌘ + Shift + Esc** to release; switching devices or losing window focus also releases control.
4. **Apply batch actions.** Click **Select**, choose devices or **Select all**, and connect, disconnect with **Close**, or use **More** to mute / unmute or send a file. **Select all** includes every page in the current group; switching groups clears the selection.
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">
<img src="docs/images/desktop-deskhub-focus-en.png" width="960" alt="DeskHub enlarged view with thumbnails above the main preview and View only status, Group control, and Control buttons; screens are illustrative" />
<img src="docs/images/desktop-deskhub-en.png" width="960" alt="DeskHub with six demonstration remote desktops and its management toolbar" />
</p>
**Group control:** click **Group control** in the enlarged view to mirror input from the current device to **all connected devices**, including other groups, pages, and connections not displayed in the wall. The scope is independent of the batch selection. Devices receive the same coordinates and keys without additional scaling for different screen sizes. Releasing control also ends group control.
DeskHub connections are muted by default. Enable sound with **Select → More → Unmute**. The bottom **Network** button shows direct / relay mode, encryption, receive/send rates, video latency, packet loss, resolution, and frame rate for all connected devices. For the full single-session toolbar, including display switching and privacy screens, click a connected device under **Recent Connections** in the main window to open its separate session window.
**Closing DeskHub disconnects all remote devices and closes session windows sharing those connections; minimizing it keeps connections open.** The disconnect scope includes all groups and pages, regardless of selection. See the [DeskHub guide](docs/deskhub.md) (Chinese) for further controls, preview behavior, and compatibility details.
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).
<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 → General → 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>
## Client settings and updates
## Session controls and settings
Open **☰ → Settings** in the top-right corner, choose **General / DeskHub**, and click **Save** to apply changes. Switching tabs preserves pending changes; **Cancel** discards them. General settings are grouped into interface, video, networking, and further sections available by scrolling. Configure codecs and connections before connecting; some settings are disabled during an active session. Adjust ordinary session quality, frame rate, and video preference from its 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">
<img src="docs/images/desktop-settings-en.png" width="720" alt="Updated General settings with General and DeskHub tabs, language, video codec, hardware codecs, and networking options" />
</p>
- [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens.
- [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
- [DeskHub technical guide](docs/deskhub.md) (Chinese): preview subscriptions, bandwidth allocation, protocol, and compatibility.
| Setting | Purpose |
| --- | --- |
| 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 |
<a id="mobile"></a>
### DeskHub video transmission
Use **Settings → DeskHub** to configure wall sessions together. Saving applies changes immediately to current DeskHub sessions and future connections; ordinary connections are unaffected.
| Setting | Purpose |
| --- | --- |
| Resolution limit | Auto, 320 × 180, 640 × 360, 1280 × 720, 1920 × 1080, 2560 × 1440, 3840 × 2160, or Original resolution; Auto adapts to grid / enlarged views, while fixed limits preserve aspect ratio without upscaling the source |
| Frame rate | Follow device settings, or choose 15 / 30 / 60 FPS |
| Video budget | Defaults to 20 Mbps; choose 2 / 5 / 10 / 20 / 50 / 100 Mbps, shared by compatible DeskHub sessions with priority for enlarged views. Audio, file transfers, and ordinary connections are excluded; this is not a hard limit on instantaneous traffic |
<p align="center">
<img src="docs/images/desktop-settings-deskhub-en.png" width="720" alt="DeskHub video settings with Auto resolution, Follow device settings frame rate, and a 20 Mbps video budget" />
</p>
Devices supporting preview subscriptions can pause video while their views are hidden and resume when visible. Older hosts fall back according to their capabilities, so not every resolution, frame-rate, or bandwidth setting will apply.
### Check for updates
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>
## Mobile and HarmonyOS
<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.
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.
[User guide](docs/USER_GUIDE_EN.md#ios) · [Build and signing](apps/ios/README.md)
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>
## 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.
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.
- **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).
<a id="self-hosting"></a>
## Self-hosting
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → General → 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.
- [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.
[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)
## Development and feedback
<a id="development"></a>
- [Build from source](docs/BUILD_EN.md): Windows, macOS, Linux, optional flags, and packaging.
- [iOS development](apps/ios/README.md): native build and physical-device checks.
- [Android development](apps/android/README.md): APK builds, installation, and emulator checks.
- [GUI architecture](docs/gui-architecture.md): desktop UI and platform organization.
- [FAQ](docs/FAQ.md#english): connection failures, blank video, 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.
## Build and feedback
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
- [Report an issue or suggestion](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
<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
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
@@ -323,7 +178,3 @@ CrossDesk uses SignPath.io to sign official Windows releases built from this rep
- **Committers and reviewers:** [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 信令服务器;授权后进入前台即自动注册或登录本机身份,首页实时显示连接、重连及失败状态。发起桌面连接时复用该身份;切换服务器会重新登录,撤回授权会关闭连接。已有远程会话优先通过系统画中画保持连接,画中画不可用时由带连接通知的前台服务兜底;首页空闲信令在后台关闭。
- 优先使用 MediaCodec 硬件解码 H.264 / AV1,硬件缓冲区直接提交 Surface;设备无对应硬件、分辨率超限或运行失败时,按原编码格式回退到 OpenH264 / dav1d。软件输出由独立显示线程将 NV12 转 RGBA;只保留最新待显示帧。MiniRTC 原有的通用压缩帧解码队列保持不变。
- 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指捏合以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。
@@ -151,7 +151,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 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 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
@@ -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;
}
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;
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=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);
safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL));
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);
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 sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
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 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 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.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)->{
if(!pictureInPictureUi&&!isInPictureInPictureMode())updatePictureInPicture();
});
@@ -463,7 +473,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private void about(){
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 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(){
@@ -635,11 +650,20 @@ public final class MainActivity extends Activity implements NativeSession.Listen
@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 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 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);}
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
@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.
@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 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();
// Closing native PiP is an explicit end, not a request for invisible
// 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 "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 "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":
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;
@@ -47,7 +47,8 @@ final class NativeSession implements AutoCloseable {
private String password, identity = "";
private final int port;
private long handle;
private boolean signalReady, joining, connected, started;
private boolean signalReady, joining, started;
private volatile boolean connected, supportsTextInput;
private int signalState;
private volatile long signalGeneration;
private boolean presenceRequested;
@@ -189,7 +190,11 @@ final class NativeSession implements AutoCloseable {
"远端密码错误", "找不到该设备 ID", "远端当前不可用"};
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) {
int width = event.getInt("width"), height = event.getInt("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 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); }); }
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) {
AudioPlayer player = audio;
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 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 nText(long handle,byte[] text);
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 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. */
final class RemoteMouseInput {
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 float x = .5f, y = .5f;
private boolean gestureLeft, virtualLeft, virtualRight;
@@ -19,7 +21,8 @@ final class RemoteMouseInput {
}
void drag(float dx, float dy) {
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) {
if (down && hasVirtualButton()) return;
@@ -24,6 +24,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private final Cursor cursor;
private final NativeSession session;
final RemoteMouseInput mouseInput;
Runnable remoteFocusChanged;
private final GestureDetector gestures;
private final ScaleGestureDetector scales;
private final RemoteViewport viewport = new RemoteViewport();
@@ -50,6 +51,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
RemoteVideoView(Context context, NativeSession session) {
super(context); this.session = session;
mouseInput = new RemoteMouseInput(this::sendMouse);
mouseInput.relativeMotion = this::moveRelative;
setBackgroundColor(Color.BLACK);
video = new SurfaceView(context); video.getHolder().addCallback(this);
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() {
@Override public boolean onScaleBegin(ScaleGestureDetector detector) {
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;
viewportPanActive = 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) {
this.x = x; this.y = y;
if((flag==1||flag==3||flag==5)&&remoteFocusChanged!=null)remoteFocusChanged.run();
session.pointer(x,y,flag,wheel);
cursor.invalidate();
}
@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) {
boolean touchpad = relative && e.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE;
float px=e.getX(),py=e.getY();
if (!touchpad && !viewport.contains(px,py)) return false;
if(touchpad){
if(e.getActionMasked()==MotionEvent.ACTION_MOVE){
x=Math.max(0,Math.min(1,x+(px-lastX)/Math.max(1,viewport.width())));
y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,viewport.height())));
moveRelative(px-lastX,py-lastY);
lastX=px;lastY=py;
return true;
}
}else{
x=viewport.normalizedX(px);
@@ -3,11 +3,14 @@ package cn.crossdesk.mobile;
/** Geometry shared by the video Surface, cursor and remote pointer input. */
final class RemoteViewport {
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;
void fit(int width, int height, int videoWidth, int videoHeight) {
if (width <= 0 || height <= 0 || videoWidth <= 0 || videoHeight <= 0) return;
containerHeight = height;
double fitScale = Math.min((double) width / videoWidth, (double) height / videoHeight);
baseWidth = Math.max(1, (int) (videoWidth * fitScale));
baseHeight = Math.max(1, (int) (videoHeight * fitScale));
@@ -54,18 +57,46 @@ final class RemoteViewport {
constrainOffset();
}
void finishGesture() {
if (!isZoomed()) reset();
void panForPointer(float y, float dy) {
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() {
// Keep the original fitted area covered, as on iOS, even with letterboxing.
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);
offsetY = clamp(offsetY, -maximumY, maximumY);
offsetY = clamp(offsetY, minimumY, maximumY);
}
private static float clamp(float value, float minimum, float maximum) {
@@ -6,12 +6,13 @@ import android.view.*;
import android.widget.*;
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")
final class SessionControls extends FrameLayout {
interface Actions {
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();
default boolean textEdit(int deletions,String value){return false;}
void videoSettings(int field,int value);
void sendFile(); void saveFile(); void secureAttention();
}
@@ -20,14 +21,20 @@ final class SessionControls extends FrameLayout {
private final View orb;
private final LinearLayout waiting;
private View shield;
private LinearLayout menu,keyboard;
private LinearLayout menu;
private final RemoteKeyboard keyboard;
private VirtualMouseBar mouseBar;
private FrameLayout mouseHost;
private boolean mouseVisible,pictureInPicture;
private ScrollView keyboardKeys;
private ScrollView menuScroll;
private int keyboardPreferredHeight;
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 transferLabel,saveFileButton;
private String transferStatus="";
@@ -40,10 +47,11 @@ final class SessionControls extends FrameLayout {
private LinearLayout[] trafficRows;
private TextView[] networkDetails;
private View encryptionLock,encryptionOpen;
private boolean muted,relative=true,shift,computerKeyboard,orbPositioned;
private final java.util.Set<Integer> modifiers=new java.util.LinkedHashSet<>();
private boolean muted,relative=true,orbPositioned;
SessionControls(Context context,MobileUi ui,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.
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));
@@ -65,7 +73,7 @@ final class SessionControls extends FrameLayout {
void cancelMouseInput(){if(mouseBar!=null)mouseBar.cancelInput();}
void setPictureInPicture(boolean value){
pictureInPicture=value;
if(value){cancelMouseInput();closeMenu();closeKeyboard();modifiers.clear();}
if(value){cancelMouseInput();closeMenu();closeKeyboard();}
setVisibility(value?GONE:VISIBLE);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
// 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(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;
}
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);
}
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"};
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();
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);
@@ -201,38 +209,11 @@ final class SessionControls extends FrameLayout {
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 int keyboardWidth(int width){return Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,width-ui.dp(16)));}
private int keyboardHeight(int height){return Math.min(keyboardPreferredHeight,Math.max(1,height-ui.dp(16)));}
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();}
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;keyboardKeys=null;}}
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
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();}}
private void positionKeyboard(){positionMouse();}
boolean closeKeyboard(){return keyboard.close();}
void resetKeyboardContext(){keyboard.resetContext();}
void keyboardInsets(int height,boolean visible,int navigation){keyboard.updateInsets(height,visible,navigation);}
private void toggleKeyboard(){keyboard.bringToFront();keyboard.toggle();}
@android.annotation.SuppressLint("ClickableViewAccessibility") // Tap dispatches performClick; dragging is an additional action.
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;
@@ -251,7 +232,7 @@ final class SessionControls extends FrameLayout {
cancelMouseInput();
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)));
if(menu!=null||keyboard!=null)requestLayout();
if(menu!=null||keyboard.isOpen())requestLayout();
positionKeyboard();
positionMouse();
}
@@ -265,9 +246,10 @@ final class SessionControls extends FrameLayout {
// measure again with the final cap, even when it did not change.
p.width=width;p.height=height;resized=true;
}
if(keyboard!=null){
ViewGroup.LayoutParams p=keyboard.getLayoutParams();int width=keyboardWidth(getMeasuredWidth()),height=keyboardHeight(getMeasuredHeight());
if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;}
if(keyboard.isOpen()){
LayoutParams p=(LayoutParams)keyboard.getLayoutParams();
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);
}
@@ -280,7 +262,8 @@ final class SessionControls extends FrameLayout {
positionKeyboard();
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);}
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_
#define _CONTROLLER_PROTOCOL_H_
#include <minirtc.h>
#include <nlohmann/json.hpp>
#include <remote_action.h>
#include "../../../libs/controller/include/mobile_controller_protocol.h"
namespace crossdesk::android_controller {
inline std::string SecureAttentionMessage() {
RemoteAction action{};
action.type = service_command;
action.c.flag = send_sas;
return action.to_json();
using mobile_controller::SecureAttentionMessage;
using mobile_controller::VideoSettingsMessage;
using mobile_controller::NetworkReport;
}
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
+4 -260
View File
@@ -7,265 +7,9 @@
#ifndef _FILE_TRANSFER_SESSION_H_
#define _FILE_TRANSFER_SESSION_H_
#include <file_transfer_format.h>
#include <stream_names.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <mutex>
#include <optional>
#include "../../../libs/controller/include/mobile_file_transfer_session.h"
namespace crossdesk::android_controller {
using mobile_controller::FileTransferSession;
}
// File I/O is bounded to one chunk per pump. Feedback is delivered by MiniRTC
// 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
#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);
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)(
JNIEnv*, jclass, jlong handle, jint type, jint value) {
auto* s = From(handle); if (!s) return;
+3 -15
View File
@@ -7,21 +7,9 @@
#ifndef _VIDEO_TIMING_H_
#define _VIDEO_TIMING_H_
#include <cstdint>
#include "../../../libs/controller/include/mobile_video_timing.h"
namespace crossdesk::android_controller {
inline double VideoLatencyMilliseconds(uint64_t captured_us,
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;
using mobile_controller::VideoLatencyMilliseconds;
}
} // namespace crossdesk::android_controller
#endif
#endif
+28 -2
View File
@@ -26,6 +26,7 @@
#include "config_center.h"
#include "daemon.h"
#include "path_manager.h"
#include "platform/instance_activation.h"
#include "platform/single_instance.h"
#include "rd_log.h"
#include "render.h"
@@ -307,6 +308,25 @@ static crossdesk::platform::InstanceResult AcquireInstance(
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(
int argc, char* argv[],
crossdesk::platform::SingleInstanceGuard& client_instance) {
@@ -314,6 +334,12 @@ static int RunApplication(
auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk");
crossdesk::InitLogger(path_manager->GetLogPath().string());
#ifdef _WIN32
crossdesk::get_logger()->info(
"CrossDesk executable: {}",
std::filesystem::path(GetCurrentExecutablePathW()).u8string());
#endif
if (IsDaemonChild(argc, argv)) {
// child process: run render directly
crossdesk::Render render;
@@ -452,11 +478,11 @@ int main(int argc, char* argv[]) {
if (!IsDaemonChild(argc, argv)) {
const auto result = AcquireInstance(launcher_instance, InstanceRole::kLauncher);
if (result != InstanceResult::kAcquired)
return result == InstanceResult::kAlreadyRunning ? 0 : 1;
return ExitForExistingInstance(result, true);
}
const auto result = AcquireInstance(client_instance, InstanceRole::kClient);
if (result != InstanceResult::kAcquired)
return result == InstanceResult::kAlreadyRunning ? 0 : 1;
return ExitForExistingInstance(result, !IsDaemonChild(argc, argv));
#ifdef __linux__
const int exit_code = crossdesk::RunWithLinuxDisplay(
@@ -52,6 +52,10 @@ int ConfigCenter::Load() {
ini_->Delete(section_, "enable_desktop_quality_optimization");
persist_config_migration |=
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 =
ini_->GetLongValue(section_, "language", static_cast<long>(language_));
@@ -131,6 +135,8 @@ int ConfigCenter::Load() {
portable_service_prompt_suppressed_ =
ini_->GetBoolValue(section_, "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 =
ini_->GetValue(section_, "file_transfer_save_path", nullptr);
@@ -221,6 +227,8 @@ int ConfigCenter::Save() {
save_remote_previews_.load());
ini_->SetBoolValue(section_, "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",
file_transfer_save_path_.c_str());
@@ -395,6 +403,16 @@ int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
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
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; }
@@ -463,6 +481,10 @@ bool ConfigCenter::IsPortableServicePromptSuppressed() const {
return portable_service_prompt_suppressed_;
}
std::string ConfigCenter::GetSponsorshipPromptDismissedVersion() const {
return sponsorship_prompt_dismissed_version_;
}
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;
std::lock_guard<std::mutex> lock(file_path_mutex_);
@@ -1,7 +1,7 @@
/*
* @Author: DI JUNKUN
* @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_
@@ -49,6 +49,7 @@ class ConfigCenter {
int SetPrivacyScreen(bool enable_privacy_screen);
int SetSaveRemotePreviews(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed);
int SetSponsorshipPromptDismissedVersion(const std::string& version);
int SetFileTransferSavePath(const std::string& path);
// read config
@@ -73,6 +74,7 @@ class ConfigCenter {
bool IsEnablePrivacyScreen() const;
bool IsSaveRemotePreviews() const;
bool IsPortableServicePromptSuppressed() const;
std::string GetSponsorshipPromptDismissedVersion() const;
std::string GetFileTransferSavePath() const;
int Load();
@@ -102,6 +104,7 @@ class ConfigCenter {
std::atomic<bool> enable_privacy_screen_{true};
std::atomic<bool> save_remote_previews_{true};
bool portable_service_prompt_suppressed_ = false;
std::string sponsorship_prompt_dismissed_version_;
std::string file_transfer_save_path_ = "";
};
} // namespace crossdesk
@@ -1,7 +1,7 @@
/*
* @Author: DI JUNKUN
* @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_
@@ -45,6 +45,11 @@ class KeyboardCapturer : public DeviceController {
// Backends with an asynchronous capture loop override this so the UI can
// fall back to window events if that loop stops unexpectedly.
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,
uint32_t scan_code = 0,
bool extended = false) = 0;
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@
#define _GUI_APPLICATION_H_
#include <chrono>
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
@@ -49,6 +50,7 @@ private:
void InitializeModules();
void InitializeUi();
void InitializeSystemTray();
void ShowMainWindow(const std::string& activation_token = {});
bool MinimizeMainWindowToTray();
void BindMainCallbacks(bool gallery_window = false);
void BindStreamCallbacks();
@@ -89,9 +91,11 @@ private:
void SyncStreamKeyboardFocus();
void SetStreamKeyboardFocus(bool focused);
void SyncServerWindow();
void SyncFileTransferNotifications();
void CancelFileTransfer(uint32_t file_id);
void SetServerPanelExpanded(bool expanded);
void PositionServerWindows();
#if defined(__linux__) && !defined(__APPLE__)
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
void SyncXWaylandWindowActivation();
#endif
void UpdateLocalization();
@@ -116,7 +120,8 @@ private:
void Cleanup();
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);
std::unique_ptr<SlintUi> ui_;
@@ -127,7 +132,7 @@ private:
uint32_t cursor_state_sequence_ = 0;
std::chrono::steady_clock::time_point next_video_frame_time_{};
SignalConnectionIndicator signal_connection_indicator_;
#if defined(__linux__) && !defined(__APPLE__)
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
bool use_xwayland_gui_ = false;
bool use_x11_custom_titlebar_ = false;
#endif
@@ -1,8 +1,9 @@
/*
* @Author: DI JUNKUN
* @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_
#define _LOCALIZATION_DATA_H_
@@ -206,8 +207,13 @@ struct TranslationRow {
u8"Способ захвата:") \
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
X(remote_update_title, u8"被控端版本过低", "Remote device needs an update", u8"Версия на удалённом устройстве устарела") \
X(remote_update, u8"请升级到最新版本。", "Please update to the latest version.", u8"Обновите её до последней версии.") \
X(remote_update_title, u8"建议升级被控端", "Remote update recommended", \
u8"Рекомендуется обновление") \
X(remote_update, \
u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", \
"Update the remote device to the latest version for improved features and performance.", \
u8"В новой версии улучшены функции и производительность. " \
u8"Рекомендуем обновить CrossDesk на удалённом устройстве.") \
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \
@@ -283,6 +289,14 @@ struct TranslationRow {
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \
X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \
X(about, u8"关于", "About", u8"О программе") \
X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \
X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
"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(new_version, u8"新版本", "New version", u8"Новая версия") \
X(new_version_available, u8"新版本可用", "New Version Available", \
@@ -323,10 +337,17 @@ struct TranslationRow {
u8"Выбрать файл для отправки") \
X(file_transfer_progress, u8"文件传输进度", "File Transfer Progress", \
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(sending, u8"正在传输", "Sending", u8"Передача") \
X(receiving, u8"正在接收", "Receiving", u8"Получение") \
X(completed, u8"已完成", "Completed", u8"Завершено") \
X(failed, u8"失败", "Failed", u8"Ошибка") \
X(cancelling, u8"正在取消", "Cancelling", u8"Отмена…") \
X(cancelled, u8"已取消", "Cancelled", u8"Отменено") \
X(controller, u8"控制端:", "Controller:", u8"Контроллер:") \
X(server_controlled, u8"被控中", "Controlled", u8"Под управлением") \
X(server_heading, u8"此设备被控中", "This device is controlled", u8"Устройство под управлением") \
@@ -30,7 +30,7 @@ namespace {
constexpr uint64_t kCaptureResumeKeyFrameGapMs = 500;
constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1);
#ifdef _WIN32
#if defined(_WIN32) || defined(__OHOS__)
constexpr auto kKeyboardCaptureRetryDelay = std::chrono::seconds(1);
#endif
@@ -419,7 +419,7 @@ int SessionDeviceManager::StartMouseController() {
return -1;
}
#if defined(__linux__) && !defined(__APPLE__)
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
if (IsWaylandSession()) {
if (!screen_capturer_) {
return 1;
@@ -467,7 +467,7 @@ int SessionDeviceManager::StopMouseController() {
int SessionDeviceManager::StartKeyboardCapturer() {
captured_keyboard_inputs_.Clear();
owner_.keyboard_capturer_uses_window_events_ = false;
#ifdef _WIN32
#if defined(_WIN32) || defined(__OHOS__)
next_keyboard_capture_retry_ =
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
#endif
@@ -479,7 +479,7 @@ int SessionDeviceManager::StartKeyboardCapturer() {
}
#endif
#if defined(__linux__) && !defined(__APPLE__)
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
if (IsWaylandSession()) {
owner_.keyboard_capturer_uses_window_events_ = true;
LOG_INFO("Start keyboard capturer with Slint Wayland backend");
@@ -546,7 +546,7 @@ int SessionDeviceManager::InitializeAudioOutput() {
};
if (!open_device()) {
#if defined(__linux__) && !defined(__APPLE__)
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
LOG_WARN("Failed to open output stream with driver [{}]: {}",
getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER")
: "(default)",
@@ -629,7 +629,7 @@ void SessionDeviceManager::UpdateInteractions() {
}
}
#endif
#if defined(__linux__) && !defined(__APPLE__)
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
const bool is_wayland_session = IsWaylandSession();
const bool stop_wayland_mouse_before_screen =
is_wayland_session && !owner_.start_screen_capturer_ &&
@@ -664,24 +664,27 @@ void SessionDeviceManager::UpdateInteractions() {
}
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
#ifdef _WIN32
#if defined(_WIN32) || defined(__OHOS__)
const int active_display = screen_capturer_->GetCurrentMonitorIndex();
const auto displays = display_info_list();
if (active_display >= 0 &&
active_display < static_cast<int>(displays.size())) {
owner_.selected_display_ = active_display;
#ifdef _WIN32
if (displays[active_display].width <= 0 ||
displays[active_display].height <= 0)
ReleaseRemoteMouseButtons();
#endif
}
#endif
}
if (owner_.start_keyboard_capturer_ && owner_.focus_on_stream_window_ &&
!owner_.privacy_.Snapshot().input_blocked) {
#ifdef _WIN32
#if defined(_WIN32) || defined(__OHOS__)
if (owner_.keyboard_capturer_is_started_ &&
owner_.keyboard_capturer_uses_window_events_ && keyboard_capturer_ &&
keyboard_capturer_->ShouldRetryHook() &&
std::chrono::steady_clock::now() >= next_keyboard_capture_retry_) {
// A transient installation failure or stalled hook must not leave
// system shortcuts in the window-event fallback until focus changes.
@@ -699,7 +702,7 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.keyboard_.ForceReleasePressedKeys();
owner_.keyboard_capturer_uses_window_events_ = true;
owner_.keyboard_capturer_is_started_ = true;
#ifdef _WIN32
#if defined(_WIN32) || defined(__OHOS__)
next_keyboard_capture_retry_ =
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
#endif
@@ -730,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,
uint32_t scan_code,
bool extended) {
@@ -73,6 +73,8 @@ public:
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 extended);
bool SendMouseCommand(const RemoteAction &action, int selected_display);
@@ -111,8 +113,10 @@ private:
std::vector<DisplayInfo> display_info_list_;
size_t registered_display_stream_count_ = 0;
CapturedKeyboardQueue captured_keyboard_inputs_;
#ifdef _WIN32
#if defined(_WIN32) || defined(__OHOS__)
std::chrono::steady_clock::time_point next_keyboard_capture_retry_{};
#endif
#ifdef _WIN32
std::mutex service_mouse_mutex_;
unsigned service_mouse_buttons_ = 0;
int service_mouse_x_ = 0, service_mouse_y_ = 0;
@@ -1,370 +1,400 @@
#include "features/file_transfer/file_transfer_manager.h"
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <limits>
#include <memory>
#include <mutex>
#include <shared_mutex>
#include <string>
#include <thread>
#include <utility>
#include "file_transfer.h"
#include "filesystem_utf8.h"
#include "rd_log.h"
#include "runtime/gui_runtime.h"
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::lock_guard lock(workers_mutex_);
stopping_ = true;
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();
}
FileTransferManager::FileTransferState &FileTransferManager::global_state() {
return global_state_;
}
FileTransferManager::FileTransferState &FileTransferManager::state_for(
const std::shared_ptr<RemoteSession> &props) {
FileTransferManager::FileTransferState& FileTransferManager::global_state() { return global_state_; }
FileTransferManager::FileTransferState& FileTransferManager::received_state() { return received_state_; }
FileTransferManager::FileTransferState& FileTransferManager::state_for(const std::shared_ptr<RemoteSession>& props) {
return props ? props->file_transfer_ : global_state_;
}
void FileTransferManager::ProcessSelectedFile(
const std::string &path,
const std::shared_ptr<RemoteSession> &props,
const std::string &file_label, const std::string &remote_id) {
if (path.empty()) {
return;
}
FileTransferState &state = state_for(props);
LOG_INFO("Selected file: {}", path.c_str());
const std::filesystem::path file_path = PathFromUtf8(path);
bool FileTransferManager::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) {
if (path.empty() || stopping_ || (props && props->closing_)) return false;
const auto file_path = PathFromUtf8(path);
std::error_code ec;
if (!std::filesystem::is_regular_file(file_path, ec)) {
LOG_ERROR("Selected path is not a regular file: {}", path);
return;
}
const uint64_t file_size = std::filesystem::file_size(file_path, ec);
if (ec) {
LOG_ERROR("Failed to get file size: {}", ec.message());
return;
}
if (!std::filesystem::is_regular_file(file_path, ec)) return false;
const auto size = std::filesystem::file_size(file_path, ec);
if (ec) return false;
auto& state = state_for(props);
// Allocate once at enqueue time so queued and active notifications keep the
// same identity, including repeated sends of the same path.
const auto id = file_id ? file_id : file_transfer::Session::NextFileId();
{
std::lock_guard<std::mutex> lock(state.file_transfer_list_mutex_);
std::lock_guard lock(state.file_transfer_list_mutex_);
FileTransferState::FileTransferInfo info;
const auto utf8_name = file_path.filename().u8string();
info.file_name.assign(reinterpret_cast<const char *>(utf8_name.data()),
utf8_name.size());
info.file_path = file_path;
info.file_size = file_size;
const auto name = file_path.filename().u8string();
info.file_name.assign(name.begin(), name.end());
info.remote_id = props ? props->remote_id_ : remote_id;
info.file_path = file_path; info.file_size = size;
info.file_id = id;
info.status = FileTransferState::FileTransferStatus::Queued;
state.file_transfer_list_.push_back(std::move(info));
}
state.file_transfer_window_visible_ = true;
auto enqueue = [&]() {
size_t queue_size = 0;
{
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();
{
std::lock_guard lock(state.file_queue_mutex_);
state.file_send_queue_.push({file_path, file_label, props ? props->remote_id_ : remote_id, id});
}
ProcessQueue(props);
return true;
}
void FileTransferManager::Start(
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);
void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
std::lock_guard workers_lock(workers_mutex_);
if (stopping_) return;
// Reap finished senders without blocking the UI.
auto& state = state_for(props);
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) {
return worker.wait_for(std::chrono::seconds(0)) == std::future_status::ready;
});
workers_.push_back(std::async(std::launch::async,
[this, peer, peer_events, file_path, file_label, props_weak, remote_id,
is_global]() {
auto props_locked = props_weak.lock();
FileTransferState *state = nullptr;
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;
workers_.push_back(std::async(std::launch::async, [this, props, file] {
auto& state = state_for(props);
const auto id = file.file_id;
PeerPtr* peer;
std::shared_ptr<PeerEventHandler> events;
{
std::lock_guard<std::mutex> lock(state->file_transfer_mutex_);
state->file_send_start_time_ = std::chrono::steady_clock::now();
state->file_send_last_update_time_ = state->file_send_start_time_;
state->file_send_last_bytes_ = 0;
std::shared_lock lock(owner_.remote_sessions_mutex_);
peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_;
}
FileSender sender;
const uint32_t file_id = FileSender::NextFileId();
if (props_locked) {
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();
auto session = std::make_shared<file_transfer::Session>(
[peer, events, file, props](const char*, const char* data, size_t size) {
if (!peer || !events || (props && props->closing_)) return -1;
auto callback = events->EnterCallback();
if (!callback) return -1;
if (remote_id.empty()) {
return SendReliableDataFrame(peer, buffer, size,
file_label.c_str());
}
return SendReliableDataFrameToPeer(
peer, buffer, size, file_label.c_str(), remote_id.c_str(),
remote_id.size());
},
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);
return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(),
file.remote_id.c_str(), file.remote_id.size());
}, file_transfer::Session::Progress{});
state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0;
state.current_file_id_ = id;
const auto started_at = std::chrono::steady_clock::now();
auto next_rate_update = started_at + std::chrono::seconds(1);
{
std::lock_guard<std::mutex> lock(state->file_transfer_list_mutex_);
for (auto &info : state->file_transfer_list_) {
if (info.file_id == file_id) {
info.status = FileTransferState::FileTransferStatus::Failed;
std::lock_guard lock(state.file_transfer_mutex_);
state.file_send_start_time_ = state.file_send_last_update_time_ = started_at;
state.file_send_last_bytes_ = 0;
}
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;
}
}
}
LOG_ERROR("FileSender::SendFile failed for [{}], ret={}",
file_path.string().c_str(), ret);
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;
const auto name = file.file_path.filename().u8string();
bool started = false;
{
std::lock_guard<std::mutex> lock(state->file_transfer_mutex_);
last_bytes = state->file_send_last_bytes_;
last_time = state->file_send_last_update_time_;
}
if (state->file_sending_.load() && ack.acked_offset >= last_bytes) {
const auto delta_bytes = ack.acked_offset - last_bytes;
const double seconds =
std::chrono::duration<double>(now - last_time).count();
if (seconds > 0.0 && delta_bytes > 0) {
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)())));
std::lock_guard lock(sessions_mutex_);
const auto closed = disconnected_.find(events.get());
const bool disconnected = closed != disconnected_.end() &&
(closed->second.count("") || closed->second.count(file.remote_id));
if (!cancelled && !stopping_ && peer && events && events->IsActive() && !disconnected &&
(!props || !props->closing_) && !file.remote_id.empty()) {
started = session->Start(file.file_path, std::string(name.begin(), name.end()),
file_transfer::Session::Clock::now(), id);
if (started) senders_.emplace(id, Sender{session, events, file.remote_id});
}
}
rate_bps =
estimated_rate > 0 && current_rate > 0
? static_cast<uint32_t>(current_rate * 0.7 + estimated_rate * 0.3)
: estimated_rate > 0 ? estimated_rate
: current_rate;
}
state->file_send_rate_bps_ = rate_bps;
state->file_send_last_bytes_ = ack.acked_offset;
state->file_send_last_update_time_ = std::chrono::steady_clock::now();
{
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;
uint64_t previous = UINT64_MAX;
auto progress = file_transfer::SendState{id, 0, 0,
cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed};
if (started) {
do {
{
std::lock_guard lock(state.file_transfer_list_mutex_);
for (const auto& info : state.file_transfer_list_) if (info.file_id == id) {
cancelled = info.status == FileTransferState::FileTransferStatus::Cancelling;
break;
}
}
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;
}
}
}
if ((ack.flags & 0x01) == 0) {
return;
}
LOG_INFO("File transfer completed: file_id={}, bytes={}", ack.file_id,
ack.total_size);
state->file_transfer_window_visible_ = true;
state->file_sending_ = false;
Unregister(ack.file_id, props != nullptr);
ProcessQueue(props);
if (progress.status != file_transfer::Status::Completed) {
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;
} else state.file_transfer_window_visible_ = true;
state.file_sending_ = false;
// Only the worker advances the queue, after all sends for this file drained.
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_
#define CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <future>
#include <mutex>
#include <vector>
#include <memory>
#include <shared_mutex>
#include <mutex>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
#include "file_transfer.h"
#include "runtime/remote_session.h"
namespace crossdesk {
class GuiRuntime;
class PeerEventHandler;
class FileTransferManager {
public:
public:
using FileTransferState = gui_detail::FileTransferState;
using RemoteSession = gui_detail::RemoteSession;
explicit FileTransferManager(GuiRuntime &owner);
explicit FileTransferManager(GuiRuntime& owner);
~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();
FileTransferState &
state_for(const std::shared_ptr<RemoteSession> &props);
void
ProcessSelectedFile(const std::string &path,
const std::shared_ptr<RemoteSession> &props,
const std::string &file_label,
const std::string &remote_id = "");
void HandleAck(const char *data, size_t size);
private:
void Start(std::shared_ptr<RemoteSession> props,
const std::filesystem::path &file_path,
const std::string &file_label, const std::string &remote_id = "");
private:
struct Sender {
std::shared_ptr<file_transfer::Session> session;
std::shared_ptr<PeerEventHandler> handler;
std::string remote;
};
struct Receiver {
std::shared_ptr<FileReceiver> session;
std::shared_ptr<PeerEventHandler> handler;
std::filesystem::path default_directory;
};
void ProcessQueue(std::shared_ptr<RemoteSession> props);
void Unregister(uint32_t file_id, bool per_peer);
std::mutex workers_mutex_;
void UpdateProgress(FileTransferState& state,
const file_transfer::SendState& progress,
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_;
bool stopping_ = false;
GuiRuntime &owner_;
std::atomic<bool> stopping_{false};
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_;
std::unordered_map<uint32_t, std::weak_ptr<RemoteSession>>
file_id_to_props_;
std::shared_mutex file_id_to_props_mutex_;
std::unordered_map<uint32_t, FileTransferState *> file_id_to_state_;
std::shared_mutex file_id_to_state_mutex_;
// Keep received files separate from outgoing queues.
FileTransferState received_state_;
// Guarded by received_state_.file_transfer_list_mutex_.
std::unordered_map<uint32_t, ReceiveRateSample> receive_rate_samples_;
};
} // 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
@@ -268,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,
const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_);
@@ -27,6 +27,7 @@ public:
void SendHeartbeat(bool force);
void ApplyRemoteEvent(const std::string &remote_id,
const RemoteAction &remote_action);
void ApplyRemoteText(const std::string& remote_id, const std::string& text);
void ApplyRemoteState(const std::string &remote_id,
const RemoteAction &remote_action);
void ReleaseRemotePressedKeys(const std::string &remote_id,
@@ -16,7 +16,7 @@
#if defined(__APPLE__)
#include "platform/macos/gui/metal_video_renderer.h"
#else
#elif !defined(__OHOS__)
#if defined(_WIN32)
#include <windows.h>
#endif
@@ -28,10 +28,10 @@
namespace crossdesk {
namespace {
#if !defined(__APPLE__)
#if !defined(__APPLE__) && !defined(__OHOS__)
constexpr GLint kGlClampToEdge = 0x812F;
constexpr float kStreamWindowCornerRadius = 12.0f;
#else
#elif defined(__APPLE__)
constexpr int kMetalAttachmentAttemptLimit = 30;
#endif
@@ -78,7 +78,7 @@ struct SlintVideoPresenter::Impl {
#if defined(__APPLE__)
int attachment_attempts = 0;
bool skip_attachment_once = false;
#else
#elif !defined(__OHOS__)
std::mutex gl_mutex;
std::vector<uint8_t> gl_conversion_frame;
std::vector<uint8_t> gl_pending_frame;
@@ -109,7 +109,7 @@ struct SlintVideoPresenter::Impl {
return true;
}
#if !defined(__APPLE__)
#if !defined(__APPLE__) && !defined(__OHOS__)
void ConfigureOpenGlNotifier() {
if (!stream) {
return;
@@ -238,7 +238,7 @@ void SlintVideoPresenter::PrepareWindow(
stream->set_native_video_enabled(false);
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
impl_->skip_attachment_once = true;
#else
#elif !defined(__OHOS__)
impl_->ConfigureOpenGlNotifier();
#endif
}
@@ -351,7 +351,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
}
return result;
}
#else
#elif !defined(__OHOS__)
auto* opengl_renderer =
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
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;
{
std::lock_guard lock(impl_->gl_mutex);
@@ -188,6 +188,7 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
void GuiRuntime::HandleServerControllerDisconnected(
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);
#ifdef _WIN32
@@ -215,6 +216,10 @@ void GuiRuntime::HandleServerControllerDisconnected(
remaining_controller_id = id;
}
}
#if _WIN32
windows_rdp_handoff_connected_->store(has_connected_controller,
std::memory_order_relaxed);
#endif
}
if (has_connected_controller) {
remote_client_id_ = remaining_controller_id;
@@ -228,7 +233,7 @@ void GuiRuntime::HandleServerControllerDisconnected(
need_to_create_server_window_.store(false, std::memory_order_release);
need_to_destroy_server_window_.store(true, std::memory_order_release);
is_server_mode_ = false;
#if defined(__linux__) && !defined(__APPLE__)
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
if (IsWaylandSession()) {
// Keep Wayland capture session warm to avoid black screen on subsequent
// reconnects.
@@ -298,7 +303,10 @@ int GuiRuntime::ForceSignalReconnect() {
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (!props || props->closing_.exchange(true)) return;
MuteRemoteAudio(props);
if (props->peer_events_) props->peer_events_->Deactivate();
if (props->peer_events_) {
props->peer_events_->Deactivate();
transfers_.Cancel(props->peer_events_.get());
}
props->connection_established_ = false;
props->streaming_ = false;
props->connection_status_.store(ConnectionStatus::Closed);
@@ -392,6 +400,7 @@ void GuiRuntime::CloseConnectionPeer() {
return;
}
peer_events_->Deactivate();
transfers_.Cancel(peer_events_.get());
announcements_.Reset();
std::vector<std::string> controllers;
{
@@ -439,6 +448,7 @@ void GuiRuntime::CloseAllRemoteSessions() {
}
void GuiRuntime::HandleSessionCleanup() {
transfers_.Poll();
auto completed = [](std::future<void>& task) {
if (!task.valid() || task.wait_for(std::chrono::seconds(0)) !=
std::future_status::ready) return false;
@@ -63,7 +63,7 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
} // namespace crossdesk
#elif defined(__linux__) && !defined(__APPLE__)
#elif (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
#include <X11/Xlib.h>
#include <X11/extensions/Xfixes.h>
+12 -4
View File
@@ -16,7 +16,7 @@
#include "localization.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#ifdef __linux__
#if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h"
#endif
@@ -26,7 +26,15 @@ GuiRuntime::GuiRuntime()
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*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;
@@ -140,7 +148,7 @@ int GuiRuntime::CreateConnectionPeer() {
const char *active_identity =
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
// not be advertised as the current connection password.
HeadlessConsole::Instance().SetIdentity(active_identity);
@@ -174,7 +182,7 @@ int GuiRuntime::CreateConnectionPeer() {
sizeof(params_.log_path) - 1);
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
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;
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
// platforms fall back to a packed CPU copy when native upload is unavailable.
@@ -4,6 +4,7 @@
#include <app_version.h>
#include <remote_action.h>
#include <stream_names.h>
#include <nlohmann/json.hpp>
#include <algorithm>
@@ -24,6 +25,9 @@
#include "platform.h"
#include "rd_log.h"
#include "runtime/gui_runtime.h"
#ifdef __OHOS__
#include "platform/harmony/input/system_shortcuts.h"
#endif
#if _WIN32
#include "desktop_transition_policy.h"
@@ -101,38 +105,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
source_id == runtime->clipboard_label_))
return;
if (source_id == runtime->file_label_) {
std::string remote_user_id = 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);
runtime->transfers_.Receive(data, size, handler, std::string(user_id, user_id_size));
return;
} else if (source_id == runtime->clipboard_label_) {
if (size > 0) {
@@ -148,7 +121,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
return;
} 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;
}
@@ -261,7 +234,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
return;
}
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))
return;
const auto& settings = remote_action.vs;
@@ -270,6 +246,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->peer_, remote_id.data(), remote_id.size(),
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
"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;
const auto message = remote_action.to_json();
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
@@ -300,6 +280,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
return;
}
if (remote_action.type == ControlType::text_input) {
// Share the ordered keyboard stream with delete/return and shortcuts.
if (source_id == runtime->keyboard_label_ &&
runtime->IsAuthorizedController(remote_id))
runtime->keyboard_.ApplyRemoteText(remote_id, remote_action.text);
return;
}
const bool operates_host = remote_action.type == ControlType::mouse ||
remote_action.type == ControlType::keyboard || remote_action.type == ControlType::keyboard_state ||
remote_action.type == ControlType::service_command || remote_action.type == ControlType::display_id ||
@@ -339,6 +326,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
GetLastError());
}
}
#elif defined(__OHOS__)
if (!SendHarmonySystemShortcut(remote_action.c.flag))
LOG_WARN("Harmony system shortcut unsupported or unauthorized, command={}",
static_cast<int>(remote_action.c.flag));
#endif
return;
}
@@ -22,7 +22,7 @@
#include "platform/video_renderer.h"
#include "rd_log.h"
#include "runtime/gui_runtime.h"
#ifdef __linux__
#if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h"
#endif
@@ -38,7 +38,9 @@ namespace {
constexpr HostPlatform kClientPlatform = HostPlatform::Windows;
#elif defined(__APPLE__)
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;
#else
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* user_data) {
@@ -257,6 +261,9 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
if (!runtime) return;
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_lock lock(runtime->remote_sessions_mutex_);
@@ -288,6 +295,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_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;
@@ -429,6 +437,12 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->ApplyPrivacyAdmission(automatic_enable);
}
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();
}
@@ -444,6 +458,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_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;
@@ -553,7 +568,7 @@ void PeerEventHandler::OnNetStatusReport(
LOG_INFO("Use client id [{}] and save id into cache file", id);
runtime->settings_.Save();
}
#ifdef __linux__
#if defined(__linux__) && !defined(__OHOS__)
HeadlessConsole::Instance().SetIdentity(id + "@" + password);
#endif
}
@@ -44,27 +44,30 @@ struct FileTransferState {
std::chrono::steady_clock::time_point file_send_start_time_;
std::chrono::steady_clock::time_point file_send_last_update_time_;
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};
struct QueuedFile {
std::filesystem::path file_path;
std::string file_label;
std::string remote_id;
uint32_t file_id = 0;
};
std::queue<QueuedFile> file_send_queue_;
std::mutex file_queue_mutex_;
enum class FileTransferStatus { Queued, Sending, Completed, Failed };
enum class FileTransferStatus { Queued, Sending, Completed, Failed, Receiving, Cancelling, Cancelled };
struct FileTransferInfo {
std::string file_name;
std::string remote_id;
std::filesystem::path file_path;
uint64_t file_size = 0;
FileTransferStatus status = FileTransferStatus::Queued;
uint64_t sent_bytes = 0;
uint32_t file_id = 0;
uint32_t rate_bps = 0;
bool receiving = false;
};
std::vector<FileTransferInfo> file_transfer_list_;
std::mutex file_transfer_list_mutex_;
@@ -92,6 +92,13 @@ struct PeerState {
struct PlatformIntegrationState {
#if _WIN32
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.
mutable std::mutex windows_input_stage_mutex_;
std::string windows_input_stage_;
+23 -4
View File
@@ -182,6 +182,17 @@ export component CompactComboBox inherits Rectangle {
property <float> popup-progress: 0.0;
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 {
duration: 160ms;
easing: ease-out;
@@ -278,12 +289,15 @@ export component CompactComboBox inherits Rectangle {
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
option-touch := TouchArea {
enabled: root.enabled;
clicked => {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0.0;
root.current-index = index;
root.selected(index);
if root.enabled && index >= 0 && index < root.model.length {
root.current-index = index;
root.selected(index);
}
combo-popup.close();
}
}
@@ -439,6 +453,8 @@ export component SecondaryButton inherits Rectangle {
export component ToggleSwitch inherits Rectangle {
in-out property <bool> checked;
in property <bool> enabled: true;
in property <bool> toggle-on-click: true;
callback clicked();
callback toggled(bool);
width: 36px;
@@ -459,8 +475,11 @@ export component ToggleSwitch inherits Rectangle {
TouchArea {
enabled: root.enabled;
clicked => {
root.checked = !root.checked;
root.toggled(root.checked);
if root.toggle-on-click {
root.checked = !root.checked;
root.toggled(root.checked);
}
root.clicked();
}
}
}
+2 -1
View File
@@ -1,9 +1,10 @@
// Single AOT entry point. Generating one header for the entire UI avoids
// duplicate imported-component definitions when C++ uses all three windows.
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 { 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";
@@ -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(); }
}
}
}
}
@@ -8,10 +8,18 @@ export component GalleryMenuButton inherits Rectangle {
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);
@@ -60,48 +68,89 @@ export component GalleryMenuButton inherits Rectangle {
trigger-focus := FocusScope {
enabled: root.enabled;
key-pressed(event) => { return root.handle-key(event); }
CompactButton {
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 * 30px + 8px;
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 {
x: 4px; y: 4px + index * 30px; width: parent.width - 8px; height: 30px;
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 ? #edf4fc : transparent;
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: 10px; width: parent.width - 20px; height: parent.height;
text: item.label; color: item.enabled ? #30343b : #92979f;
font-size: 12px; vertical-alignment: center;
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,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
+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
+1 -1
View File
@@ -247,7 +247,7 @@ export component ServerWindow inherits Window {
x: root.footer-height > 28px ? parent.width - self.width : disconnect-all.x - self.width - 6px;
y: 0px;
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(); } }
}
disconnect-all := SessionButton {
@@ -5,10 +5,13 @@ 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
@@ -19,11 +22,26 @@ export component SettingsButton inherits Rectangle {
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border;
accessible-role: button; accessible-label: root.text;
accessible-action-default => { if root.enabled { root.clicked(); } }
Text {
x: 12px; width: parent.width - 24px;
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;
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(); } }
}
@@ -31,6 +49,7 @@ export component SettingsButton inherits Rectangle {
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;
@@ -74,7 +93,7 @@ export component SettingsDialog inherits DialogSurface {
clicked => { root.action(); }
action-label-metrics := Text { visible: false; text: root.action-text; font-size: 12px; }
}
IconButton {
if root.show-close-button: IconButton {
width: 28px; height: 28px;
icon: FontAwesomeIcons.xmark; icon-size: 14px;
icon-color: WorkbenchStyle.muted;
@@ -130,9 +149,13 @@ export component SettingsRow inherits Rectangle {
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(); }
}
}
+36 -24
View File
@@ -25,8 +25,8 @@ export struct NetworkStatsRow {
}
export global StreamStrings {
in-out property <string> remote-update-title: "Remote device needs an update";
in-out property <string> remote-update: "Please update to the latest version.";
in-out property <string> remote-update-title: "Remote update recommended";
in-out property <string> remote-update: "Update the remote device to the latest version for improved features and performance.";
in-out property <string> remote-update-dismiss: "Got it";
in-out property <string> video-settings: "Video settings";
in-out property <string> video-settings-unavailable: "The remote device does not support live video settings";
@@ -238,6 +238,11 @@ export component StreamWindow inherits Window {
callback reorder-tab(int, length, length);
callback close-tab(string);
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 toggle-mouse-control;
callback toggle-privacy;
@@ -265,6 +270,10 @@ export component StreamWindow inherits Window {
private property <length> stats-header-height: 18px;
private property <length> stats-content-height:
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-animation-enabled: false;
private property <float> display-menu-progress: 0.0;
@@ -392,11 +401,14 @@ export component StreamWindow inherits Window {
&& pointer-x >= shortcut-menu-x
&& pointer-x <= shortcut-menu-x + 180px
&& 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
&& 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;
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) => {
@@ -1246,11 +1258,11 @@ export component StreamWindow inherits Window {
}
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: UiRadius.menu; z: 11;
VerticalLayout {
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;
Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
shortcut-touch := TouchArea {
@@ -1302,8 +1314,8 @@ export component StreamWindow inherits Window {
visible: root.remote-update-visible;
x: (parent.width - self.width) / 2;
y: min(max(64px, parent.height * 0.18), max(16px, parent.height - self.height - 16px));
width: min(520px, parent.width - 48px);
height: max(208px, remote-update-message.y + remote-update-message.height + 92px);
width: min(400px, parent.width - 48px);
height: remote-update-message.y + remote-update-message.height + 84px;
border-color: #e5e9f0;
border-radius: UiRadius.surface;
drop-shadow-color: #07142b30;
@@ -1319,19 +1331,8 @@ export component StreamWindow inherits Window {
}
scroll-event(event) => { accept }
}
Rectangle {
x: 32px; y: 32px; width: 48px; height: 48px;
background: #edf4ff; border-radius: UiRadius.surface;
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 {
x: 100px; y: 34px; width: parent.width - 144px;
x: 24px; y: 24px; width: parent.width - 80px;
height: max(28px, self.preferred-height);
text: StreamStrings.remote-update-title;
color: #1b2433; font-size: 20px; font-weight: 600;
@@ -1340,7 +1341,7 @@ export component StreamWindow inherits Window {
remote-update-message := Text {
x: remote-update-title.x;
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);
text: StreamStrings.remote-update;
color: #667085; font-size: 15px;
@@ -1354,7 +1355,7 @@ export component StreamWindow inherits Window {
clicked => { root.dismiss-remote-update(); }
}
PrimaryButton {
x: parent.width - 136px; y: parent.height - 68px;
x: parent.width - 128px; y: parent.height - 60px;
width: 104px; height: 36px;
border-radius: UiRadius.control;
text: StreamStrings.remote-update-dismiss;
@@ -1365,10 +1366,21 @@ export component StreamWindow inherits Window {
// Transfer progress overlay at bottom-left.
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;
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; }
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 {
x: 10px; y: 32px; width: parent.width - 20px; height: parent.height - 42px;
VerticalLayout {
@@ -74,6 +74,10 @@ inline int ApplyMainWindowStrings(
strings.set_connect(Text(localization::connect[language]));
strings.set_settings(Text(localization::settings[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_cancel(Text(localization::cancel[language]));
strings.set_new_password(Text(localization::new_password[language]));
@@ -18,23 +18,42 @@ export global WorkbenchStyle {
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 : 1px;
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 : WorkbenchStyle.control-border;
background: root.primary
: 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;
@@ -46,24 +65,24 @@ export component WorkbenchButton inherits Rectangle {
accessible-action-default => { if root.enabled { root.clicked(); } }
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
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: 14px;
color: root.primary ? white : WorkbenchStyle.muted;
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: 13px;
font-size: root.text-size;
font-weight: root.primary ? 600 : 500;
color: root.primary ? white : WorkbenchStyle.text;
color: root.text-color;
vertical-alignment: center;
accessible-role: none;
}
@@ -98,24 +117,76 @@ export component WorkbenchButton inherits Rectangle {
clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); }
}
}
if touch.has-hover && root.tooltip != "": Rectangle {
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.height + 6px;
width: tip.preferred-width + 16px;
height: 28px;
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: 11px;
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;
+22
View File
@@ -6,11 +6,29 @@
#include "spdlog/sinks/rotating_file_sink.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 {
#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_logger_name = LOGGER_NAME;
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);
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>());
#endif
sinks.push_back(file_sink);
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
@@ -0,0 +1,59 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-17
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _INSTANCE_ACTIVATION_H_
#define _INSTANCE_ACTIVATION_H_
#ifdef _WIN32
#include <windows.h>
#else
#include <cstdint>
#endif
#include <string>
namespace crossdesk::platform {
// Created only by the interactive GUI, on its event-loop thread. Requests can
// arrive during startup and remain pending until the UI is ready to show
// itself.
class InstanceActivationReceiver {
public:
explicit InstanceActivationReceiver(std::string application = "CrossDesk");
~InstanceActivationReceiver();
InstanceActivationReceiver(const InstanceActivationReceiver&) = delete;
InstanceActivationReceiver& operator=(const InstanceActivationReceiver&) =
delete;
// The caller must hold this application's client single-instance lease.
bool Start(std::string& error);
bool TakeRequest(std::string* activation_token = nullptr);
private:
std::string application_;
#ifdef _WIN32
static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam,
LPARAM lparam);
HWND window_ = nullptr;
UINT activation_message_ = 0;
bool requested_ = false;
#else
int socket_ = -1;
std::string socket_path_;
std::uint64_t socket_device_ = 0;
std::uint64_t socket_inode_ = 0;
#endif
};
// Targets the current user's GUI on the current desktop, including a daemon
// child. Waits briefly for startup/restart, without waiting on its UI thread.
bool RequestInstanceActivation(std::string& error,
const std::string& application = "CrossDesk");
} // namespace crossdesk::platform
#endif
@@ -0,0 +1,162 @@
#include "wayland_activation.h"
#include <dlfcn.h>
#include <cstdint>
#include <cstring>
namespace crossdesk::platform {
namespace {
// The public libwayland-client wire ABI. Load it at runtime so X11-only builds
// do not acquire a new Wayland build or runtime dependency.
struct Interface;
struct Message {
const char* name;
const char* signature;
const Interface** types;
};
struct Interface {
const char* name;
int version;
int method_count;
const Message* methods;
int event_count;
const Message* events;
};
struct WaylandApi {
void* library = dlopen("libwayland-client.so.0", RTLD_NOW | RTLD_LOCAL);
void* (*create_queue)(void*) = nullptr;
void (*destroy_queue)(void*) = nullptr;
void* (*create_wrapper)(void*) = nullptr;
void (*destroy_wrapper)(void*) = nullptr;
void (*set_queue)(void*, void*) = nullptr;
void (*destroy_proxy)(void*) = nullptr;
void* (*marshal)(void*, uint32_t, const Interface*, uint32_t, uint32_t, ...) = nullptr;
int (*add_listener)(void*, void (**)(void), void*) = nullptr;
int (*roundtrip)(void*, void*) = nullptr;
int (*flush)(void*) = nullptr;
const Interface* registry_interface = nullptr;
const Interface* surface_interface = nullptr;
bool available = false;
template <class T>
bool Load(T& target, const char* name) {
target = reinterpret_cast<T>(dlsym(library, name));
return target != nullptr;
}
WaylandApi() {
available = library &&
Load(create_queue, "wl_display_create_queue") &&
Load(destroy_queue, "wl_event_queue_destroy") &&
Load(create_wrapper, "wl_proxy_create_wrapper") &&
Load(destroy_wrapper, "wl_proxy_wrapper_destroy") &&
Load(set_queue, "wl_proxy_set_queue") &&
Load(destroy_proxy, "wl_proxy_destroy") &&
Load(marshal, "wl_proxy_marshal_flags") &&
Load(add_listener, "wl_proxy_add_listener") &&
Load(roundtrip, "wl_display_roundtrip_queue") &&
Load(flush, "wl_display_flush") &&
Load(registry_interface, "wl_registry_interface") &&
Load(surface_interface, "wl_surface_interface");
}
~WaylandApi() { if (library) dlclose(library); }
};
struct Activation {
WaylandApi& api;
void* queue = nullptr;
void* wrapper = nullptr;
void* registry = nullptr;
void* activation = nullptr;
void* token = nullptr;
std::string token_value;
const Interface* surface_types[1];
const Interface* activate_types[2];
const Message token_methods[5];
const Message token_events[1] = {{"done", "s", nullptr}};
const Interface token_interface;
const Interface* new_token_types[1];
const Message activation_methods[3];
const Interface activation_interface;
explicit Activation(WaylandApi& value)
: api(value), surface_types{api.surface_interface},
activate_types{nullptr, api.surface_interface},
token_methods{{"set_serial", "uo", nullptr}, {"set_app_id", "s", nullptr},
{"set_surface", "o", surface_types}, {"commit", "", nullptr},
{"destroy", "", nullptr}},
token_interface{"xdg_activation_token_v1", 1, 5, token_methods, 1, token_events},
new_token_types{&token_interface},
activation_methods{{"destroy", "", nullptr},
{"get_activation_token", "n", new_token_types},
{"activate", "so", activate_types}},
activation_interface{"xdg_activation_v1", 1, 3, activation_methods, 0, nullptr} {}
~Activation() {
// Destroy protocol objects as well as client-side proxies on every path.
constexpr uint32_t destroy = 1; // WL_MARSHAL_FLAG_DESTROY
if (token) api.marshal(token, 4, nullptr, 1, destroy);
if (activation) api.marshal(activation, 0, nullptr, 1, destroy);
if (registry) api.destroy_proxy(registry);
if (wrapper) api.destroy_wrapper(wrapper);
if (queue) api.destroy_queue(queue);
}
static void Global(void* data, void* registry, uint32_t name,
const char* interface, uint32_t version) {
auto& self = *static_cast<Activation*>(data);
if (self.activation || version < 1 || std::strcmp(interface, "xdg_activation_v1")) return;
self.activation = self.api.marshal(registry, 0, &self.activation_interface, 1, 0,
name, self.activation_interface.name, uint32_t{1}, nullptr);
}
static void GlobalRemove(void*, void*, uint32_t) {}
static void Done(void* data, void*, const char* value) {
static_cast<Activation*>(data)->token_value = value;
}
};
} // namespace
bool ActivateWaylandWindow(void* display, void* surface,
const std::string& activation_token) {
if (!display || !surface) return false;
static WaylandApi api;
if (!api.available) return false;
Activation request(api);
// Use a separate queue and a display wrapper: roundtrips must never dispatch
// Slint/winit's own events from inside its timer callback.
request.queue = api.create_queue(display);
request.wrapper = api.create_wrapper(display);
if (!request.queue || !request.wrapper) return false;
api.set_queue(request.wrapper, request.queue);
request.registry = api.marshal(request.wrapper, 1, api.registry_interface, 1, 0, nullptr);
if (!request.registry) return false;
void (*registry_listener[])(void) = {
reinterpret_cast<void (*)(void)>(&Activation::Global),
reinterpret_cast<void (*)(void)>(&Activation::GlobalRemove)};
if (api.add_listener(request.registry, registry_listener, &request) != 0 ||
api.roundtrip(display, request.queue) < 0 || !request.activation) return false;
request.token_value = activation_token;
if (request.token_value.empty()) {
// Terminal/tray launches may carry no startup token. Ask the compositor
// for one; it remains free to refuse focus without a recent input serial.
request.token = api.marshal(request.activation, 1, &request.token_interface, 1, 0, nullptr);
if (!request.token) return false;
void (*token_listener[])(void) = {reinterpret_cast<void (*)(void)>(&Activation::Done)};
if (api.add_listener(request.token, token_listener, &request) != 0) return false;
api.marshal(request.token, 1, nullptr, 1, 0, "crossdesk");
api.marshal(request.token, 2, nullptr, 1, 0, surface);
api.marshal(request.token, 3, nullptr, 1, 0);
if (api.roundtrip(display, request.queue) < 0 || request.token_value.empty()) return false;
}
api.marshal(request.activation, 2, nullptr, 1, 0, request.token_value.c_str(), surface);
api.flush(display);
return true;
}
} // namespace crossdesk::platform
@@ -0,0 +1,21 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-17
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WAYLAND_ACTIVATION_H_
#define _WAYLAND_ACTIVATION_H_
#include <string>
namespace crossdesk::platform {
// Uses Slint's native handles and xdg-activation-v1 when the compositor offers
// it. A successful request still leaves the focus decision to the compositor.
bool ActivateWaylandWindow(void* display, void* surface,
const std::string& activation_token);
} // namespace crossdesk::platform
#endif
@@ -33,6 +33,7 @@ class MacTray {
void MinimizeToTray();
void RemoveTrayIcon();
bool TakeReopenRequest();
private:
std::unique_ptr<MacTrayImpl> impl_;
@@ -5,6 +5,7 @@
#include <SDL3/SDL.h>
#import <Cocoa/Cocoa.h>
#import <CoreServices/CoreServices.h>
#include "localization.h"
@@ -16,6 +17,8 @@
- (void)showMainWindow:(id)sender;
- (void)openSettings:(id)sender;
- (void)exitApplication:(id)sender;
- (void)reopenApplication:(NSAppleEventDescriptor *)event
withReply:(NSAppleEventDescriptor *)reply;
@end
namespace crossdesk {
@@ -28,6 +31,7 @@ struct MacTrayImpl {
language_index(language_index_value),
target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) {
EnsureStatusItem();
InstallReopenHandler();
}
explicit MacTrayImpl(std::function<void()> show_window_callback,
@@ -43,9 +47,13 @@ struct MacTrayImpl {
language_index(language_index_value),
target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) {
EnsureStatusItem();
InstallReopenHandler();
}
~MacTrayImpl() {
[[NSAppleEventManager sharedAppleEventManager]
removeEventHandlerForEventClass:kCoreEventClass
andEventID:kAEReopenApplication];
RemoveTrayIcon();
target = nil;
}
@@ -156,7 +164,28 @@ struct MacTrayImpl {
[NSApp activateIgnoringOtherApps:YES];
}
void RequestReopen() { reopen_requested = true; }
bool TakeReopenRequest() {
if (!reopen_handler_ready) {
// AppKit may replace handlers even after its did-finish-launching
// notification. The first GUI tick runs after finishLaunching returns.
InstallReopenHandler();
reopen_handler_ready = true;
}
return std::exchange(reopen_requested, false);
}
private:
void InstallReopenHandler() {
// Finder/Dock normally sends a reopen Apple event instead of launching a
// second process. Queue it for the UI tick, just like the IPC notification.
[[NSAppleEventManager sharedAppleEventManager]
setEventHandler:target
andSelector:@selector(reopenApplication:withReply:)
forEventClass:kCoreEventClass
andEventID:kAEReopenApplication];
}
void EnsureStatusItem() {
if (status_item) {
return;
@@ -265,6 +294,8 @@ struct MacTrayImpl {
int language_index = 0;
NSStatusItem *status_item = nil;
CrossDeskMacTrayTarget *target = nil;
bool reopen_requested = false;
bool reopen_handler_ready = false;
};
MacTray::MacTray(::SDL_Window *app_window, const std::string &tooltip,
@@ -285,8 +316,12 @@ MacTray::MacTray(std::function<void()> show_window,
MacTray::~MacTray() = default;
void MacActivateWindow(void *appkit_view) {
// IPC/Finder activation bypasses the tray's ShowWindow(), including when
// MinimizeToTray() previously removed the app from the Dock.
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
NSView *view = (__bridge NSView *)appkit_view;
NSWindow *window = [view window];
[window deminiaturize:nil];
[NSApp activateIgnoringOtherApps:YES];
[window makeKeyAndOrderFront:nil];
}
@@ -295,6 +330,8 @@ void MacTray::MinimizeToTray() { impl_->MinimizeToTray(); }
void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); }
bool MacTray::TakeReopenRequest() { return impl_->TakeReopenRequest(); }
} // namespace crossdesk
@implementation CrossDeskMacTrayTarget {
@@ -317,6 +354,13 @@ void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); }
owner_->ShowMenu();
}
- (void)reopenApplication:(NSAppleEventDescriptor *)event
withReply:(NSAppleEventDescriptor *)reply {
(void)event;
(void)reply;
if (owner_) owner_->RequestReopen();
}
- (void)exitApplication:(id)sender {
(void)sender;
if (owner_) {
@@ -3,6 +3,9 @@
#import <AppKit/AppKit.h>
#include <cmath>
#include <cstdint>
#include <memory>
#include <utility>
@interface CrossDeskStreamFullscreenTarget : NSObject
- (void)toggleStreamFullscreen:(id)sender;
@@ -40,6 +43,24 @@ BOOL stream_restore_zoom_hidden = NO;
NSApplicationPresentationOptions stream_restore_presentation_options =
NSApplicationPresentationDefault;
struct FileDialogRequest {
std::string title;
std::function<void(std::string)> completion;
};
std::shared_ptr<FileDialogRequest> file_dialog_request;
NSOpenPanel *file_dialog = nil;
NSOpenPanel *prepared_file_dialog = nil;
uint64_t file_dialog_prepare_generation = 0;
NSOpenPanel *CreateFileDialog() {
NSOpenPanel *panel = [NSOpenPanel openPanel];
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.allowsMultipleSelection = NO;
panel.resolvesAliases = YES;
return panel;
}
void InstallStreamFullscreenButton(NSWindow *window) {
if (window == nil) {
return;
@@ -358,41 +379,109 @@ bool IsStreamWindowFullscreen() {
return stream_fullscreen;
}
std::string OpenNativeFileDialog(const std::string &title) {
void ShowFileTransferNotification(void* slint_view,
const std::function<void()>& show) {
@autoreleasepool {
NSOpenPanel *panel = [NSOpenPanel openPanel];
panel.canChooseFiles = YES;
panel.canChooseDirectories = NO;
panel.allowsMultipleSelection = NO;
panel.resolvesAliases = YES;
NSWindow *previous_key = NSApp.keyWindow;
NSView *view = (__bridge NSView *)slint_view;
NSWindow *notification = view.window;
notification.excludedFromWindowsMenu = YES;
notification.hidesOnDeactivate = NO;
notification.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorIgnoresCycle;
show();
// Winit's show path calls makeKeyAndOrderFront even for frameless windows.
// Restore the input owner synchronously before the next event-loop turn.
if (notification.isKeyWindow) {
if (previous_key && previous_key != notification) [previous_key makeKeyWindow];
else [notification resignKeyWindow];
}
}
}
void PrepareNativeFileDialog() {
const auto generation = ++file_dialog_prepare_generation;
// Creating NSOpenPanel initializes the system file browser and can take
// hundreds of milliseconds. Do that once after the main window is visible,
// without showing a panel, changing focus, or blocking the initial frame.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC),
dispatch_get_main_queue(), ^{
if (generation != file_dialog_prepare_generation ||
file_dialog_request || prepared_file_dialog) return;
NSOpenPanel *panel = CreateFileDialog();
if (generation == file_dialog_prepare_generation &&
!file_dialog_request && !prepared_file_dialog)
prepared_file_dialog = panel;
});
}
void OpenNativeFileDialog(const std::string& title,
std::function<void(std::string)> completion) {
if (file_dialog_request) {
completion({});
return;
}
const auto request = std::make_shared<FileDialogRequest>(
FileDialogRequest{title, std::move(completion)});
file_dialog_request = request;
// Leave the current Slint callback before AppKit creates or shows the panel.
// runModal would let display-link callbacks reenter Slint's timer dispatch.
dispatch_async(dispatch_get_main_queue(), ^{
if (file_dialog_request != request) return;
NSOpenPanel *panel = prepared_file_dialog ?: CreateFileDialog();
prepared_file_dialog = nil;
file_dialog = panel;
NSString *prompt =
[[NSString alloc] initWithBytes:title.data()
length:title.size()
[[NSString alloc] initWithBytes:request->title.data()
length:request->title.size()
encoding:NSUTF8StringEncoding];
if (prompt != nil && prompt.length > 0) {
panel.title = prompt;
panel.message = prompt;
}
panel.title = prompt ?: @"";
panel.message = prompt ?: @"";
// tinyfiledialogs uses an external osascript process on macOS, so its
// chooser can appear without becoming the active window. Keep the picker
// inside CrossDesk and explicitly activate both the app and panel.
[NSApp activateIgnoringOtherApps:YES];
NSWindow *owner = stream_window ?: NSApp.keyWindow ?: NSApp.mainWindow;
NSWindow *owner = NSApp.keyWindow ?: NSApp.mainWindow;
if (owner != nil) {
[owner makeKeyAndOrderFront:nil];
}
NSScreen *target_screen = owner.screen ?: NSScreen.mainScreen;
CenterWindowOnScreen(panel, target_screen);
[panel makeKeyAndOrderFront:nil];
__weak NSOpenPanel *weak_panel = panel;
[panel beginWithCompletionHandler:^(NSModalResponse result) {
// Cancellation can complete a request before AppKit calls this block.
if (file_dialog_request != request) return;
NSOpenPanel *finished_panel = weak_panel;
const char *selected = result == NSModalResponseOK
? finished_panel.URL.fileSystemRepresentation : nullptr;
const std::string path = selected ? selected : "";
[finished_panel orderOut:nil];
file_dialog = nil;
// AppKit has returned the panel to its configuration phase. Keep the
// browser alive for the next selection instead of rebuilding it.
prepared_file_dialog = finished_panel;
file_dialog_request.reset();
auto finished = std::move(request->completion);
finished(path);
}];
if (file_dialog_request == request) [panel makeKeyAndOrderFront:nil];
});
}
if ([panel runModal] != NSModalResponseOK || panel.URL == nil) {
return {};
}
const char *path = panel.URL.fileSystemRepresentation;
return path != nullptr ? std::string(path) : std::string{};
}
void CancelNativeFileDialog() {
++file_dialog_prepare_generation;
prepared_file_dialog = nil;
const auto request = std::move(file_dialog_request);
if (!request) return;
NSOpenPanel *panel = file_dialog;
file_dialog = nil;
[panel cancel:nil];
[panel orderOut:nil];
auto finished = std::move(request->completion);
finished({});
}
} // namespace crossdesk
@@ -331,4 +331,48 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
return 0;
}
int PlatformKeyboardCapturer::SendTextCommand(const std::string& text) {
if (text.empty() || text.size() > kMaxTextInputBytes) return -1;
CFStringRef string = CFStringCreateWithBytes(
kCFAllocatorDefault, reinterpret_cast<const UInt8*>(text.data()),
text.size(), kCFStringEncodingUTF8, false);
if (!string) return -1;
const CFIndex length = CFStringGetLength(string);
std::vector<UniChar> characters(length);
CFStringGetCharacters(string, CFRangeMake(0, length), characters.data());
CFRelease(string);
for (CFIndex index = 0; index < length;) {
const UniChar character = characters[index];
if (character == '\n' || character == '\r' || character == '\t') {
const int key = character == '\t' ? 0x09 : 0x0D;
SendKeyboardCommand(key, true, 0, false);
SendKeyboardCommand(key, false, 0, false);
++index;
if (character == '\r' && index < length && characters[index] == '\n') ++index;
continue;
}
// Keep surrogate pairs together. Separate events let apps handle long
// dictation results without CGEvent's small inline Unicode buffer limit.
const CFIndex count = CFStringIsSurrogateHighCharacter(character) &&
index + 1 < length ? 2 : 1;
CGEventRef down = CGEventCreateKeyboardEvent(nullptr, 0, true);
CGEventRef up = CGEventCreateKeyboardEvent(nullptr, 0, false);
if (!down || !up) {
if (down) CFRelease(down);
if (up) CFRelease(up);
return -1;
}
for (CGEventRef event : {down, up}) {
CGEventSetFlags(event, 0);
CGEventKeyboardSetUnicodeString(event, count, characters.data() + index);
CGEventSetIntegerValueField(event, kCGEventSourceUserData,
kCrossDeskInjectedEvent);
CGEventPost(kCGHIDEventTap, event);
CFRelease(event);
}
index += count;
}
return 0;
}
} // namespace crossdesk
@@ -1,7 +1,7 @@
/*
* @Author: DI JUNKUN
* @Date: 2024-11-22
* Copyright (c) 2024 by DI JUNKUN, All Rights Reserved.
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _KEYBOARD_CAPTURER_H_
@@ -22,6 +22,8 @@ class PlatformKeyboardCapturer final : public KeyboardCapturer {
public:
virtual int Hook(OnKeyAction on_key_action, void* user_ptr);
virtual int Unhook();
bool SupportsTextInput() const override { return true; }
int SendTextCommand(const std::string& text) override;
virtual int SendKeyboardCommand(int key_code, bool is_down,
uint32_t scan_code = 0,
bool extended = false);
+16 -3
View File
@@ -1,5 +1,6 @@
#pragma once
#include <functional>
#include <string>
namespace crossdesk {
@@ -42,8 +43,20 @@ bool IsStreamWindowActive();
bool SetStreamWindowFullscreen(bool fullscreen);
bool IsStreamWindowFullscreen();
// Opens an application-modal macOS file picker. The panel is made key before
// entering its modal loop so it receives keyboard input immediately.
std::string OpenNativeFileDialog(const std::string &title);
// Shows a desktop transfer notification without replacing the current key
// window, including while the stream is fullscreen.
// |slint_view| must be the notification's existing AppKit NSView*.
void ShowFileTransferNotification(void* slint_view,
const std::function<void()>& show);
// Schedule hidden initialization after the main window's initial display.
void PrepareNativeFileDialog();
// Opens a macOS file picker asynchronously on the main thread, without a
// nested modal loop. Completion receives an empty path on cancel or failure.
void OpenNativeFileDialog(const std::string& title,
std::function<void(std::string)> completion);
// Cancels an open or pending picker, completes it once with an empty path, and
// releases the cached panel and pending initialization during application exit.
void CancelNativeFileDialog();
} // namespace crossdesk
@@ -0,0 +1,161 @@
#include "platform/instance_activation.h"
#include "platform/single_instance.h"
#include <sddl.h>
#include <utility>
#include <vector>
namespace crossdesk::platform {
namespace {
constexpr wchar_t kWindowClass[] = L"CrossDesk.InstanceActivationWindow";
constexpr wchar_t kActivationMessage[] = L"CrossDesk.ActivateMainWindow.v1";
constexpr ULONGLONG kStartupWaitMs = 5000;
std::wstring ActivationWindowName(const std::string& application,
std::string& error) {
if (!ValidInstanceApplication(application)) {
error = "Invalid activation application name";
return {};
}
HANDLE token = nullptr;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) {
error =
"Cannot read activation user token: " + std::to_string(GetLastError());
return {};
}
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<unsigned char> buffer(size);
if (!GetTokenInformation(token, TokenUser, buffer.data(), size, &size)) {
const DWORD code = GetLastError();
CloseHandle(token);
error = "Cannot read activation user SID: " + std::to_string(code);
return {};
}
CloseHandle(token);
wchar_t* sid = nullptr;
if (!ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(buffer.data())->User.Sid, &sid)) {
error =
"Cannot format activation user SID: " + std::to_string(GetLastError());
return {};
}
const std::wstring name =
std::wstring(application.begin(), application.end()) + L".Activation." + sid;
LocalFree(sid);
return name;
}
} // namespace
InstanceActivationReceiver::InstanceActivationReceiver(std::string application)
: application_(std::move(application)) {}
InstanceActivationReceiver::~InstanceActivationReceiver() {
if (window_) DestroyWindow(window_);
}
bool InstanceActivationReceiver::Start(std::string& error) {
error.clear();
if (window_) return true;
const auto name = ActivationWindowName(application_, error);
if (name.empty()) return false;
activation_message_ = RegisterWindowMessageW(kActivationMessage);
if (!activation_message_) {
error =
"Cannot register activation message: " + std::to_string(GetLastError());
return false;
}
WNDCLASSW window_class{};
window_class.lpfnWndProc = WindowProc;
window_class.hInstance = GetModuleHandleW(nullptr);
window_class.lpszClassName = kWindowClass;
if (!RegisterClassW(&window_class) &&
GetLastError() != ERROR_CLASS_ALREADY_EXISTS) {
error =
"Cannot register activation window: " + std::to_string(GetLastError());
return false;
}
window_ =
CreateWindowExW(0, kWindowClass, name.c_str(), 0, 0, 0, 0, 0,
HWND_MESSAGE, nullptr, window_class.hInstance, this);
if (!window_) {
error =
"Cannot create activation window: " + std::to_string(GetLastError());
return false;
}
// An unelevated portable launch may need to activate an elevated installed
// client. Allow only this payload-free notification through UIPI.
if (!ChangeWindowMessageFilterEx(window_, activation_message_, MSGFLT_ALLOW,
nullptr)) {
error =
"Cannot allow activation message: " + std::to_string(GetLastError());
DestroyWindow(window_);
window_ = nullptr;
return false;
}
return true;
}
bool InstanceActivationReceiver::TakeRequest(std::string* activation_token) {
if (activation_token) activation_token->clear();
return std::exchange(requested_, false);
}
LRESULT CALLBACK InstanceActivationReceiver::WindowProc(HWND window,
UINT message,
WPARAM wparam,
LPARAM lparam) {
if (message == WM_NCCREATE) {
const auto* create = reinterpret_cast<CREATESTRUCTW*>(lparam);
SetWindowLongPtrW(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(create->lpCreateParams));
}
auto* receiver = reinterpret_cast<InstanceActivationReceiver*>(
GetWindowLongPtrW(window, GWLP_USERDATA));
if (receiver && message == receiver->activation_message_) {
// Let the regular UI tick restore the window, rather than entering Slint
// from a native message callback (which may run inside a nested event
// loop).
receiver->requested_ = true;
return 0;
}
return DefWindowProcW(window, message, wparam, lparam);
}
bool RequestInstanceActivation(std::string& error, const std::string& application) {
error.clear();
const auto name = ActivationWindowName(application, error);
if (name.empty()) return false;
const UINT message = RegisterWindowMessageW(kActivationMessage);
if (!message) {
error =
"Cannot register activation message: " + std::to_string(GetLastError());
return false;
}
const ULONGLONG deadline = GetTickCount64() + kStartupWaitMs;
DWORD last_error = ERROR_NOT_FOUND;
do {
if (const HWND window =
FindWindowExW(HWND_MESSAGE, nullptr, kWindowClass, name.c_str())) {
DWORD process_id = 0;
if (GetWindowThreadProcessId(window, &process_id) && process_id != 0) {
// Transfer the new launch's foreground permission to the GUI process,
// not its supervisor, before posting the request. Windows may still
// deny foreground focus; the receiver will restore visibility anyway.
AllowSetForegroundWindow(process_id);
if (PostMessageW(window, message, 0, 0)) return true;
last_error = GetLastError();
}
}
if (GetTickCount64() >= deadline) break;
Sleep(50);
} while (true);
error = "Cannot notify the existing CrossDesk window on this desktop: " +
std::to_string(last_error);
return false;
}
} // namespace crossdesk::platform
@@ -18,6 +18,7 @@
#include "interactive_state.h"
#include "service_host.h"
#include "unattended_config.h"
#include "rdp_session_handoff.h"
#endif
namespace crossdesk {
@@ -126,11 +127,36 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
ResetLocalWindowsServiceState(false);
return;
}
windows_rdp_handoff_allowed_->store(
!unattended_host_, std::memory_order_relaxed);
if (!windows_service_worker_) {
windows_service_worker_ = std::make_unique<ServiceQueryWorker>(
[](const std::string& command) {
[allowed = windows_rdp_handoff_allowed_,
connected = windows_rdp_handoff_connected_, next_handoff_tick = ULONGLONG{0},
last_handoff_response = std::string{}]
(const std::string& command) mutable {
ServiceQueryResult result;
if (command == "status") {
const bool eligible = allowed->load(std::memory_order_relaxed) &&
connected->load(std::memory_order_relaxed) &&
IsCurrentProcessSessionDisconnected();
const ULONGLONG now = GetTickCount64();
if (!eligible) {
next_handoff_tick = 0;
last_handoff_response.clear();
} else if (now >= next_handoff_tick &&
allowed->load(std::memory_order_relaxed) &&
connected->load(std::memory_order_relaxed)) {
next_handoff_tick = now + 5000;
const auto response = QueryCrossDeskService(kRdpHandoffCommand, 1000);
if (response != last_handoff_response) {
// A reply timeout does not imply that Windows rejected the switch.
LOG_INFO("RDP auto handoff service response: {}", response);
last_handoff_response = response;
}
}
}
result.response = QueryCrossDeskService(
command, command == "sas" ? kWindowsServiceSasTimeoutMs
: kWindowsServiceQueryTimeoutMs);
@@ -277,6 +303,7 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
#if _WIN32
void GuiRuntime::ResetLocalWindowsServiceState(bool clear_pending_sas) {
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
if (windows_service_worker_) windows_service_worker_->Reset();
last_windows_service_status_tick_ = 0;
if (clear_pending_sas) {
@@ -860,9 +860,17 @@ int ScreenCapturerWin::Start(bool show_cursor) {
const bool needs_wgc = usbmmidd &&
capture_method_ == ScreenCaptureMethod::Auto &&
dynamic_cast<WgcPluginCapturer*>(impl_.get()) == nullptr;
if (headless != was_headless || (headless && !backend_is_gdi) || needs_wgc) {
// A prior session may have fallen back while RDP was changing its output.
// Retry the preferred GPU backend on a new connection instead of retaining
// WGC/GDI indefinitely. Keep the headless and usbmmidd cursor policies.
const bool needs_dxgi = capture_method_ == ScreenCaptureMethod::Auto &&
!headless && !usbmmidd &&
dynamic_cast<ScreenCapturerDxgi*>(impl_.get()) == nullptr;
if (headless != was_headless || (headless && !backend_is_gdi) || needs_wgc ||
needs_dxgi) {
LOG_INFO("Windows capturer: reselecting capture backend "
"(headless={}, usbmmidd={})", headless, usbmmidd);
"(headless={}, usbmmidd={}, retry_dxgi={})", headless, usbmmidd,
needs_dxgi);
impl_->Destroy();
// Keep an instance around on failure so the next Start can retry.
std::unique_ptr<ScreenCapturer> previous;
@@ -1229,9 +1237,22 @@ bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() {
const bool show_cursor = show_cursor_.load(std::memory_order_relaxed);
const int current_monitor = monitor_index_.load(std::memory_order_relaxed);
const bool usbmmidd = virtual_display_.active() || CapturesUsbmmiddOutput(impl_.get());
const bool headless = DetectHeadlessDesktop(capture_method_, false, true);
headless_compat_.store(headless, std::memory_order_relaxed);
LOG_INFO("Windows capturer: restarting capture backend after secure desktop");
impl_->Stop();
if (capture_method_ == ScreenCaptureMethod::Auto && !headless && !usbmmidd &&
dynamic_cast<ScreenCapturerDxgi*>(impl_.get()) == nullptr) {
LOG_INFO("Windows capturer: desktop recovered; retrying DXGI before {}",
CaptureBackendName(impl_.get()));
if (StartPreferredBackend(headless, current_monitor, usbmmidd)) {
NotifyPrivacyCapture(true);
return true;
}
// Failed candidates retain the previous instance, which may still recover
// even if constructing a replacement failed during the display transition.
}
last_capture_progress_tick_.store(GetTickCount64(), std::memory_order_relaxed);
int ret = impl_->Start(show_cursor);
if (ret == 0) {
@@ -1282,7 +1303,7 @@ void ScreenCapturerWin::CheckDisplayPresence(ULONGLONG now) {
last_display_probe_tick_ = now;
const bool topology_changed =
display_probe_requested_.exchange(false, std::memory_order_relaxed);
if (!IsWindowsPrivacyDesktopAvailable()) {
if (!IsCurrentSessionUserDesktopActive()) {
headless_recovery_.ShouldProvision(now, false, false, false);
return;
}
@@ -1621,6 +1642,8 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
ULONGLONG capture_stage_started_tick = 0;
bool post_secure_restart_pending = false;
bool desktop_was_unavailable = false;
std::string last_desktop_probe_reason;
bool privacy_recovery_pending = false;
bool normal_backend_suspended = false;
ULONGLONG post_secure_restart_deadline_tick = 0;
ULONGLONG last_post_secure_restart_tick = 0;
@@ -1664,11 +1687,22 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
};
while (running_.load(std::memory_order_relaxed)) {
// Capture follows this process's session, including an active RDP session.
// Privacy additionally requires the console desktop and must not gate video.
std::string desktop_probe_reason;
const bool desktop_available = IsCurrentSessionUserDesktopActive(&desktop_probe_reason);
if (desktop_probe_reason != last_desktop_probe_reason) {
LOG_INFO("Windows capturer: user desktop probe: {}",
desktop_available ? "available" : desktop_probe_reason);
last_desktop_probe_reason = desktop_probe_reason;
}
const bool privacy_available =
desktop_available && IsWindowsPrivacyDesktopAvailable();
if (!desktop_available) desktop_was_unavailable = true;
// Remember automatic privacy even if the connection started while locked
// and its first enable attempt has already failed.
const bool desktop_available = IsWindowsPrivacyDesktopAvailable();
if (!desktop_available) {
desktop_was_unavailable = true;
if (!privacy_available) {
privacy_recovery_pending = true;
if (privacy_) privacy_->SuspendForDesktop();
}
if (paused_.load(std::memory_order_relaxed)) {
@@ -1772,7 +1806,13 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
if (impl_) {
impl_->Stop();
normal_backend_suspended = true;
LOG_INFO("Windows capturer: normal backend suspended for secure desktop");
DWORD process_session_id = 0xFFFFFFFF;
ProcessIdToSessionId(GetCurrentProcessId(), &process_session_id);
LOG_INFO("Windows capturer: normal backend suspended, "
"user_desktop_available={}, secure_capture_active={}, "
"process_session_id={}, console_session_id={}",
desktop_available, status.capture_active, process_session_id,
WTSGetActiveConsoleSessionId());
}
}
@@ -1801,11 +1841,18 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
last_post_secure_restart_tick = now;
post_secure_restart_pending =
!RestartCaptureBackendAfterSecureDesktop();
if (!post_secure_restart_pending) normal_backend_suspended = false;
if (!post_secure_restart_pending && privacy_)
privacy_->ResumeAfterDesktopRecovery();
if (!post_secure_restart_pending) {
normal_backend_suspended = false;
// Input callbacks may have suspended privacy between local probes.
privacy_recovery_pending = true;
}
}
}
if (privacy_recovery_pending && privacy_available &&
!post_secure_restart_pending && !normal_backend_suspended) {
privacy_recovery_pending = false;
if (privacy_) privacy_->ResumeAfterDesktopRecovery();
}
if (!post_secure_restart_pending && !normal_backend_suspended &&
now >= next_cursor_update_tick) {
next_cursor_update_tick = now + 500;
@@ -54,35 +54,53 @@ inline bool IsCurrentProcessSession(DWORD session_id) {
session_id == process_session_id;
}
inline bool IsCurrentSessionUnlocked() {
inline bool DesktopProbeFailed(std::string* reason, const char* stage, DWORD value) {
if (reason) *reason = std::string(stage) + ":" + std::to_string(value);
return false;
}
inline bool IsCurrentSessionUnlocked(std::string* reason = nullptr) {
if (reason) reason->clear();
DWORD session_id = 0xFFFFFFFF;
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session_id)) return false;
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session_id))
return DesktopProbeFailed(reason, "session_id_query_failed", GetLastError());
PWTSINFOEXW info = nullptr;
DWORD bytes = 0;
if (!WTSQuerySessionInformationW(
WTS_CURRENT_SERVER_HANDLE, session_id, WTSSessionInfoEx,
reinterpret_cast<LPWSTR*>(&info), &bytes)) {
return false;
return DesktopProbeFailed(reason, "session_info_query_failed", GetLastError());
}
const bool unlocked =
info != nullptr && bytes >= sizeof(WTSINFOEXW) && info->Level == 1 &&
info->Data.WTSInfoExLevel1.SessionState == WTSActive &&
info->Data.WTSInfoExLevel1.SessionFlags == WTS_SESSIONSTATE_UNLOCK;
const bool valid = info && bytes >= sizeof(WTSINFOEXW) && info->Level == 1;
const DWORD state = valid ? info->Data.WTSInfoExLevel1.SessionState : 0xFFFFFFFF;
const DWORD flags = valid ? info->Data.WTSInfoExLevel1.SessionFlags : 0xFFFFFFFF;
if (info != nullptr) WTSFreeMemory(info);
return unlocked;
if (!valid) return DesktopProbeFailed(reason, "session_info_invalid", bytes);
if (state != WTSActive) return DesktopProbeFailed(reason, "session_not_active", state);
if (flags != WTS_SESSIONSTATE_UNLOCK)
return DesktopProbeFailed(reason, "session_not_unlocked", flags);
return true;
}
inline bool IsCurrentSessionUserDesktopActive() {
inline bool IsCurrentSessionUserDesktopActive(std::string* reason = nullptr) {
// LockApp can be visible on Default while the session is still locked.
if (!IsCurrentSessionUnlocked()) return false;
if (!IsCurrentSessionUnlocked(reason)) return false;
HDESK desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
if (desktop == nullptr) return false;
// OpenInputDesktop also succeeds in disconnected RDP sessions. The name
// alone does not tell us whether this desktop can currently receive input.
const bool active = IsDesktopReceivingInput(desktop) &&
_wcsicmp(GetDesktopNameW(desktop).c_str(), L"Default") == 0;
if (desktop == nullptr)
return DesktopProbeFailed(reason, "open_input_desktop_failed", GetLastError());
// A minimized RDP connection can suppress desktop output while WTS still
// reports Active/Unlocked. Do not treat its black WGC callbacks as recovery.
BOOL receiving_input = FALSE;
const bool queried = GetUserObjectInformationW(
desktop, UOI_IO, &receiving_input, sizeof(receiving_input), nullptr) != FALSE;
const DWORD error = queried ? ERROR_SUCCESS : GetLastError();
const bool is_default = queried && receiving_input &&
_wcsicmp(GetDesktopNameW(desktop).c_str(), L"Default") == 0;
CloseDesktop(desktop);
return active;
if (!queried) return DesktopProbeFailed(reason, "desktop_input_query_failed", error);
if (!receiving_input) return DesktopProbeFailed(reason, "desktop_not_receiving_input", 0);
if (!is_default) return DesktopProbeFailed(reason, "desktop_not_default", 0);
return true;
}
// Use only on worker threads without windows or hooks. Restore the original
@@ -0,0 +1,148 @@
#include "rdp_session_handoff.h"
#include <WtsApi32.h>
#include <filesystem>
#include <vector>
namespace crossdesk {
namespace {
struct Handle {
HANDLE value = nullptr;
~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); }
};
bool ReadTokenUser(HANDLE token, std::vector<unsigned char>& data) {
DWORD bytes = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &bytes);
if (bytes == 0) return false;
data.resize(bytes);
return GetTokenInformation(token, TokenUser, data.data(), bytes, &bytes) != FALSE;
}
bool RunningAsSystem() {
Handle token;
std::vector<unsigned char> user;
return OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token.value) &&
ReadTokenUser(token.value, user) &&
IsWellKnownSid(reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid,
WinLocalSystemSid);
}
template <typename Value>
bool QuerySessionValue(DWORD session, WTS_INFO_CLASS field, Value& value) {
LPWSTR data = nullptr;
DWORD bytes = 0;
if (!WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session, field,
&data, &bytes)) return false;
const bool valid = data && bytes >= sizeof(Value);
if (valid) value = *reinterpret_cast<const Value*>(data);
WTSFreeMemory(data);
return valid;
}
bool IsInstalledClient(HANDLE process) {
wchar_t service_path[32768]{}, client_path[32768]{};
const DWORD length = GetModuleFileNameW(nullptr, service_path, ARRAYSIZE(service_path));
DWORD capacity = ARRAYSIZE(client_path);
if (!length || length >= ARRAYSIZE(service_path) ||
!QueryFullProcessImageNameW(process, 0, client_path, &capacity)) return false;
const auto expected = std::filesystem::path(service_path).parent_path() / L"crossdesk.exe";
return _wcsicmp(expected.c_str(), client_path) == 0;
}
bool VerifyOwner(HANDLE process, DWORD session) {
Handle caller, owner;
std::vector<unsigned char> caller_user, owner_user;
DWORD token_session = 0xFFFFFFFF, bytes = 0;
if (!OpenProcessToken(process, TOKEN_QUERY, &caller.value) ||
!GetTokenInformation(caller.value, TokenSessionId, &token_session,
sizeof(token_session), &bytes) || token_session != session ||
!ReadTokenUser(caller.value, caller_user) ||
!WTSQueryUserToken(session, &owner.value) || !ReadTokenUser(owner.value, owner_user))
return false;
return EqualSid(reinterpret_cast<TOKEN_USER*>(caller_user.data())->User.Sid,
reinterpret_cast<TOKEN_USER*>(owner_user.data())->User.Sid) != FALSE;
}
const char* ValidateSessions(DWORD session, bool observed_remote,
DWORD& console_session) {
console_session = WTSGetActiveConsoleSessionId();
if (session == console_session) return nullptr;
if (console_session == 0 || console_session == 0xFFFFFFFF)
return "rdp_handoff_state_unavailable";
WTS_CONNECTSTATE_CLASS source_state{}, console_state{};
USHORT protocol = 0;
wchar_t source_user = 0, console_user = 0;
// Only the first character is needed to distinguish an empty user name.
if (!QuerySessionValue(session, WTSConnectState, source_state) ||
(!observed_remote &&
!QuerySessionValue(session, WTSClientProtocolType, protocol)) ||
!QuerySessionValue(session, WTSUserName, source_user) || !source_user ||
!QuerySessionValue(console_session, WTSUserName, console_user) ||
!QuerySessionValue(console_session, WTSConnectState, console_state))
return "rdp_handoff_state_unavailable";
if (source_state != WTSDisconnected || (!observed_remote && protocol != 2))
return "rdp_handoff_session_not_disconnected";
// Even a locked console with the same account must not be displaced.
if (console_user || (console_state != WTSConnected && console_state != WTSActive))
return "rdp_handoff_console_busy";
return nullptr;
}
} // namespace
bool IsCurrentProcessSessionDisconnected() {
DWORD session = 0xFFFFFFFF;
WTS_CONNECTSTATE_CLASS state{};
return ProcessIdToSessionId(GetCurrentProcessId(), &session) &&
QuerySessionValue(session, WTSConnectState, state) && state == WTSDisconnected;
}
RdpHandoffResult HandoffDisconnectedRdpSession(HANDLE pipe, DWORD client_session,
bool observed_rdp_session) {
if (!RunningAsSystem()) return {"rdp_handoff_requires_service"};
if (client_session == 0 || client_session == 0xFFFFFFFF)
return {"rdp_handoff_client_denied", ERROR_ACCESS_DENIED};
ULONG pid = 0, pipe_session = 0xFFFFFFFF;
if (!GetNamedPipeClientProcessId(pipe, &pid) ||
!GetNamedPipeClientSessionId(pipe, &pipe_session))
return {"rdp_handoff_client_denied", GetLastError()};
if (pipe_session != client_session)
return {"rdp_handoff_client_denied", ERROR_ACCESS_DENIED};
Handle process{OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, pid)};
if (!process.value) return {"rdp_handoff_process_query_failed", GetLastError()};
if (!IsInstalledClient(process.value))
return {"rdp_handoff_client_path_mismatch", ERROR_ACCESS_DENIED};
if (!VerifyOwner(process.value, client_session))
return {"rdp_handoff_owner_unverified", ERROR_ACCESS_DENIED};
DWORD console_session = 0xFFFFFFFF;
if (const char* error = ValidateSessions(client_session, observed_rdp_session,
console_session))
return {error};
if (client_session == console_session) return {nullptr, 0, true};
// Recheck immediately before acting: WTS enumeration and token queries can
// span a logon, reconnect or console switch. Retain the process object by
// handle to prevent PID reuse and refuse a change of console destination.
if (WaitForSingleObject(process.value, 0) != WAIT_TIMEOUT ||
!VerifyOwner(process.value, client_session))
return {"rdp_handoff_client_denied", ERROR_ACCESS_DENIED};
DWORD current_console = 0xFFFFFFFF;
if (const char* error = ValidateSessions(client_session, observed_rdp_session,
current_console))
return {error};
if (client_session == current_console) return {nullptr, 0, true};
if (console_session != current_console)
return {"rdp_handoff_console_changed"};
// Ask Windows to perform the same session routing as tscon /dest:console.
// Asynchronous: the capture/status loops observe the resulting WTS change;
// the single service IPC thread must remain available for status and input.
wchar_t password[] = L"";
if (!WTSConnectSessionW(client_session, current_console, password, FALSE))
return {"rdp_handoff_connect_failed", GetLastError()};
return {};
}
} // namespace crossdesk
@@ -0,0 +1,27 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-16
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _RDP_SESSION_HANDOFF_H_
#define _RDP_SESSION_HANDOFF_H_
#include <Windows.h>
namespace crossdesk {
inline constexpr char kRdpHandoffCommand[] = "resume-rdp-session";
struct RdpHandoffResult {
const char* error = nullptr;
DWORD error_code = 0;
bool already_console = false;
};
// The session comes from GetNamedPipeClientSessionId, never request payloads.
RdpHandoffResult HandoffDisconnectedRdpSession(HANDLE pipe, DWORD client_session,
bool observed_rdp_session);
bool IsCurrentProcessSessionDisconnected();
} // namespace crossdesk
#endif
@@ -25,6 +25,7 @@
#include "session_helper_shared.h"
#include "unattended_config.h"
#include "unattended_policy.h"
#include "rdp_session_handoff.h"
namespace crossdesk {
@@ -1021,6 +1022,24 @@ int CrossDeskServiceHost::InitializeRuntime() {
if (!session_state_event_) return static_cast<int>(GetLastError());
last_sas_tick_ = 0;
active_session_id_ = WTSGetActiveConsoleSessionId();
// A service started inside an existing RDP login may not receive its earlier
// remote-connect notification. Seed currently identifiable RDP sessions.
PWTS_SESSION_INFOW sessions = nullptr;
DWORD session_count = 0;
if (WTSEnumerateSessionsW(WTS_CURRENT_SERVER_HANDLE, 0, 1, &sessions, &session_count)) {
for (DWORD index = 0; index < session_count; ++index) {
LPWSTR protocol = nullptr;
DWORD bytes = 0;
if (WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, sessions[index].SessionId,
WTSClientProtocolType, &protocol, &bytes)) {
if (protocol && bytes >= sizeof(USHORT) &&
*reinterpret_cast<USHORT*>(protocol) == 2)
rdp_sessions_.insert(sessions[index].SessionId);
WTSFreeMemory(protocol);
}
}
WTSFreeMemory(sessions);
}
process_session_id_ = 0xFFFFFFFF;
input_desktop_error_code_ = 0;
session_helper_process_id_ = 0;
@@ -1415,7 +1434,7 @@ void CrossDeskServiceHost::IpcServerLoop() {
const std::string response =
GetNamedPipeClientSessionId(pipe, &client_session_id)
? HandleIpcCommand(std::string(buffer, buffer + bytes_read),
client_session_id)
client_session_id, pipe)
: BuildErrorJson("client_session_query_failed", GetLastError());
DWORD bytes_written = 0;
if (pipe_deadline_detail::Transfer(
@@ -2174,6 +2193,10 @@ void CrossDeskServiceHost::RecordSessionEvent(DWORD event_type,
{
std::lock_guard<std::mutex> lock(state_mutex_);
last_session_event_type_ = event_type;
if (event_type == WTS_REMOTE_CONNECT || event_type == WTS_REMOTE_DISCONNECT)
rdp_sessions_.insert(session_id);
else if (event_type == WTS_CONSOLE_CONNECT || event_type == WTS_SESSION_LOGOFF)
rdp_sessions_.erase(session_id);
++session_state_generation_;
last_session_event_session_id_ = session_id;
const DWORD previous_session_id = active_session_id_;
@@ -2210,8 +2233,28 @@ void CrossDeskServiceHost::RecordSessionEvent(DWORD event_type,
}
std::string CrossDeskServiceHost::HandleIpcCommand(const std::string& command,
DWORD client_session_id) {
DWORD client_session_id,
HANDLE client_pipe) {
std::string normalized = ToLower(Trim(command));
if (normalized == kRdpHandoffCommand) {
bool observed_rdp = false;
{
std::lock_guard<std::mutex> lock(state_mutex_);
observed_rdp = rdp_sessions_.count(client_session_id) != 0;
}
const auto result = HandoffDisconnectedRdpSession(client_pipe, client_session_id,
observed_rdp);
if (result.error) {
LOG_WARN("RDP handoff refused: session_id={}, error={}, code={}",
client_session_id, result.error, result.error_code);
return BuildErrorJson(result.error, result.error_code);
}
LOG_INFO("RDP handoff accepted: session_id={}, already_console={}",
client_session_id, result.already_console);
WakeSessionState();
return Json{{"ok", true}, {"rdp_handoff", result.already_console ?
"already_console" : "requested"}, {"session_id", client_session_id}}.dump();
}
if (normalized == "ping") {
return "{\"ok\":true,\"reply\":\"pong\"}";
}
@@ -14,6 +14,7 @@
#include <mutex>
#include <string>
#include <thread>
#include <unordered_set>
namespace crossdesk {
@@ -72,7 +73,7 @@ class CrossDeskServiceHost {
void RefreshSessionHelperReportedState();
void RecordSessionEvent(DWORD event_type, DWORD session_id);
std::string HandleIpcCommand(const std::string& command,
DWORD client_session_id);
DWORD client_session_id, HANDLE client_pipe);
std::string BuildStatusResponse(DWORD client_session_id);
std::string SendSecureAttentionSequence(DWORD client_session_id);
struct SecureInputTarget {
@@ -109,6 +110,8 @@ class CrossDeskServiceHost {
DWORD unattended_session_ = 0xFFFFFFFF;
std::atomic<uint64_t> unattended_logoff_generation_{0};
std::mutex state_mutex_;
// Preserve RDP ownership when Windows clears the protocol after disconnect.
std::unordered_set<DWORD> rdp_sessions_;
uint64_t session_state_generation_ = 0;
ULONGLONG last_session_transition_tick_ = 0;
bool session_lock_state_known_ = false;
@@ -48,7 +48,9 @@ InstanceResult SingleInstanceGuard::TryAcquire(InstanceRole role,
std::wstring(role_name.begin(), role_name.end());
LocalFree(sid);
// Object lifetime is the lease. No thread-owned mutex to transfer to a daemon.
HANDLE mutex = CreateMutexW(nullptr, FALSE, name.c_str());
// CreateMutexW requests full access to an existing mutex, which can prevent
// an unelevated launch from recognizing the same user's elevated instance.
HANDLE mutex = CreateMutexExW(nullptr, name.c_str(), 0, SYNCHRONIZE);
const DWORD code = GetLastError();
if (!mutex) {
error = "Cannot create single-instance mutex: " + std::to_string(code);
+1 -1
View File
@@ -57,7 +57,7 @@ std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend();
// Flush queued AppKit cleanup after joining the controller at app shutdown.
void FlushMacPrivacyTasks();
#endif
#ifdef __linux__
#if defined(__linux__) && !defined(__OHOS__)
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend();
#endif
@@ -16,7 +16,7 @@ PrivacyController::PrivacyController(Factory factory) {
if (!factory) factory = CreateWindowsPrivacyBackend;
#elif defined(__APPLE__)
if (!factory) factory = CreateMacPrivacyBackend;
#elif defined(__linux__)
#elif (defined(__linux__) && !defined(__OHOS__))
if (!factory) factory = CreateLinuxPrivacyBackend;
#endif
thread_ = std::thread([this, factory] { Run(factory); });
+20 -218
View File
@@ -1,119 +1,33 @@
#include "file_transfer.h"
#include <atomic>
#include <chrono>
#include <cstdlib>
#include "rd_log.h"
#if defined(__OHOS__)
#include "platform/path_backend.h"
#endif
namespace crossdesk {
namespace {
std::atomic<uint32_t> g_next_file_id{1};
} // namespace
uint32_t FileSender::NextFileId() { return g_next_file_id.fetch_add(1); }
int FileSender::SendFile(const std::filesystem::path& path,
const std::string& label, const SendFunc& send,
std::size_t chunk_size, uint32_t file_id) {
if (!send) {
LOG_ERROR("FileSender::SendFile: send function is empty");
return -1;
}
std::error_code ec;
if (!std::filesystem::exists(path, ec) ||
!std::filesystem::is_regular_file(path, ec)) {
LOG_ERROR("FileSender::SendFile: file [{}] not found or not regular",
path.string().c_str());
return -1;
}
uint64_t total_size = std::filesystem::file_size(path, ec);
if (ec) {
LOG_ERROR("FileSender::SendFile: failed to get size of [{}]: {}",
path.string().c_str(), ec.message().c_str());
return -1;
}
std::ifstream ifs(path, std::ios::binary);
if (!ifs.is_open()) {
LOG_ERROR("FileSender::SendFile: failed to open [{}]",
path.string().c_str());
return -1;
}
LOG_INFO("FileSender send file {}, total size {}, file_id={}",
path.string().c_str(), total_size, file_id);
if (file_id == 0) {
file_id = NextFileId();
}
const uint32_t final_file_id = file_id;
uint64_t offset = 0;
bool is_first = true;
std::string file_name = label.empty() ? path.filename().string() : label;
std::vector<char> buffer;
buffer.resize(chunk_size);
while (ifs && offset < total_size) {
uint64_t remaining = total_size - offset;
uint32_t to_read =
static_cast<uint32_t>(std::min<uint64_t>(remaining, chunk_size));
ifs.read(buffer.data(), static_cast<std::streamsize>(to_read));
std::streamsize bytes_read = ifs.gcount();
if (bytes_read <= 0) {
break;
}
bool is_last = (offset + static_cast<uint64_t>(bytes_read) >= total_size);
const std::string* name_ptr = is_first ? &file_name : nullptr;
std::vector<char> chunk = BuildChunk(
final_file_id, offset, total_size, buffer.data(),
static_cast<uint32_t>(bytes_read), name_ptr, is_first, is_last);
int ret = send(chunk.data(), chunk.size());
if (ret != 0) {
LOG_ERROR("FileSender::SendFile: send failed for [{}], ret={}",
path.string().c_str(), ret);
return ret;
}
offset += static_cast<uint64_t>(bytes_read);
is_first = false;
}
return 0;
}
std::vector<char> FileSender::BuildChunk(uint32_t file_id, uint64_t offset,
uint64_t total_size, const char* data,
uint32_t data_size,
const std::string* file_name,
bool is_first, bool is_last) {
return EncodeFileChunk(file_id, offset, total_size, data,
data_size, file_name, is_first, is_last);
}
// ---------- FileReceiver ----------
FileReceiver::FileReceiver() : output_dir_(GetDefaultDesktopPath()) {}
FileReceiver::FileReceiver() : FileReceiver(GetDefaultDesktopPath()) {}
FileReceiver::FileReceiver(const std::filesystem::path& output_dir)
: output_dir_(output_dir) {
std::error_code ec;
if (!output_dir_.empty()) {
std::filesystem::create_directories(output_dir_, ec);
if (ec) {
LOG_ERROR("FileReceiver: failed to create output dir [{}]: {}",
output_dir_.string().c_str(), ec.message().c_str());
}
}
: output_dir_(output_dir), session_(
[this](const char*, const char* data, size_t size) {
FileTransferAck ack{};
return on_send_ack_ && DecodeFileTransferAck(data, size, &ack)
? on_send_ack_(ack) : 0;
}, {}, {}, [this](const file_transfer::ReceiveState& progress) {
if (on_receive_progress_) on_receive_progress_(progress);
}) {
session_.SetDirectory(output_dir);
}
std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
#if defined(__OHOS__)
// Harmony has no process HOME/Desktop. Use writable application storage
// until the user selects and authorizes a public directory in settings.
const auto desktop_path = platform::GetCachePath("CrossDesk") / "Received Files";
#else
#ifdef _WIN32
const char* home_env = std::getenv("USERPROFILE");
#else
@@ -125,6 +39,7 @@ std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
std::filesystem::path desktop_path =
std::filesystem::path(home_env) / "Desktop";
#endif
std::error_code ec;
std::filesystem::create_directories(desktop_path, ec);
@@ -136,117 +51,4 @@ std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
return desktop_path;
}
bool FileReceiver::OnData(const char* data, size_t size) {
FileChunkView chunk;
if (!DecodeFileChunk(data, size, &chunk)) {
LOG_ERROR("FileReceiver::OnData: invalid buffer");
return false;
}
const std::string* file_name =
chunk.file_name.empty() ? nullptr : &chunk.file_name;
return HandleChunk(chunk.header, chunk.payload, chunk.payload_size,
file_name);
}
bool FileReceiver::HandleChunk(const FileChunkHeader& header,
const char* payload, size_t payload_size,
const std::string* file_name) {
auto it = contexts_.find(header.file_id);
if (it == contexts_.end()) {
// new file context must start with first chunk.
if ((header.flags & 0x01) == 0) {
LOG_ERROR("FileReceiver: received non-first chunk for unknown file_id={}",
header.file_id);
return false;
}
FileContext ctx;
ctx.total_size = header.total_size;
std::string filename;
if (file_name && !file_name->empty()) {
filename = *file_name;
} else {
filename = "received_" + std::to_string(header.file_id);
}
ctx.file_name = filename;
std::filesystem::path save_path = output_dir_.empty()
? std::filesystem::path(filename)
: output_dir_ / filename;
// if file exists, append timestamp.
std::error_code ec;
if (std::filesystem::exists(save_path, ec)) {
auto now = std::chrono::system_clock::now();
auto ts = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch())
.count();
save_path = save_path.parent_path() /
(save_path.stem().string() + "_" + std::to_string(ts) +
save_path.extension().string());
}
ctx.ofs.open(save_path, std::ios::binary | std::ios::trunc);
if (!ctx.ofs.is_open()) {
LOG_ERROR("FileReceiver: failed to open [{}] for writing",
save_path.string().c_str());
return false;
}
contexts_.emplace(header.file_id, std::move(ctx));
it = contexts_.find(header.file_id);
}
FileContext& ctx = it->second;
if (payload_size > 0 && payload) {
ctx.ofs.seekp(static_cast<std::streamoff>(header.offset), std::ios::beg);
ctx.ofs.write(payload, static_cast<std::streamsize>(payload_size));
if (!ctx.ofs.good()) {
LOG_ERROR("FileReceiver: write failed for file_id={}", header.file_id);
return false;
}
ctx.received += static_cast<uint64_t>(payload_size);
}
// Send ACK after processing chunk
if (on_send_ack_) {
FileTransferAck ack{};
ack.magic = kFileAckMagic;
ack.file_id = header.file_id;
ack.acked_offset = header.offset + static_cast<uint64_t>(payload_size);
ack.total_size = header.total_size;
ack.flags = 0;
bool is_last = (header.flags & 0x02) != 0;
if (is_last || ctx.received >= ctx.total_size) {
ack.flags |= 0x01; // completed
}
int ret = on_send_ack_(ack);
if (ret != 0) {
LOG_ERROR("FileReceiver: failed to send ACK for file_id={}, ret={}",
header.file_id, ret);
}
}
bool is_last = (header.flags & 0x02) != 0;
if (is_last || ctx.received >= ctx.total_size) {
ctx.ofs.close();
std::filesystem::path saved_path =
output_dir_.empty() ? std::filesystem::path(ctx.file_name)
: output_dir_ / ctx.file_name;
LOG_INFO("FileReceiver: file received complete, file_id={}, size={}",
header.file_id, ctx.received);
contexts_.erase(header.file_id);
}
return true;
}
} // namespace crossdesk
+22 -72
View File
@@ -1,99 +1,49 @@
/*
* @Author: DI JUNKUN
* @Date: 2025-12-18
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _FILE_TRANSFER_H_
#define _FILE_TRANSFER_H_
#include <filesystem>
#include <fstream>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
#include <file_transfer_format.h>
#include <stream_names.h>
#include <file_transfer_core.h>
namespace crossdesk {
class FileSender {
public:
using SendFunc = std::function<int(const char* data, size_t size)>;
public:
FileSender() = default;
// generate a new file id
static uint32_t NextFileId();
// synchronously send a file using the provided send function.
// `path` : full path to the local file.
// `label` : logical filename to send (usually path.filename()).
// `send` : callback that pushes one encoded chunk into the data channel.
// `file_id` : file id to use (0 means auto-generate).
// Return 0 on success, <0 on error.
int SendFile(const std::filesystem::path& path, const std::string& label,
const SendFunc& send,
std::size_t chunk_size = kFileChunkSize,
uint32_t file_id = 0);
// Build a single encoded chunk buffer using the FileChunkHeader wire format.
static std::vector<char> BuildChunk(uint32_t file_id, uint64_t offset,
uint64_t total_size, const char* data,
uint32_t data_size,
const std::string* file_name,
bool is_first, bool is_last);
};
// Desktop destination policy; protocol, assembly, ACKs and cleanup live in the
// same engine used by the mobile adapter.
class FileReceiver {
public:
struct FileContext {
std::string file_name;
uint64_t total_size = 0;
uint64_t received = 0;
std::ofstream ofs;
};
using OnFileComplete =
std::function<void(const std::filesystem::path& saved_path)>;
using OnSendAck = std::function<int(const FileTransferAck& ack)>;
public:
// save to default desktop directory.
using OnSendAck = std::function<int(const FileTransferAck&)>;
using OnReceiveProgress = file_transfer::Session::ReceiveProgress;
FileReceiver();
// save to a specified directory.
explicit FileReceiver(const std::filesystem::path& output_dir);
// process one received data buffer (one chunk).
// return true if parsed and processed successfully, false otherwise.
bool OnData(const char* data, size_t size);
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = cb; }
const std::filesystem::path& OutputDir() const { return output_dir_; }
bool OnData(const char* data, size_t size) { return session_.Receive(data, size); }
void Poll() { session_.Pump(); }
void Cancel() { session_.Cancel(); }
// Set once, before the receiver is published to transport callbacks.
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = std::move(cb); }
void SetOnReceiveProgress(OnReceiveProgress cb) { on_receive_progress_ = std::move(cb); }
std::filesystem::path OutputDir() const {
std::lock_guard lock(directory_mutex_);
return output_dir_;
}
void SetOutputDir(const std::filesystem::path& dir) {
std::lock_guard lock(directory_mutex_);
output_dir_ = dir;
if (!output_dir_.empty()) {
std::error_code ec;
std::filesystem::create_directories(output_dir_, ec);
}
session_.SetDirectory(dir);
}
private:
static std::filesystem::path GetDefaultDesktopPath();
bool HandleChunk(const FileChunkHeader& header, const char* payload,
size_t payload_size, const std::string* file_name);
private:
mutable std::mutex directory_mutex_;
std::filesystem::path output_dir_;
std::unordered_map<uint32_t, FileContext> contexts_;
OnSendAck on_send_ack_ = nullptr;
OnSendAck on_send_ack_;
OnReceiveProgress on_receive_progress_;
file_transfer::Session session_;
};
} // namespace crossdesk
@@ -122,7 +122,11 @@ std::optional<VersionInfo> CheckUpdate() {
const auto manifest = nlohmann::json::parse(res->body);
auto info = ParseVersionInfo(manifest);
if (info) {
#if defined(__linux__)
#if defined(__OHOS__)
// A Linux package cannot be installed by Harmony's package manager.
// Until the manifest has a Harmony artifact, use the release website.
info->download_url = "https://crossdesk.cn";
#elif defined(__linux__)
std::string architecture;
#if defined(__x86_64__)
architecture = "amd64";
@@ -0,0 +1,363 @@
#include "crossdesk_ui.h"
#include "slint_render_test_platform.h"
#include "ui/model_update.h"
#include <iostream>
#include <fstream>
#include <cstdlib>
#include <string>
using namespace std::chrono_literals;
namespace {
bool Check(bool value, const char* message) {
if (!value) std::cerr << message << '\n';
return value;
}
bool VerifyStreamPopups(RenderTestPlatform& platform) {
auto stream = crossdesk::ui::StreamWindow::create();
auto& screen = *platform.adapter;
stream->window().set_size(slint::LogicalSize({1280, 720}));
stream->set_video_settings_enabled(true);
stream->show();
const int top = int(stream->get_video_area_top());
auto displays = std::make_shared<slint::VectorModel<slint::SharedString>>();
auto transfers = std::make_shared<slint::VectorModel<crossdesk::ui::FileTransferEntry>>();
stream->set_displays(displays);
stream->set_file_transfers(transfers);
bool file_visible = false;
unsigned ticks = 0;
slint::Timer sync;
sync.start(slint::TimerMode::Repeated, 16ms, [&] {
// Match the live session's model synchronization, including long clicks.
crossdesk::ui::UpdateModelRows(*displays,
std::vector<slint::SharedString>{"Display 1", "Display 2", "Display 3"});
crossdesk::ui::FileTransferEntry entry{};
entry.name = "test.txt"; entry.progress = (++ticks % 100) / 100.f;
crossdesk::ui::UpdateModelRows(*transfers, std::vector(8, entry));
stream->set_file_transfer_visible(file_visible);
});
int remote_presses = 0, remote_releases = 0, remote_scrolls = 0;
stream->on_pointer_input([&](auto, auto kind, float, float) {
if (kind == slint::PointerEventKind::Down) ++remote_presses;
if (kind == slint::PointerEventKind::Up) ++remote_releases;
});
stream->on_scroll_input([&](float, float, float, float) { ++remote_scrolls; });
int selected_display = -1;
stream->on_switch_display([&](int index) { selected_display = index; });
std::string shortcut;
stream->on_send_shortcut([&](slint::SharedString text) { shortcut = text; });
int video_changes = 0;
stream->on_change_video_settings([&](int quality, int rate, int policy) {
stream->set_video_quality_index(quality);
stream->set_frame_rate_index(rate);
stream->set_adaptation_policy_index(policy);
++video_changes;
});
stream->on_select_file([&] { file_visible = true; });
bool close_synchronously = false;
stream->on_close_file_transfer([&] {
// The real application hides this on its next synchronization tick.
file_visible = false;
if (close_synchronously) stream->set_file_transfer_visible(false);
});
screen.Settle();
for (int repeat = 0; repeat < 2; ++repeat) {
screen.Click(21, top + 19);
if (!Check(screen.Light(12, top + 128), "Display menu must fully open without pointer movement")) return false;
screen.Click(60, top + 86, 4);
if (!Check(selected_display == 1 && screen.Black(12, top + 128),
"Display selection must survive live refresh and close the menu")) return false;
screen.Click(53, top + 19);
if (!Check(screen.Light(44, top + 102), "Shortcut menu must open without pointer movement")) return false;
screen.Click(70, top + 88, 4);
if (!Check(shortcut == "Win+L" && screen.Black(44, top + 102),
"Shortcut selection must close the menu immediately")) return false;
screen.Click(213, top + 19);
if (!Check(stream->get_video_menu_open() && screen.Light(203, top + 210),
"Video settings menu must fully open without pointer movement")) return false;
screen.Click(240, top + 80, 4);
screen.Click(900, top + 300);
if (!Check(!stream->get_video_menu_open() && video_changes == repeat + 1,
"Video selection and outside dismissal must respond immediately")) return false;
}
if (!Check(remote_presses == 0 && remote_releases == 0 && remote_scrolls == 0,
"Menu gestures must not be forwarded to the peer")) return false;
screen.Click(181, top + 19);
const int panel_y = top + int(stream->get_video_area_height()) - 210;
if (!Check(file_visible && screen.Light(20, panel_y + 25),
"File progress must appear without pointer movement")) return false;
// Header/padding is outside the list's own input handler.
screen.Click(25, panel_y + 20, 4);
stream->window().dispatch_pointer_scroll_event(
slint::LogicalPosition({25, float(panel_y + 20)}), 0, -60);
screen.Settle();
if (!Check(remote_presses == 0 && remote_releases == 0 && remote_scrolls == 0,
"File progress header and padding must consume input locally")) return false;
const slint::LogicalPosition panel_point({25, float(panel_y + 20)});
const slint::LogicalPosition outside({900, float(top + 300)});
stream->window().dispatch_pointer_press_event(panel_point, slint::PointerEventButton::Left);
screen.Settle(4);
stream->window().dispatch_pointer_move_event(outside);
stream->window().dispatch_pointer_release_event(outside, slint::PointerEventButton::Left);
screen.Settle();
if (!Check(remote_presses == 0 && remote_releases == 0,
"Dragging out of the file panel must not release a button on the peer")) return false;
screen.Click(430, panel_y + 16, 4);
if (!Check(!file_visible && screen.Black(20, panel_y + 25),
"File progress close must clear its pixels without pointer movement")) return false;
if (!Check(remote_presses == 0 && remote_releases == 0,
"Closing file progress must not send an orphan pointer release")) return false;
close_synchronously = true;
screen.Click(181, top + 19);
screen.Click(430, panel_y + 16, 4);
if (!Check(!file_visible && screen.Black(20, panel_y + 25) && remote_releases == 0,
"Synchronous file panel dismissal must consume its closing release")) return false;
screen.Click(900, top + 300);
return Check(remote_presses == 1 && remote_releases == 1,
"Closing local menus must restore remote input");
}
bool VerifyWorkbenchPopups(RenderTestPlatform& platform) {
auto main = crossdesk::ui::MainWindow::create();
auto& screen = *platform.adapter;
const float width = main->get_minimum_window_width();
const float height = main->get_minimum_window_height();
main->window().set_size(slint::LogicalSize({width, height}));
main->show();
auto groups = std::make_shared<slint::VectorModel<slint::SharedString>>(
std::vector<slint::SharedString>{"All", "Ungrouped", "Lab"});
main->set_recent_groups(groups);
int selected_group = -1;
main->on_recent_group_selected([&](int index) { selected_group = index; });
screen.Settle();
const auto filter_closed = screen.Region(width - 170, 302, 140, 70);
screen.Click(width - 90, 276);
if (!Check(screen.Region(width - 170, 302, 140, 70) != filter_closed,
"Group filter must reveal its contents while the pointer stays on the trigger")) return false;
screen.Key(slint::platform::key_codes::Escape);
if (!Check(screen.Region(width - 170, 302, 140, 70) == filter_closed,
"Escape must dismiss the group filter")) return false;
screen.Click(width - 90, 276);
screen.Click(width - 90, 371, 4);
if (!Check(selected_group == 2 && main->get_recent_group_index() == 2,
"Group selection must survive a held click")) return false;
screen.Click(width - 38, 20);
screen.Click(width - 78, 56, 4);
if (!Check(main->get_settings_open(), "Application menu must open settings on click")) return false;
const float settings_y = (height - 446) / 2;
screen.Click(width / 2 + 87, settings_y + 76, 4);
if (!Check(main->get_settings_tab() == 1, "Settings tabs must switch without pointer movement")) return false;
screen.Click(width / 2 - 87, settings_y + 76, 4);
if (!Check(main->get_settings_tab() == 0, "Settings tabs must switch back immediately")) return false;
// The first row uses the same CompactComboBox as codec/capture settings.
const int combo_x = int(width / 2 + 100);
const int combo_y = int(settings_y + 150);
const int popup_y = int(settings_y + 164);
const auto combo_closed = screen.Region(combo_x - 35, popup_y + 2, 60, 60);
screen.Click(combo_x, combo_y);
if (!Check(screen.Region(combo_x - 35, popup_y + 2, 60, 60) != combo_closed,
"Settings dropdown must animate open without pointer movement")) return false;
screen.Key(slint::platform::key_codes::Escape);
if (!Check(screen.Region(combo_x - 35, popup_y + 2, 60, 60) == combo_closed,
"Escape must dismiss the settings dropdown")) return false;
screen.Click(combo_x, combo_y);
screen.Click(combo_x, popup_y + 34, 4);
if (!Check(main->get_language_index() == 1, "Settings dropdown must apply a selected row")) return false;
// A session starting while a codec menu is open disables the setting.
// Existing popup rows must not remain actionable after that transition.
const int codec_y = int(settings_y + 224);
screen.Click(combo_x, codec_y);
main->set_settings_session_active(true);
screen.Settle();
screen.Click(combo_x, codec_y + 48, 4);
if (!Check(main->get_codec_index() == 0,
"A disabled codec dropdown must close and reject its stale options")) return false;
main->set_settings_session_active(false);
screen.Click(width / 2 + 32, settings_y + 412);
if (!Check(!main->get_settings_open(), "Settings Cancel must close the dialog immediately")) return false;
screen.Click(width - 38, 20);
screen.Click(width - 78, 86, 4);
if (!Check(main->get_messages_open(), "Messages must open directly from the application menu")) return false;
screen.Key(slint::platform::key_codes::Escape);
if (!Check(!main->get_messages_open(), "Escape must dismiss Messages")) return false;
screen.Click(width - 38, 20);
screen.Click(width - 78, 132, 4);
if (!Check(main->get_about_open(), "About must open directly from the application menu")) return false;
screen.Key(slint::platform::key_codes::Escape);
return Check(!main->get_about_open(), "Escape must dismiss About");
}
bool VerifyAutostartSettings(RenderTestPlatform& platform) {
auto main = crossdesk::ui::MainWindow::create();
auto& screen = *platform.adapter;
using Status = crossdesk::ui::AutostartStatus;
const float width = main->get_minimum_window_width();
const float height = main->get_minimum_window_height();
main->window().set_size(slint::LogicalSize({width, height}));
main->set_autostart_system_managed(true);
main->set_autostart_enabled(true); // Stale app preference must not label system state.
main->set_autostart_status(Status::Disabled);
int refreshes = 0;
main->on_refresh_autostart_status([&] { ++refreshes; });
main->show(); main->set_settings_open(true); screen.Settle();
if (!Check(refreshes == 1, "Opening managed autostart settings must query the system")) return false;
const float top = (height - 446) / 2;
main->window().dispatch_pointer_scroll_event(slint::LogicalPosition({width / 2, top + 260}), 0, -1200);
screen.Settle();
const float toggle_x = width / 2 + 144, toggle_y = top + 322;
const auto toggle_pixels = [&] {
return screen.Region(int(toggle_x - 18), int(toggle_y - 10), 36, 20);
};
const auto save_snapshot = [&](const std::string& suffix) {
if (const char* snapshot = std::getenv("CROSSDESK_AUTOSTART_UI_SNAPSHOT")) {
std::ofstream out(std::string(snapshot) + suffix, std::ios::binary);
out << "P6\n" << screen.extent.width << ' ' << screen.extent.height << "\n255\n";
for (const auto pixel : screen.pixels) { out.put(pixel.r); out.put(pixel.g); out.put(pixel.b); }
}
};
const auto disabled = toggle_pixels();
save_snapshot(".settings.ppm");
main->set_autostart_status(Status::Enabled); screen.Settle();
const auto enabled = toggle_pixels();
if (!Check(enabled != disabled,
"The autostart switch must reflect system changes while settings stay open")) return false;
main->set_autostart_status(Status::Disabled); screen.Settle();
screen.Click(toggle_x, toggle_y);
if (!Check(main->get_autostart_guide_open() && main->get_autostart_status() == Status::Disabled &&
main->get_autostart_enabled(),
"Clicking the disabled system switch opens guidance without changing state or preference")) return false;
screen.Key(slint::platform::key_codes::Escape);
if (!Check(toggle_pixels() == disabled, "The system switch must not flip when guidance closes")) return false;
screen.Click(toggle_x, toggle_y);
// Both actions stay below the scrollable instructions at minimum size.
const auto button_y = [&]() {
const int x = int(width / 2 + 120), y = int(height / 2 + 158);
const auto p = screen.pixels.at(y * screen.extent.width + x);
return p.b > 180 && p.r < 90 && p.g > 80 && p.g < 160 ? y : -1;
};
int refresh_y = button_y();
if (!Check(refresh_y > 0, "Autostart guide buttons must fit inside the minimum window")) return false;
save_snapshot("");
screen.Click(width / 2 - 145, refresh_y);
if (!Check(refreshes == 2, "Autostart guide Refresh must query the actual system")) return false;
main->set_autostart_status(Status::Checking); screen.Settle();
screen.Click(width / 2 - 145, button_y());
if (!Check(refreshes == 2, "Refresh is disabled while the system query is pending")) return false;
for (const auto status : {Status::Failed, Status::Unsupported, Status::Enabled}) {
main->set_autostart_status(status); screen.Settle();
if (!Check(button_y() > 0, "Error and unsupported states must remain readable and dismissible")) return false;
}
screen.Key(slint::platform::key_codes::Escape);
if (!Check(!main->get_autostart_guide_open() && main->get_settings_open(),
"Escape closes the guide without cancelling other settings")) return false;
if (!Check(toggle_pixels() == enabled, "The switch must still follow system state after opening guidance")) return false;
screen.Click(toggle_x, toggle_y);
if (!Check(main->get_autostart_guide_open() && main->get_autostart_status() == Status::Enabled,
"Clicking the enabled system switch must also open guidance without changing state")) return false;
screen.Click(width / 2 + 160, button_y());
if (!Check(!main->get_autostart_guide_open(), "The guide confirmation must close immediately")) return false;
if (!Check(toggle_pixels() == enabled, "Dismissing guidance must keep the system switch enabled")) return false;
main->set_autostart_guide_open(true); main->set_settings_open(false); screen.Settle();
if (!Check(!main->get_autostart_guide_open(), "Closing settings must also dismiss its guide")) return false;
main->set_autostart_system_managed(false);
main->set_settings_open(true); screen.Settle();
main->window().dispatch_pointer_scroll_event(slint::LogicalPosition({width / 2, top + 260}), 0, -1200);
screen.Settle();
screen.Click(toggle_x, toggle_y);
return Check(refreshes == 2 && !main->get_autostart_enabled() && !main->get_autostart_guide_open(),
"Other platforms toggle their autostart preference directly without querying Harmony or opening guidance");
}
bool VerifyGalleryPopups(RenderTestPlatform& platform) {
auto wall = crossdesk::ui::MainWindow::create();
auto& screen = *platform.adapter;
wall->set_gallery_mode(true);
wall->window().set_size(slint::LogicalSize({900, 600}));
wall->set_gallery_count(8);
wall->set_gallery_connected(8);
wall->set_gallery_groups(std::make_shared<slint::VectorModel<slint::SharedString>>(
std::vector<slint::SharedString>{"All", "Lab"}));
auto network = std::make_shared<slint::VectorModel<crossdesk::ui::GalleryNetworkDevice>>();
wall->set_gallery_network_devices(network);
wall->show();
unsigned ticks = 0;
slint::Timer sync;
sync.start(slint::TimerMode::Repeated, 16ms, [&] {
crossdesk::ui::GalleryNetworkDevice row{};
row.id = "123456789"; row.name = "Test device";
row.inbound = slint::SharedString(std::to_string(++ticks) + " kbps");
crossdesk::ui::UpdateModelRows(*network, std::vector(8, row));
});
screen.Settle();
const auto baseline = screen.Region(75, 100, 740, 400);
for (int repeat = 0; repeat < 2; ++repeat) {
screen.Click(160, 578);
if (!Check(wall->get_gallery_network_open() && screen.Region(75, 100, 740, 400) != baseline,
"DeskHub network dialog must paint immediately during live refresh")) return false;
if (repeat == 0) screen.Click(780, 102, 4);
else screen.Key(slint::platform::key_codes::Escape);
if (!Check(!wall->get_gallery_network_open() && screen.Region(75, 100, 740, 400) == baseline,
"DeskHub network dialog must close completely without pointer movement")) return false;
}
// Open the add menu, leave the pointer on it, then select its first row.
const auto menu_closed = screen.Region(490, 42, 220, 60);
screen.Click(680, 24);
if (!Check(screen.Region(490, 42, 220, 60) != menu_closed,
"DeskHub add menu must appear without pointer movement")) return false;
screen.Click(600, 55, 4);
if (!Check(wall->get_gallery_adding(), "Add menu must open the device dialog")) return false;
screen.Click(458, 421, 4);
return Check(!wall->get_gallery_adding(), "Add dialog Cancel must respond immediately");
}
bool VerifyServerPanel(RenderTestPlatform& platform) {
auto panel = crossdesk::ui::ServerWindow::create();
auto& screen = *platform.adapter;
panel->set_controllers(std::make_shared<slint::VectorModel<crossdesk::ui::ControllerEntry>>(
std::vector<crossdesk::ui::ControllerEntry>{{"1", "First"}, {"2", "Second"}}));
panel->window().set_size(slint::LogicalSize({225, panel->get_content_height()}));
panel->show();
int selected = -1, disconnected = 0, picked = 0;
panel->on_controller_selected([&](int index) { selected = index; });
panel->on_disconnect_all([&] { ++disconnected; });
panel->on_select_file([&] { ++picked; panel->set_file_transfer_visible(true); });
slint::Timer sync;
sync.start(slint::TimerMode::Repeated, 16ms, [&] {
const auto height = unsigned(panel->get_content_height());
if (screen.extent.height != height)
panel->window().set_size(slint::LogicalSize({225, float(height)}));
panel->set_file_progress(0.5);
});
screen.Settle();
screen.Click(50, 84, 4);
if (!Check(selected == 1, "Controlled-side controller selection must respond to a held click")) return false;
const auto normal_height = screen.extent.height;
screen.Click(180, 123, 4);
if (!Check(panel->get_confirming_disconnect() && screen.extent.height == normal_height + 36,
"Disconnect confirmation must expand immediately")) return false;
screen.Click(110, 123, 4);
if (!Check(!panel->get_confirming_disconnect() && disconnected == 0 && screen.extent.height == normal_height,
"Cancel must collapse the controlled-side confirmation")) return false;
screen.Click(180, 123, 4);
screen.Click(180, 123, 4);
if (!Check(disconnected == 1 && !panel->get_confirming_disconnect(),
"Disconnect all must require and accept confirmation")) return false;
screen.Click(110, 123, 4);
return Check(picked == 1 && screen.extent.height == normal_height + 58,
"Controlled-side file progress must resize the panel immediately");
}
} // namespace
int main() {
auto platform = std::make_unique<RenderTestPlatform>();
auto* backend = platform.get();
slint::platform::set_platform(std::move(platform));
if (!VerifyStreamPopups(*backend)) return 1;
if (!VerifyWorkbenchPopups(*backend)) return 2;
if (!VerifyGalleryPopups(*backend)) return 3;
if (!VerifyServerPanel(*backend)) return 4;
if (!VerifyAutostartSettings(*backend)) return 5;
std::cout << "Slint popup rendering and local input passed.\n";
}
@@ -0,0 +1,91 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SLINT_RENDER_TEST_PLATFORM_H_
#define _SLINT_RENDER_TEST_PLATFORM_H_
#include <slint-platform.h>
#include <chrono>
#include <cstdint>
#include <memory>
#include <thread>
#include <vector>
// Retain pixels and honor redraw notifications, as the Harmony adapter does.
// take_snapshot() forces a repaint and can conceal a stalled popup/animation.
class RenderTestWindow : public slint::platform::WindowAdapter {
public:
slint::platform::SoftwareRenderer software{
slint::platform::SoftwareRenderer::RepaintBufferType::ReusedBuffer};
slint::PhysicalSize extent{{1280, 720}};
std::vector<slint::Rgb8Pixel> pixels = std::vector<slint::Rgb8Pixel>(1280 * 720);
bool dirty = true;
slint::PhysicalSize size() override { return extent; }
slint::platform::AbstractRenderer& renderer() override { return software; }
void request_redraw() override { dirty = true; }
void set_size(slint::PhysicalSize value) override {
extent = value;
pixels.resize(extent.width * extent.height);
window().dispatch_resize_event(slint::LogicalSize(
{float(extent.width), float(extent.height)}));
dirty = true;
}
void Render() {
if (!dirty && !window().has_active_animations()) return;
dirty = false;
software.render(pixels, extent.width);
}
void Settle(int frames = 15) {
for (int i = 0; i < frames; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(16));
slint::platform::update_timers_and_animations();
Render();
}
}
void Click(float x, float y, int hold_frames = 0) {
const slint::LogicalPosition point({x, y});
window().dispatch_pointer_move_event(point);
window().dispatch_pointer_press_event(point, slint::PointerEventButton::Left);
Settle(hold_frames);
window().dispatch_pointer_release_event(point, slint::PointerEventButton::Left);
Settle(); // Do not move the pointer to make a delayed repaint pass.
}
bool Light(int x, int y) const {
const auto p = pixels.at(y * extent.width + x);
return p.r >= 230 && p.g >= 230 && p.b >= 230;
}
bool Black(int x, int y) const {
const auto p = pixels.at(y * extent.width + x);
return p.r == 0 && p.g == 0 && p.b == 0;
}
std::uint64_t Region(int x, int y, int width, int height) const {
std::uint64_t hash = 14695981039346656037ull;
for (int row = y; row < y + height; ++row)
for (int col = x; col < x + width; ++col) {
const auto p = pixels.at(row * extent.width + col);
for (auto channel : {p.r, p.g, p.b}) hash = (hash ^ channel) * 1099511628211ull;
}
return hash;
}
void Key(const slint::SharedString& key) {
window().dispatch_key_press_event(key);
window().dispatch_key_release_event(key);
Settle();
}
};
class RenderTestPlatform : public slint::platform::Platform {
public:
RenderTestWindow* adapter = nullptr;
std::unique_ptr<slint::platform::WindowAdapter> create_window_adapter() override {
auto result = std::make_unique<RenderTestWindow>();
adapter = result.get();
return result;
}
};
#endif
@@ -0,0 +1,112 @@
#include "crossdesk_ui.h"
#include "ui/model_update.h"
#include <slint-platform.h>
#include <chrono>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
namespace {
// Match the retained software buffer used by the Harmony desktop adapter.
// A full window snapshot would conceal missing damage/redraw notifications.
class TestWindow : public slint::platform::WindowAdapter {
public:
slint::platform::SoftwareRenderer software{
slint::platform::SoftwareRenderer::RepaintBufferType::ReusedBuffer};
std::vector<slint::Rgb8Pixel> pixels = std::vector<slint::Rgb8Pixel>(1280 * 720);
bool dirty = true;
slint::PhysicalSize size() override { return slint::PhysicalSize({1280, 720}); }
slint::platform::AbstractRenderer& renderer() override { return software; }
void request_redraw() override { dirty = true; }
void Render() {
if (!dirty && !window().has_active_animations()) return;
dirty = false;
software.render(pixels, 1280);
}
};
class TestPlatform : public slint::platform::Platform {
public:
TestWindow* adapter = nullptr;
std::unique_ptr<slint::platform::WindowAdapter> create_window_adapter() override {
auto result = std::make_unique<TestWindow>();
adapter = result.get();
return result;
}
};
} // namespace
int main() {
auto platform = std::make_unique<TestPlatform>();
auto* backend = platform.get();
slint::platform::set_platform(std::move(platform));
auto stream = crossdesk::ui::StreamWindow::create();
auto& adapter = *backend->adapter;
stream->window().dispatch_resize_event(slint::LogicalSize({1280, 720}));
stream->show();
auto rows = std::make_shared<slint::VectorModel<crossdesk::ui::NetworkStatsRow>>();
stream->set_stats_rows(rows);
std::vector<crossdesk::ui::NetworkStatsRow> stats(4);
unsigned samples = 0;
bool stats_visible = false;
stream->on_toggle_stats([&] { stats_visible = !stats_visible; });
// Synchronize inside a Slint timer, exactly as GuiApplication does. Updating
// before update_timers_and_animations would use the previous animation tick.
slint::Timer sync;
sync.start(slint::TimerMode::Repeated, std::chrono::milliseconds(16), [&] {
stream->set_stats_visible(stats_visible);
// Keep values changing while the panel animates, as during a live session.
stats[0].inbound = slint::SharedString(std::to_string(++samples) + " kbps");
crossdesk::ui::UpdateModelRows(*rows, stats);
});
const auto tick = [&] {
slint::platform::update_timers_and_animations();
adapter.Render();
};
const auto settle = [&](int frames = 12) {
for (int i = 0; i < frames; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(16));
tick();
}
};
settle();
const float top = stream->get_video_area_top();
for (bool dock_right : {false, true}) {
if (dock_right) {
stream->invoke_begin_control_drag(5, 20);
stream->invoke_move_control_drag(1275, 20);
stream->invoke_end_control_drag();
settle();
}
const int control_x = dock_right ? 916 : 0;
const slint::LogicalPosition button({float(control_x + (dock_right ? 278 : 245)), top + 19});
stream->window().dispatch_pointer_move_event(button);
settle(35); // Include the tooltip that appears before the click.
for (bool expanded : {true, false, true, false}) {
stream->window().dispatch_pointer_press_event(button, slint::PointerEventButton::Left);
stream->window().dispatch_pointer_release_event(button, slint::PointerEventButton::Left);
settle(); // No pointer movement: animation must finish on its own.
for (int y : {65, 195}) {
const auto pixel = adapter.pixels[(int(top) + y) * 1280 + control_x + 4];
const bool painted = pixel.r >= 240 && pixel.g >= 240 && pixel.b >= 240;
const bool cleared = pixel.r == 0 && pixel.g == 0 && pixel.b == 0;
if (stats_visible != expanded || (expanded ? !painted : !cleared)) {
std::cerr << "Stats must fully " << (expanded ? "expand" : "collapse")
<< " without pointer movement; dock_right=" << dock_right
<< ", y=" << y << ", pixel=" << int(pixel.r) << ','
<< int(pixel.g) << ',' << int(pixel.b) << '\n';
return 1;
}
if (stream->invoke_is_local_control_area(control_x + 4, y) != expanded) {
std::cerr << "Stats input area must match the rendered panel\n";
return 1;
}
}
}
}
std::cout << "Stream stats retained-buffer rendering passed.\n";
}
+66 -1
View File
@@ -4,6 +4,8 @@
#include "ui/release_notes_text.h"
#include "ui/ui_localization.h"
#include <slint-platform.h>
#include <algorithm>
#include <cassert>
#include <charconv>
@@ -14,6 +16,7 @@
#include <memory>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
namespace {
@@ -845,9 +848,71 @@ bool VerifyWorkbenchActions() {
return true;
}
bool VerifySystemShortcutMenu() {
auto stream = crossdesk::ui::StreamWindow::create();
stream->window().set_size(slint::LogicalSize(slint::Size<float>{1280, 720}));
std::vector<std::string> commands;
stream->on_send_shortcut([&](slint::SharedString command) { commands.emplace_back(command); });
const auto click = [&](float y) {
const slint::LogicalPosition point({55, stream->get_video_area_top() + y});
stream->window().dispatch_pointer_move_event(point);
stream->window().dispatch_pointer_press_event(point, slint::PointerEventButton::Left);
stream->window().dispatch_pointer_release_event(point, slint::PointerEventButton::Left);
};
click(20);
if (!stream->invoke_is_local_control_area(55, 90)) return false;
click(58);
stream->set_secure_attention_supported(false);
click(20);
if (stream->invoke_is_local_control_area(55, 90)) return false;
click(58);
stream->set_secure_attention_supported(true);
click(20); click(88);
return commands == std::vector<std::string>{"Ctrl+Alt+Del", "Win+L", "Win+L"};
}
bool VerifyRemoteIdEditing() {
auto window = crossdesk::ui::MainWindow::create();
window->window().set_size(slint::LogicalSize(slint::Size<float>{
window->get_minimum_window_width(), window->get_minimum_window_height()}));
window->on_format_remote_id([](slint::SharedString text) { return text; });
window->invoke_reset_remote_id();
const auto key = [&](const slint::SharedString& text) {
window->window().dispatch_key_press_event(text);
window->window().dispatch_key_release_event(text);
};
const auto settle = [] {
for (int i = 0; i < 10; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(2));
slint::platform::update_timers_and_animations();
}
};
settle(); // Let the initial timer state settle before the first user edit.
for (char digit : std::string("1234")) {
key(slint::SharedString(std::string(1, digit)));
settle(); // Separate edits must not leave the formatter repeating.
}
if (std::string(window->get_remote_id_input()) != "1234") return false;
window->window().dispatch_key_press_event(slint::platform::key_codes::Control);
key("a");
window->window().dispatch_key_release_event(slint::platform::key_codes::Control);
settle(); // Formatting must not repeatedly collapse the selection.
key("5");
settle();
return std::string(window->get_remote_id_input()) == "5";
}
} // namespace
int main() {
if (!VerifySystemShortcutMenu()) {
std::cerr << "Shortcut menu must omit unsupported SAS and resize its input area\n";
return 13;
}
if (!VerifyRemoteIdEditing()) {
std::cerr << "Remote ID editor must preserve Ctrl+A selection after formatting\n";
return 12;
}
const CaptureOptions capture_options = LoadCaptureOptions();
if (!VerifyMainWindowGeometry()) {
std::cerr << "Main window dimensions must stay stable through startup\n";
@@ -1154,7 +1219,7 @@ int main() {
stream->set_remote_update_visible(true);
const auto notice_button = slint::LogicalPosition(slint::Point<float>{
816.0f, stream->get_video_area_top() + notice_y - 24.0f + 158.0f});
764.0f, stream->get_video_area_top() + notice_y - 24.0f + 124.0f});
stream->window().dispatch_pointer_press_event(
notice_button, slint::PointerEventButton::Left);
stream->window().dispatch_pointer_release_event(
+36 -3
View File
@@ -154,9 +154,11 @@ function setup_targets()
add_includedirs("apps/desktop/src", {public = true})
if is_os("windows") then
add_files("apps/desktop/src/platform/windows/single_instance.cpp")
add_syslinks("Advapi32")
add_files("apps/desktop/src/platform/windows/gui/instance_activation.cpp")
add_syslinks("Advapi32", "User32")
else
add_files("apps/desktop/src/platform/common/posix_single_instance.cpp")
add_files("apps/desktop/src/platform/common/posix_instance_activation.cpp")
end
target("repository_structure_test")
@@ -230,6 +232,32 @@ function setup_targets()
add_files("apps/desktop/tests/slint_ui_smoke_test.cpp")
add_rules("crossdesk.slint_runtime")
target("slint_stream_render_test")
set_kind("binary")
set_languages("c++20")
set_default(false)
if is_os("windows") then
add_cxxflags("/bigobj")
end
add_packages("slint")
add_deps("crossdesk_ui")
add_includedirs("apps/desktop/src/gui")
add_files("apps/desktop/tests/slint_stream_render_test.cpp")
add_rules("crossdesk.slint_runtime")
target("slint_popup_render_test")
set_kind("binary")
set_languages("c++20")
set_default(false)
if is_os("windows") then
add_cxxflags("/bigobj")
end
add_packages("slint")
add_deps("crossdesk_ui")
add_includedirs("apps/desktop/src/gui")
add_files("apps/desktop/tests/slint_popup_render_test.cpp")
add_rules("crossdesk.slint_runtime")
target("version_checker_test")
set_kind("binary")
set_default(false)
@@ -485,6 +513,7 @@ function setup_targets()
add_files("apps/desktop/src/platform/linux/clipboard.cpp")
end
add_includedirs("apps/desktop/src", "apps/desktop/src/tools", {public = true})
add_includedirs("libs/controller/include", {public = true})
target("gui")
set_kind("object")
@@ -501,6 +530,7 @@ function setup_targets()
add_defines("CROSSDESK_PORTABLE=1", {public = true})
end
add_deps("rd_log", "desktop_common", "assets", "config_center", "minirtc",
"single_instance",
"path_manager", "screen_capturer", "speaker_capturer",
"device_controller", "thumbnail", "version_checker", "tools",
"crossdesk_wire", "privacy", "crossdesk_ui")
@@ -549,6 +579,7 @@ function setup_targets()
add_links("GL")
add_deps("headless_console")
add_files("apps/desktop/src/platform/linux/gui/tray/linux_tray.cpp",
"apps/desktop/src/platform/linux/gui/wayland_activation.cpp",
"apps/desktop/src/platform/linux/gui/cuda_graphics.cpp",
"apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp",
"apps/desktop/src/platform/common/gui/cuda_driver.cpp",
@@ -584,7 +615,8 @@ function setup_targets()
add_deps("rd_log", "path_manager", "windows_unattended_config")
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
add_files("apps/desktop/src/platform/windows/service/main.cpp",
"apps/desktop/src/platform/windows/service/service_host.cpp")
"apps/desktop/src/platform/windows/service/service_host.cpp",
"apps/desktop/src/platform/windows/service/rdp_session_handoff.cpp")
add_files("apps/desktop/resources/windows/crossdesk_service.rc",
{defines = windows_resource_defines})
add_includedirs("apps/desktop/src/platform/windows/service",
@@ -616,7 +648,8 @@ function setup_targets()
add_includedirs("apps/desktop/src", "apps/desktop/src/app", {public = true})
if is_os("windows") then
add_files("apps/desktop/src/platform/windows/daemon_backend.cpp")
add_files("apps/desktop/src/platform/windows/service/service_host.cpp")
add_files("apps/desktop/src/platform/windows/service/service_host.cpp",
"apps/desktop/src/platform/windows/service/rdp_session_handoff.cpp")
add_includedirs("apps/desktop/src/platform/windows/service",
"apps/desktop/src/platform/windows/screen_capturer")
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
+1
View File
@@ -0,0 +1 @@
Checks: 'misc-missing-switch-cases,misc-napi-module-name,misc-replace-if-else-with-ternary-operator,misc-unused-local-variable,misc-unused-parameters,modernize-use-auto,readability-system-capabilities,misc-napi-macro-module-name,capi-version-validation'
+16
View File
@@ -0,0 +1,16 @@
CompileFlags:
Add: [-Wunreachable-code-aggressive]
Remove: [-fsanitize=hwaddress, -fsanitize=thread]
Diagnostics:
ClangTidy:
Add: [misc-missing-switch-cases,misc-napi-module-name,misc-replace-if-else-with-ternary-operator,misc-unused-local-variable,misc-unused-parameters,modernize-use-auto,readability-system-capabilities,misc-napi-macro-module-name,capi-version-validation]
Remove: []
CheckOptions:
misc-unused-parameters.StrictMode: true
misc-unused-parameters.IgnoreVirtual: true
modernize-use-auto.MinTypeNameLength: 0
UnusedIncludes: Strict
UnusedFunctions: Check
Hover:
ShowNewHover: Yes
+25
View File
@@ -0,0 +1,25 @@
.hvigor/
.native/
.idea/
**/__pycache__/
*.py[cod]
oh_modules/
**/oh_modules/
**/build/
**/.cxx/
native/.xmake/
entry/libs/
local.properties
*.p12
*.p7b
*.cer
*.csr
# Generated mobile legal resources; regenerated from checked-in sources and the SDK.
entry/src/main/resources/rawfile/about/
entry/src/main/resources/rawfile/LICENSE
entry/src/main/resources/rawfile/PRIVACY.md
entry/src/main/resources/rawfile/OPEN_SOURCE_NOTICE.txt
entry/src/main/resources/rawfile/THIRD_PARTY_NOTICES.txt
entry/src/main/resources/rawfile/ThirdPartyLicenses.json
entry/src/main/resources/rawfile/SourceMetadata.json
+10
View File
@@ -0,0 +1,10 @@
{
"app": {
"bundleName": "com.chunkdash.crossdesk",
"vendor": "CrossDesk",
"versionCode": 10000,
"versionName": "0.1.0",
"icon": "$media:app_icon",
"label": "$string:app_name"
}
}
@@ -0,0 +1,8 @@
{
"string": [
{
"name": "app_name",
"value": "CrossDesk"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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