mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 17:10:20 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40670de954 | ||
|
|
33cbfa5b8a | ||
|
|
81430d78d9 | ||
|
|
aed6ad99c2 | ||
|
|
1e2520d472 | ||
|
|
697843cccd | ||
|
|
45bf6ec4ee | ||
|
|
43a0789c1e | ||
|
|
c4756b298b | ||
|
|
47e6385f9a | ||
|
|
9f610bfc9b |
+17
-9
@@ -1,18 +1,18 @@
|
|||||||
# CrossDesk Privacy Policy
|
# CrossDesk Privacy Policy
|
||||||
|
|
||||||
Last updated: October 10, 2026
|
Last updated: October 15, 2026
|
||||||
|
|
||||||
[中文](#中文) | [English](#english)
|
[中文](#中文) | [English](#english)
|
||||||
|
|
||||||
## 中文
|
## 中文
|
||||||
|
|
||||||
更新日期:2026 年 10 月 10 日
|
更新日期:2026 年 10 月 15 日
|
||||||
|
|
||||||
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
|
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
|
||||||
|
|
||||||
### 一、适用范围
|
### 一、适用范围
|
||||||
|
|
||||||
本政策适用于 CrossDesk 桌面客户端、iOS 与 Android 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
|
本政策适用于 CrossDesk 桌面客户端、iOS、Android 与鸿蒙移动客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
|
||||||
|
|
||||||
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
|
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
|
||||||
|
|
||||||
@@ -50,11 +50,13 @@ Android 中的设备身份及您选择保存的访问密码使用 Android Keysto
|
|||||||
|
|
||||||
iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。
|
iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。
|
||||||
|
|
||||||
Android 默认信令连接同样进行 TLS 证书校验。
|
Android 与鸿蒙移动端默认信令连接同样进行 TLS 证书校验。
|
||||||
|
|
||||||
|
鸿蒙移动端使用系统 Asset Store 加密保存设备身份及您选择保存的访问密码,明确禁止凭据跨设备同步。设置和连接记录按服务器隔离。远程预览默认关闭,单独确认后从下一次连接起保存一张 640×360 预览;预览保存在应用私有缓存中,不上传,可随时清除,也可能被系统回收。关闭预览或撤回联网授权会清除预览。接收文件保存在应用私有目录,您可在会话中通过系统文件选择器导出。
|
||||||
|
|
||||||
### 五、移动端权限与授权选择
|
### 五、移动端权限与授权选择
|
||||||
|
|
||||||
**隐私授权。** iOS 与 Android 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
|
**隐私授权。** iOS、Android 与鸿蒙移动端 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
|
||||||
|
|
||||||
**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。
|
**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。
|
||||||
|
|
||||||
@@ -64,6 +66,8 @@ Android 客户端同样作为控制端接收远程画面与音频,不采集本
|
|||||||
|
|
||||||
Android 远程会话优先使用系统画中画在切换应用后继续显示画面。画中画不可用时,通过前台服务和连接通知保持您已建立的远程连接;后台暂停本地画面渲染和音频播放,不申请悬浮窗权限。通知权限用于提供返回及断开入口,CPU 唤醒锁用于会话后台期间维持连接,返回前台或会话结束时释放。您可以在应用或连接通知中断开,移除应用任务也会结束会话及保活服务。
|
Android 远程会话优先使用系统画中画在切换应用后继续显示画面。画中画不可用时,通过前台服务和连接通知保持您已建立的远程连接;后台暂停本地画面渲染和音频播放,不申请悬浮窗权限。通知权限用于提供返回及断开入口,CPU 唤醒锁用于会话后台期间维持连接,返回前台或会话结束时释放。您可以在应用或连接通知中断开,移除应用任务也会结束会话及保活服务。
|
||||||
|
|
||||||
|
鸿蒙手机和平板只作为控制端,不申请本机录屏、输入注入、摄像头或麦克风权限;文件选择和剪贴板操作由您主动触发。已建立会话在切入后台时优先使用系统画中画,提供静音控制;画中画不可用时申请数据传输后台任务并显示系统通知。若系统不允许或停止后台任务且画中画不可用,会话会断开。后台切换释放远端按键;空闲后台不维持信令连接。完成联网授权后,应用在前台检查鸿蒙本机版本,并在连接后检查远端版本;版本请求不携带设备身份或远端版本。
|
||||||
|
|
||||||
应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。
|
应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。
|
||||||
|
|
||||||
### 六、查询与删除请求
|
### 六、查询与删除请求
|
||||||
@@ -86,13 +90,13 @@ Android 远程会话优先使用系统画中画在切换应用后继续显示画
|
|||||||
|
|
||||||
## English
|
## English
|
||||||
|
|
||||||
Updated: October 2, 2026
|
Updated: October 15, 2026
|
||||||
|
|
||||||
CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software.
|
CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software.
|
||||||
|
|
||||||
### 1. Scope
|
### 1. Scope
|
||||||
|
|
||||||
This policy applies to the CrossDesk desktop, iOS and Android clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
|
This policy applies to the CrossDesk desktop, iOS, Android and HarmonyOS mobile clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
|
||||||
|
|
||||||
If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply.
|
If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply.
|
||||||
|
|
||||||
@@ -130,11 +134,13 @@ On Android, device identities and access passwords you choose to save are encryp
|
|||||||
|
|
||||||
On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people.
|
On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people.
|
||||||
|
|
||||||
Default signaling connections on Android also validate TLS certificates.
|
Default signaling connections on Android and HarmonyOS mobile also validate TLS certificates.
|
||||||
|
|
||||||
|
HarmonyOS mobile uses the system Asset Store to encrypt device identities and saved access passwords, with cross-device credential synchronization disabled. Settings and connection records are scoped to the server. Remote previews are off by default. After separate confirmation, one 640×360 preview is saved per connection starting with the next connection. Previews remain in private app cache, are never uploaded, can be cleared and may be reclaimed by the system. Disabling previews or withdrawing network consent clears them. Received files remain in private app storage and can be exported through the system document picker during the session.
|
||||||
|
|
||||||
### 5. Mobile permissions and consent choices
|
### 5. Mobile permissions and consent choices
|
||||||
|
|
||||||
**Privacy authorization.** On first use of iOS or Android, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
|
**Privacy authorization.** On first use of iOS, Android or HarmonyOS mobile, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
|
||||||
|
|
||||||
**Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data.
|
**Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data.
|
||||||
|
|
||||||
@@ -144,6 +150,8 @@ The Android client also receives remote screens and audio as a controller and do
|
|||||||
|
|
||||||
Android remote sessions prefer system picture-in-picture to continue displaying video when you switch apps. If picture-in-picture is unavailable, a foreground service and connection notification maintain the remote connection you have established. Local video rendering and audio playback pause in the background; no overlay permission is requested. Notification permission provides return and disconnect actions. A CPU wake lock helps maintain the connection while the session is backgrounded and is released on foreground return or session end. You can disconnect in the app or its connection notification; removing the app task also ends the session and its foreground service.
|
Android remote sessions prefer system picture-in-picture to continue displaying video when you switch apps. If picture-in-picture is unavailable, a foreground service and connection notification maintain the remote connection you have established. Local video rendering and audio playback pause in the background; no overlay permission is requested. Notification permission provides return and disconnect actions. A CPU wake lock helps maintain the connection while the session is backgrounded and is released on foreground return or session end. You can disconnect in the app or its connection notification; removing the app task also ends the session and its foreground service.
|
||||||
|
|
||||||
|
HarmonyOS phones and tablets act only as controllers and do not request local screen recording, input injection, camera or microphone permissions. File selection and clipboard actions are user initiated. An established session prefers system picture-in-picture with a mute control when backgrounded. If unavailable, the app requests a data-transfer background task with a system notification. The session ends if the system disallows or stops that task while picture-in-picture is unavailable. Backgrounding releases remote keys; idle background sessions do not retain signaling. After network consent, the foreground app checks the local HarmonyOS version and checks the remote version after connection. These requests contain neither device identity nor the remote version.
|
||||||
|
|
||||||
The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking.
|
The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking.
|
||||||
|
|
||||||
### 6. Access and deletion requests
|
### 6. Access and deletion requests
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](#download)
|
[](#download)
|
||||||
[](https://github.com/kunkundi/crossdesk/releases)
|
[](https://github.com/kunkundi/crossdesk/releases)
|
||||||
[](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
|
[](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS / Android 客户端控制电脑。桌面端新增 **桌面墙(DeskHub)**,可在独立窗口中同时查看多台远程桌面,按分组管理设备,并进行批量操作与群控。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。
|
CrossDesk 支持 Windows、macOS、Linux 与 **鸿蒙(HarmonyOS)PC** 之间的远程访问,也可从浏览器或原生 iOS / Android / 鸿蒙手机和平板客户端控制电脑。鸿蒙 PC 与移动端目前为开发版,具体要求见 [鸿蒙支持](#harmonyos)。桌面端提供 **桌面墙(DeskHub)**,可在独立窗口中同时查看多台远程桌面,按分组管理设备,并进行批量操作与群控。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。
|
||||||
|
|
||||||
> 本文按当前仓库代码整理。截图展示当前 Slint 桌面界面,使用演示 ID、密码、在线状态和连接记录;缩略图来自公开页面;下载包的功能与系统要求请同时核对对应 Release 说明。原生 iOS 客户端的构建、签名方式见下文。
|
> 本文按当前仓库代码整理。截图展示当前 Slint 桌面界面,使用演示 ID、密码、在线状态和连接记录;缩略图来自公开页面;下载包的功能与系统要求请同时核对对应 Release 说明。原生 iOS 客户端的构建、签名方式见下文。
|
||||||
|
|
||||||
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [桌面墙](#deskhub) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [Android](#android) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md)
|
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [桌面墙](#deskhub) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [Android](#android) · [鸿蒙](#harmonyos) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md)
|
||||||
|
|
||||||
<a id="preview"></a>
|
<a id="preview"></a>
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
|||||||
|
|
||||||
| 能力 | 当前支持 |
|
| 能力 | 当前支持 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS / Android 作为控制端 |
|
| 跨平台控制 | Windows / macOS / Linux / 鸿蒙 PC 桌面互控;浏览器与原生 iOS / Android / 鸿蒙手机和平板作为控制端;鸿蒙目前为开发版 |
|
||||||
| 实时画面与声音 | H.264 / AV1、硬件编解码、远端声音播放;会话中可调整画质、30 / 60 fps 与画面偏好 |
|
| 实时画面与声音 | H.264 / AV1、硬件编解码、远端声音播放;会话中可调整画质、30 / 60 fps 与画面偏好 |
|
||||||
| 多设备与多显示器 | 近期连接、设备别名与分组筛选、会话标签与远端显示器切换 |
|
| 多设备与多显示器 | 近期连接、设备别名与分组筛选、会话标签与远端显示器切换 |
|
||||||
| 桌面墙(DeskHub) | 多桌面实时预览、分组与拖动排序、放大接管、群控、批量连接 / 断开 / 静音 / 文件分发;可配置分辨率、帧率与视频总带宽 |
|
| 桌面墙(DeskHub) | 多桌面实时预览、分组与拖动排序、放大接管、群控、批量连接 / 断开 / 静音 / 文件分发;可配置分辨率、帧率与视频总带宽 |
|
||||||
@@ -65,7 +65,7 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
|||||||
| Windows 锁屏控制 | 通过 CrossDesk Service 支持锁屏、登录界面及安全桌面的输入转发 |
|
| Windows 锁屏控制 | 通过 CrossDesk Service 支持锁屏、登录界面及安全桌面的输入转发 |
|
||||||
| Linux 无头运行 | 复用已有桌面和应用,或创建独立 Xvfb 虚拟桌面;终端控制台可查看状态、修改密码和设置,见 [运行说明](docs/HEADLESS.md) |
|
| Linux 无头运行 | 复用已有桌面和应用,或创建独立 Xvfb 虚拟桌面;终端控制台可查看状态、修改密码和设置,见 [运行说明](docs/HEADLESS.md) |
|
||||||
|
|
||||||
各客户端的入口和能力有所不同;桌面会话操作见下文,iOS 的具体功能见 [开发说明](apps/ios/README.md)。
|
各客户端的入口和能力有所不同;桌面会话操作见下文,移动端具体功能见 [iOS](apps/ios/README.md)、[Android](apps/android/README.md) 与 [鸿蒙](apps/harmony/README.md) 开发说明。
|
||||||
|
|
||||||
<a id="download"></a>
|
<a id="download"></a>
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
|||||||
| Linux | Ubuntu 20.04+,amd64 / arm64,glibc 2.31 基线 | 普通版 / Xfce 虚拟桌面版 `.deb` |
|
| Linux | Ubuntu 20.04+,amd64 / arm64,glibc 2.31 基线 | 普通版 / Xfce 虚拟桌面版 `.deb` |
|
||||||
| iOS / iPadOS | iOS 16.0+,arm64 真机 | 原生客户端;使用 Xcode 构建并签名,CI 产物为未签名应用 |
|
| iOS / iPadOS | iOS 16.0+,arm64 真机 | 原生客户端;使用 Xcode 构建并签名,CI 产物为未签名应用 |
|
||||||
| Android | Android 8.0+,arm64 | 原生控制端;使用 Gradle 构建 APK,见 [开发说明](apps/android/README.md) |
|
| Android | Android 8.0+,arm64 | 原生控制端;使用 Gradle 构建 APK,见 [开发说明](apps/android/README.md) |
|
||||||
|
| 鸿蒙 HarmonyOS(开发版) | PC:API 26;手机 / 平板:API 20+;arm64,待真机验收 | 使用 DevEco 构建并签名 HAP;见 [鸿蒙支持](#harmonyos) 与 [构建说明](apps/harmony/README.md) |
|
||||||
| Web | 支持 WebRTC 的浏览器 | 访问 [web.crossdesk.cn](https://web.crossdesk.cn/) |
|
| Web | 支持 WebRTC 的浏览器 | 访问 [web.crossdesk.cn](https://web.crossdesk.cn/) |
|
||||||
|
|
||||||
Linux 每个架构提供两个可独立安装的包,**选择其中一个**:
|
Linux 每个架构提供两个可独立安装的包,**选择其中一个**:
|
||||||
@@ -246,6 +247,21 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
|||||||
|
|
||||||
按 [Android 开发说明](apps/android/README.md) 构建并安装 APK,在首页填写电脑端的设备 ID 和密码即可连接。长按右键、双指滚动,横屏可扩大画面;切换到后台会结束会话。
|
按 [Android 开发说明](apps/android/README.md) 构建并安装 APK,在首页填写电脑端的设备 ID 和密码即可连接。长按右键、双指滚动,横屏可扩大画面;切换到后台会结束会话。
|
||||||
|
|
||||||
|
<a id="harmonyos"></a>
|
||||||
|
|
||||||
|
## 鸿蒙(HarmonyOS)支持
|
||||||
|
|
||||||
|
CrossDesk 已支持鸿蒙 PC、手机和平板,PC 与移动端分别构建 HAP,应用名称统一为 **CrossDesk**,包名统一为 `com.chunkdash.crossdesk`。
|
||||||
|
|
||||||
|
| 客户端 | 系统与架构 | 功能 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 鸿蒙 PC(`2in1`) | API 26,arm64 | 可作为控制端和被控端,复用公共 Slint 桌面界面、会话管理、桌面墙与文件传输 |
|
||||||
|
| 鸿蒙手机 / 平板 | API 20+,arm64 | 原生 ArkUI 控制端,支持远端画面与声音、触控和外接键鼠、多屏切换、文本剪贴板及双向文件传输 |
|
||||||
|
|
||||||
|
鸿蒙 PC、移动端与其他 PC 平台共用文件传输核心。移动端通过系统文件选择器发送和导出文件,PC 可在设置中选择接收目录。
|
||||||
|
|
||||||
|
当前为开发版,需按 [鸿蒙构建说明](apps/harmony/README.md) 使用 DevEco 构建并签名后安装。PC 录屏和输入控制需获得相应系统授权,真机与长期会话验收仍在进行;具体权限、平台差异及验证范围见 [PC 说明](apps/harmony/desktop-port/README.md) 与 [移动端功能说明](apps/harmony/MOBILE_PARITY.md)。
|
||||||
|
|
||||||
<a id="windows-service"></a>
|
<a id="windows-service"></a>
|
||||||
|
|
||||||
## Windows 锁屏与登录界面
|
## Windows 锁屏与登录界面
|
||||||
@@ -313,6 +329,7 @@ Windows 的“开机自启”在当前用户登录后启动客户端。安装版
|
|||||||
- [源码构建](docs/BUILD.md):Windows、macOS、Linux、可选编译参数与打包。
|
- [源码构建](docs/BUILD.md):Windows、macOS、Linux、可选编译参数与打包。
|
||||||
- [iOS 开发说明](apps/ios/README.md):原生端构建和真机验证。
|
- [iOS 开发说明](apps/ios/README.md):原生端构建和真机验证。
|
||||||
- [Android 开发说明](apps/android/README.md):APK 构建、安装与模拟器验证。
|
- [Android 开发说明](apps/android/README.md):APK 构建、安装与模拟器验证。
|
||||||
|
- [鸿蒙开发说明](apps/harmony/README.md):PC 与手机 / 平板 HAP 构建、签名、权限及验证。
|
||||||
- [GUI 架构](docs/gui-architecture.md):桌面 UI、平台实现与工程结构。
|
- [GUI 架构](docs/gui-architecture.md):桌面 UI、平台实现与工程结构。
|
||||||
- [常见问题](docs/FAQ.md):连接失败、黑屏、文件位置与编译问题。
|
- [常见问题](docs/FAQ.md):连接失败、黑屏、文件位置与编译问题。
|
||||||
- [提交问题](https://github.com/kunkundi/crossdesk/issues):请附上两端系统、客户端版本、连接方式和可复现步骤。
|
- [提交问题](https://github.com/kunkundi/crossdesk/issues):请附上两端系统、客户端版本、连接方式和可复现步骤。
|
||||||
@@ -321,7 +338,9 @@ Windows 的“开机自启”在当前用户登录后启动客户端。安装版
|
|||||||
|
|
||||||
感谢 [HelloGitHub](https://hellogithub.com/)、[阮一峰的科技爱好者周刊](https://github.com/ruanyf/weekly) 与 [LinuxDo](https://linux.do) 社区的推荐、交流与支持。
|
感谢 [HelloGitHub](https://hellogithub.com/)、[阮一峰的科技爱好者周刊](https://github.com/ruanyf/weekly) 与 [LinuxDo](https://linux.do) 社区的推荐、交流与支持。
|
||||||
|
|
||||||
CrossDesk 使用 [GPL-3.0](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
|
Copyright (c) 2026 JUNKUN DI.
|
||||||
|
|
||||||
|
CrossDesk 使用 [GPL-3.0-only](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
|
||||||
|
|
||||||
### Code signing policy
|
### Code signing policy
|
||||||
|
|
||||||
|
|||||||
+25
-6
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](#download)
|
[](#download)
|
||||||
[](https://github.com/kunkundi/crossdesk/releases)
|
[](https://github.com/kunkundi/crossdesk/releases)
|
||||||
[](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
|
[](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
|
|
||||||
</div>
|
</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 Windows, macOS, Linux, and **HarmonyOS PCs**, with browser and native iOS / Android / HarmonyOS phone and tablet clients for controlling computers. The HarmonyOS PC and mobile clients are development builds; see [HarmonyOS support](#harmonyos) for requirements. The desktop client 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.
|
||||||
|
|
||||||
> 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.
|
> 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.
|
||||||
|
|
||||||
[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)
|
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [DeskHub](#deskhub) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [HarmonyOS](#harmonyos) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english)
|
||||||
|
|
||||||
<a id="preview"></a>
|
<a id="preview"></a>
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ See the [screenshot notes](docs/images/README.md) for image sources and refresh
|
|||||||
|
|
||||||
| Capability | Current support |
|
| Capability | Current support |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Cross-platform control | Windows / macOS / Linux desktop control; browser and native iOS / Android controllers |
|
| Cross-platform control | Windows / macOS / Linux / HarmonyOS PC desktop control; browser and native iOS / Android / HarmonyOS phone and tablet controllers; HarmonyOS clients are development builds |
|
||||||
| Live video and audio | H.264 / AV1, hardware codecs, and remote audio; adjust quality, 30 / 60 fps, and video preference during a session |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -65,7 +65,7 @@ See the [screenshot notes](docs/images/README.md) for image sources and refresh
|
|||||||
| Windows protected desktops | CrossDesk Service forwards input on lock screens, sign-in screens, and secure desktops |
|
| 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) |
|
| 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.
|
Controls and capabilities vary by client. Desktop controls are described below; see the [iOS](apps/ios/README.md), [Android](apps/android/README.md), and [HarmonyOS](apps/harmony/README.md) guides for native mobile features.
|
||||||
|
|
||||||
<a id="download"></a>
|
<a id="download"></a>
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ Choose a package matching your OS and CPU architecture from [GitHub Releases](ht
|
|||||||
| Linux | Ubuntu 20.04+, amd64 / arm64, glibc 2.31 baseline | Standard / Xfce virtual desktop `.deb` |
|
| 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 |
|
| 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)) |
|
| Android | Android 8.0+, arm64 | Native controller; build an APK with Gradle ([guide](apps/android/README.md)) |
|
||||||
|
| HarmonyOS (development) | PC: API 26; phones / tablets: API 20+; arm64, physical-device validation pending | Build and sign HAPs with DevEco; see [HarmonyOS support](#harmonyos) and the [build guide](apps/harmony/README.md) (Chinese) |
|
||||||
| Web | A WebRTC-capable browser | Open [web.crossdesk.cn](https://web.crossdesk.cn/) |
|
| Web | A WebRTC-capable browser | Open [web.crossdesk.cn](https://web.crossdesk.cn/) |
|
||||||
|
|
||||||
Linux provides two standalone packages per architecture. **Choose one**:
|
Linux provides two standalone packages per architecture. **Choose one**:
|
||||||
@@ -238,6 +239,21 @@ The app currently receives remote text into the iOS clipboard. Its UI does not y
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
<a id="harmonyos"></a>
|
||||||
|
|
||||||
|
## HarmonyOS support
|
||||||
|
|
||||||
|
CrossDesk supports HarmonyOS PCs, phones, and tablets. PC and mobile clients use separate HAP builds, both named **CrossDesk** with the bundle name `com.chunkdash.crossdesk`.
|
||||||
|
|
||||||
|
| Client | System and architecture | Features |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| HarmonyOS PC (`2in1`) | API 26, arm64 | Controller and host roles, sharing the Slint desktop UI, session management, DeskHub, and file transfer |
|
||||||
|
| HarmonyOS phones / tablets | API 20+, arm64 | Native ArkUI controller with remote video/audio, touch and external keyboard/mouse input, display switching, text clipboard, and bidirectional file transfer |
|
||||||
|
|
||||||
|
HarmonyOS PC and mobile clients share the file transfer core with other PC platforms. Mobile clients use the system picker to send and export files; PC users can choose the receive directory in Settings.
|
||||||
|
|
||||||
|
These are development builds. Follow the [HarmonyOS build guide](apps/harmony/README.md) to build and sign them with DevEco before installation. PC screen capture and input control require system authorization, and physical-device and long-session validation are ongoing. See the [PC guide](apps/harmony/desktop-port/README.md) and [mobile feature guide](apps/harmony/MOBILE_PARITY.md) for permissions, platform differences, and validation coverage (Chinese).
|
||||||
|
|
||||||
<a id="windows-service"></a>
|
<a id="windows-service"></a>
|
||||||
|
|
||||||
## Windows lock screens and sign-in
|
## Windows lock screens and sign-in
|
||||||
@@ -305,6 +321,7 @@ Host your own signaling and TURN services, then enter their connection details u
|
|||||||
- [Build from source](docs/BUILD_EN.md): Windows, macOS, Linux, optional flags, and packaging.
|
- [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.
|
- [iOS development](apps/ios/README.md): native build and physical-device checks.
|
||||||
- [Android development](apps/android/README.md): APK builds, installation, and emulator checks.
|
- [Android development](apps/android/README.md): APK builds, installation, and emulator checks.
|
||||||
|
- [HarmonyOS development](apps/harmony/README.md): PC and phone / tablet HAP builds, signing, permissions, and validation (Chinese).
|
||||||
- [GUI architecture](docs/gui-architecture.md): desktop UI and platform organization.
|
- [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.
|
- [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.
|
- [Report an issue](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
|
||||||
@@ -313,7 +330,9 @@ Host your own signaling and TURN services, then enter their connection details u
|
|||||||
|
|
||||||
Thanks to [HelloGitHub](https://hellogithub.com/), [Ruanyf Weekly](https://github.com/ruanyf/weekly), and the [LinuxDo](https://linux.do) community for featuring CrossDesk and contributing feedback.
|
Thanks to [HelloGitHub](https://hellogithub.com/), [Ruanyf Weekly](https://github.com/ruanyf/weekly), and the [LinuxDo](https://linux.do) community for featuring CrossDesk and contributing feedback.
|
||||||
|
|
||||||
CrossDesk is licensed under [GPL-3.0](LICENSE). See the [Privacy Policy](PRIVACY.md#english).
|
Copyright (c) 2026 JUNKUN DI.
|
||||||
|
|
||||||
|
CrossDesk is licensed under [GPL-3.0-only](LICENSE). See the [Privacy Policy](PRIVACY.md#english).
|
||||||
|
|
||||||
### Code signing policy
|
### Code signing policy
|
||||||
|
|
||||||
|
|||||||
@@ -7,35 +7,11 @@
|
|||||||
#ifndef _CONTROLLER_PROTOCOL_H_
|
#ifndef _CONTROLLER_PROTOCOL_H_
|
||||||
#define _CONTROLLER_PROTOCOL_H_
|
#define _CONTROLLER_PROTOCOL_H_
|
||||||
|
|
||||||
#include <minirtc.h>
|
#include "../../../libs/controller/include/mobile_controller_protocol.h"
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
#include <remote_action.h>
|
|
||||||
|
|
||||||
namespace crossdesk::android_controller {
|
namespace crossdesk::android_controller {
|
||||||
inline std::string SecureAttentionMessage() {
|
using mobile_controller::SecureAttentionMessage;
|
||||||
RemoteAction action{};
|
using mobile_controller::VideoSettingsMessage;
|
||||||
action.type = service_command;
|
using mobile_controller::NetworkReport;
|
||||||
action.c.flag = send_sas;
|
|
||||||
return action.to_json();
|
|
||||||
}
|
}
|
||||||
inline std::string VideoSettingsMessage(int quality, int frame_rate, int preference,
|
|
||||||
uint32_t request_id) {
|
|
||||||
RemoteAction action{};
|
|
||||||
action.type = video_settings;
|
|
||||||
action.vs = {quality, frame_rate, preference, request_id, false};
|
|
||||||
return action.to_json();
|
|
||||||
}
|
|
||||||
|
|
||||||
inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, TraversalMode mode) {
|
|
||||||
const auto traffic = [](const MiniRtcInboundStats& in, const MiniRtcOutboundStats& out) {
|
|
||||||
return nlohmann::json{{"inbound", in.bitrate}, {"outbound", out.bitrate}, {"loss", in.loss_rate}};
|
|
||||||
};
|
|
||||||
return {{"rtt", stats.rtt_ms}, {"mode", mode}, {"srtp", stats.srtp_active},
|
|
||||||
{"video", traffic(stats.video_inbound_stats, stats.video_outbound_stats)},
|
|
||||||
{"audio", traffic(stats.audio_inbound_stats, stats.audio_outbound_stats)},
|
|
||||||
{"data", traffic(stats.data_inbound_stats, stats.data_outbound_stats)},
|
|
||||||
{"total", traffic(stats.total_inbound_stats, stats.total_outbound_stats)}};
|
|
||||||
}
|
|
||||||
} // namespace crossdesk::android_controller
|
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -7,265 +7,9 @@
|
|||||||
#ifndef _FILE_TRANSFER_SESSION_H_
|
#ifndef _FILE_TRANSFER_SESSION_H_
|
||||||
#define _FILE_TRANSFER_SESSION_H_
|
#define _FILE_TRANSFER_SESSION_H_
|
||||||
|
|
||||||
#include <file_transfer_format.h>
|
#include "../../../libs/controller/include/mobile_file_transfer_session.h"
|
||||||
#include <stream_names.h>
|
|
||||||
#include <algorithm>
|
|
||||||
#include <array>
|
|
||||||
#include <chrono>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <fstream>
|
|
||||||
#include <functional>
|
|
||||||
#include <map>
|
|
||||||
#include <mutex>
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
namespace crossdesk::android_controller {
|
namespace crossdesk::android_controller {
|
||||||
|
using mobile_controller::FileTransferSession;
|
||||||
|
}
|
||||||
|
|
||||||
// File I/O is bounded to one chunk per pump. Feedback is delivered by MiniRTC
|
#endif
|
||||||
// callback threads; no callback waits for the RTC executor or for a remote ACK.
|
|
||||||
class FileTransferSession {
|
|
||||||
public:
|
|
||||||
using Clock = std::chrono::steady_clock;
|
|
||||||
using Send = std::function<int(const char*, const char*, size_t)>;
|
|
||||||
using Progress = std::function<void(const std::string&, double, bool,
|
|
||||||
const std::string&)>;
|
|
||||||
FileTransferSession(Send send, Progress progress)
|
|
||||||
: send_(std::move(send)), progress_(std::move(progress)) {}
|
|
||||||
~FileTransferSession() {
|
|
||||||
if (outgoing_) {
|
|
||||||
outgoing_->input.close();
|
|
||||||
std::error_code ec;
|
|
||||||
std::filesystem::remove(outgoing_->path, ec);
|
|
||||||
}
|
|
||||||
for (auto& entry : incoming_) Remove(entry.second);
|
|
||||||
}
|
|
||||||
|
|
||||||
void SetDirectory(std::filesystem::path directory) {
|
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
directory_ = std::move(directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Start(const std::filesystem::path& path, const std::string& name,
|
|
||||||
Clock::time_point now = Clock::now()) {
|
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
if (outgoing_ || name.empty() || name.size() > UINT16_MAX) return false;
|
|
||||||
std::error_code ec;
|
|
||||||
if (!std::filesystem::is_regular_file(path, ec)) return false;
|
|
||||||
const auto size = std::filesystem::file_size(path, ec);
|
|
||||||
if (ec) return false;
|
|
||||||
Outgoing file;
|
|
||||||
file.input.open(path, std::ios::binary);
|
|
||||||
if (!file.input) return false;
|
|
||||||
file.id = ++next_id_;
|
|
||||||
file.name = name;
|
|
||||||
file.path = path;
|
|
||||||
file.total = size;
|
|
||||||
file.updated = now;
|
|
||||||
outgoing_ = std::move(file);
|
|
||||||
progress_(name, 0, true, "");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Pump(Clock::time_point now = Clock::now()) {
|
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
for (auto it = incoming_.begin(); it != incoming_.end();) {
|
|
||||||
if (now - it->second.updated < timeout_) { ++it; continue; }
|
|
||||||
Reject(it->first, it->second.total, it->second.received);
|
|
||||||
progress_(it->second.name, -1, false, "");
|
|
||||||
Remove(it->second);
|
|
||||||
it = incoming_.erase(it);
|
|
||||||
}
|
|
||||||
if (!outgoing_) return;
|
|
||||||
auto& file = *outgoing_;
|
|
||||||
if (now - file.updated >= timeout_) { FinishSend(false); return; }
|
|
||||||
if (file.last || file.sent - file.acked >= 1024 * 1024) return;
|
|
||||||
std::array<char, kFileChunkSize> payload{};
|
|
||||||
const auto size = static_cast<uint32_t>(std::min<uint64_t>(payload.size(), file.total - file.sent));
|
|
||||||
if (size) {
|
|
||||||
file.input.read(payload.data(), size);
|
|
||||||
if (file.input.gcount() != size) { FinishSend(false); return; }
|
|
||||||
}
|
|
||||||
const bool first = file.sent == 0;
|
|
||||||
const bool last = file.sent + size == file.total;
|
|
||||||
const auto chunk = EncodeFileChunk(file.id, file.sent, file.total,
|
|
||||||
payload.data(), size, first ? &file.name : nullptr, first, last);
|
|
||||||
if (chunk.empty() || send_(kFileStream, chunk.data(), chunk.size()) != 0) {
|
|
||||||
FinishSend(false); return;
|
|
||||||
}
|
|
||||||
file.sent += size;
|
|
||||||
file.last = last;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Ack(const char* data, size_t size, Clock::time_point now = Clock::now()) {
|
|
||||||
FileTransferAck ack{};
|
|
||||||
if (!DecodeFileTransferAck(data, size, &ack)) return;
|
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
if (!outgoing_ || ack.file_id != outgoing_->id) return;
|
|
||||||
auto& file = *outgoing_;
|
|
||||||
if (ack.total_size != file.total || ack.acked_offset > file.sent ||
|
|
||||||
(ack.flags & ~3u)) return;
|
|
||||||
if (ack.flags & 2) { FinishSend(false); return; }
|
|
||||||
if (ack.flags & 1) {
|
|
||||||
if (file.last && ack.acked_offset == file.total) FinishSend(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ack.acked_offset <= file.acked) return;
|
|
||||||
file.acked = ack.acked_offset;
|
|
||||||
file.updated = now;
|
|
||||||
Report(file.name, file.acked, file.total, true, file.percent);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Receive(const char* data, size_t size, Clock::time_point now = Clock::now()) {
|
|
||||||
FileChunkView chunk;
|
|
||||||
if (!DecodeFileChunk(data, size, &chunk)) return;
|
|
||||||
const auto& h = chunk.header;
|
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
auto it = incoming_.find(h.file_id);
|
|
||||||
const bool first = h.flags & 1, last = h.flags & 2;
|
|
||||||
const bool invalid = (h.flags & ~3u) || h.total_size > INT64_MAX ||
|
|
||||||
(first && h.offset != 0) || chunk.payload_size > kFileChunkSize ||
|
|
||||||
size != sizeof(FileChunkHeader) + h.name_len + chunk.payload_size ||
|
|
||||||
(!first && h.name_len) || (!chunk.payload_size && !last) ||
|
|
||||||
last != (h.offset + chunk.payload_size == h.total_size);
|
|
||||||
if (invalid || (it == incoming_.end() && incoming_.size() >= 4)) {
|
|
||||||
Reject(h.file_id, h.total_size, h.offset);
|
|
||||||
if (it != incoming_.end()) { progress_(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (it == incoming_.end()) {
|
|
||||||
Incoming file;
|
|
||||||
file.name = SafeName(chunk.file_name);
|
|
||||||
file.total = h.total_size;
|
|
||||||
std::error_code ec;
|
|
||||||
if (!directory_.empty()) std::filesystem::create_directories(directory_, ec);
|
|
||||||
// A per-transfer directory avoids overwrites, even for duplicate names.
|
|
||||||
auto folder = directory_ / (std::to_string(h.file_id) + "-" + std::to_string(++receive_id_));
|
|
||||||
if (directory_.empty() || ec || !std::filesystem::create_directory(folder, ec)) {
|
|
||||||
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); return;
|
|
||||||
}
|
|
||||||
file.path = folder / file.name;
|
|
||||||
file.partial = folder / ".partial";
|
|
||||||
file.output.open(file.partial, std::ios::binary | std::ios::trunc);
|
|
||||||
if (!file.output) {
|
|
||||||
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); Remove(file); return;
|
|
||||||
}
|
|
||||||
it = incoming_.emplace(h.file_id, std::move(file)).first;
|
|
||||||
}
|
|
||||||
auto& file = it->second;
|
|
||||||
if (h.total_size != file.total || (first && file.first) ||
|
|
||||||
!AddRange(file, h.offset, h.offset + chunk.payload_size)) {
|
|
||||||
Reject(h.file_id, h.total_size, h.offset);
|
|
||||||
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
|
|
||||||
}
|
|
||||||
if (first) {
|
|
||||||
file.first = true;
|
|
||||||
file.name = SafeName(chunk.file_name);
|
|
||||||
file.path = file.path.parent_path() / file.name;
|
|
||||||
}
|
|
||||||
file.last |= last;
|
|
||||||
// KCP removes messages in order, but delivers batches outside its lock on
|
|
||||||
// both RTP and timer threads. Track coverage instead of assuming callbacks
|
|
||||||
// arrive in order; ACK only the contiguous prefix and never expose holes.
|
|
||||||
file.received = !file.ranges.empty() && file.ranges.begin()->first == 0
|
|
||||||
? file.ranges.begin()->second : 0;
|
|
||||||
const bool complete = file.first && file.last && file.received == file.total;
|
|
||||||
file.output.seekp(static_cast<std::streamoff>(h.offset));
|
|
||||||
file.output.write(chunk.payload, chunk.payload_size);
|
|
||||||
if (complete) { file.output.flush(); file.output.close(); }
|
|
||||||
std::error_code ec;
|
|
||||||
if (complete && !file.output.fail()) std::filesystem::rename(file.partial, file.path, ec);
|
|
||||||
if (file.output.fail() || ec) {
|
|
||||||
Reject(h.file_id, h.total_size, h.offset);
|
|
||||||
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
|
|
||||||
}
|
|
||||||
file.updated = now;
|
|
||||||
const FileTransferAck ack{kFileAckMagic, h.file_id, file.received, file.total, complete ? 1u : 0u};
|
|
||||||
const auto packet = EncodeFileTransferAck(ack);
|
|
||||||
send_(kFileFeedbackStream, packet.data(), packet.size());
|
|
||||||
if (complete) {
|
|
||||||
progress_(file.name, 1, false, file.path.string());
|
|
||||||
incoming_.erase(it);
|
|
||||||
} else Report(file.name, file.received, file.total, false, file.percent);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Sending() {
|
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
return outgoing_.has_value();
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct Outgoing {
|
|
||||||
uint32_t id = 0;
|
|
||||||
std::string name;
|
|
||||||
std::filesystem::path path;
|
|
||||||
std::ifstream input;
|
|
||||||
uint64_t total = 0, sent = 0, acked = 0;
|
|
||||||
bool last = false;
|
|
||||||
int percent = -1;
|
|
||||||
Clock::time_point updated;
|
|
||||||
};
|
|
||||||
struct Incoming {
|
|
||||||
std::string name;
|
|
||||||
std::filesystem::path path, partial;
|
|
||||||
std::ofstream output;
|
|
||||||
uint64_t total = 0, received = 0;
|
|
||||||
bool first = false, last = false;
|
|
||||||
std::map<uint64_t, uint64_t> ranges;
|
|
||||||
int percent = -1;
|
|
||||||
Clock::time_point updated;
|
|
||||||
};
|
|
||||||
static bool AddRange(Incoming& file, uint64_t start, uint64_t end) {
|
|
||||||
if (start == end) return true;
|
|
||||||
auto next = file.ranges.lower_bound(start);
|
|
||||||
if (next != file.ranges.end() && next->first < end) return false;
|
|
||||||
if (next != file.ranges.begin()) {
|
|
||||||
auto previous = std::prev(next);
|
|
||||||
if (previous->second > start) return false;
|
|
||||||
if (previous->second == start) { start = previous->first; file.ranges.erase(previous); }
|
|
||||||
}
|
|
||||||
if (next != file.ranges.end() && next->first == end) { end = next->second; file.ranges.erase(next); }
|
|
||||||
file.ranges.emplace(start, end);
|
|
||||||
return file.ranges.size() <= 1024;
|
|
||||||
}
|
|
||||||
static std::string SafeName(std::string name) {
|
|
||||||
const auto slash = name.find_last_of("/\\");
|
|
||||||
if (slash != std::string::npos) name.erase(0, slash + 1);
|
|
||||||
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == ':') ch = '_';
|
|
||||||
if (name.empty() || name == "." || name == ".." || name == ".partial" || name.size() > 240) return "received_file";
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
static void Remove(Incoming& file) {
|
|
||||||
file.output.close();
|
|
||||||
std::error_code ec;
|
|
||||||
std::filesystem::remove(file.partial, ec);
|
|
||||||
std::filesystem::remove(file.path.parent_path(), ec);
|
|
||||||
}
|
|
||||||
void Reject(uint32_t id, uint64_t total, uint64_t offset) {
|
|
||||||
const auto packet = EncodeFileTransferAck({kFileAckMagic, id, offset, total, 2});
|
|
||||||
send_(kFileFeedbackStream, packet.data(), packet.size());
|
|
||||||
}
|
|
||||||
void Report(const std::string& name, uint64_t offset, uint64_t total, bool sending, int& previous) {
|
|
||||||
const double progress = total ? static_cast<double>(offset) / total : 0;
|
|
||||||
const int percent = static_cast<int>(progress * 100);
|
|
||||||
if (percent != previous) { previous = percent; progress_(name, std::min(progress, .99), sending, ""); }
|
|
||||||
}
|
|
||||||
void FinishSend(bool success) {
|
|
||||||
progress_(outgoing_->name, success ? 1 : -1, true, "");
|
|
||||||
outgoing_->input.close();
|
|
||||||
std::error_code ec;
|
|
||||||
std::filesystem::remove(outgoing_->path, ec);
|
|
||||||
outgoing_.reset();
|
|
||||||
}
|
|
||||||
const std::chrono::seconds timeout_{30};
|
|
||||||
std::mutex mutex_;
|
|
||||||
Send send_;
|
|
||||||
Progress progress_;
|
|
||||||
std::filesystem::path directory_;
|
|
||||||
std::optional<Outgoing> outgoing_;
|
|
||||||
std::map<uint32_t, Incoming> incoming_;
|
|
||||||
uint32_t next_id_ = 0;
|
|
||||||
uint64_t receive_id_ = 0;
|
|
||||||
};
|
|
||||||
} // namespace crossdesk::android_controller
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -7,21 +7,9 @@
|
|||||||
#ifndef _VIDEO_TIMING_H_
|
#ifndef _VIDEO_TIMING_H_
|
||||||
#define _VIDEO_TIMING_H_
|
#define _VIDEO_TIMING_H_
|
||||||
|
|
||||||
#include <cstdint>
|
#include "../../../libs/controller/include/mobile_video_timing.h"
|
||||||
|
|
||||||
namespace crossdesk::android_controller {
|
namespace crossdesk::android_controller {
|
||||||
inline double VideoLatencyMilliseconds(uint64_t captured_us,
|
using mobile_controller::VideoLatencyMilliseconds;
|
||||||
uint64_t received_us,
|
|
||||||
int64_t submitted_us) {
|
|
||||||
// MiniRTC calibrates capture time when the frame is reassembled. Validate
|
|
||||||
// that mapping at reception, not after decoding/Surface waits: a real local
|
|
||||||
// backlog over five seconds must remain visible in the latency measurement.
|
|
||||||
if (!captured_us || received_us < captured_us || submitted_us <= 0 ||
|
|
||||||
received_us > static_cast<uint64_t>(submitted_us) ||
|
|
||||||
received_us - captured_us > 5'000'000)
|
|
||||||
return -1;
|
|
||||||
return (static_cast<uint64_t>(submitted_us) - captured_us) / 1000.0;
|
|
||||||
}
|
}
|
||||||
} // namespace crossdesk::android_controller
|
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ class KeyboardCapturer : public DeviceController {
|
|||||||
// Backends with an asynchronous capture loop override this so the UI can
|
// Backends with an asynchronous capture loop override this so the UI can
|
||||||
// fall back to window events if that loop stops unexpectedly.
|
// fall back to window events if that loop stops unexpectedly.
|
||||||
virtual bool IsHookActive() const { return true; }
|
virtual bool IsHookActive() const { return true; }
|
||||||
|
// Permission-denied backends can wait for an OS grant notification rather
|
||||||
|
// than repeatedly releasing held fallback keys to retry an impossible hook.
|
||||||
|
virtual bool ShouldRetryHook() const { return true; }
|
||||||
virtual int SendKeyboardCommand(int key_code, bool is_down,
|
virtual int SendKeyboardCommand(int key_code, bool is_down,
|
||||||
uint32_t scan_code = 0,
|
uint32_t scan_code = 0,
|
||||||
bool extended = false) = 0;
|
bool extended = false) = 0;
|
||||||
|
|||||||
@@ -53,7 +53,12 @@
|
|||||||
#elif defined(__APPLE__)
|
#elif defined(__APPLE__)
|
||||||
#include "platform/macos/gui/tray/mac_tray.h"
|
#include "platform/macos/gui/tray/mac_tray.h"
|
||||||
#include "platform/window_drag.h"
|
#include "platform/window_drag.h"
|
||||||
#elif defined(__linux__)
|
#elif defined(__OHOS__)
|
||||||
|
#include "platform/harmony/harmony_autostart.h"
|
||||||
|
#include "platform/harmony/gui/tray/harmony_tray.h"
|
||||||
|
#include "platform/harmony/gui/window_state.h"
|
||||||
|
#include "tools/file_transfer.h"
|
||||||
|
#elif (defined(__linux__) && !defined(__OHOS__))
|
||||||
#include <GL/gl.h>
|
#include <GL/gl.h>
|
||||||
#include <X11/Xatom.h>
|
#include <X11/Xatom.h>
|
||||||
#include <X11/Xlib.h>
|
#include <X11/Xlib.h>
|
||||||
@@ -70,6 +75,7 @@
|
|||||||
#include "signal_server_settings.h"
|
#include "signal_server_settings.h"
|
||||||
#include "signal_server_address.h"
|
#include "signal_server_address.h"
|
||||||
#include "ui/announcement_text.h"
|
#include "ui/announcement_text.h"
|
||||||
|
#include "ui/model_update.h"
|
||||||
#include "ui/release_notes_text.h"
|
#include "ui/release_notes_text.h"
|
||||||
#include "ui/ui_localization.h"
|
#include "ui/ui_localization.h"
|
||||||
#include "update_checker.h"
|
#include "update_checker.h"
|
||||||
@@ -111,7 +117,7 @@ std::string CreatePasswordChangeRequestId(uint64_t sequence) {
|
|||||||
std::to_string(sequence);
|
std::to_string(sequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
bool HasNonEmptyEnvironmentVariable(const char* name) {
|
bool HasNonEmptyEnvironmentVariable(const char* name) {
|
||||||
const char* value = std::getenv(name);
|
const char* value = std::getenv(name);
|
||||||
return value != nullptr && value[0] != '\0';
|
return value != nullptr && value[0] != '\0';
|
||||||
@@ -639,14 +645,14 @@ struct WindowDragState {
|
|||||||
float global_to_physical_scale = 1;
|
float global_to_physical_scale = 1;
|
||||||
slint::PhysicalPosition window_start;
|
slint::PhysicalPosition window_start;
|
||||||
slint::PhysicalPosition last_target;
|
slint::PhysicalPosition last_target;
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
std::unique_ptr<Display, decltype(&XCloseDisplay)> x11_display{nullptr,
|
std::unique_ptr<Display, decltype(&XCloseDisplay)> x11_display{nullptr,
|
||||||
XCloseDisplay};
|
XCloseDisplay};
|
||||||
slint::Timer pointer_timer;
|
slint::Timer pointer_timer;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
bool ReadGlobalPointer(float* x, float* y, bool* left_pressed = nullptr) {
|
bool ReadGlobalPointer(float* x, float* y, bool* left_pressed = nullptr) {
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
const char* driver = SDL_GetCurrentVideoDriver();
|
const char* driver = SDL_GetCurrentVideoDriver();
|
||||||
if (driver && std::strcmp(driver, "x11") == 0) {
|
if (driver && std::strcmp(driver, "x11") == 0) {
|
||||||
// Slint can dispatch motion before SDL pumps its XInput2 events, and
|
// Slint can dispatch motion before SDL pumps its XInput2 events, and
|
||||||
@@ -683,7 +689,7 @@ bool CanUseGlobalPointerPosition() {
|
|||||||
if (driver == nullptr) {
|
if (driver == nullptr) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
// Wayland deliberately does not expose global pointer coordinates or allow
|
// Wayland deliberately does not expose global pointer coordinates or allow
|
||||||
// clients to position top-level windows.
|
// clients to position top-level windows.
|
||||||
return std::strcmp(driver, "wayland") != 0;
|
return std::strcmp(driver, "wayland") != 0;
|
||||||
@@ -821,7 +827,7 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
|||||||
state.global_to_physical_scale = 1.0f;
|
state.global_to_physical_scale = 1.0f;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
if (state.use_global_pointer && state.x11_display) {
|
if (state.use_global_pointer && state.x11_display) {
|
||||||
// Moving the window can leave consecutive pointer events at identical
|
// Moving the window can leave consecutive pointer events at identical
|
||||||
// local coordinates, so Slint need not emit another TouchArea::moved.
|
// local coordinates, so Slint need not emit another TouchArea::moved.
|
||||||
@@ -841,7 +847,7 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (phase == 0) {
|
if (phase == 0) {
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
state.pointer_timer.stop();
|
state.pointer_timer.stop();
|
||||||
if (state.use_global_pointer && state.x11_display) {
|
if (state.use_global_pointer && state.x11_display) {
|
||||||
DragWindow(component, 2, mouse_x, mouse_y, state);
|
DragWindow(component, 2, mouse_x, mouse_y, state);
|
||||||
@@ -1017,14 +1023,16 @@ struct GuiApplication::SlintUi {
|
|||||||
std::unique_ptr<WinTray> tray;
|
std::unique_ptr<WinTray> tray;
|
||||||
#elif defined(__APPLE__)
|
#elif defined(__APPLE__)
|
||||||
std::unique_ptr<MacTray> tray;
|
std::unique_ptr<MacTray> tray;
|
||||||
#elif defined(__linux__)
|
#elif defined(__OHOS__)
|
||||||
|
std::unique_ptr<HarmonyTray> tray;
|
||||||
|
#elif (defined(__linux__) && !defined(__OHOS__))
|
||||||
std::unique_ptr<LinuxTray> tray;
|
std::unique_ptr<LinuxTray> tray;
|
||||||
Display* x11_focus_display = nullptr;
|
Display* x11_focus_display = nullptr;
|
||||||
std::chrono::steady_clock::time_point last_x11_activation_poll{};
|
std::chrono::steady_clock::time_point last_x11_activation_poll{};
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
~SlintUi() {
|
~SlintUi() {
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
if (x11_focus_display) {
|
if (x11_focus_display) {
|
||||||
XCloseDisplay(x11_focus_display);
|
XCloseDisplay(x11_focus_display);
|
||||||
}
|
}
|
||||||
@@ -1059,7 +1067,7 @@ int GuiApplication::Run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
InitializeSettings();
|
InitializeSettings();
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().BindSettings(
|
HeadlessConsole::Instance().BindSettings(
|
||||||
config_center_.get(), [this] {
|
config_center_.get(), [this] {
|
||||||
std::lock_guard<std::mutex> lock(password_change_mutex_);
|
std::lock_guard<std::mutex> lock(password_change_mutex_);
|
||||||
@@ -1121,7 +1129,7 @@ void GuiApplication::InitializeSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool GuiApplication::InitializeSDL() {
|
bool GuiApplication::InitializeSDL() {
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
ConfigureLinuxCudaGraphics(config_center_->IsHardwareVideoCodec());
|
ConfigureLinuxCudaGraphics(config_center_->IsHardwareVideoCodec());
|
||||||
// Standard Wayland top-level windows cannot choose an absolute screen
|
// Standard Wayland top-level windows cannot choose an absolute screen
|
||||||
// position. Use the session's XWayland compatibility server for the GUI so
|
// position. Use the session's XWayland compatibility server for the GUI so
|
||||||
@@ -1146,7 +1154,7 @@ bool GuiApplication::InitializeSDL() {
|
|||||||
LOG_ERROR("SDL initialization failed: {}", SDL_GetError());
|
LOG_ERROR("SDL initialization failed: {}", SDL_GetError());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
const char* active_video_driver = SDL_GetCurrentVideoDriver();
|
const char* active_video_driver = SDL_GetCurrentVideoDriver();
|
||||||
// X11 permits the manual move/position operations used by the frameless
|
// X11 permits the manual move/position operations used by the frameless
|
||||||
// main window. Keep native Wayland decorations as a compatibility fallback.
|
// main window. Keep native Wayland decorations as a compatibility fallback.
|
||||||
@@ -1192,7 +1200,7 @@ void GuiApplication::InitializeUi() {
|
|||||||
LOG_INFO("Using requested Slint backend: {}", requested_slint_backend);
|
LOG_INFO("Using requested Slint backend: {}", requested_slint_backend);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
// Detect the desktop session before temporarily hiding Wayland variables
|
// Detect the desktop session before temporarily hiding Wayland variables
|
||||||
// for the XWayland GUI backend below.
|
// for the XWayland GUI backend below.
|
||||||
const bool is_wayland_session = IsWaylandSession();
|
const bool is_wayland_session = IsWaylandSession();
|
||||||
@@ -1214,7 +1222,7 @@ void GuiApplication::InitializeUi() {
|
|||||||
std::make_unique<SlintVideoPresenter>(*video_renderer_);
|
std::make_unique<SlintVideoPresenter>(*video_renderer_);
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
ui_->main->set_custom_titlebar(true);
|
ui_->main->set_custom_titlebar(true);
|
||||||
#elif defined(__linux__)
|
#elif (defined(__linux__) && !defined(__OHOS__))
|
||||||
ui_->main->set_custom_titlebar(use_x11_custom_titlebar_);
|
ui_->main->set_custom_titlebar(use_x11_custom_titlebar_);
|
||||||
ui_->main->set_wayland_titlebar(use_xwayland_gui_);
|
ui_->main->set_wayland_titlebar(use_xwayland_gui_);
|
||||||
// Set this only at startup so dismissing the notice lasts until exit.
|
// Set this only at startup so dismissing the notice lasts until exit.
|
||||||
@@ -1342,7 +1350,11 @@ void GuiApplication::InitializeSystemTray() {
|
|||||||
ui_->tray = std::make_unique<MacTray>(
|
ui_->tray = std::make_unique<MacTray>(
|
||||||
std::move(show_window), std::move(hide_window), std::move(open_settings),
|
std::move(show_window), std::move(hide_window), std::move(open_settings),
|
||||||
std::move(exit_app), "CrossDesk", localization_language_index_);
|
std::move(exit_app), "CrossDesk", localization_language_index_);
|
||||||
#elif defined(__linux__)
|
#elif defined(__OHOS__)
|
||||||
|
ui_->tray = std::make_unique<HarmonyTray>(
|
||||||
|
std::move(show_window), std::move(hide_window), std::move(open_settings),
|
||||||
|
std::move(exit_app), "CrossDesk", localization_language_index_);
|
||||||
|
#elif (defined(__linux__) && !defined(__OHOS__))
|
||||||
ui_->tray = std::make_unique<LinuxTray>(
|
ui_->tray = std::make_unique<LinuxTray>(
|
||||||
std::move(show_window), std::move(hide_window),
|
std::move(show_window), std::move(hide_window),
|
||||||
std::move(open_settings), std::move(exit_app), "CrossDesk",
|
std::move(open_settings), std::move(exit_app), "CrossDesk",
|
||||||
@@ -1384,6 +1396,9 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
|||||||
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
||||||
main->set_self_hosted_enabled(enable_self_hosted_);
|
main->set_self_hosted_enabled(enable_self_hosted_);
|
||||||
main->set_autostart_enabled(enable_autostart_);
|
main->set_autostart_enabled(enable_autostart_);
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
main->set_autostart_system_managed(true);
|
||||||
|
#endif
|
||||||
main->set_daemon_enabled(enable_daemon_);
|
main->set_daemon_enabled(enable_daemon_);
|
||||||
main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
|
main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
|
||||||
main->set_privacy_on_connect_enabled(
|
main->set_privacy_on_connect_enabled(
|
||||||
@@ -1393,12 +1408,20 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
|||||||
main->set_capture_method_index(
|
main->set_capture_method_index(
|
||||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||||
#endif
|
#endif
|
||||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||||
main->set_privacy_setting_available(true);
|
main->set_privacy_setting_available(true);
|
||||||
#else
|
#else
|
||||||
main->set_privacy_setting_available(false);
|
main->set_privacy_setting_available(false);
|
||||||
#endif
|
#endif
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
// The sandbox default is not the system desktop. Show the actual receive
|
||||||
|
// location until a public directory has been selected and authorized.
|
||||||
|
main->set_file_save_path(UiText(file_transfer_save_path_buf_[0]
|
||||||
|
? std::string(file_transfer_save_path_buf_)
|
||||||
|
: FileReceiver().OutputDir().string()));
|
||||||
|
#else
|
||||||
main->set_file_save_path(file_transfer_save_path_buf_);
|
main->set_file_save_path(file_transfer_save_path_buf_);
|
||||||
|
#endif
|
||||||
main->set_server_host(signal_server_ip_self_);
|
main->set_server_host(signal_server_ip_self_);
|
||||||
main->set_server_port(signal_server_port_self_);
|
main->set_server_port(signal_server_port_self_);
|
||||||
main->set_self_host_settings_error("");
|
main->set_self_host_settings_error("");
|
||||||
@@ -1633,6 +1656,9 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
|||||||
main->on_cancel_settings([this, main] {
|
main->on_cancel_settings([this, main] {
|
||||||
ResetSettingsUi(main->get_gallery_mode());
|
ResetSettingsUi(main->get_gallery_mode());
|
||||||
});
|
});
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
main->on_refresh_autostart_status([] { RefreshHarmonyAutostartStatus(); });
|
||||||
|
#endif
|
||||||
main->on_save_self_hosted_settings([this, main] {
|
main->on_save_self_hosted_settings([this, main] {
|
||||||
// Stage the endpoint until the surrounding settings dialog is confirmed.
|
// Stage the endpoint until the surrounding settings dialog is confirmed.
|
||||||
return ValidateSelfHostedSettingsFromUi(main->get_gallery_mode());
|
return ValidateSelfHostedSettingsFromUi(main->get_gallery_mode());
|
||||||
@@ -1832,12 +1858,19 @@ void GuiApplication::BindStreamCallbacks() {
|
|||||||
});
|
});
|
||||||
stream->on_send_shortcut([this](slint::SharedString shortcut) {
|
stream->on_send_shortcut([this](slint::SharedString shortcut) {
|
||||||
auto props = SelectedSession();
|
auto props = SelectedSession();
|
||||||
if (!props || !props->peer_) {
|
if (!props || !props->peer_ || !props->control_mouse_ ||
|
||||||
|
props->connection_status_.load() != ConnectionStatus::Connected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const std::string name(shortcut);
|
||||||
|
if (name != "Ctrl+Alt+Del" && name != "Win+L") return;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(props->remote_version_mutex_);
|
||||||
|
if (name == "Ctrl+Alt+Del" && props->remote_platform_ == HostPlatform::HarmonyOS) return;
|
||||||
|
}
|
||||||
RemoteAction action{};
|
RemoteAction action{};
|
||||||
action.type = ControlType::service_command;
|
action.type = ControlType::service_command;
|
||||||
action.c.flag = std::string(shortcut) == "Ctrl+Alt+Del"
|
action.c.flag = name == "Ctrl+Alt+Del"
|
||||||
? ServiceCommandFlag::send_sas
|
? ServiceCommandFlag::send_sas
|
||||||
: ServiceCommandFlag::lock_workstation;
|
: ServiceCommandFlag::lock_workstation;
|
||||||
const std::string message = action.to_json();
|
const std::string message = action.to_json();
|
||||||
@@ -2099,7 +2132,7 @@ void GuiApplication::Tick() {
|
|||||||
signal_connection_indicator_.Reset();
|
signal_connection_indicator_.Reset();
|
||||||
CreateConnectionPeer();
|
CreateConnectionPeer();
|
||||||
}
|
}
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().Poll(
|
HeadlessConsole::Instance().Poll(
|
||||||
signal_connected_.load(),
|
signal_connected_.load(),
|
||||||
[this](const std::string& password) { RequestPasswordChange(password); },
|
[this](const std::string& password) { RequestPasswordChange(password); },
|
||||||
@@ -2130,7 +2163,7 @@ void GuiApplication::Tick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clipboard_.ApplyPendingRemoteText();
|
clipboard_.ApplyPendingRemoteText();
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
if (ui_->tray) {
|
if (ui_->tray) {
|
||||||
ui_->tray->ProcessEvents();
|
ui_->tray->ProcessEvents();
|
||||||
}
|
}
|
||||||
@@ -2156,7 +2189,7 @@ void GuiApplication::Tick() {
|
|||||||
HandlePresenceProbeTimeout();
|
HandlePresenceProbeTimeout();
|
||||||
HandlePrivacy();
|
HandlePrivacy();
|
||||||
HandleWindowsServiceIntegration();
|
HandleWindowsServiceIntegration();
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
SyncXWaylandWindowActivation();
|
SyncXWaylandWindowActivation();
|
||||||
#else
|
#else
|
||||||
SyncStreamKeyboardFocus();
|
SyncStreamKeyboardFocus();
|
||||||
@@ -2367,7 +2400,7 @@ void GuiApplication::ShareLocalCursorState() {
|
|||||||
|
|
||||||
bool GuiApplication::RequestPasswordChange(const std::string &password) {
|
bool GuiApplication::RequestPasswordChange(const std::string &password) {
|
||||||
const auto notify = [](const std::string& message) {
|
const auto notify = [](const std::string& message) {
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().NotifyKey(message.c_str());
|
HeadlessConsole::Instance().NotifyKey(message.c_str());
|
||||||
#else
|
#else
|
||||||
(void)message;
|
(void)message;
|
||||||
@@ -2486,7 +2519,7 @@ void GuiApplication::HandlePasswordChangeResult() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!succeeded) {
|
if (!succeeded) {
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().NotifyKey(
|
HeadlessConsole::Instance().NotifyKey(
|
||||||
uncertain ? "console_password_uncertain" : "console_password_rejected");
|
uncertain ? "console_password_uncertain" : "console_password_rejected");
|
||||||
#endif
|
#endif
|
||||||
@@ -2517,7 +2550,7 @@ void GuiApplication::HandlePasswordChangeResult() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("Password changed successfully for [{}]", client_id_);
|
LOG_INFO("Password changed successfully for [{}]", client_id_);
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().NotifyKey("console_password_confirmed");
|
HeadlessConsole::Instance().NotifyKey("console_password_confirmed");
|
||||||
#endif
|
#endif
|
||||||
if (peer_) {
|
if (peer_) {
|
||||||
@@ -2551,7 +2584,7 @@ void GuiApplication::HandleCredentialRecovery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (promote_pending) {
|
if (promote_pending) {
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().NotifyKey("console_password_recovered");
|
HeadlessConsole::Instance().NotifyKey("console_password_recovered");
|
||||||
#endif
|
#endif
|
||||||
if (!settings_.PromotePendingPasswordChange()) {
|
if (!settings_.PromotePendingPasswordChange()) {
|
||||||
@@ -2560,7 +2593,7 @@ void GuiApplication::HandleCredentialRecovery() {
|
|||||||
LOG_INFO("Recovered password change with pending credential");
|
LOG_INFO("Recovered password change with pending credential");
|
||||||
}
|
}
|
||||||
} else if (clear_pending) {
|
} else if (clear_pending) {
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().NotifyKey("console_password_retained");
|
HeadlessConsole::Instance().NotifyKey("console_password_retained");
|
||||||
#endif
|
#endif
|
||||||
if (!settings_.ClearPendingPasswordChange()) {
|
if (!settings_.ClearPendingPasswordChange()) {
|
||||||
@@ -2699,6 +2732,17 @@ void GuiApplication::SyncMainWindow() {
|
|||||||
const bool signal_online = signal_connected_.load();
|
const bool signal_online = signal_connected_.load();
|
||||||
const bool signal_reconnecting = signal_connection_indicator_.Update(signal_online);
|
const bool signal_reconnecting = signal_connection_indicator_.Update(signal_online);
|
||||||
for (const auto& main : {ui_->main, ui_->wall}) {
|
for (const auto& main : {ui_->main, ui_->wall}) {
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
// Only the operating system owns this setting. Never bind it to the local
|
||||||
|
// saved checkbox or wait for an ArkTS request in this 16 ms GUI tick.
|
||||||
|
switch (GetHarmonyAutostartStatus()) {
|
||||||
|
case HarmonyAutostartStatus::checking: main->set_autostart_status(ui::AutostartStatus::Checking); break;
|
||||||
|
case HarmonyAutostartStatus::enabled: main->set_autostart_status(ui::AutostartStatus::Enabled); break;
|
||||||
|
case HarmonyAutostartStatus::disabled: main->set_autostart_status(ui::AutostartStatus::Disabled); break;
|
||||||
|
case HarmonyAutostartStatus::unsupported: main->set_autostart_status(ui::AutostartStatus::Unsupported); break;
|
||||||
|
case HarmonyAutostartStatus::failed: main->set_autostart_status(ui::AutostartStatus::Failed); break;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
main->set_signal_connected(signal_online);
|
main->set_signal_connected(signal_online);
|
||||||
main->set_signal_reconnecting(signal_reconnecting);
|
main->set_signal_reconnecting(signal_reconnecting);
|
||||||
main->set_signal_tls_error(signal_status_ ==
|
main->set_signal_tls_error(signal_status_ ==
|
||||||
@@ -2983,7 +3027,7 @@ void GuiApplication::SyncPlatformDialogs() {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
void GuiApplication::SyncXWaylandWindowActivation() {
|
void GuiApplication::SyncXWaylandWindowActivation() {
|
||||||
if (!ui_ || !use_xwayland_gui_) {
|
if (!ui_ || !use_xwayland_gui_) {
|
||||||
return;
|
return;
|
||||||
@@ -3057,12 +3101,20 @@ void GuiApplication::SetStreamKeyboardFocus(bool focused) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GuiApplication::SyncStreamKeyboardFocus() {
|
void GuiApplication::SyncStreamKeyboardFocus() {
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
if (ui_) {
|
||||||
|
ui_->main->set_window_active(IsHarmonyWindowActive(ui_->main->window()));
|
||||||
|
ui_->wall->set_window_active(IsHarmonyWindowActive(ui_->wall->window()));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
if (ui_ && !ui_->gallery_control_id.empty()) {
|
if (ui_ && !ui_->gallery_control_id.empty()) {
|
||||||
#if defined(__APPLE__)
|
#if defined(__APPLE__)
|
||||||
if (!IsGalleryWindowActive()) ReleaseControl();
|
if (!IsGalleryWindowActive()) ReleaseControl();
|
||||||
#elif defined(_WIN32)
|
#elif defined(_WIN32)
|
||||||
if (GetForegroundWindow() != ui_->wall->window().win32_hwnd())
|
if (GetForegroundWindow() != ui_->wall->window().win32_hwnd())
|
||||||
ReleaseControl();
|
ReleaseControl();
|
||||||
|
#elif defined(__OHOS__)
|
||||||
|
if (!IsHarmonyWindowActive(ui_->wall->window())) ReleaseControl();
|
||||||
#endif
|
#endif
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3077,6 +3129,10 @@ void GuiApplication::SyncStreamKeyboardFocus() {
|
|||||||
GetForegroundWindow() == stream_hwnd);
|
GetForegroundWindow() == stream_hwnd);
|
||||||
#elif defined(__APPLE__)
|
#elif defined(__APPLE__)
|
||||||
SetStreamKeyboardFocus(IsStreamWindowActive());
|
SetStreamKeyboardFocus(IsStreamWindowActive());
|
||||||
|
#elif defined(__OHOS__)
|
||||||
|
const bool active = IsHarmonyWindowActive((*ui_->stream)->window());
|
||||||
|
(*ui_->stream)->set_window_active(active);
|
||||||
|
SetStreamKeyboardFocus(active);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3093,7 +3149,7 @@ void GuiApplication::SyncStreamWindow() {
|
|||||||
if (!has_sessions) need_to_create_stream_window_ = false;
|
if (!has_sessions) need_to_create_stream_window_ = false;
|
||||||
if (need_to_create_stream_window_ && !ui_->stream) {
|
if (need_to_create_stream_window_ && !ui_->stream) {
|
||||||
ui_->stream.emplace(ui::StreamWindow::create());
|
ui_->stream.emplace(ui::StreamWindow::create());
|
||||||
#if defined(__linux__)
|
#if (defined(__linux__) && !defined(__OHOS__))
|
||||||
(*ui_->stream)->set_custom_titlebar(use_xwayland_gui_);
|
(*ui_->stream)->set_custom_titlebar(use_xwayland_gui_);
|
||||||
#endif
|
#endif
|
||||||
RegisterFontAwesome((*ui_->stream)->window());
|
RegisterFontAwesome((*ui_->stream)->window());
|
||||||
@@ -3257,6 +3313,9 @@ void GuiApplication::SyncStreamWindow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(*ui_->stream)->set_remote_update_visible(remote_update_available);
|
(*ui_->stream)->set_remote_update_visible(remote_update_available);
|
||||||
|
// All desktop clients use the same menu for a Harmony host. Windows SAS is
|
||||||
|
// not an ordinary key chord and has no public Harmony implementation.
|
||||||
|
(*ui_->stream)->set_secure_attention_supported(remote_platform != HostPlatform::HarmonyOS);
|
||||||
if (remote_update_available) {
|
if (remote_update_available) {
|
||||||
SetStreamKeyboardFocus(false);
|
SetStreamKeyboardFocus(false);
|
||||||
}
|
}
|
||||||
@@ -3358,7 +3417,7 @@ void GuiApplication::SyncStreamWindow() {
|
|||||||
index, props->display_info_list_[index].name,
|
index, props->display_info_list_[index].name,
|
||||||
localization_language_index_)));
|
localization_language_index_)));
|
||||||
}
|
}
|
||||||
ui_->display_model->set_vector(std::move(displays));
|
ui::UpdateModelRows(*ui_->display_model, std::move(displays));
|
||||||
(*ui_->stream)->set_selected_display(props->selected_display_);
|
(*ui_->stream)->set_selected_display(props->selected_display_);
|
||||||
|
|
||||||
const auto net = props->net_traffic_stats_.Load();
|
const auto net = props->net_traffic_stats_.Load();
|
||||||
@@ -3383,7 +3442,7 @@ void GuiApplication::SyncStreamWindow() {
|
|||||||
net.data_inbound_stats, net.data_outbound_stats);
|
net.data_inbound_stats, net.data_outbound_stats);
|
||||||
append_stats_row(localization::total[localization_language_index_],
|
append_stats_row(localization::total[localization_language_index_],
|
||||||
net.total_inbound_stats, net.total_outbound_stats);
|
net.total_inbound_stats, net.total_outbound_stats);
|
||||||
ui_->stats_model->set_vector(std::move(stats_rows));
|
ui::UpdateModelRows(*ui_->stats_model, std::move(stats_rows));
|
||||||
(*ui_->stream)
|
(*ui_->stream)
|
||||||
->set_stats_resolution(UiText(std::to_string(props->video_width_) + "x" +
|
->set_stats_resolution(UiText(std::to_string(props->video_width_) + "x" +
|
||||||
std::to_string(props->video_height_)));
|
std::to_string(props->video_height_)));
|
||||||
@@ -3444,7 +3503,7 @@ void GuiApplication::SyncStreamWindow() {
|
|||||||
item.size = UiText(FormatFileSize(info.file_size));
|
item.size = UiText(FormatFileSize(info.file_size));
|
||||||
transfers.push_back(std::move(item));
|
transfers.push_back(std::move(item));
|
||||||
}
|
}
|
||||||
ui_->transfer_model->set_vector(std::move(transfers));
|
ui::UpdateModelRows(*ui_->transfer_model, std::move(transfers));
|
||||||
(*ui_->stream)
|
(*ui_->stream)
|
||||||
->set_file_transfer_visible(
|
->set_file_transfer_visible(
|
||||||
props->file_transfer_.file_transfer_window_visible_);
|
props->file_transfer_.file_transfer_window_visible_);
|
||||||
@@ -3698,7 +3757,7 @@ void GuiApplication::SyncServerWindow() {
|
|||||||
// Keep the panel alive throughout privacy transitions so remote
|
// Keep the panel alive throughout privacy transitions so remote
|
||||||
// controllers can still see and operate it without resetting its UI state.
|
// controllers can still see and operate it without resetting its UI state.
|
||||||
bool show_controller_window = has_connected_controller;
|
bool show_controller_window = has_connected_controller;
|
||||||
#if !defined(_WIN32) && !defined(__APPLE__) && !defined(__linux__)
|
#if !defined(_WIN32) && !defined(__APPLE__) && !(defined(__linux__) && !defined(__OHOS__))
|
||||||
// Preserve the existing panel behavior on other platforms.
|
// Preserve the existing panel behavior on other platforms.
|
||||||
const auto privacy_status = privacy_.Snapshot();
|
const auto privacy_status = privacy_.Snapshot();
|
||||||
const bool privacy_covering = privacy_status.overlay_active ||
|
const bool privacy_covering = privacy_status.overlay_active ||
|
||||||
@@ -3902,7 +3961,9 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
|||||||
main->get_hardware_codec_enabled();
|
main->get_hardware_codec_enabled();
|
||||||
enable_turn_ = main->get_turn_enabled();
|
enable_turn_ = main->get_turn_enabled();
|
||||||
enable_self_hosted_ = main->get_self_hosted_enabled();
|
enable_self_hosted_ = main->get_self_hosted_enabled();
|
||||||
|
#if !defined(__OHOS__)
|
||||||
enable_autostart_ = main->get_autostart_enabled();
|
enable_autostart_ = main->get_autostart_enabled();
|
||||||
|
#endif
|
||||||
enable_daemon_ = main->get_daemon_enabled();
|
enable_daemon_ = main->get_daemon_enabled();
|
||||||
|
|
||||||
localization_language_ =
|
localization_language_ =
|
||||||
@@ -3917,7 +3978,9 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
|||||||
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_);
|
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_);
|
||||||
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
|
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
|
||||||
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
||||||
|
#if !defined(__OHOS__)
|
||||||
config_center_->SetAutostart(enable_autostart_);
|
config_center_->SetAutostart(enable_autostart_);
|
||||||
|
#endif
|
||||||
config_center_->SetDaemon(enable_daemon_);
|
config_center_->SetDaemon(enable_daemon_);
|
||||||
if (config_center_->SetSaveRemotePreviews(main->get_save_remote_previews()) != 0) {
|
if (config_center_->SetSaveRemotePreviews(main->get_save_remote_previews()) != 0) {
|
||||||
LOG_ERROR("Failed to save remote preview preference");
|
LOG_ERROR("Failed to save remote preview preference");
|
||||||
@@ -3945,7 +4008,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
|||||||
main->set_capture_method_index(
|
main->set_capture_method_index(
|
||||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||||
#endif
|
#endif
|
||||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||||
if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) !=
|
if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) !=
|
||||||
0) {
|
0) {
|
||||||
main->set_privacy_on_connect_enabled(
|
main->set_privacy_on_connect_enabled(
|
||||||
@@ -4000,7 +4063,7 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
|||||||
enable_autostart_last_ = enable_autostart_;
|
enable_autostart_last_ = enable_autostart_;
|
||||||
enable_daemon_last_ = enable_daemon_;
|
enable_daemon_last_ = enable_daemon_;
|
||||||
file_transfer_save_path_last_ = path;
|
file_transfer_save_path_last_ = path;
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().SetLanguage(localization_language_index_);
|
HeadlessConsole::Instance().SetLanguage(localization_language_index_);
|
||||||
#endif
|
#endif
|
||||||
if (ui_) {
|
if (ui_) {
|
||||||
@@ -4449,7 +4512,7 @@ void GuiApplication::ShowGalleryWindow() {
|
|||||||
#elif defined(_WIN32)
|
#elif defined(_WIN32)
|
||||||
ConfigureWindowsWindowIcons(window.win32_hwnd());
|
ConfigureWindowsWindowIcons(window.win32_hwnd());
|
||||||
SetForegroundWindow(window.win32_hwnd());
|
SetForegroundWindow(window.win32_hwnd());
|
||||||
#else
|
#elif defined(__linux__) && !defined(__OHOS__)
|
||||||
if (!ui_->x11_focus_display) ui_->x11_focus_display = XOpenDisplay(nullptr);
|
if (!ui_->x11_focus_display) ui_->x11_focus_display = XOpenDisplay(nullptr);
|
||||||
X11ActivateGalleryWindow(ui_->x11_focus_display);
|
X11ActivateGalleryWindow(ui_->x11_focus_display);
|
||||||
#endif
|
#endif
|
||||||
@@ -5758,7 +5821,7 @@ void GuiApplication::SyncGallery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GuiApplication::Cleanup() {
|
void GuiApplication::Cleanup() {
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().BindSettings(nullptr, {}, {});
|
HeadlessConsole::Instance().BindSettings(nullptr, {}, {});
|
||||||
#endif
|
#endif
|
||||||
if (!ui_) {
|
if (!ui_) {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ private:
|
|||||||
void SyncServerWindow();
|
void SyncServerWindow();
|
||||||
void SetServerPanelExpanded(bool expanded);
|
void SetServerPanelExpanded(bool expanded);
|
||||||
void PositionServerWindows();
|
void PositionServerWindows();
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
void SyncXWaylandWindowActivation();
|
void SyncXWaylandWindowActivation();
|
||||||
#endif
|
#endif
|
||||||
void UpdateLocalization();
|
void UpdateLocalization();
|
||||||
@@ -127,7 +127,7 @@ private:
|
|||||||
uint32_t cursor_state_sequence_ = 0;
|
uint32_t cursor_state_sequence_ = 0;
|
||||||
std::chrono::steady_clock::time_point next_video_frame_time_{};
|
std::chrono::steady_clock::time_point next_video_frame_time_{};
|
||||||
SignalConnectionIndicator signal_connection_indicator_;
|
SignalConnectionIndicator signal_connection_indicator_;
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
bool use_xwayland_gui_ = false;
|
bool use_xwayland_gui_ = false;
|
||||||
bool use_x11_custom_titlebar_ = false;
|
bool use_x11_custom_titlebar_ = false;
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ namespace {
|
|||||||
|
|
||||||
constexpr uint64_t kCaptureResumeKeyFrameGapMs = 500;
|
constexpr uint64_t kCaptureResumeKeyFrameGapMs = 500;
|
||||||
constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1);
|
constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1);
|
||||||
#ifdef _WIN32
|
#if defined(_WIN32) || defined(__OHOS__)
|
||||||
constexpr auto kKeyboardCaptureRetryDelay = std::chrono::seconds(1);
|
constexpr auto kKeyboardCaptureRetryDelay = std::chrono::seconds(1);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -419,7 +419,7 @@ int SessionDeviceManager::StartMouseController() {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
if (IsWaylandSession()) {
|
if (IsWaylandSession()) {
|
||||||
if (!screen_capturer_) {
|
if (!screen_capturer_) {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -467,7 +467,7 @@ int SessionDeviceManager::StopMouseController() {
|
|||||||
int SessionDeviceManager::StartKeyboardCapturer() {
|
int SessionDeviceManager::StartKeyboardCapturer() {
|
||||||
captured_keyboard_inputs_.Clear();
|
captured_keyboard_inputs_.Clear();
|
||||||
owner_.keyboard_capturer_uses_window_events_ = false;
|
owner_.keyboard_capturer_uses_window_events_ = false;
|
||||||
#ifdef _WIN32
|
#if defined(_WIN32) || defined(__OHOS__)
|
||||||
next_keyboard_capture_retry_ =
|
next_keyboard_capture_retry_ =
|
||||||
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
|
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
|
||||||
#endif
|
#endif
|
||||||
@@ -479,7 +479,7 @@ int SessionDeviceManager::StartKeyboardCapturer() {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
if (IsWaylandSession()) {
|
if (IsWaylandSession()) {
|
||||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||||
LOG_INFO("Start keyboard capturer with Slint Wayland backend");
|
LOG_INFO("Start keyboard capturer with Slint Wayland backend");
|
||||||
@@ -546,7 +546,7 @@ int SessionDeviceManager::InitializeAudioOutput() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!open_device()) {
|
if (!open_device()) {
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
LOG_WARN("Failed to open output stream with driver [{}]: {}",
|
LOG_WARN("Failed to open output stream with driver [{}]: {}",
|
||||||
getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER")
|
getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER")
|
||||||
: "(default)",
|
: "(default)",
|
||||||
@@ -629,7 +629,7 @@ void SessionDeviceManager::UpdateInteractions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
const bool is_wayland_session = IsWaylandSession();
|
const bool is_wayland_session = IsWaylandSession();
|
||||||
const bool stop_wayland_mouse_before_screen =
|
const bool stop_wayland_mouse_before_screen =
|
||||||
is_wayland_session && !owner_.start_screen_capturer_ &&
|
is_wayland_session && !owner_.start_screen_capturer_ &&
|
||||||
@@ -664,24 +664,27 @@ void SessionDeviceManager::UpdateInteractions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
|
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
|
||||||
#ifdef _WIN32
|
#if defined(_WIN32) || defined(__OHOS__)
|
||||||
const int active_display = screen_capturer_->GetCurrentMonitorIndex();
|
const int active_display = screen_capturer_->GetCurrentMonitorIndex();
|
||||||
const auto displays = display_info_list();
|
const auto displays = display_info_list();
|
||||||
if (active_display >= 0 &&
|
if (active_display >= 0 &&
|
||||||
active_display < static_cast<int>(displays.size())) {
|
active_display < static_cast<int>(displays.size())) {
|
||||||
owner_.selected_display_ = active_display;
|
owner_.selected_display_ = active_display;
|
||||||
|
#ifdef _WIN32
|
||||||
if (displays[active_display].width <= 0 ||
|
if (displays[active_display].width <= 0 ||
|
||||||
displays[active_display].height <= 0)
|
displays[active_display].height <= 0)
|
||||||
ReleaseRemoteMouseButtons();
|
ReleaseRemoteMouseButtons();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
if (owner_.start_keyboard_capturer_ && owner_.focus_on_stream_window_ &&
|
if (owner_.start_keyboard_capturer_ && owner_.focus_on_stream_window_ &&
|
||||||
!owner_.privacy_.Snapshot().input_blocked) {
|
!owner_.privacy_.Snapshot().input_blocked) {
|
||||||
#ifdef _WIN32
|
#if defined(_WIN32) || defined(__OHOS__)
|
||||||
if (owner_.keyboard_capturer_is_started_ &&
|
if (owner_.keyboard_capturer_is_started_ &&
|
||||||
owner_.keyboard_capturer_uses_window_events_ && keyboard_capturer_ &&
|
owner_.keyboard_capturer_uses_window_events_ && keyboard_capturer_ &&
|
||||||
|
keyboard_capturer_->ShouldRetryHook() &&
|
||||||
std::chrono::steady_clock::now() >= next_keyboard_capture_retry_) {
|
std::chrono::steady_clock::now() >= next_keyboard_capture_retry_) {
|
||||||
// A transient installation failure or stalled hook must not leave
|
// A transient installation failure or stalled hook must not leave
|
||||||
// system shortcuts in the window-event fallback until focus changes.
|
// system shortcuts in the window-event fallback until focus changes.
|
||||||
@@ -699,7 +702,7 @@ void SessionDeviceManager::UpdateInteractions() {
|
|||||||
owner_.keyboard_.ForceReleasePressedKeys();
|
owner_.keyboard_.ForceReleasePressedKeys();
|
||||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||||
owner_.keyboard_capturer_is_started_ = true;
|
owner_.keyboard_capturer_is_started_ = true;
|
||||||
#ifdef _WIN32
|
#if defined(_WIN32) || defined(__OHOS__)
|
||||||
next_keyboard_capture_retry_ =
|
next_keyboard_capture_retry_ =
|
||||||
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
|
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -111,8 +111,10 @@ private:
|
|||||||
std::vector<DisplayInfo> display_info_list_;
|
std::vector<DisplayInfo> display_info_list_;
|
||||||
size_t registered_display_stream_count_ = 0;
|
size_t registered_display_stream_count_ = 0;
|
||||||
CapturedKeyboardQueue captured_keyboard_inputs_;
|
CapturedKeyboardQueue captured_keyboard_inputs_;
|
||||||
#ifdef _WIN32
|
#if defined(_WIN32) || defined(__OHOS__)
|
||||||
std::chrono::steady_clock::time_point next_keyboard_capture_retry_{};
|
std::chrono::steady_clock::time_point next_keyboard_capture_retry_{};
|
||||||
|
#endif
|
||||||
|
#ifdef _WIN32
|
||||||
std::mutex service_mouse_mutex_;
|
std::mutex service_mouse_mutex_;
|
||||||
unsigned service_mouse_buttons_ = 0;
|
unsigned service_mouse_buttons_ = 0;
|
||||||
int service_mouse_x_ = 0, service_mouse_y_ = 0;
|
int service_mouse_x_ = 0, service_mouse_y_ = 0;
|
||||||
|
|||||||
@@ -1,370 +1,290 @@
|
|||||||
#include "features/file_transfer/file_transfer_manager.h"
|
#include "features/file_transfer/file_transfer_manager.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <memory>
|
|
||||||
#include <mutex>
|
|
||||||
#include <shared_mutex>
|
|
||||||
#include <string>
|
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <utility>
|
|
||||||
|
|
||||||
#include "file_transfer.h"
|
|
||||||
#include "filesystem_utf8.h"
|
#include "filesystem_utf8.h"
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
#include "runtime/gui_runtime.h"
|
#include "runtime/gui_runtime.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
FileTransferManager::FileTransferManager(GuiRuntime &owner) : owner_(owner) {}
|
FileTransferManager::FileTransferManager(GuiRuntime& owner) : owner_(owner) {}
|
||||||
|
FileTransferManager::~FileTransferManager() { Shutdown(); }
|
||||||
|
|
||||||
FileTransferManager::~FileTransferManager() {
|
void FileTransferManager::Shutdown() {
|
||||||
std::vector<std::future<void>> workers;
|
std::vector<std::future<void>> workers;
|
||||||
{
|
{
|
||||||
std::lock_guard lock(workers_mutex_);
|
std::lock_guard lock(workers_mutex_);
|
||||||
stopping_ = true;
|
stopping_ = true;
|
||||||
workers.swap(workers_);
|
workers.swap(workers_);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard lock(sessions_mutex_);
|
||||||
|
for (auto& [id, sender] : senders_) sender.session->Cancel();
|
||||||
|
for (auto& [handler, peers] : receivers_)
|
||||||
|
for (auto& [id, receiver] : peers) receiver.session->Cancel();
|
||||||
|
receivers_.clear();
|
||||||
|
}
|
||||||
|
wake_.notify_all();
|
||||||
for (auto& worker : workers) worker.wait();
|
for (auto& worker : workers) worker.wait();
|
||||||
}
|
}
|
||||||
|
|
||||||
FileTransferManager::FileTransferState &FileTransferManager::global_state() {
|
FileTransferManager::FileTransferState& FileTransferManager::global_state() { return global_state_; }
|
||||||
return global_state_;
|
FileTransferManager::FileTransferState& FileTransferManager::state_for(const std::shared_ptr<RemoteSession>& props) {
|
||||||
}
|
|
||||||
|
|
||||||
FileTransferManager::FileTransferState &FileTransferManager::state_for(
|
|
||||||
const std::shared_ptr<RemoteSession> &props) {
|
|
||||||
return props ? props->file_transfer_ : global_state_;
|
return props ? props->file_transfer_ : global_state_;
|
||||||
}
|
}
|
||||||
|
|
||||||
void FileTransferManager::ProcessSelectedFile(
|
void FileTransferManager::ProcessSelectedFile(const std::string& path,
|
||||||
const std::string &path,
|
const std::shared_ptr<RemoteSession>& props, const std::string& file_label,
|
||||||
const std::shared_ptr<RemoteSession> &props,
|
const std::string& remote_id) {
|
||||||
const std::string &file_label, const std::string &remote_id) {
|
if (path.empty() || stopping_ || (props && props->closing_)) return;
|
||||||
if (path.empty()) {
|
const auto file_path = PathFromUtf8(path);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
FileTransferState &state = state_for(props);
|
|
||||||
LOG_INFO("Selected file: {}", path.c_str());
|
|
||||||
|
|
||||||
const std::filesystem::path file_path = PathFromUtf8(path);
|
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
if (!std::filesystem::is_regular_file(file_path, ec)) {
|
if (!std::filesystem::is_regular_file(file_path, ec)) return;
|
||||||
LOG_ERROR("Selected path is not a regular file: {}", path);
|
const auto size = std::filesystem::file_size(file_path, ec);
|
||||||
return;
|
if (ec) return;
|
||||||
}
|
auto& state = state_for(props);
|
||||||
const uint64_t file_size = std::filesystem::file_size(file_path, ec);
|
|
||||||
if (ec) {
|
|
||||||
LOG_ERROR("Failed to get file size: {}", ec.message());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(state.file_transfer_list_mutex_);
|
std::lock_guard lock(state.file_transfer_list_mutex_);
|
||||||
FileTransferState::FileTransferInfo info;
|
FileTransferState::FileTransferInfo info;
|
||||||
const auto utf8_name = file_path.filename().u8string();
|
const auto name = file_path.filename().u8string();
|
||||||
info.file_name.assign(reinterpret_cast<const char *>(utf8_name.data()),
|
info.file_name.assign(name.begin(), name.end());
|
||||||
utf8_name.size());
|
info.file_path = file_path; info.file_size = size;
|
||||||
info.file_path = file_path;
|
|
||||||
info.file_size = file_size;
|
|
||||||
info.status = FileTransferState::FileTransferStatus::Queued;
|
info.status = FileTransferState::FileTransferStatus::Queued;
|
||||||
state.file_transfer_list_.push_back(std::move(info));
|
state.file_transfer_list_.push_back(std::move(info));
|
||||||
}
|
}
|
||||||
state.file_transfer_window_visible_ = true;
|
state.file_transfer_window_visible_ = true;
|
||||||
|
|
||||||
auto enqueue = [&]() {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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_);
|
std::lock_guard lock(state.file_queue_mutex_);
|
||||||
if (props && props->closing_) return;
|
state.file_send_queue_.push({file_path, file_label, props ? props->remote_id_ : remote_id});
|
||||||
peer = is_global ? owner_.peer_ : props->peer_;
|
|
||||||
peer_events = is_global ? owner_.peer_events_ : props->peer_events_;
|
|
||||||
}
|
}
|
||||||
if (!peer) {
|
|
||||||
LOG_ERROR("StartFileTransfer: invalid peer");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
FileTransferState &initial_state = state_for(props);
|
|
||||||
bool expected = false;
|
|
||||||
if (!initial_state.file_sending_.compare_exchange_strong(expected, true)) {
|
|
||||||
LOG_WARN("StartFileTransfer called while another file is active: {}",
|
|
||||||
file_path.filename().string().c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto props_weak = std::weak_ptr<RemoteSession>(props);
|
|
||||||
std::lock_guard workers_lock(workers_mutex_);
|
|
||||||
if (stopping_) return;
|
|
||||||
// Reap finished senders without blocking the UI.
|
|
||||||
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;
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
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);
|
|
||||||
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
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;
|
|
||||||
{
|
|
||||||
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)())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
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);
|
ProcessQueue(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace crossdesk
|
void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
|
||||||
|
std::lock_guard workers_lock(workers_mutex_);
|
||||||
|
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, props, file] {
|
||||||
|
auto& state = state_for(props);
|
||||||
|
const auto id = file_transfer::Session::NextFileId();
|
||||||
|
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_;
|
||||||
|
}
|
||||||
|
auto session = std::make_shared<file_transfer::Session>(
|
||||||
|
[peer, events, file, props](const char*, const char* data, size_t size) {
|
||||||
|
if (!peer || !events || (props && props->closing_)) return -1;
|
||||||
|
auto callback = events->EnterCallback();
|
||||||
|
if (!callback) return -1;
|
||||||
|
return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(),
|
||||||
|
file.remote_id.c_str(), file.remote_id.size());
|
||||||
|
}, 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;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state.file_transfer_mutex_);
|
||||||
|
state.file_send_start_time_ = state.file_send_last_update_time_ = std::chrono::steady_clock::now();
|
||||||
|
state.file_send_last_bytes_ = 0;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state.file_transfer_list_mutex_);
|
||||||
|
for (auto& info : state.file_transfer_list_) {
|
||||||
|
if (info.file_path == file.file_path && info.status == FileTransferState::FileTransferStatus::Queued) {
|
||||||
|
info.status = FileTransferState::FileTransferStatus::Sending;
|
||||||
|
info.file_id = id; info.sent_bytes = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto name = file.file_path.filename().u8string();
|
||||||
|
bool started = false;
|
||||||
|
{
|
||||||
|
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 (!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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uint64_t previous = UINT64_MAX;
|
||||||
|
auto progress = file_transfer::SendState{id, 0, 0, file_transfer::Status::Failed};
|
||||||
|
if (started) {
|
||||||
|
do {
|
||||||
|
if (stopping_ || !events->IsActive() || (props && props->closing_)) session->Cancel();
|
||||||
|
const bool pumped = session->Pump();
|
||||||
|
progress = session->Snapshot();
|
||||||
|
if (progress.acknowledged != previous || progress.status != file_transfer::Status::Sending) {
|
||||||
|
UpdateProgress(state, props, progress); previous = progress.acknowledged;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
info.status = progress.status == file_transfer::Status::Completed
|
||||||
|
? FileTransferState::FileTransferStatus::Completed : FileTransferState::FileTransferStatus::Failed;
|
||||||
|
if (info.status == FileTransferState::FileTransferStatus::Completed) info.sent_bytes = info.file_size;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileTransferManager::UpdateProgress(FileTransferState& state,
|
||||||
|
const std::shared_ptr<RemoteSession>& props, const file_transfer::SendState& progress) {
|
||||||
|
state.file_sent_bytes_ = progress.acknowledged; state.file_total_bytes_ = progress.total;
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
const uint32_t current = state.file_send_rate_bps_.load();
|
||||||
|
uint32_t rate = current;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state.file_transfer_mutex_);
|
||||||
|
if (props) {
|
||||||
|
const auto bitrate = props->net_traffic_stats_.DataOutboundBitrate();
|
||||||
|
if (bitrate) rate = current ? static_cast<uint32_t>(current * .7 + bitrate * .99 * .3)
|
||||||
|
: static_cast<uint32_t>(bitrate * .99);
|
||||||
|
} else {
|
||||||
|
const double seconds = std::chrono::duration<double>(now - state.file_send_last_update_time_).count();
|
||||||
|
if (seconds > 0 && progress.acknowledged > state.file_send_last_bytes_) {
|
||||||
|
const double measured = (progress.acknowledged - state.file_send_last_bytes_) * 8.0 / seconds;
|
||||||
|
const auto value = static_cast<uint32_t>((std::min)(measured,
|
||||||
|
static_cast<double>((std::numeric_limits<uint32_t>::max)())));
|
||||||
|
rate = current ? static_cast<uint32_t>(current * .7 + value * .3) : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.file_send_last_bytes_ = progress.acknowledged; state.file_send_last_update_time_ = now;
|
||||||
|
}
|
||||||
|
state.file_send_rate_bps_ = rate;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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::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();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -1,61 +1,66 @@
|
|||||||
#ifndef CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
|
#ifndef CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
|
||||||
#define CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
|
#define CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
|
||||||
|
|
||||||
#include <cstddef>
|
#include <atomic>
|
||||||
#include <cstdint>
|
#include <condition_variable>
|
||||||
#include <filesystem>
|
|
||||||
#include <future>
|
#include <future>
|
||||||
#include <mutex>
|
|
||||||
#include <vector>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <shared_mutex>
|
#include <mutex>
|
||||||
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "file_transfer.h"
|
||||||
#include "runtime/remote_session.h"
|
#include "runtime/remote_session.h"
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
class GuiRuntime;
|
class GuiRuntime;
|
||||||
|
class PeerEventHandler;
|
||||||
|
|
||||||
class FileTransferManager {
|
class FileTransferManager {
|
||||||
public:
|
public:
|
||||||
using FileTransferState = gui_detail::FileTransferState;
|
using FileTransferState = gui_detail::FileTransferState;
|
||||||
using RemoteSession = gui_detail::RemoteSession;
|
using RemoteSession = gui_detail::RemoteSession;
|
||||||
|
explicit FileTransferManager(GuiRuntime& owner);
|
||||||
explicit FileTransferManager(GuiRuntime &owner);
|
|
||||||
~FileTransferManager();
|
~FileTransferManager();
|
||||||
|
FileTransferState& global_state();
|
||||||
|
FileTransferState& 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, 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 = "");
|
||||||
|
void Poll();
|
||||||
|
void Shutdown();
|
||||||
|
|
||||||
FileTransferState &global_state();
|
private:
|
||||||
FileTransferState &
|
struct Sender {
|
||||||
state_for(const std::shared_ptr<RemoteSession> &props);
|
std::shared_ptr<file_transfer::Session> session;
|
||||||
|
std::shared_ptr<PeerEventHandler> handler;
|
||||||
void
|
std::string remote;
|
||||||
ProcessSelectedFile(const std::string &path,
|
};
|
||||||
const std::shared_ptr<RemoteSession> &props,
|
struct Receiver {
|
||||||
const std::string &file_label,
|
std::shared_ptr<FileReceiver> session;
|
||||||
const std::string &remote_id = "");
|
std::shared_ptr<PeerEventHandler> handler;
|
||||||
void HandleAck(const char *data, size_t size);
|
std::filesystem::path default_directory;
|
||||||
|
};
|
||||||
private:
|
|
||||||
void Start(std::shared_ptr<RemoteSession> props,
|
|
||||||
const std::filesystem::path &file_path,
|
|
||||||
const std::string &file_label, const std::string &remote_id = "");
|
|
||||||
void ProcessQueue(std::shared_ptr<RemoteSession> props);
|
void ProcessQueue(std::shared_ptr<RemoteSession> props);
|
||||||
void Unregister(uint32_t file_id, bool per_peer);
|
void UpdateProgress(FileTransferState& state, const std::shared_ptr<RemoteSession>& props,
|
||||||
|
const file_transfer::SendState& progress);
|
||||||
std::mutex workers_mutex_;
|
std::mutex workers_mutex_, sessions_mutex_, wake_mutex_;
|
||||||
|
std::condition_variable wake_;
|
||||||
std::vector<std::future<void>> workers_;
|
std::vector<std::future<void>> workers_;
|
||||||
bool stopping_ = false;
|
std::atomic<bool> stopping_{false};
|
||||||
GuiRuntime &owner_;
|
std::unordered_map<uint32_t, Sender> senders_;
|
||||||
|
std::unordered_map<PeerEventHandler*, std::unordered_map<std::string, Receiver>> receivers_;
|
||||||
|
std::unordered_map<PeerEventHandler*, std::set<std::string>> disconnected_;
|
||||||
|
GuiRuntime& owner_;
|
||||||
FileTransferState global_state_;
|
FileTransferState global_state_;
|
||||||
std::unordered_map<uint32_t, std::weak_ptr<RemoteSession>>
|
|
||||||
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_;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|
||||||
#endif // CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
|
#endif
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
#if defined(__APPLE__)
|
#if defined(__APPLE__)
|
||||||
#include "platform/macos/gui/metal_video_renderer.h"
|
#include "platform/macos/gui/metal_video_renderer.h"
|
||||||
#else
|
#elif !defined(__OHOS__)
|
||||||
#if defined(_WIN32)
|
#if defined(_WIN32)
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#endif
|
#endif
|
||||||
@@ -28,10 +28,10 @@
|
|||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
#if !defined(__APPLE__)
|
#if !defined(__APPLE__) && !defined(__OHOS__)
|
||||||
constexpr GLint kGlClampToEdge = 0x812F;
|
constexpr GLint kGlClampToEdge = 0x812F;
|
||||||
constexpr float kStreamWindowCornerRadius = 12.0f;
|
constexpr float kStreamWindowCornerRadius = 12.0f;
|
||||||
#else
|
#elif defined(__APPLE__)
|
||||||
constexpr int kMetalAttachmentAttemptLimit = 30;
|
constexpr int kMetalAttachmentAttemptLimit = 30;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ struct SlintVideoPresenter::Impl {
|
|||||||
#if defined(__APPLE__)
|
#if defined(__APPLE__)
|
||||||
int attachment_attempts = 0;
|
int attachment_attempts = 0;
|
||||||
bool skip_attachment_once = false;
|
bool skip_attachment_once = false;
|
||||||
#else
|
#elif !defined(__OHOS__)
|
||||||
std::mutex gl_mutex;
|
std::mutex gl_mutex;
|
||||||
std::vector<uint8_t> gl_conversion_frame;
|
std::vector<uint8_t> gl_conversion_frame;
|
||||||
std::vector<uint8_t> gl_pending_frame;
|
std::vector<uint8_t> gl_pending_frame;
|
||||||
@@ -109,7 +109,7 @@ struct SlintVideoPresenter::Impl {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !defined(__APPLE__)
|
#if !defined(__APPLE__) && !defined(__OHOS__)
|
||||||
void ConfigureOpenGlNotifier() {
|
void ConfigureOpenGlNotifier() {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return;
|
return;
|
||||||
@@ -238,7 +238,7 @@ void SlintVideoPresenter::PrepareWindow(
|
|||||||
stream->set_native_video_enabled(false);
|
stream->set_native_video_enabled(false);
|
||||||
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
|
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
|
||||||
impl_->skip_attachment_once = true;
|
impl_->skip_attachment_once = true;
|
||||||
#else
|
#elif !defined(__OHOS__)
|
||||||
impl_->ConfigureOpenGlNotifier();
|
impl_->ConfigureOpenGlNotifier();
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -351,7 +351,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
#else
|
#elif !defined(__OHOS__)
|
||||||
auto* opengl_renderer =
|
auto* opengl_renderer =
|
||||||
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
|
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
|
||||||
if (opengl_renderer && opengl_renderer->IsReady()) {
|
if (opengl_renderer && opengl_renderer->IsReady()) {
|
||||||
@@ -419,7 +419,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !defined(__APPLE__)
|
#if !defined(__APPLE__) && !defined(__OHOS__)
|
||||||
uint32_t texture_id = 0;
|
uint32_t texture_id = 0;
|
||||||
{
|
{
|
||||||
std::lock_guard lock(impl_->gl_mutex);
|
std::lock_guard lock(impl_->gl_mutex);
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
|
|||||||
|
|
||||||
void GuiRuntime::HandleServerControllerDisconnected(
|
void GuiRuntime::HandleServerControllerDisconnected(
|
||||||
const std::string& remote_id, const char* reason) {
|
const std::string& remote_id, const char* reason) {
|
||||||
|
transfers_.Cancel(peer_events_.get(), remote_id);
|
||||||
preview_subscriptions_.Remove(remote_id);
|
preview_subscriptions_.Remove(remote_id);
|
||||||
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
|
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
@@ -228,7 +229,7 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
|||||||
need_to_create_server_window_.store(false, std::memory_order_release);
|
need_to_create_server_window_.store(false, std::memory_order_release);
|
||||||
need_to_destroy_server_window_.store(true, std::memory_order_release);
|
need_to_destroy_server_window_.store(true, std::memory_order_release);
|
||||||
is_server_mode_ = false;
|
is_server_mode_ = false;
|
||||||
#if defined(__linux__) && !defined(__APPLE__)
|
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
if (IsWaylandSession()) {
|
if (IsWaylandSession()) {
|
||||||
// Keep Wayland capture session warm to avoid black screen on subsequent
|
// Keep Wayland capture session warm to avoid black screen on subsequent
|
||||||
// reconnects.
|
// reconnects.
|
||||||
@@ -298,7 +299,10 @@ int GuiRuntime::ForceSignalReconnect() {
|
|||||||
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||||
if (!props || props->closing_.exchange(true)) return;
|
if (!props || props->closing_.exchange(true)) return;
|
||||||
MuteRemoteAudio(props);
|
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->connection_established_ = false;
|
||||||
props->streaming_ = false;
|
props->streaming_ = false;
|
||||||
props->connection_status_.store(ConnectionStatus::Closed);
|
props->connection_status_.store(ConnectionStatus::Closed);
|
||||||
@@ -392,6 +396,7 @@ void GuiRuntime::CloseConnectionPeer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
peer_events_->Deactivate();
|
peer_events_->Deactivate();
|
||||||
|
transfers_.Cancel(peer_events_.get());
|
||||||
announcements_.Reset();
|
announcements_.Reset();
|
||||||
std::vector<std::string> controllers;
|
std::vector<std::string> controllers;
|
||||||
{
|
{
|
||||||
@@ -439,6 +444,7 @@ void GuiRuntime::CloseAllRemoteSessions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GuiRuntime::HandleSessionCleanup() {
|
void GuiRuntime::HandleSessionCleanup() {
|
||||||
|
transfers_.Poll();
|
||||||
auto completed = [](std::future<void>& task) {
|
auto completed = [](std::future<void>& task) {
|
||||||
if (!task.valid() || task.wait_for(std::chrono::seconds(0)) !=
|
if (!task.valid() || task.wait_for(std::chrono::seconds(0)) !=
|
||||||
std::future_status::ready) return false;
|
std::future_status::ready) return false;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
|
|||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|
||||||
#elif defined(__linux__) && !defined(__APPLE__)
|
#elif (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||||
|
|
||||||
#include <X11/Xlib.h>
|
#include <X11/Xlib.h>
|
||||||
#include <X11/extensions/Xfixes.h>
|
#include <X11/extensions/Xfixes.h>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
#include "localization.h"
|
#include "localization.h"
|
||||||
#include "platform/video_renderer.h"
|
#include "platform/video_renderer.h"
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
#include "platform/linux/headless/headless_console.h"
|
#include "platform/linux/headless/headless_console.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -26,7 +26,10 @@ GuiRuntime::GuiRuntime()
|
|||||||
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this),
|
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this),
|
||||||
keyboard_(*this) {}
|
keyboard_(*this) {}
|
||||||
|
|
||||||
GuiRuntime::~GuiRuntime() { WaitForSessionCleanup(); }
|
GuiRuntime::~GuiRuntime() {
|
||||||
|
transfers_.Shutdown();
|
||||||
|
WaitForSessionCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
|
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
|
||||||
const bool mouse = action.type == ControlType::mouse;
|
const bool mouse = action.type == ControlType::mouse;
|
||||||
@@ -140,7 +143,7 @@ int GuiRuntime::CreateConnectionPeer() {
|
|||||||
|
|
||||||
const char *active_identity =
|
const char *active_identity =
|
||||||
self_hosted ? self_hosted_user_id_ : client_id_with_password_;
|
self_hosted ? self_hosted_user_id_ : client_id_with_password_;
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
// Publish the committed identity; an unconfirmed recovery credential must
|
// Publish the committed identity; an unconfirmed recovery credential must
|
||||||
// not be advertised as the current connection password.
|
// not be advertised as the current connection password.
|
||||||
HeadlessConsole::Instance().SetIdentity(active_identity);
|
HeadlessConsole::Instance().SetIdentity(active_identity);
|
||||||
@@ -174,7 +177,7 @@ int GuiRuntime::CreateConnectionPeer() {
|
|||||||
sizeof(params_.log_path) - 1);
|
sizeof(params_.log_path) - 1);
|
||||||
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
|
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
|
||||||
params_.hardware_acceleration = config_center_->IsHardwareVideoCodec();
|
params_.hardware_acceleration = config_center_->IsHardwareVideoCodec();
|
||||||
#if defined(_WIN32) || defined(__linux__) || defined(__APPLE__)
|
#if defined(_WIN32) || (defined(__linux__) && !defined(__OHOS__)) || defined(__APPLE__)
|
||||||
// Windows and Linux retain pooled CPU NV12 frames or CUDA device frames;
|
// Windows and Linux retain pooled CPU NV12 frames or CUDA device frames;
|
||||||
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
|
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
|
||||||
// platforms fall back to a packed CPU copy when native upload is unavailable.
|
// platforms fall back to a packed CPU copy when native upload is unavailable.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#include <app_version.h>
|
#include <app_version.h>
|
||||||
#include <remote_action.h>
|
#include <remote_action.h>
|
||||||
|
#include <stream_names.h>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -24,6 +25,9 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
#include "runtime/gui_runtime.h"
|
#include "runtime/gui_runtime.h"
|
||||||
|
#ifdef __OHOS__
|
||||||
|
#include "platform/harmony/input/system_shortcuts.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
#if _WIN32
|
#if _WIN32
|
||||||
#include "desktop_transition_policy.h"
|
#include "desktop_transition_policy.h"
|
||||||
@@ -101,38 +105,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
source_id == runtime->clipboard_label_))
|
source_id == runtime->clipboard_label_))
|
||||||
return;
|
return;
|
||||||
if (source_id == runtime->file_label_) {
|
if (source_id == runtime->file_label_) {
|
||||||
std::string remote_user_id = std::string(user_id, user_id_size);
|
runtime->transfers_.Receive(data, size, handler, std::string(user_id, user_id_size));
|
||||||
|
|
||||||
static FileReceiver receiver;
|
|
||||||
// Update output directory from config
|
|
||||||
std::string configured_path =
|
|
||||||
runtime->config_center_->GetFileTransferSavePath();
|
|
||||||
if (!configured_path.empty()) {
|
|
||||||
receiver.SetOutputDir(PathFromUtf8(configured_path));
|
|
||||||
} else if (receiver.OutputDir().empty()) {
|
|
||||||
receiver = FileReceiver(); // re-init with default desktop path
|
|
||||||
}
|
|
||||||
receiver.SetOnSendAck([runtime,
|
|
||||||
remote_user_id](const FileTransferAck &ack) -> int {
|
|
||||||
const auto encoded_ack = EncodeFileTransferAck(ack);
|
|
||||||
bool is_server_sending = remote_user_id.rfind("C-", 0) != 0;
|
|
||||||
if (is_server_sending) {
|
|
||||||
auto props =
|
|
||||||
runtime->FindRemoteSession(remote_user_id);
|
|
||||||
if (props) {
|
|
||||||
PeerPtr *peer = props->peer_;
|
|
||||||
return SendReliableDataFrame(
|
|
||||||
peer, encoded_ack.data(), encoded_ack.size(),
|
|
||||||
runtime->file_feedback_label_.c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SendReliableDataFrame(
|
|
||||||
runtime->peer_, encoded_ack.data(), encoded_ack.size(),
|
|
||||||
runtime->file_feedback_label_.c_str());
|
|
||||||
});
|
|
||||||
|
|
||||||
receiver.OnData(data, size);
|
|
||||||
return;
|
return;
|
||||||
} else if (source_id == runtime->clipboard_label_) {
|
} else if (source_id == runtime->clipboard_label_) {
|
||||||
if (size > 0) {
|
if (size > 0) {
|
||||||
@@ -148,7 +121,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else if (source_id == runtime->file_feedback_label_) {
|
} else if (source_id == runtime->file_feedback_label_) {
|
||||||
runtime->transfers_.HandleAck(data, size);
|
runtime->transfers_.HandleAck(data, size, handler, std::string(user_id, user_id_size));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +234,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (remote_action.type == ControlType::video_settings) {
|
if (remote_action.type == ControlType::video_settings) {
|
||||||
if (source_id != runtime->control_data_label_ ||
|
// Web multiplexes its reliable control actions on a single DataChannel.
|
||||||
|
// Accept that transport's source alias while retaining controller admission.
|
||||||
|
if ((source_id != runtime->control_data_label_ &&
|
||||||
|
source_id != kWebControlStream) ||
|
||||||
!runtime->IsAuthorizedController(remote_id))
|
!runtime->IsAuthorizedController(remote_id))
|
||||||
return;
|
return;
|
||||||
const auto& settings = remote_action.vs;
|
const auto& settings = remote_action.vs;
|
||||||
@@ -270,6 +246,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||||
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
|
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
|
||||||
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
|
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
|
||||||
|
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
|
||||||
|
"preference={} accepted={}",
|
||||||
|
remote_id, source_id, settings.request_id, settings.quality,
|
||||||
|
settings.frame_rate, settings.preference, remote_action.vs.accepted);
|
||||||
remote_action.type = ControlType::video_settings_status;
|
remote_action.type = ControlType::video_settings_status;
|
||||||
const auto message = remote_action.to_json();
|
const auto message = remote_action.to_json();
|
||||||
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
|
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
|
||||||
@@ -339,6 +319,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
|||||||
GetLastError());
|
GetLastError());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#elif defined(__OHOS__)
|
||||||
|
if (!SendHarmonySystemShortcut(remote_action.c.flag))
|
||||||
|
LOG_WARN("Harmony system shortcut unsupported or unauthorized, command={}",
|
||||||
|
static_cast<int>(remote_action.c.flag));
|
||||||
#endif
|
#endif
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
#include "platform/video_renderer.h"
|
#include "platform/video_renderer.h"
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
#include "runtime/gui_runtime.h"
|
#include "runtime/gui_runtime.h"
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
#include "platform/linux/headless/headless_console.h"
|
#include "platform/linux/headless/headless_console.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -38,7 +38,9 @@ namespace {
|
|||||||
constexpr HostPlatform kClientPlatform = HostPlatform::Windows;
|
constexpr HostPlatform kClientPlatform = HostPlatform::Windows;
|
||||||
#elif defined(__APPLE__)
|
#elif defined(__APPLE__)
|
||||||
constexpr HostPlatform kClientPlatform = HostPlatform::MacOS;
|
constexpr HostPlatform kClientPlatform = HostPlatform::MacOS;
|
||||||
#elif defined(__linux__)
|
#elif defined(__OHOS__)
|
||||||
|
constexpr HostPlatform kClientPlatform = HostPlatform::HarmonyOS;
|
||||||
|
#elif (defined(__linux__) && !defined(__OHOS__))
|
||||||
constexpr HostPlatform kClientPlatform = HostPlatform::Linux;
|
constexpr HostPlatform kClientPlatform = HostPlatform::Linux;
|
||||||
#else
|
#else
|
||||||
constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
|
constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
|
||||||
@@ -61,7 +63,9 @@ void PeerEventHandler::SendClientInfo(PeerPtr* peer,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {}
|
PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {
|
||||||
|
owner_.transfers_.Connected(this, "");
|
||||||
|
}
|
||||||
|
|
||||||
void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
||||||
void* user_data) {
|
void* user_data) {
|
||||||
@@ -257,6 +261,9 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
|||||||
if (!runtime) return;
|
if (!runtime) return;
|
||||||
|
|
||||||
std::string remote_id(user_id, user_id_size);
|
std::string remote_id(user_id, user_id_size);
|
||||||
|
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
|
||||||
|
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
|
||||||
|
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
|
||||||
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
||||||
{
|
{
|
||||||
std::shared_lock lock(runtime->remote_sessions_mutex_);
|
std::shared_lock lock(runtime->remote_sessions_mutex_);
|
||||||
@@ -553,7 +560,7 @@ void PeerEventHandler::OnNetStatusReport(
|
|||||||
LOG_INFO("Use client id [{}] and save id into cache file", id);
|
LOG_INFO("Use client id [{}] and save id into cache file", id);
|
||||||
runtime->settings_.Save();
|
runtime->settings_.Save();
|
||||||
}
|
}
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
HeadlessConsole::Instance().SetIdentity(id + "@" + password);
|
HeadlessConsole::Instance().SetIdentity(id + "@" + password);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct FileTransferState {
|
|||||||
std::chrono::steady_clock::time_point file_send_start_time_;
|
std::chrono::steady_clock::time_point file_send_start_time_;
|
||||||
std::chrono::steady_clock::time_point file_send_last_update_time_;
|
std::chrono::steady_clock::time_point file_send_last_update_time_;
|
||||||
uint64_t file_send_last_bytes_ = 0;
|
uint64_t file_send_last_bytes_ = 0;
|
||||||
bool file_transfer_window_visible_ = false;
|
std::atomic<bool> file_transfer_window_visible_ = false;
|
||||||
std::atomic<uint32_t> current_file_id_{0};
|
std::atomic<uint32_t> current_file_id_{0};
|
||||||
|
|
||||||
struct QueuedFile {
|
struct QueuedFile {
|
||||||
|
|||||||
@@ -182,6 +182,17 @@ export component CompactComboBox inherits Rectangle {
|
|||||||
property <float> popup-progress: 0.0;
|
property <float> popup-progress: 0.0;
|
||||||
callback selected(int);
|
callback selected(int);
|
||||||
|
|
||||||
|
// Session state can disable a setting while its popup is already open.
|
||||||
|
// Remove the stale options and stop a pending reveal on every backend.
|
||||||
|
changed enabled => {
|
||||||
|
if !root.enabled {
|
||||||
|
popup-open-timer.running = false;
|
||||||
|
root.popup-animation-enabled = false;
|
||||||
|
root.popup-progress = 0.0;
|
||||||
|
combo-popup.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
animate popup-progress {
|
animate popup-progress {
|
||||||
duration: 160ms;
|
duration: 160ms;
|
||||||
easing: ease-out;
|
easing: ease-out;
|
||||||
@@ -278,12 +289,15 @@ export component CompactComboBox inherits Rectangle {
|
|||||||
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
|
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
|
||||||
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||||
option-touch := TouchArea {
|
option-touch := TouchArea {
|
||||||
|
enabled: root.enabled;
|
||||||
clicked => {
|
clicked => {
|
||||||
popup-open-timer.running = false;
|
popup-open-timer.running = false;
|
||||||
root.popup-animation-enabled = false;
|
root.popup-animation-enabled = false;
|
||||||
root.popup-progress = 0.0;
|
root.popup-progress = 0.0;
|
||||||
root.current-index = index;
|
if root.enabled && index >= 0 && index < root.model.length {
|
||||||
root.selected(index);
|
root.current-index = index;
|
||||||
|
root.selected(index);
|
||||||
|
}
|
||||||
combo-popup.close();
|
combo-popup.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,6 +453,8 @@ export component SecondaryButton inherits Rectangle {
|
|||||||
export component ToggleSwitch inherits Rectangle {
|
export component ToggleSwitch inherits Rectangle {
|
||||||
in-out property <bool> checked;
|
in-out property <bool> checked;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
|
in property <bool> toggle-on-click: true;
|
||||||
|
callback clicked();
|
||||||
callback toggled(bool);
|
callback toggled(bool);
|
||||||
|
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -459,8 +475,11 @@ export component ToggleSwitch inherits Rectangle {
|
|||||||
TouchArea {
|
TouchArea {
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
clicked => {
|
clicked => {
|
||||||
root.checked = !root.checked;
|
if root.toggle-on-click {
|
||||||
root.toggled(root.checked);
|
root.checked = !root.checked;
|
||||||
|
root.toggled(root.checked);
|
||||||
|
}
|
||||||
|
root.clicked();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Single AOT entry point. Generating one header for the entire UI avoids
|
// Single AOT entry point. Generating one header for the entire UI avoids
|
||||||
// duplicate imported-component definitions when C++ uses all three windows.
|
// duplicate imported-component definitions when C++ uses all three windows.
|
||||||
export { AnnouncementItem, MessageStrings } from "message_panel.slint";
|
export { AnnouncementItem, MessageStrings } from "message_panel.slint";
|
||||||
export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint";
|
export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint";
|
||||||
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
|
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
|
||||||
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
|
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { UiRadius } from "ui_radius.slint";
|
import { UiRadius } from "ui_radius.slint";
|
||||||
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard } from "workbench_controls.slint";
|
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint";
|
||||||
import { GalleryStrings } from "device_strings.slint";
|
import { GalleryStrings } from "device_strings.slint";
|
||||||
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
|
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
|
||||||
import { DeviceGroupDialog, DeviceGroupOption } from "device_group_dialog.slint";
|
import { DeviceGroupDialog, DeviceGroupOption } from "device_group_dialog.slint";
|
||||||
@@ -11,7 +11,7 @@ import { GroupFilter } from "group_filter.slint";
|
|||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
||||||
import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint";
|
import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint";
|
||||||
import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
|
import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
|
||||||
import "../assets/fonts/message_spacing.ttf";
|
import "../assets/fonts/message_spacing.ttf";
|
||||||
|
|
||||||
export struct RecentConnection {
|
export struct RecentConnection {
|
||||||
@@ -32,6 +32,8 @@ export struct ReleaseNoteBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum UpdateCheckStatus { idle, checking, up-to-date, available, failed }
|
export enum UpdateCheckStatus { idle, checking, up-to-date, available, failed }
|
||||||
|
export enum AutostartStatus { checking, enabled, disabled, unsupported, failed }
|
||||||
|
enum CopyFeedback { idle, success, failed }
|
||||||
|
|
||||||
export global UiStrings {
|
export global UiStrings {
|
||||||
in-out property <string> local-desktop: "Local Desktop";
|
in-out property <string> local-desktop: "Local Desktop";
|
||||||
@@ -400,6 +402,16 @@ export component MainWindow inherits Window {
|
|||||||
}
|
}
|
||||||
in-out property <bool> self-hosted-enabled: false;
|
in-out property <bool> self-hosted-enabled: false;
|
||||||
in-out property <bool> autostart-enabled: false;
|
in-out property <bool> autostart-enabled: false;
|
||||||
|
in property <bool> autostart-system-managed: false;
|
||||||
|
in property <AutostartStatus> autostart-status: AutostartStatus.checking;
|
||||||
|
out property <string> autostart-status-text:
|
||||||
|
root.autostart-status == AutostartStatus.checking ? GalleryStrings.tr("正在读取…", "Checking…", "Проверка…") :
|
||||||
|
root.autostart-status == AutostartStatus.enabled ? GalleryStrings.tr("已开启", "Enabled", "Включён") :
|
||||||
|
root.autostart-status == AutostartStatus.disabled ? GalleryStrings.tr("未开启", "Disabled", "Выключен") :
|
||||||
|
root.autostart-status == AutostartStatus.unsupported ? GalleryStrings.tr("设备不支持", "Unsupported", "Не поддерживается") :
|
||||||
|
GalleryStrings.tr("读取失败", "Read failed", "Ошибка чтения");
|
||||||
|
in-out property <bool> autostart-guide-open: false;
|
||||||
|
changed autostart-guide-open => { if root.autostart-guide-open { focus-sink.focus(); } }
|
||||||
in-out property <bool> daemon-enabled: false;
|
in-out property <bool> daemon-enabled: false;
|
||||||
in-out property <bool> save-remote-previews: true;
|
in-out property <bool> save-remote-previews: true;
|
||||||
// Show the preference on every platform; only adapted hosts can change it.
|
// Show the preference on every platform; only adapted hosts can change it.
|
||||||
@@ -426,6 +438,7 @@ export component MainWindow inherits Window {
|
|||||||
callback recent-delete(string);
|
callback recent-delete(string);
|
||||||
callback save-settings() -> bool;
|
callback save-settings() -> bool;
|
||||||
callback cancel-settings;
|
callback cancel-settings;
|
||||||
|
callback refresh-autostart-status;
|
||||||
callback save-self-hosted-settings() -> bool;
|
callback save-self-hosted-settings() -> bool;
|
||||||
callback cancel-self-hosted-settings;
|
callback cancel-self-hosted-settings;
|
||||||
callback browse-save-path;
|
callback browse-save-path;
|
||||||
@@ -467,7 +480,7 @@ export component MainWindow inherits Window {
|
|||||||
in-out property <bool> delete-open: false;
|
in-out property <bool> delete-open: false;
|
||||||
private property <string> delete-remote-id: "";
|
private property <string> delete-remote-id: "";
|
||||||
private property <bool> delete-from-gallery: false;
|
private property <bool> delete-from-gallery: false;
|
||||||
private property <bool> copy-toast: false;
|
private property <CopyFeedback> copy-feedback: idle;
|
||||||
private property <bool> compact-language: root.language-index == 0;
|
private property <bool> compact-language: root.language-index == 0;
|
||||||
private property <bool> recent-tooltip-visible: false;
|
private property <bool> recent-tooltip-visible: false;
|
||||||
private property <string> recent-tooltip-name: "";
|
private property <string> recent-tooltip-name: "";
|
||||||
@@ -478,7 +491,9 @@ export component MainWindow inherits Window {
|
|||||||
private property <length> recent-tooltip-pointer-x: 0px;
|
private property <length> recent-tooltip-pointer-x: 0px;
|
||||||
private property <length> recent-tooltip-pointer-y: 0px;
|
private property <length> recent-tooltip-pointer-y: 0px;
|
||||||
|
|
||||||
changed menu-open => { if root.menu-open { root.gallery-action("release", ""); } }
|
changed menu-open => {
|
||||||
|
if root.menu-open { root.copy-feedback = CopyFeedback.idle; root.gallery-action("release", ""); }
|
||||||
|
}
|
||||||
changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } }
|
changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } }
|
||||||
changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } }
|
changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } }
|
||||||
changed settings-open => {
|
changed settings-open => {
|
||||||
@@ -491,7 +506,10 @@ export component MainWindow inherits Window {
|
|||||||
root.gallery-frame-rate-draft = root.gallery-frame-rate-index;
|
root.gallery-frame-rate-draft = root.gallery-frame-rate-index;
|
||||||
root.gallery-bandwidth-draft = root.gallery-bandwidth-index;
|
root.gallery-bandwidth-draft = root.gallery-bandwidth-index;
|
||||||
root.gallery-action("settings-open", "");
|
root.gallery-action("settings-open", "");
|
||||||
|
if root.autostart-system-managed { root.refresh-autostart-status(); }
|
||||||
focus-sink.focus();
|
focus-sink.focus();
|
||||||
|
} else {
|
||||||
|
root.autostart-guide-open = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +562,7 @@ export component MainWindow inherits Window {
|
|||||||
width: parent.width - 80px;
|
width: parent.width - 80px;
|
||||||
property <int> phase: self.pressed ? 1 : 0;
|
property <int> phase: self.pressed ? 1 : 0;
|
||||||
changed phase => {
|
changed phase => {
|
||||||
|
if self.pressed { root.copy-feedback = CopyFeedback.idle; }
|
||||||
root.main-title-drag(phase, self.mouse-x, self.mouse-y);
|
root.main-title-drag(phase, self.mouse-x, self.mouse-y);
|
||||||
}
|
}
|
||||||
moved => {
|
moved => {
|
||||||
@@ -643,7 +662,10 @@ export component MainWindow inherits Window {
|
|||||||
x: 0px;
|
x: 0px;
|
||||||
width: parent.width - 92px;
|
width: parent.width - 92px;
|
||||||
property <int> phase: self.pressed ? 1 : 0;
|
property <int> phase: self.pressed ? 1 : 0;
|
||||||
changed phase => { root.main-title-drag(phase, self.mouse-x, self.mouse-y); }
|
changed phase => {
|
||||||
|
if self.pressed { root.copy-feedback = CopyFeedback.idle; }
|
||||||
|
root.main-title-drag(phase, self.mouse-x, self.mouse-y);
|
||||||
|
}
|
||||||
moved => { if (self.pressed) { root.main-title-drag(2, self.mouse-x, self.mouse-y); } }
|
moved => { if (self.pressed) { root.main-title-drag(2, self.mouse-x, self.mouse-y); } }
|
||||||
}
|
}
|
||||||
minimize-button := Rectangle {
|
minimize-button := Rectangle {
|
||||||
@@ -714,6 +736,10 @@ export component MainWindow inherits Window {
|
|||||||
focus-sink := FocusScope {
|
focus-sink := FocusScope {
|
||||||
x: 0px; y: 0px; width: 0px; height: 0px;
|
x: 0px; y: 0px; width: 0px; height: 0px;
|
||||||
key-pressed(event) => {
|
key-pressed(event) => {
|
||||||
|
if root.autostart-guide-open {
|
||||||
|
if event.text == Key.Return || event.text == Key.Escape { root.autostart-guide-open = false; }
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
if root.wayland-notice-open {
|
if root.wayland-notice-open {
|
||||||
if event.text == Key.Return || event.text == Key.Escape {
|
if event.text == Key.Return || event.text == Key.Escape {
|
||||||
root.wayland-notice-open = false;
|
root.wayland-notice-open = false;
|
||||||
@@ -735,7 +761,12 @@ export component MainWindow inherits Window {
|
|||||||
return reject;
|
return reject;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TouchArea { clicked => { focus-sink.focus(); } }
|
TouchArea {
|
||||||
|
pointer-event(event) => {
|
||||||
|
if event.kind == PointerEventKind.down { root.copy-feedback = CopyFeedback.idle; }
|
||||||
|
}
|
||||||
|
clicked => { focus-sink.focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
// Slint owns the client area below the native macOS title bar. Draw
|
// Slint owns the client area below the native macOS title bar. Draw
|
||||||
// the boundary here because AppKit's titlebar separator is not shown
|
// the boundary here because AppKit's titlebar separator is not shown
|
||||||
@@ -860,11 +891,21 @@ export component MainWindow inherits Window {
|
|||||||
border-width: 1px; border-color: WorkbenchStyle.border;
|
border-width: 1px; border-color: WorkbenchStyle.border;
|
||||||
Text { x: 12px; width: parent.width - 64px; height: parent.height; text: root.local-id; font-size: 24px; font-weight: 600; letter-spacing: 1px; color: WorkbenchStyle.text; vertical-alignment: center; }
|
Text { x: 12px; width: parent.width - 64px; height: parent.height; text: root.local-id; font-size: 24px; font-weight: 600; letter-spacing: 1px; color: WorkbenchStyle.text; vertical-alignment: center; }
|
||||||
Rectangle { x: parent.width - 48px; y: 10px; width: 1px; height: parent.height - 20px; background: WorkbenchStyle.border; }
|
Rectangle { x: parent.width - 48px; y: 10px; width: 1px; height: parent.height - 20px; background: WorkbenchStyle.border; }
|
||||||
WorkbenchButton {
|
copy-button := WorkbenchButton {
|
||||||
x: parent.width - 42px; y: 2px; width: 36px; quiet: true;
|
x: parent.width - 42px; y: 2px; width: 36px; quiet: true;
|
||||||
icon: root.copy-toast ? FontAwesomeIcons.check : FontAwesomeIcons.copy;
|
icon: root.copy-feedback == CopyFeedback.success ? FontAwesomeIcons.check : FontAwesomeIcons.copy;
|
||||||
tooltip: root.copy-toast ? UiStrings.copied : GalleryStrings.tr("复制本机 ID", "Copy local ID", "Копировать ID");
|
icon-color: root.copy-feedback == CopyFeedback.success ? WorkbenchStyle.success : WorkbenchStyle.muted;
|
||||||
clicked => { if root.copy-local-id() { root.copy-toast = true; copy-timer.restart(); } }
|
tooltip: GalleryStrings.tr("复制本机 ID", "Copy local ID", "Копировать ID");
|
||||||
|
tooltip-suppressed: root.copy-feedback != CopyFeedback.idle;
|
||||||
|
// Let other controls receive the original click; moving
|
||||||
|
// focus away from Copy dismisses its transient feedback.
|
||||||
|
changed has-focus => {
|
||||||
|
if !self.has-focus { root.copy-feedback = CopyFeedback.idle; }
|
||||||
|
}
|
||||||
|
clicked => {
|
||||||
|
root.copy-feedback = root.copy-local-id() ? CopyFeedback.success : CopyFeedback.failed;
|
||||||
|
copy-timer.restart();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text { x: 16px; y: 138px; height: 18px; text: UiStrings.password; font-size: 12px; color: WorkbenchStyle.muted; }
|
Text { x: 16px; y: 138px; height: 18px; text: UiStrings.password; font-size: 12px; color: WorkbenchStyle.muted; }
|
||||||
@@ -917,7 +958,7 @@ export component MainWindow inherits Window {
|
|||||||
accessible-label: UiStrings.remote-id;
|
accessible-label: UiStrings.remote-id;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
selection-foreground-color: white; selection-background-color: WorkbenchStyle.accent;
|
selection-foreground-color: white; selection-background-color: WorkbenchStyle.accent;
|
||||||
edited => { root.connection-input-error = ""; remote-id-format-timer.restart(); }
|
edited => { root.connection-input-error = ""; remote-id-format-timer.running = true; }
|
||||||
accepted => { if !root.connection-pending { root.connect-requested(root.remote-id-input); } }
|
accepted => { if !root.connection-pending { root.connect-requested(root.remote-id-input); } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -994,7 +1035,10 @@ export component MainWindow inherits Window {
|
|||||||
width: parent.width - 12px;
|
width: parent.width - 12px;
|
||||||
height: parent.height - 6px;
|
height: parent.height - 6px;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
changed viewport-x => { root.recent-tooltip-visible = false; }
|
changed viewport-x => {
|
||||||
|
root.recent-tooltip-visible = false;
|
||||||
|
root.copy-feedback = CopyFeedback.idle;
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
@@ -1094,7 +1138,7 @@ export component MainWindow inherits Window {
|
|||||||
width: connection-card.content-width;
|
width: connection-card.content-width;
|
||||||
height: 124px;
|
height: 124px;
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: pointer;
|
||||||
clicked => { root.recent-connect(connection.remote-id); }
|
clicked => { root.copy-feedback = CopyFeedback.idle; root.recent-connect(connection.remote-id); }
|
||||||
image-touch := TouchArea {
|
image-touch := TouchArea {
|
||||||
x: 0px; y: 0px;
|
x: 0px; y: 0px;
|
||||||
width: parent.width;
|
width: parent.width;
|
||||||
@@ -1111,7 +1155,7 @@ export component MainWindow inherits Window {
|
|||||||
root.recent-tooltip-groups = self.group-names;
|
root.recent-tooltip-groups = self.group-names;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clicked => { root.recent-connect(connection.remote-id); }
|
clicked => { root.copy-feedback = CopyFeedback.idle; root.recent-connect(connection.remote-id); }
|
||||||
function show-details() {
|
function show-details() {
|
||||||
if !self.has-hover { return; }
|
if !self.has-hover { return; }
|
||||||
root.recent-tooltip-name = connection.display-name;
|
root.recent-tooltip-name = connection.display-name;
|
||||||
@@ -1145,18 +1189,20 @@ export component MainWindow inherits Window {
|
|||||||
remove-label: GalleryStrings.tr("删除记录", "Delete record", "Удалить запись");
|
remove-label: GalleryStrings.tr("删除记录", "Delete record", "Удалить запись");
|
||||||
connected: connection.connected;
|
connected: connection.connected;
|
||||||
edit-alias => {
|
edit-alias => {
|
||||||
|
root.copy-feedback = CopyFeedback.idle;
|
||||||
root.alias-remote-id = connection.remote-id;
|
root.alias-remote-id = connection.remote-id;
|
||||||
root.alias-input = connection.display-name;
|
root.alias-input = connection.display-name;
|
||||||
root.alias-open = true;
|
root.alias-open = true;
|
||||||
}
|
}
|
||||||
edit-groups => { root.open-device-groups(connection.remote-id, connection.display-name); }
|
edit-groups => { root.copy-feedback = CopyFeedback.idle; root.open-device-groups(connection.remote-id, connection.display-name); }
|
||||||
remove-device => {
|
remove-device => {
|
||||||
|
root.copy-feedback = CopyFeedback.idle;
|
||||||
root.delete-remote-id = connection.remote-id;
|
root.delete-remote-id = connection.remote-id;
|
||||||
root.delete-from-gallery = false;
|
root.delete-from-gallery = false;
|
||||||
root.delete-open = true;
|
root.delete-open = true;
|
||||||
}
|
}
|
||||||
connect-device => { root.recent-connect(connection.remote-id); }
|
connect-device => { root.copy-feedback = CopyFeedback.idle; root.recent-connect(connection.remote-id); }
|
||||||
disconnect-device => { root.recent-disconnect(connection.remote-id); }
|
disconnect-device => { root.copy-feedback = CopyFeedback.idle; root.recent-disconnect(connection.remote-id); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -1261,6 +1307,7 @@ export component MainWindow inherits Window {
|
|||||||
TouchArea {
|
TouchArea {
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: pointer;
|
||||||
clicked => {
|
clicked => {
|
||||||
|
root.copy-feedback = CopyFeedback.idle;
|
||||||
root.signal-reconnect-requested();
|
root.signal-reconnect-requested();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1516,12 +1563,18 @@ export component MainWindow inherits Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
copy-timer := Timer {
|
copy-timer := Timer {
|
||||||
interval: 1000ms;
|
interval: root.copy-feedback == CopyFeedback.failed ? 4s : 2500ms;
|
||||||
triggered => { root.copy-toast = false; }
|
running: root.copy-feedback != CopyFeedback.idle;
|
||||||
|
triggered => { root.copy-feedback = CopyFeedback.idle; }
|
||||||
}
|
}
|
||||||
remote-id-format-timer := Timer {
|
remote-id-format-timer := Timer {
|
||||||
interval: 1ms;
|
interval: 1ms;
|
||||||
|
running: false;
|
||||||
triggered => {
|
triggered => {
|
||||||
|
// Use the running property for both transitions. restart() resumes
|
||||||
|
// the native repeating timer without restoring this property, so
|
||||||
|
// a later stop() cannot change it and the selection keeps resetting.
|
||||||
|
self.stop();
|
||||||
let formatted = root.format-remote-id(root.remote-id-input);
|
let formatted = root.format-remote-id(root.remote-id-input);
|
||||||
root.remote-id-input = formatted;
|
root.remote-id-input = formatted;
|
||||||
// IDs are ASCII-only, so character-count is also the UTF-8 offset
|
// IDs are ASCII-only, so character-count is also the UTF-8 offset
|
||||||
@@ -1530,14 +1583,17 @@ export component MainWindow inherits Window {
|
|||||||
formatted.character-count, formatted.character-count);
|
formatted.character-count, formatted.character-count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if root.copy-toast: Rectangle {
|
// Float above the workbench while keeping modal dialogs in front.
|
||||||
x: (parent.width - self.width) / 2; y: parent.height - 92px; width: 280px; height: 40px;
|
if !root.gallery-mode && root.copy-feedback != CopyFeedback.idle: WorkbenchNotice {
|
||||||
background: #fffffff2;
|
x: (parent.width - self.width) / 2;
|
||||||
border-radius: UiRadius.surface;
|
y: (parent.height - self.height) / 2 - 24px;
|
||||||
border-width: 1px;
|
width-limit: min(440px, parent.width - 40px);
|
||||||
border-color: ImGuiLineStyle.border;
|
error: root.copy-feedback == CopyFeedback.failed;
|
||||||
|
icon: self.error ? FontAwesomeIcons.xmark : FontAwesomeIcons.check;
|
||||||
|
text: self.error
|
||||||
|
? GalleryStrings.tr("复制失败,请重试", "Couldn’t copy. Try again", "Не удалось скопировать. Повторите попытку")
|
||||||
|
: UiStrings.copied;
|
||||||
z: 30;
|
z: 30;
|
||||||
Text { text: UiStrings.copied; color: #333840; horizontal-alignment: center; vertical-alignment: center; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset password modal.
|
// Reset password modal.
|
||||||
@@ -1724,42 +1780,94 @@ export component MainWindow inherits Window {
|
|||||||
|
|
||||||
// macOS permission gate. On other platforms this remains hidden.
|
// macOS permission gate. On other platforms this remains hidden.
|
||||||
if root.permission-dialog-open: Rectangle {
|
if root.permission-dialog-open: Rectangle {
|
||||||
background: transparent;
|
background: #15223850;
|
||||||
z: 50;
|
z: 50;
|
||||||
TouchArea { clicked => { focus-sink.focus(); } }
|
TouchArea { clicked => { focus-sink.focus(); } }
|
||||||
DialogSurface {
|
DialogSurface {
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
width: root.compact-language ? 130px : 260px;
|
width: min(400px, parent.width - 40px);
|
||||||
height: 125px;
|
height: permission-content.preferred-height;
|
||||||
border-radius: UiRadius.surface;
|
background: #f8fafc; border-color: WorkbenchStyle.separator;
|
||||||
Rectangle {
|
drop-shadow-color: #14294424; drop-shadow-blur: 24px; drop-shadow-offset-y: 8px;
|
||||||
x: 0px;
|
TouchArea { clicked => { focus-sink.focus(); } }
|
||||||
y: 0px;
|
permission-content := VerticalLayout {
|
||||||
width: parent.width;
|
padding: 24px; spacing: 20px;
|
||||||
height: 21px;
|
VerticalLayout {
|
||||||
background: #d3d3d3;
|
spacing: 8px;
|
||||||
border-top-left-radius: UiRadius.surface;
|
Text {
|
||||||
border-top-right-radius: UiRadius.surface;
|
text: UiStrings.request-permissions;
|
||||||
z: 5;
|
font-size: 20px; font-weight: 600; color: WorkbenchStyle.text;
|
||||||
Text { x: 6px; text: UiStrings.request-permissions; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; }
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
Text { x: 10px; y: 32px; width: root.compact-language ? 110px : 240px; height: 24px; text: UiStrings.permission-required; color: #202124; font-size: 8px; wrap: word-wrap; }
|
Text {
|
||||||
Text { x: 10px; y: 61px; width: root.compact-language ? 78px : 198px; height: 18px; text: "1. " + UiStrings.accessibility-permission + ":"; color: #202124; font-size: 8px; overflow: elide; vertical-alignment: center; }
|
text: UiStrings.permission-required;
|
||||||
MiniToggleSwitch {
|
font-size: 12px; color: WorkbenchStyle.muted; wrap: word-wrap;
|
||||||
x: root.compact-language ? 90px : 210px;
|
}
|
||||||
y: 61px;
|
}
|
||||||
checked: root.accessibility-granted;
|
VerticalLayout {
|
||||||
enabled: !root.accessibility-granted;
|
spacing: 12px;
|
||||||
toggled(value) => { if (value) { root.request-accessibility-permission(); } }
|
for permission[index] in [
|
||||||
}
|
{label: UiStrings.accessibility-permission, granted: root.accessibility-granted,
|
||||||
Text { x: 10px; y: 85px; width: root.compact-language ? 78px : 198px; height: 18px; text: "2. " + UiStrings.screen-recording-permission + ":"; color: #202124; font-size: 8px; overflow: elide; vertical-alignment: center; }
|
description: GalleryStrings.tr("允许远程键盘和鼠标控制", "Allow remote keyboard and mouse input", "Разрешить удалённый ввод с клавиатуры и мыши")},
|
||||||
MiniToggleSwitch {
|
{label: UiStrings.screen-recording-permission, granted: root.screen-recording-granted,
|
||||||
x: root.compact-language ? 90px : 210px;
|
description: GalleryStrings.tr("允许共享此 Mac 的屏幕画面", "Allow sharing this Mac’s screen", "Разрешить показ экрана этого Mac")},
|
||||||
y: 85px;
|
]: WorkbenchCard {
|
||||||
checked: root.screen-recording-granted;
|
height: max(80px, permission-labels.preferred-height + 32px);
|
||||||
enabled: !root.screen-recording-granted;
|
permission-labels := VerticalLayout {
|
||||||
toggled(value) => { if (value) { root.request-screen-recording-permission(); } }
|
x: 16px; y: 16px; width: parent.width - permission-action.width - 48px;
|
||||||
|
height: parent.height - 32px; spacing: 6px; alignment: center;
|
||||||
|
Text {
|
||||||
|
text: permission.label; font-size: 13px; font-weight: 600;
|
||||||
|
color: WorkbenchStyle.text; wrap: word-wrap;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: permission.description; font-size: 11px;
|
||||||
|
color: WorkbenchStyle.muted; wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
permission-action := Rectangle {
|
||||||
|
x: parent.width - self.width - 16px; y: (parent.height - self.height) / 2;
|
||||||
|
width: max(96px, permission-action-metrics.preferred-width + 24px);
|
||||||
|
height: 36px;
|
||||||
|
permission-action-metrics := Text {
|
||||||
|
visible: false; font-size: 13px; font-weight: 600;
|
||||||
|
text: GalleryStrings.tr("前往设置", "Open settings", "Настройки");
|
||||||
|
}
|
||||||
|
if !permission.granted: WorkbenchButton {
|
||||||
|
width: parent.width; height: parent.height; primary: true;
|
||||||
|
text: permission-action-metrics.text;
|
||||||
|
accessible-label: permission.label + ": " + self.text;
|
||||||
|
clicked => {
|
||||||
|
if index == 0 { root.request-accessibility-permission(); }
|
||||||
|
else { root.request-screen-recording-permission(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if permission.granted: HorizontalLayout {
|
||||||
|
alignment: center; spacing: 6px;
|
||||||
|
Text {
|
||||||
|
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
|
||||||
|
font-weight: 900; font-size: 12px; color: WorkbenchStyle.success;
|
||||||
|
vertical-alignment: center; accessible-role: none;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: GalleryStrings.tr("已授权", "Granted", "Разрешено");
|
||||||
|
font-size: 12px; color: WorkbenchStyle.success; vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 12px;
|
||||||
|
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
|
||||||
|
Text {
|
||||||
|
text: GalleryStrings.tr("请在系统设置中允许 CrossDesk 访问。如系统提示重新打开应用,请按提示操作。",
|
||||||
|
"Allow CrossDesk access in System Settings. Reopen the app if macOS asks you to.",
|
||||||
|
"Разрешите доступ для CrossDesk в Системных настройках. Перезапустите приложение, если macOS попросит об этом.");
|
||||||
|
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1879,7 +1987,13 @@ export component MainWindow inherits Window {
|
|||||||
}
|
}
|
||||||
SettingsGroup {
|
SettingsGroup {
|
||||||
title: GalleryStrings.tr("启动与后台", "Startup & background", "Запуск и фоновая работа"); rows: 2;
|
title: GalleryStrings.tr("启动与后台", "Startup & background", "Запуск и фоновая работа"); rows: 2;
|
||||||
SettingsToggleRow { label: UiStrings.autostart; checked <=> root.autostart-enabled; enabled: !root.settings-session-active; }
|
if !root.autostart-system-managed: SettingsToggleRow { label: UiStrings.autostart; checked <=> root.autostart-enabled; enabled: !root.settings-session-active; }
|
||||||
|
if root.autostart-system-managed: SettingsToggleRow {
|
||||||
|
label: UiStrings.autostart;
|
||||||
|
checked: root.autostart-status == AutostartStatus.enabled;
|
||||||
|
toggle-on-click: false;
|
||||||
|
clicked => { root.autostart-guide-open = true; }
|
||||||
|
}
|
||||||
SettingsToggleRow { label: UiStrings.daemon; checked <=> root.daemon-enabled; enabled: !root.settings-session-active; divider: false; }
|
SettingsToggleRow { label: UiStrings.daemon; checked <=> root.daemon-enabled; enabled: !root.settings-session-active; divider: false; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1914,6 +2028,59 @@ export component MainWindow inherits Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if root.autostart-guide-open && root.settings-open: Rectangle {
|
||||||
|
background: #15223835; z: 49;
|
||||||
|
TouchArea { clicked => { focus-sink.focus(); } }
|
||||||
|
DialogSurface {
|
||||||
|
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
|
||||||
|
width: min(460px, parent.width - 32px);
|
||||||
|
height: min(380px, parent.height - 24px);
|
||||||
|
background: #f8fafc; border-color: #e1e7ef;
|
||||||
|
drop-shadow-color: #14294424; drop-shadow-blur: 18px; drop-shadow-offset-y: 6px;
|
||||||
|
TouchArea { clicked => { focus-sink.focus(); } }
|
||||||
|
Text {
|
||||||
|
x: 24px; y: 18px; width: parent.width - 48px; height: 26px;
|
||||||
|
text: GalleryStrings.tr("设置开机自启动", "Set up auto start", "Настройка автозапуска");
|
||||||
|
font-size: 18px; font-weight: 600; color: WorkbenchStyle.text;
|
||||||
|
}
|
||||||
|
ScrollView {
|
||||||
|
x: 24px; y: 58px; width: parent.width - 48px; height: parent.height - 120px;
|
||||||
|
viewport-width: self.visible-width;
|
||||||
|
horizontal-scrollbar-policy: always-off;
|
||||||
|
viewport-height: guide-content.preferred-height;
|
||||||
|
guide-content := VerticalLayout {
|
||||||
|
width: parent.width; padding: 0px; spacing: 12px;
|
||||||
|
Text { text: root.autostart-status-text; color: WorkbenchStyle.accent; font-size: 13px; wrap: word-wrap; }
|
||||||
|
Text {
|
||||||
|
text: root.autostart-status == AutostartStatus.unsupported ? GalleryStrings.tr(
|
||||||
|
"当前设备不支持开机自启动。", "This device does not support auto start.", "Это устройство не поддерживает автозапуск.") :
|
||||||
|
root.autostart-status == AutostartStatus.failed ? GalleryStrings.tr(
|
||||||
|
"无法读取系统状态。请刷新重试,或在系统设置中核对。", "Could not read the system status. Retry or check it in Settings.", "Не удалось прочитать состояние. Повторите попытку или проверьте настройки системы.") :
|
||||||
|
GalleryStrings.tr("请在系统设置中添加或移除本应用,返回后会自动刷新状态。", "Add or remove this app in system Settings. The status refreshes when you return.", "Добавьте или удалите приложение в настройках системы. После возвращения статус обновится.");
|
||||||
|
color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap;
|
||||||
|
}
|
||||||
|
if root.autostart-status != AutostartStatus.unsupported: Text {
|
||||||
|
text: GalleryStrings.tr(
|
||||||
|
"设置 → 应用和元服务 → 应用启动管理\n\n开启:点击“添加应用”,选择 CrossDesk,然后点击“完成”。\n\n关闭:在自启动列表中找到本应用,点击“移除”。",
|
||||||
|
"Settings → Apps & services → App startup management\n\nEnable: choose Add app, select CrossDesk, then choose Done.\n\nDisable: find this app in the startup list and choose Remove.",
|
||||||
|
"Настройки → Приложения и сервисы → Управление запуском\n\nВключить: добавьте CrossDesk в список и подтвердите выбор.\n\nВыключить: удалите приложение из списка автозапуска.");
|
||||||
|
color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton {
|
||||||
|
x: 24px; y: parent.height - 48px; width: 112px;
|
||||||
|
text: GalleryStrings.tr("刷新状态", "Refresh status", "Обновить");
|
||||||
|
enabled: root.autostart-status != AutostartStatus.checking;
|
||||||
|
clicked => { root.refresh-autostart-status(); }
|
||||||
|
}
|
||||||
|
SettingsButton {
|
||||||
|
x: parent.width - 116px; y: parent.height - 48px; width: 92px; primary: true;
|
||||||
|
text: UiStrings.ok; clicked => { root.autostart-guide-open = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if root.self-host-settings-open: Rectangle {
|
if root.self-host-settings-open: Rectangle {
|
||||||
background: #15223835;
|
background: #15223835;
|
||||||
z: 49;
|
z: 49;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -130,9 +130,13 @@ export component SettingsRow inherits Rectangle {
|
|||||||
|
|
||||||
export component SettingsToggleRow inherits SettingsRow {
|
export component SettingsToggleRow inherits SettingsRow {
|
||||||
in-out property <bool> checked;
|
in-out property <bool> checked;
|
||||||
|
in property <bool> toggle-on-click: true;
|
||||||
|
callback clicked();
|
||||||
label-reserve: 64px;
|
label-reserve: 64px;
|
||||||
ToggleSwitch {
|
ToggleSwitch {
|
||||||
x: parent.width - self.width - 12px; y: (parent.height - self.height) / 2;
|
x: parent.width - self.width - 12px; y: (parent.height - self.height) / 2;
|
||||||
checked <=> root.checked; enabled: root.enabled;
|
checked <=> root.checked; enabled: root.enabled;
|
||||||
|
toggle-on-click: root.toggle-on-click;
|
||||||
|
clicked => { root.clicked(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ export component StreamWindow inherits Window {
|
|||||||
callback reorder-tab(int, length, length);
|
callback reorder-tab(int, length, length);
|
||||||
callback close-tab(string);
|
callback close-tab(string);
|
||||||
callback switch-display(int);
|
callback switch-display(int);
|
||||||
|
in property <bool> secure-attention-supported: true;
|
||||||
|
private property <[string]> secure-shortcut-items: ["Ctrl+Alt+Del", "Win+L"];
|
||||||
|
private property <[string]> ordinary-shortcut-items: ["Win+L"];
|
||||||
|
private property <[string]> shortcut-items: root.secure-attention-supported ? root.secure-shortcut-items : root.ordinary-shortcut-items;
|
||||||
|
private property <length> shortcut-menu-height: root.shortcut-items.length * 30px + 8px;
|
||||||
callback send-shortcut(string);
|
callback send-shortcut(string);
|
||||||
callback toggle-mouse-control;
|
callback toggle-mouse-control;
|
||||||
callback toggle-privacy;
|
callback toggle-privacy;
|
||||||
@@ -265,6 +270,10 @@ export component StreamWindow inherits Window {
|
|||||||
private property <length> stats-header-height: 18px;
|
private property <length> stats-header-height: 18px;
|
||||||
private property <length> stats-content-height:
|
private property <length> stats-content-height:
|
||||||
root.stats-header-height + root.stats-rows.length * root.stats-row-height + 65px;
|
root.stats-header-height + root.stats-rows.length * root.stats-row-height + 65px;
|
||||||
|
private property <length> file-panel-x: 18px;
|
||||||
|
private property <length> file-panel-y: video.height - min(210px, video.height * 0.35);
|
||||||
|
private property <length> file-panel-width: min(430px, video.width * 0.6);
|
||||||
|
private property <length> file-panel-height: min(190px, video.height * 0.32);
|
||||||
private property <bool> display-menu-open: false;
|
private property <bool> display-menu-open: false;
|
||||||
private property <bool> display-menu-animation-enabled: false;
|
private property <bool> display-menu-animation-enabled: false;
|
||||||
private property <float> display-menu-progress: 0.0;
|
private property <float> display-menu-progress: 0.0;
|
||||||
@@ -392,11 +401,14 @@ export component StreamWindow inherits Window {
|
|||||||
&& pointer-x >= shortcut-menu-x
|
&& pointer-x >= shortcut-menu-x
|
||||||
&& pointer-x <= shortcut-menu-x + 180px
|
&& pointer-x <= shortcut-menu-x + 180px
|
||||||
&& pointer-y >= shortcut-menu-y
|
&& pointer-y >= shortcut-menu-y
|
||||||
&& pointer-y <= shortcut-menu-y + 68px;
|
&& pointer-y <= shortcut-menu-y + root.shortcut-menu-height;
|
||||||
let over-video-menu = root.video-menu-open
|
let over-video-menu = root.video-menu-open
|
||||||
&& pointer-x >= root.video-menu-x && pointer-x <= root.video-menu-x + root.video-menu-width
|
&& pointer-x >= root.video-menu-x && pointer-x <= root.video-menu-x + root.video-menu-width
|
||||||
&& pointer-y >= root.video-menu-y && pointer-y <= root.video-menu-y + root.video-menu-height;
|
&& pointer-y >= root.video-menu-y && pointer-y <= root.video-menu-y + root.video-menu-height;
|
||||||
return over-control || over-display-menu || over-shortcut-menu || over-video-menu;
|
let over-file-panel = root.file-transfer-visible
|
||||||
|
&& pointer-x >= root.file-panel-x && pointer-x <= root.file-panel-x + root.file-panel-width
|
||||||
|
&& pointer-y >= root.file-panel-y && pointer-y <= root.file-panel-y + root.file-panel-height;
|
||||||
|
return over-control || over-display-menu || over-shortcut-menu || over-video-menu || over-file-panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
begin-control-drag(press-x, press-y) => {
|
begin-control-drag(press-x, press-y) => {
|
||||||
@@ -1246,11 +1258,11 @@ export component StreamWindow inherits Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if root.shortcut-menu-open: Rectangle {
|
if root.shortcut-menu-open: Rectangle {
|
||||||
x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: 68px;
|
x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: root.shortcut-menu-height;
|
||||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.menu; z: 11;
|
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.menu; z: 11;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
padding: 4px; spacing: 1px;
|
padding: 4px; spacing: 1px;
|
||||||
for shortcut in ["Ctrl+Alt+Del", "Win+L"]: Rectangle {
|
for shortcut in root.shortcut-items: Rectangle {
|
||||||
height: 29px; background: shortcut-touch.has-hover ? #e8eef9 : transparent;
|
height: 29px; background: shortcut-touch.has-hover ? #e8eef9 : transparent;
|
||||||
Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
||||||
shortcut-touch := TouchArea {
|
shortcut-touch := TouchArea {
|
||||||
@@ -1365,10 +1377,21 @@ export component StreamWindow inherits Window {
|
|||||||
|
|
||||||
// Transfer progress overlay at bottom-left.
|
// Transfer progress overlay at bottom-left.
|
||||||
if root.file-transfer-visible: Rectangle {
|
if root.file-transfer-visible: Rectangle {
|
||||||
x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32);
|
x: root.file-panel-x; y: root.file-panel-y;
|
||||||
|
width: root.file-panel-width; height: root.file-panel-height;
|
||||||
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.surface; z: 7;
|
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.surface; z: 7;
|
||||||
|
// Header/padding and drags leaving the panel belong to local UI.
|
||||||
|
TouchArea {
|
||||||
|
pointer-event(event) => {
|
||||||
|
if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; }
|
||||||
|
}
|
||||||
|
scroll-event(event) => { accept }
|
||||||
|
}
|
||||||
Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; }
|
Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; }
|
||||||
IconButton { x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px; clicked => { root.close-file-transfer(); } }
|
IconButton {
|
||||||
|
x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px;
|
||||||
|
clicked => { root.suppress-next-remote-pointer-release = true; root.close-file-transfer(); }
|
||||||
|
}
|
||||||
ScrollView {
|
ScrollView {
|
||||||
x: 10px; y: 32px; width: parent.width - 20px; height: parent.height - 42px;
|
x: 10px; y: 32px; width: parent.width - 20px; height: parent.height - 42px;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
|
|||||||
@@ -20,11 +20,19 @@ export component WorkbenchButton inherits Rectangle {
|
|||||||
in property <string> text;
|
in property <string> text;
|
||||||
in property <string> icon;
|
in property <string> icon;
|
||||||
in property <string> tooltip;
|
in property <string> tooltip;
|
||||||
|
in property <bool> tooltip-suppressed: false;
|
||||||
|
in property <color> icon-color: root.primary ? white : WorkbenchStyle.muted;
|
||||||
in property <bool> primary: false;
|
in property <bool> primary: false;
|
||||||
in property <bool> quiet: false;
|
in property <bool> quiet: false;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
|
out property <bool> has-focus: button-focus.has-focus;
|
||||||
private property <bool> keyboard-focus: false;
|
private property <bool> keyboard-focus: false;
|
||||||
private property <bool> show-focus: button-focus.has-focus && root.keyboard-focus;
|
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;
|
callback clicked;
|
||||||
width: 112px;
|
width: 112px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -55,7 +63,7 @@ export component WorkbenchButton inherits Rectangle {
|
|||||||
font-family: "Font Awesome 6 Free";
|
font-family: "Font Awesome 6 Free";
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: root.primary ? white : WorkbenchStyle.muted;
|
color: root.icon-color;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
accessible-role: none;
|
accessible-role: none;
|
||||||
}
|
}
|
||||||
@@ -98,24 +106,76 @@ export component WorkbenchButton inherits Rectangle {
|
|||||||
clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); }
|
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);
|
x: min(0px, root.width - self.width);
|
||||||
y: root.height + 6px;
|
y: root.height + 6px;
|
||||||
width: tip.preferred-width + 16px;
|
width: min(260px, tip.preferred-width + 20px);
|
||||||
height: 28px;
|
height: max(28px, tip.preferred-height + 12px);
|
||||||
background: #24324a;
|
background: #24324a;
|
||||||
border-radius: UiRadius.control;
|
border-radius: UiRadius.control;
|
||||||
z: 20;
|
z: 20;
|
||||||
tip := Text {
|
tip := Text {
|
||||||
|
x: 10px; y: 6px;
|
||||||
|
width: parent.width - 20px;
|
||||||
|
height: parent.height - 12px;
|
||||||
text: root.tooltip;
|
text: root.tooltip;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: white;
|
color: white;
|
||||||
|
wrap: word-wrap;
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
vertical-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 {
|
export component WorkbenchCard inherits Rectangle {
|
||||||
background: WorkbenchStyle.surface;
|
background: WorkbenchStyle.surface;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
|
|||||||
@@ -6,11 +6,29 @@
|
|||||||
|
|
||||||
#include "spdlog/sinks/rotating_file_sink.h"
|
#include "spdlog/sinks/rotating_file_sink.h"
|
||||||
#include "spdlog/sinks/stdout_color_sinks.h"
|
#include "spdlog/sinks/stdout_color_sinks.h"
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
#include <hilog/log.h>
|
||||||
|
#include "spdlog/sinks/base_sink.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
class HarmonyLogSink final : public spdlog::sinks::base_sink<std::mutex> {
|
||||||
|
void sink_it_(const spdlog::details::log_msg& message) override {
|
||||||
|
spdlog::memory_buf_t formatted;
|
||||||
|
formatter_->format(message, formatted);
|
||||||
|
const std::string text(formatted.data(), formatted.size());
|
||||||
|
const auto level = message.level >= spdlog::level::err ? LOG_ERROR :
|
||||||
|
message.level >= spdlog::level::warn ? LOG_WARN : LOG_INFO;
|
||||||
|
OH_LOG_Print(LOG_APP, level, 0, "CrossDeskDesktop", "%{public}s", text.c_str());
|
||||||
|
}
|
||||||
|
void flush_() override {}
|
||||||
|
};
|
||||||
|
#endif
|
||||||
|
|
||||||
std::string g_log_dir = "logs";
|
std::string g_log_dir = "logs";
|
||||||
std::string g_logger_name = LOGGER_NAME;
|
std::string g_logger_name = LOGGER_NAME;
|
||||||
std::once_flag g_logger_once_flag;
|
std::once_flag g_logger_once_flag;
|
||||||
@@ -56,7 +74,11 @@ std::shared_ptr<spdlog::logger> get_logger() {
|
|||||||
g_log_dir + "/" + g_logger_name + ".log", 5 * 1024 * 1024, 3);
|
g_log_dir + "/" + g_logger_name + ".log", 5 * 1024 * 1024, 3);
|
||||||
|
|
||||||
std::vector<spdlog::sink_ptr> sinks;
|
std::vector<spdlog::sink_ptr> sinks;
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
sinks.push_back(std::make_shared<HarmonyLogSink>());
|
||||||
|
#else
|
||||||
sinks.push_back(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
|
sinks.push_back(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
|
||||||
|
#endif
|
||||||
sinks.push_back(file_sink);
|
sinks.push_back(file_sink);
|
||||||
|
|
||||||
auto logger = std::make_shared<spdlog::logger>(g_logger_name, sinks.begin(),
|
auto logger = std::make_shared<spdlog::logger>(g_logger_name, sinks.begin(),
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -57,7 +57,7 @@ std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend();
|
|||||||
// Flush queued AppKit cleanup after joining the controller at app shutdown.
|
// Flush queued AppKit cleanup after joining the controller at app shutdown.
|
||||||
void FlushMacPrivacyTasks();
|
void FlushMacPrivacyTasks();
|
||||||
#endif
|
#endif
|
||||||
#ifdef __linux__
|
#if defined(__linux__) && !defined(__OHOS__)
|
||||||
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend();
|
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ PrivacyController::PrivacyController(Factory factory) {
|
|||||||
if (!factory) factory = CreateWindowsPrivacyBackend;
|
if (!factory) factory = CreateWindowsPrivacyBackend;
|
||||||
#elif defined(__APPLE__)
|
#elif defined(__APPLE__)
|
||||||
if (!factory) factory = CreateMacPrivacyBackend;
|
if (!factory) factory = CreateMacPrivacyBackend;
|
||||||
#elif defined(__linux__)
|
#elif (defined(__linux__) && !defined(__OHOS__))
|
||||||
if (!factory) factory = CreateLinuxPrivacyBackend;
|
if (!factory) factory = CreateLinuxPrivacyBackend;
|
||||||
#endif
|
#endif
|
||||||
thread_ = std::thread([this, factory] { Run(factory); });
|
thread_ = std::thread([this, factory] { Run(factory); });
|
||||||
|
|||||||
@@ -1,119 +1,31 @@
|
|||||||
#include "file_transfer.h"
|
#include "file_transfer.h"
|
||||||
|
|
||||||
#include <atomic>
|
#include <cstdlib>
|
||||||
#include <chrono>
|
|
||||||
|
|
||||||
#include "rd_log.h"
|
#include "rd_log.h"
|
||||||
|
#if defined(__OHOS__)
|
||||||
|
#include "platform/path_backend.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
namespace {
|
FileReceiver::FileReceiver() : FileReceiver(GetDefaultDesktopPath()) {}
|
||||||
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(const std::filesystem::path& output_dir)
|
FileReceiver::FileReceiver(const std::filesystem::path& output_dir)
|
||||||
: output_dir_(output_dir) {
|
: output_dir_(output_dir), session_(
|
||||||
std::error_code ec;
|
[this](const char*, const char* data, size_t size) {
|
||||||
if (!output_dir_.empty()) {
|
FileTransferAck ack{};
|
||||||
std::filesystem::create_directories(output_dir_, ec);
|
return on_send_ack_ && DecodeFileTransferAck(data, size, &ack)
|
||||||
if (ec) {
|
? on_send_ack_(ack) : 0;
|
||||||
LOG_ERROR("FileReceiver: failed to create output dir [{}]: {}",
|
}, {}) {
|
||||||
output_dir_.string().c_str(), ec.message().c_str());
|
session_.SetDirectory(output_dir);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
|
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
|
#ifdef _WIN32
|
||||||
const char* home_env = std::getenv("USERPROFILE");
|
const char* home_env = std::getenv("USERPROFILE");
|
||||||
#else
|
#else
|
||||||
@@ -125,6 +37,7 @@ std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
|
|||||||
|
|
||||||
std::filesystem::path desktop_path =
|
std::filesystem::path desktop_path =
|
||||||
std::filesystem::path(home_env) / "Desktop";
|
std::filesystem::path(home_env) / "Desktop";
|
||||||
|
#endif
|
||||||
|
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::create_directories(desktop_path, ec);
|
std::filesystem::create_directories(desktop_path, ec);
|
||||||
@@ -136,117 +49,4 @@ std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
|
|||||||
return desktop_path;
|
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
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -1,99 +1,46 @@
|
|||||||
/*
|
/*
|
||||||
* @Author: DI JUNKUN
|
* @Author: DI JUNKUN
|
||||||
* @Date: 2025-12-18
|
* @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_
|
#ifndef _FILE_TRANSFER_H_
|
||||||
#define _FILE_TRANSFER_H_
|
#define _FILE_TRANSFER_H_
|
||||||
|
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <file_transfer_core.h>
|
||||||
#include <unordered_map>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <file_transfer_format.h>
|
|
||||||
#include <stream_names.h>
|
|
||||||
|
|
||||||
namespace crossdesk {
|
namespace crossdesk {
|
||||||
|
|
||||||
class FileSender {
|
// Desktop destination policy; protocol, assembly, ACKs and cleanup live in the
|
||||||
public:
|
// same engine used by the mobile adapter.
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
class FileReceiver {
|
class FileReceiver {
|
||||||
public:
|
public:
|
||||||
struct FileContext {
|
using OnSendAck = std::function<int(const FileTransferAck&)>;
|
||||||
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.
|
|
||||||
FileReceiver();
|
FileReceiver();
|
||||||
|
|
||||||
// save to a specified directory.
|
|
||||||
explicit FileReceiver(const std::filesystem::path& output_dir);
|
explicit FileReceiver(const std::filesystem::path& output_dir);
|
||||||
|
bool OnData(const char* data, size_t size) { return session_.Receive(data, size); }
|
||||||
// process one received data buffer (one chunk).
|
void Poll() { session_.Pump(); }
|
||||||
// return true if parsed and processed successfully, false otherwise.
|
void Cancel() { session_.Cancel(); }
|
||||||
bool OnData(const char* data, size_t size);
|
// Set once, before the receiver is published to transport callbacks.
|
||||||
|
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = std::move(cb); }
|
||||||
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = cb; }
|
std::filesystem::path OutputDir() const {
|
||||||
|
std::lock_guard lock(directory_mutex_);
|
||||||
const std::filesystem::path& OutputDir() const { return output_dir_; }
|
return output_dir_;
|
||||||
|
}
|
||||||
void SetOutputDir(const std::filesystem::path& dir) {
|
void SetOutputDir(const std::filesystem::path& dir) {
|
||||||
|
std::lock_guard lock(directory_mutex_);
|
||||||
output_dir_ = dir;
|
output_dir_ = dir;
|
||||||
if (!output_dir_.empty()) {
|
session_.SetDirectory(dir);
|
||||||
std::error_code ec;
|
|
||||||
std::filesystem::create_directories(output_dir_, ec);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static std::filesystem::path GetDefaultDesktopPath();
|
static std::filesystem::path GetDefaultDesktopPath();
|
||||||
|
mutable std::mutex directory_mutex_;
|
||||||
bool HandleChunk(const FileChunkHeader& header, const char* payload,
|
|
||||||
size_t payload_size, const std::string* file_name);
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::filesystem::path output_dir_;
|
std::filesystem::path output_dir_;
|
||||||
std::unordered_map<uint32_t, FileContext> contexts_;
|
OnSendAck on_send_ack_;
|
||||||
OnSendAck on_send_ack_ = nullptr;
|
file_transfer::Session session_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace crossdesk
|
} // namespace crossdesk
|
||||||
|
|||||||
@@ -122,7 +122,11 @@ std::optional<VersionInfo> CheckUpdate() {
|
|||||||
const auto manifest = nlohmann::json::parse(res->body);
|
const auto manifest = nlohmann::json::parse(res->body);
|
||||||
auto info = ParseVersionInfo(manifest);
|
auto info = ParseVersionInfo(manifest);
|
||||||
if (info) {
|
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;
|
std::string architecture;
|
||||||
#if defined(__x86_64__)
|
#if defined(__x86_64__)
|
||||||
architecture = "amd64";
|
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";
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
#include "ui/release_notes_text.h"
|
#include "ui/release_notes_text.h"
|
||||||
#include "ui/ui_localization.h"
|
#include "ui/ui_localization.h"
|
||||||
|
|
||||||
|
#include <slint-platform.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <charconv>
|
#include <charconv>
|
||||||
@@ -14,6 +16,7 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -845,9 +848,71 @@ bool VerifyWorkbenchActions() {
|
|||||||
return true;
|
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
|
} // namespace
|
||||||
|
|
||||||
int main() {
|
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();
|
const CaptureOptions capture_options = LoadCaptureOptions();
|
||||||
if (!VerifyMainWindowGeometry()) {
|
if (!VerifyMainWindowGeometry()) {
|
||||||
std::cerr << "Main window dimensions must stay stable through startup\n";
|
std::cerr << "Main window dimensions must stay stable through startup\n";
|
||||||
|
|||||||
@@ -230,6 +230,32 @@ function setup_targets()
|
|||||||
add_files("apps/desktop/tests/slint_ui_smoke_test.cpp")
|
add_files("apps/desktop/tests/slint_ui_smoke_test.cpp")
|
||||||
add_rules("crossdesk.slint_runtime")
|
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")
|
target("version_checker_test")
|
||||||
set_kind("binary")
|
set_kind("binary")
|
||||||
set_default(false)
|
set_default(false)
|
||||||
@@ -485,6 +511,7 @@ function setup_targets()
|
|||||||
add_files("apps/desktop/src/platform/linux/clipboard.cpp")
|
add_files("apps/desktop/src/platform/linux/clipboard.cpp")
|
||||||
end
|
end
|
||||||
add_includedirs("apps/desktop/src", "apps/desktop/src/tools", {public = true})
|
add_includedirs("apps/desktop/src", "apps/desktop/src/tools", {public = true})
|
||||||
|
add_includedirs("libs/controller/include", {public = true})
|
||||||
|
|
||||||
target("gui")
|
target("gui")
|
||||||
set_kind("object")
|
set_kind("object")
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 |
@@ -0,0 +1,81 @@
|
|||||||
|
# 鸿蒙移动控制端与 Android 功能对照
|
||||||
|
|
||||||
|
手机、平板使用 `pages/Mobile`,角色固定为控制端。对照基线为本仓库 `apps/android`,不包含 PC 被控能力。移动专用 HAP 只声明 `INTERNET` 和 `KEEP_BACKGROUND_RUNNING`;不申请录屏、输入注入、相机、麦克风或整个文件库权限。
|
||||||
|
|
||||||
|
## 构建与安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 apps/harmony/scripts/build.py --mobile
|
||||||
|
python3 apps/harmony/tests/run.py --mobile
|
||||||
|
```
|
||||||
|
|
||||||
|
需要 Python 3.10+、Xmake 3.1.1+、DevEco SDK。兼容 API 20、目标 API 26、ARM64。产物:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/harmony/.native/mobile-project/entry/build/default/outputs/default/entry-default-unsigned.hap
|
||||||
|
```
|
||||||
|
|
||||||
|
手机真机调试时,在 DevEco 打开生成的 `.native/mobile-project` 并配置本机签名,再安装。不要把签名材料提交到仓库。重新执行构建脚本会重新生成隔离工程,移除签名配置。PC 的 ACL 和签名配置不用于移动包。
|
||||||
|
|
||||||
|
移动构建只复制 `ets/mobile/`、`pages/Mobile.ets`、N-API 桥接和实际使用的资源,只有 `pages/Mobile` 一个页面入口。`crossdesk_harmony_mobile` 将控制会话与共享音视频播放编译为独立原生库,输出到 `.native/mobile-libs/arm64-v8a/`;不编译 PC 会话、屏幕采集或输入注入,也不导出 PC 桥接 API。只重编原生库时使用 `build.py --mobile --native-only`,随后可使用 `--mobile --hap-only` 打包。PC 直接链接 MiniRTC 与平台服务,不再生成旧 ArkUI PC 核心;两端产物不会互相覆盖。
|
||||||
|
|
||||||
|
生成工程每次重建源码与库目录,以移除旧版本残留;请修改 `entry/src/main/` 下的源码,再运行构建脚本。桌面字体不进入移动包,开源许可证只保留界面可逐项阅读并校验 SHA-256 的完整文档,移除了重复汇总副本。
|
||||||
|
|
||||||
|
## 功能对照
|
||||||
|
|
||||||
|
| Android 功能 | 鸿蒙实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| 前台自动登录、空闲后台停止、服务器隔离 | 独立 identity peer;只有主动远控才创建 controller peer;拒绝任何入站被控连接 |
|
||||||
|
| 首次隐私确认、撤回与离线阅读 | 同意前不发起身份、公告、在线状态或版本网络请求;撤回结束会话并清除预览 |
|
||||||
|
| 加密身份、可选记住访问密码 | 系统 Asset Store,设备首次解锁后可读,禁止跨设备同步;普通 Preferences 不存密码 |
|
||||||
|
| 最近连接、删除、在线状态 | 最近 20 台、在线优先、真实信令订阅、30 秒刷新和 60 秒过期;删除同步清理密码与预览 |
|
||||||
|
| H.264 / AV1 | 系统硬件解码;无能力、配置/运行失败时保持协商格式回退 OpenH264 / dav1d;NV12 GLES 显示 |
|
||||||
|
| 远端音频与静音 | 48 kHz 单声道 OHAudio,控制菜单与画中画静音同步 |
|
||||||
|
| 相对/绝对鼠标、触控 | 单击、绝对拖动、长按右键、1–10 倍焦点缩放、放大后双指平移、1 倍时双指滚轮 |
|
||||||
|
| 外接键鼠 | 实体按键映射、鼠标移动/按键/滚轮;失焦、后台、断开及切屏释放输入 |
|
||||||
|
| 悬浮控制 | 可拖动品牌悬浮球、三列菜单(低高度横屏四列)、可拖动 ASCII/电脑键盘、完整快捷键组合、140×48 虚拟鼠标 |
|
||||||
|
| 视频设置 | 低/中/高、30/60 fps、帧率/画质/平衡;能力协商、请求序号、旧 ACK 丢弃、拒绝回滚、5 秒超时 |
|
||||||
|
| 网络诊断 | 视频/音频/数据/总流量、双向速率、丢包率、实际提交帧率/尺寸、画面延迟、平滑 RTT、P2P/TURN、SRTP;3 秒失效 |
|
||||||
|
| 多显示器、Ctrl+Alt+Del | 按远端显示器列表切换;SAS 仅在远端 Windows 服务报告可用时发送 |
|
||||||
|
| 剪贴板 | 主动发送(128 KB 上限)、远端粘贴、手动复制收到的文本;退出清空内存文本 |
|
||||||
|
| 文件双向传输 | 系统文件选择/导出、进度、可靠分块/ACK、乱序覆盖、失败/超时清理;直接共用 Android 文件引擎 |
|
||||||
|
| 系统画中画与后台保活 | VIDEO_LIVE PiP 与静音控制,后台数据传输任务及系统通知兜底;选择器期间抑制 PiP,系统拒绝后台时结束会话 |
|
||||||
|
| 公告 | 摘要每页 200、正文每页 20、已读/删除与修订、分页、刷新、12 秒超时、身份及服务器隔离、迟到应答过滤 |
|
||||||
|
| 版本提示 | 每会话检查远端版本;本机更新使用 `harmony-arm64`,无该发布项时明确显示无鸿蒙版本信息 |
|
||||||
|
| 远程预览 | 默认关闭、单独确认、下一次连接起每会话一张 640×360、私有缓存、不上传;关闭/撤回清除,异步任务不能恢复已撤回内容 |
|
||||||
|
| 关于和开源合规 | 实际包版本、离线许可证和组件清单、SHA-256 文档校验、源码版本与构建说明 |
|
||||||
|
| 手机/平板布局 | 900 vp 双栏断点、最近连接 1–4 列、主体最大 1200/720 vp、手机首页竖屏、会话/平板自动旋转、200 ms 页面/选项过渡 |
|
||||||
|
|
||||||
|
平台 API 有差异:鸿蒙使用 Asset Store、OHAudio、ArkUI PiP 和后台任务,Android 使用 Keystore、AAudio、Android PiP 和前台服务。系统保留快捷键、后台策略、硬件解码能力受具体设备约束。
|
||||||
|
|
||||||
|
## 移动界面
|
||||||
|
|
||||||
|
移动端滚动容器显式隐藏叠加式滚动条,包括首页内外层、设置与公告、长文档、弹窗、远控菜单、虚拟键盘及剪贴板文本框;保留原有触摸和滚轮滚动,避免滑块覆盖文字、按钮或按键。新增滚动区域应沿用这一设置。
|
||||||
|
|
||||||
|
首页、设置、弹窗和远控菜单以 Android `MobileUi` 为视觉基准:统一灰白底色、蓝色主操作、文字层级、线条图标、圆角与阴影。首页提供连接卡片、最近设备卡片及居中空态,宽屏采用固定宽度连接栏与自适应设备列表;设置使用分组卡片、分段选择器和内联服务器配置,隐私、关于、公告及许可页面沿用相同导航与列表样式。
|
||||||
|
|
||||||
|
访问密码在手机上以底部面板显示,在平板上居中显示;输入法弹出时缩小可用区域,保留密码、保存开关和连接按钮,较小窗口可滚动。隐私政策面板保持标题与授权操作可见,正文独立滚动。删除、撤回、清除和断开操作使用统一确认框。远控菜单提供图标网格、画面设置、对齐的网络统计、品牌悬浮球以及可拖动键盘和鼠标;菜单和键盘按可用窗口尺寸限制位置。
|
||||||
|
|
||||||
|
此次 UI 检查在 API 26 手机和平板模拟器中使用独立包名、无联网权限的离线状态预览,检查了首页、设置、确认取消、密码显隐与输入法避让、弹窗及远控面板。预览中的设备、传输状态和屏幕尺寸为测试数据,不作为网络互通验收依据。图标与鼠标指针按显示密度绘制,避免 SVG 路径的像素坐标在高密度屏幕上缩小。
|
||||||
|
|
||||||
|
## 自动化验证
|
||||||
|
|
||||||
|
`tests/run.py --mobile` 运行生产 ArkTS 模型、控制器和平台生命周期代码的测试,包含隐私前置、身份/密码调用、重复/跨会话回调、重连后台任务串行化、PiP 去重及静音、文件选择器保护、双指/虚拟鼠标独立按键、画质应答乱序与序号回绕、统计失效、在线状态与公告完整分页。
|
||||||
|
|
||||||
|
同时运行 Android 原生协议及视频管线回归测试、共享文件传输测试、鸿蒙输入/采集适配与公共 wire 测试。共享代码位于 `libs/controller/include`;Android 旧头文件保留兼容导出,避免两平台协议漂移。
|
||||||
|
|
||||||
|
`tests/verify_hap.py --mobile <hap>` 校验 phone/tablet 设备类型、最小权限、后台模式、编译后的 ArkTS、ARM64 原生库依赖及许可证资料。构建脚本自动执行该检查。
|
||||||
|
|
||||||
|
## 设备验收
|
||||||
|
|
||||||
|
### 确认实际解码器
|
||||||
|
|
||||||
|
解码诊断通过 HiLog 标签 `CrossDeskDecoder` 输出,不受 MiniRTC 通用日志关闭的影响。在 DevEco 的 HiLog 中按此标签过滤,或运行 `hdc shell hilog -x -T CrossDeskDecoder`。`Decode request` 记录当前格式的硬解能力,`Decoder selected` 记录选中的实现和软解回退原因;应以 `First decoded frame` 的 `mode` 和 `name` 确认实际成功工作的解码器,而不能只看硬件加速配置或初始化结果。会话结束的 `Decode summary` 分别统计硬解和软解成功帧数,也能识别中途回退。日志仅记录格式、解码器、尺寸、错误阶段及帧数,不输出连接凭据或视频内容。
|
||||||
|
|
||||||
|
2026-10-15 在 API 26 ARM64 Mate 90 Pro 模拟器实测:H.264 3840×2160 远控会话使用 OpenH264,成功软解 2722 帧、硬解 0 帧;AV1 640×360 合成视频使用 Dav1dAv1,成功软解 1203 帧、硬解 0 帧。两者均为 `hardware_available=0`,回退原因 `no hardware capability`。这只证明当前模拟器的解码路径,不代表真机硬解能力。
|
||||||
|
|
||||||
|
### 已覆盖的功能
|
||||||
|
|
||||||
|
已在 API 26 ARM64 手机(Mate 90 Pro)和平板(MatePad Pro 13)模拟器安装运行,验证首次同意、登录、公告同步、设置及双栏布局。在手机上使用同版本 MiniRTC 合成发送端验证 H.264 / AV1 640×360 画面、48 kHz PCM 接收、鼠标与虚拟键盘回传、画质请求/应答、双屏切换、剪贴板、文件接收及系统导出/选择后反向发送的字节一致性、旋转和后台画中画恢复。发现并修复了公开 ID 覆盖完整登录凭据、连接成功回调前的主机信息丢失、返回前台后画中画遮挡文件选择器等问题,并补充回归测试。主机侧测试不能替代真机上的触摸、多指、外接键鼠、音频路由、硬件编解码性能以及长期后台/画中画测试;这些仍应在发布前用 API 20 与当前系统真机验收。
|
||||||
|
|
||||||
|
可使用 `tests/mobile_synthetic_host.cpp` 作为互通测试发送端:它只生成测试色条、静音 PCM、虚拟显示器和小文件,不采集本机屏幕或输入。运行参数为 `<server> <port> [av1]`,测试身份写入工作目录 `fixture-login`,运行状态写入 `fixture-status`,15 分钟自动结束。使用独立私有测试目录,测试后删除身份、日志和接收文件。鸿蒙 fixture 可用 `tests/build_mobile_fixture.py` 编译;受限模拟器 shell 可能不能加载系统媒体库,可在开发机链接 MiniRTC 运行同一源文件。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# CrossDesk HarmonyOS
|
||||||
|
|
||||||
|
鸿蒙 PC 只保留共享 Slint 桌面客户端;手机和平板使用独立 ArkUI 控制端。旧 ArkUI PC 界面、独立 PC 会话状态机和早期 Slint 验证程序已移除。
|
||||||
|
|
||||||
|
| 设备 | 实现 | 要求与说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PC(`2in1`) | 公共桌面 UI、业务逻辑和鸿蒙平台适配 | API 26、arm64;[桌面构建、权限与验收](desktop-port/README.md) |
|
||||||
|
| 手机 / 平板 | ArkUI 移动控制端 | 兼容 API 20、目标 API 26、arm64;[功能对照与验收](MOBILE_PARITY.md) |
|
||||||
|
|
||||||
|
两者仍为开发版。已完成的模拟器验证及未完成的真机验收见各端说明。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
在仓库根目录执行,当前构建脚本面向 macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 默认构建 PC:MiniRTC、Slint runtime、公共桌面应用和 HAP
|
||||||
|
python3 apps/harmony/scripts/build.py
|
||||||
|
|
||||||
|
# PC 增量构建:复用已生成的 runtime;补丁变化仍会触发重建
|
||||||
|
python3 apps/harmony/scripts/build.py --reuse-runtime
|
||||||
|
|
||||||
|
# 手机 / 平板:独立控制端,不包含 PC 采集和输入注入代码
|
||||||
|
python3 apps/harmony/scripts/build.py --mobile
|
||||||
|
|
||||||
|
# 仅准备 PC 的 MiniRTC 依赖;不再构建旧 ArkUI PC 核心
|
||||||
|
python3 apps/harmony/scripts/build.py --native-only
|
||||||
|
|
||||||
|
# 使用已有原生依赖重新打包
|
||||||
|
python3 apps/harmony/scripts/build.py --hap-only --reuse-runtime
|
||||||
|
python3 apps/harmony/scripts/build.py --mobile --hap-only
|
||||||
|
```
|
||||||
|
|
||||||
|
`--release` 选择 release HAP;原生依赖默认使用 release。PC 的安装、设备选择、版本号、权限和签名选项见 `python3 apps/harmony/scripts/build_desktop.py --help`,也可直接传给默认的 `build.py`。移动端仅构建控制端核心时使用 `--mobile --native-only`。
|
||||||
|
|
||||||
|
需要 DevEco Studio(SDK、Native SDK、Node、ohpm、hvigor、Java)、Xmake 3.1.1+ 和完整的 MiniRTC 子模块。PC 另需 Python 3.12+、Rust 1.92+ 和主机版 Slint compiler 1.17.1;移动端需 Python 3.10+。如果系统 Python 版本过旧,可使用 `/opt/homebrew/bin/python3`。
|
||||||
|
|
||||||
|
| 环境变量 | 用途 / 默认值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `DEVECO_STUDIO_HOME` | `/Applications/DevEco-Studio.app/Contents` |
|
||||||
|
| `DEVECO_SDK_HOME` | `$DEVECO_STUDIO_HOME/sdk`,包含 `default/openharmony/native` |
|
||||||
|
| `XMAKE_BIN` | PATH 中的 `xmake`;本机也可指定 `$HOME/.local/bin/xmake` |
|
||||||
|
| `SLINT_COMPILER` | 主机 Slint compiler;默认查找 Xmake 的 1.17.1 缓存 |
|
||||||
|
|
||||||
|
脚本使用独立的 Xmake 配置与包缓存,不切换仓库根目录的桌面配置。Slint、httplib、Xmake 包仓库和 Python 构建工具版本固定。PC 与移动端构建共用锁,避免同时改写原生依赖。
|
||||||
|
|
||||||
|
## 工程与产物
|
||||||
|
|
||||||
|
| 客户端 | 生成的 DevEco 工程 |
|
||||||
|
| --- | --- |
|
||||||
|
| PC | `apps/harmony/.native/slint-poc/desktop-project` |
|
||||||
|
| 手机 / 平板 | `apps/harmony/.native/mobile-project` |
|
||||||
|
|
||||||
|
HAP 均位于对应工程的 `entry/build/default/outputs/default/entry-default-unsigned.hap`。PC 的 `slint-poc` 缓存名保留以复用现有 Rust 构建缓存,源码中已没有验证程序。PC 与手机 / 平板共用 `AppScope/app.json5` 和应用名称资源,包名统一为 `com.chunkdash.crossdesk`,名称统一为 **CrossDesk**。旧 PC 验证包 `cn.crossdesk.slintdesktop` 与新包属于不同应用,已有数据不会自动迁移。
|
||||||
|
|
||||||
|
使用 DevEco 打开生成工程进行调试和签名。请修改本目录源码后重新执行脚本;生成工程不是源码入口。移动端构建会重新生成源码与库目录;PC 首次从旧构建迁移时会清除旧桥接目标的产物。两端打包后均检查实际 HAP 的设备类型、权限、ArkTS、ARM64 库、SONAME 和依赖,拒绝混入已移除入口的库。
|
||||||
|
|
||||||
|
签名材料与本机配置放在仓库外或已忽略的 `.native` 中;PC 使用显式 `--signing-config`,不会继承移动工程的签名。MiniRTC 是独立 Git 子模块,交付时需要保留其中的鸿蒙适配改动。
|
||||||
|
|
||||||
|
## 代码与验证
|
||||||
|
|
||||||
|
- `desktop-port/`:PC 平台服务、窗口、输入法、Slint 适配及 runtime 补丁;业务逻辑和 UI 直接编译 `apps/desktop`。
|
||||||
|
- `entry/src/main/`:移动 ArkUI 页面与移动 N-API 桥接。
|
||||||
|
- `native/mobile_session.*`、`media.*`:移动控制会话与 NV12 视频显示。
|
||||||
|
- `native/audio.*`:两端共用的 OHAudio 播放服务。
|
||||||
|
- `native/capture*`、`input.*`:PC 录屏与被控输入;`key_mapping.h` 也由移动端使用。
|
||||||
|
- `libs/wire`:仓库公共协议。
|
||||||
|
- `libs/controller/include/file_transfer_core.h`:鸿蒙 PC、其他桌面平台和移动端共用的文件传输核心;`mobile_file_transfer_session.h` 仅保留移动端文件生命周期适配。
|
||||||
|
|
||||||
|
文件传输使用同一份分块、确认窗口、乱序重组、超时和取消逻辑,保持现有 wire 格式。PC 继续使用公共桌面的多文件队列、进度列表、目录设置及同名文件追加时间戳规则,不删除发送原文件;移动端继续使用文件选择器生成的临时副本,并在完成或失败后清理副本,接收文件仍保存在独立子目录。桌面接收状态按连接和对端隔离,反馈只接受对应对端的 ACK,断开时清理未完成的接收文件。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 apps/harmony/tests/run.py --desktop --mobile
|
||||||
|
python3 apps/harmony/tests/verify_hap.py
|
||||||
|
python3 apps/harmony/tests/verify_hap.py --mobile \
|
||||||
|
apps/harmony/.native/mobile-project/entry/build/default/outputs/default/entry-default-unsigned.hap
|
||||||
|
```
|
||||||
|
|
||||||
|
主机侧测试覆盖协议、输入、采集、显示器、键盘钩子、窗口、输入法、自启动状态及移动生命周期。它们使用 SDK 声明与模拟系统接口;真机录屏、输入授权、多屏、音频、后台与长期会话仍需设备验收。
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"signingConfigs": [],
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"targetSdkVersion": "26.0.0",
|
||||||
|
"compatibleSdkVersion": "6.0.0(20)",
|
||||||
|
"runtimeOS": "HarmonyOS",
|
||||||
|
"buildOption": {
|
||||||
|
"strictMode": {
|
||||||
|
"caseSensitiveCheck": true,
|
||||||
|
"useNormalizedOHMUrl": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buildModeSet": [
|
||||||
|
{
|
||||||
|
"name": "debug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"name": "entry",
|
||||||
|
"srcPath": "./entry",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "default",
|
||||||
|
"applyToProducts": [
|
||||||
|
"default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { autoStartupManager } from '@kit.AbilityKit';
|
||||||
|
|
||||||
|
export interface AutostartEvent {
|
||||||
|
op: string;
|
||||||
|
pending?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
supported?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public APIs can only query the user's system setting. Never derive this
|
||||||
|
// state from an application preference, or treat a failed query as disabled.
|
||||||
|
export class AutostartHost {
|
||||||
|
private static running: boolean = false;
|
||||||
|
private static sequence: number = 0;
|
||||||
|
private static timer: number = -1;
|
||||||
|
private static notify: (event: AutostartEvent) => void = () => {};
|
||||||
|
|
||||||
|
static start(notify: (event: AutostartEvent) => void): void {
|
||||||
|
AutostartHost.stop();
|
||||||
|
AutostartHost.notify = notify;
|
||||||
|
AutostartHost.running = true;
|
||||||
|
AutostartHost.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
static stop(): void {
|
||||||
|
AutostartHost.running = false;
|
||||||
|
++AutostartHost.sequence;
|
||||||
|
clearTimeout(AutostartHost.timer);
|
||||||
|
AutostartHost.timer = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static refresh(): void {
|
||||||
|
if (!AutostartHost.running) return;
|
||||||
|
const sequence = ++AutostartHost.sequence;
|
||||||
|
clearTimeout(AutostartHost.timer);
|
||||||
|
AutostartHost.notify({ op: 'autostartState', pending: true });
|
||||||
|
// A stalled system service must not leave the settings page loading forever.
|
||||||
|
AutostartHost.timer = setTimeout(() => {
|
||||||
|
AutostartHost.finish(sequence, { op: 'autostartState', ok: false });
|
||||||
|
}, 5000);
|
||||||
|
try {
|
||||||
|
if (!autoStartupManager.isAutoStartupSupported()) {
|
||||||
|
AutostartHost.finish(sequence, { op: 'autostartState', ok: true, supported: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
autoStartupManager.getAutoStartupStatusForSelf().then((active: boolean) => {
|
||||||
|
AutostartHost.finish(sequence, { op: 'autostartState', ok: true, supported: true, active });
|
||||||
|
}).catch((error: Error) => {
|
||||||
|
console.warn(`Slint autostart query: ${error.message}`);
|
||||||
|
AutostartHost.finish(sequence, { op: 'autostartState', ok: false });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Slint autostart capability: ${error.message}`);
|
||||||
|
AutostartHost.finish(sequence, { op: 'autostartState', ok: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static finish(sequence: number, event: AutostartEvent): void {
|
||||||
|
if (!AutostartHost.running || sequence !== AutostartHost.sequence) return;
|
||||||
|
++AutostartHost.sequence; // Also reject a result arriving after its timeout.
|
||||||
|
clearTimeout(AutostartHost.timer);
|
||||||
|
AutostartHost.timer = -1;
|
||||||
|
AutostartHost.notify(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Compile the desktop application itself. Harmony owns only platform services;
|
||||||
|
# callbacks, settings, history, DeskHub and session state stay in apps/desktop.
|
||||||
|
set(DESKTOP "${CROSSDESK_ROOT}/apps/desktop/src")
|
||||||
|
set(HARMONY "${CROSSDESK_ROOT}/apps/harmony")
|
||||||
|
file(GLOB RUNTIME CONFIGURE_DEPENDS "${DESKTOP}/gui/runtime/*.cpp")
|
||||||
|
file(GLOB_RECURSE FEATURES CONFIGURE_DEPENDS "${DESKTOP}/gui/features/*.cpp")
|
||||||
|
file(GLOB COMMON CONFIGURE_DEPENDS "${DESKTOP}/common/*.cpp" "${DESKTOP}/thumbnail/*.cpp"
|
||||||
|
"${DESKTOP}/version_checker/*.cpp" "${CROSSDESK_ROOT}/libs/wire/src/*.cpp")
|
||||||
|
target_sources(slint_desktop PRIVATE ${RUNTIME} ${FEATURES} ${COMMON}
|
||||||
|
"${DESKTOP}/gui/application/gui_application.cpp"
|
||||||
|
"${DESKTOP}/gui/rendering/slint_video_presenter.cpp"
|
||||||
|
"${DESKTOP}/platform/common/gui/service_status_runtime.cpp"
|
||||||
|
"${DESKTOP}/privacy/privacy_controller.cpp"
|
||||||
|
"${DESKTOP}/speaker_capturer/speaker_capture_controller.cpp"
|
||||||
|
"${DESKTOP}/config_center/config_center.cpp" "${DESKTOP}/autostart/autostart.cpp"
|
||||||
|
"${DESKTOP}/path_manager/path_manager.cpp" "${DESKTOP}/log/rd_log.cpp"
|
||||||
|
"${DESKTOP}/tools/file_transfer.cpp"
|
||||||
|
"${CMAKE_CURRENT_LIST_DIR}/tray.cpp" "${CMAKE_CURRENT_LIST_DIR}/services.cpp" "${CMAKE_CURRENT_LIST_DIR}/devices.cpp"
|
||||||
|
"${CMAKE_CURRENT_LIST_DIR}/keyboard_hook.cpp"
|
||||||
|
"${HARMONY}/native/input.cpp" "${HARMONY}/native/audio.cpp" "${HARMONY}/native/capture.cpp"
|
||||||
|
"${HARMONY}/native/capture_surface.cpp")
|
||||||
|
target_include_directories(slint_desktop PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include"
|
||||||
|
"${CMAKE_CURRENT_LIST_DIR}" "${HARMONY}/native" "${DESKTOP}" "${DESKTOP}/gui"
|
||||||
|
"${DESKTOP}/common" "${DESKTOP}/config_center" "${DESKTOP}/autostart"
|
||||||
|
"${DESKTOP}/path_manager" "${DESKTOP}/log" "${DESKTOP}/tools"
|
||||||
|
"${DESKTOP}/thumbnail" "${DESKTOP}/version_checker" "${DESKTOP}/privacy"
|
||||||
|
"${DESKTOP}/device_controller" "${DESKTOP}/speaker_capturer" "${DESKTOP}/screen_capturer"
|
||||||
|
"${DESKTOP}/platform/common/input" "${CROSSDESK_ROOT}/libs/wire/include"
|
||||||
|
"${CROSSDESK_ROOT}/libs/controller/include"
|
||||||
|
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/api"
|
||||||
|
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/common"
|
||||||
|
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/ws"
|
||||||
|
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/pc"
|
||||||
|
"${CROSSDESK_ROOT}/deps/submodules/minirtc/src/media/resolution_adapter")
|
||||||
|
file(GLOB PACKAGE_INCLUDES "${HARMONY}/.native/packages/*/*/*/*/include")
|
||||||
|
target_include_directories(slint_desktop SYSTEM PRIVATE ${PACKAGE_INCLUDES})
|
||||||
|
file(GLOB QUEUE_INCLUDE "${HARMONY}/.native/packages/c/concurrentqueue/*/*/include/concurrentqueue/moodycamel")
|
||||||
|
target_include_directories(slint_desktop SYSTEM PRIVATE ${QUEUE_INCLUDE} "${SLINT_DESKTOP_DEPS}")
|
||||||
|
target_compile_definitions(slint_desktop PRIVATE FMT_CONSTEVAL= CPPHTTPLIB_OPENSSL_SUPPORT
|
||||||
|
CROSSDESK_VERSION="${CROSSDESK_VERSION}" CROSSDESK_HARMONY_DESKTOP=1)
|
||||||
|
# Use exactly the native dependency versions built by scripts/build.py.
|
||||||
|
set(NATIVE_LIB "${HARMONY}/.native/build/harmony/arm64-v8a/release/libminirtc.a")
|
||||||
|
if(NOT EXISTS "${NATIVE_LIB}")
|
||||||
|
message(FATAL_ERROR "Build apps/harmony/scripts/build.py --native-only first")
|
||||||
|
endif()
|
||||||
|
file(GLOB PACKAGE_LIBS "${HARMONY}/.native/packages/*/*/*/*/lib/*.a")
|
||||||
|
target_link_libraries(slint_desktop PRIVATE "-Wl,--start-group" "${NATIVE_LIB}"
|
||||||
|
${PACKAGE_LIBS} "-Wl,--end-group" native_buffer native_image ohaudio native_avscreen_capture
|
||||||
|
native_media_core native_media_codecbase native_media_vdec ohinput pasteboard udmf)
|
||||||
|
target_link_options(slint_desktop PRIVATE -Wl,--no-undefined)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
|
||||||
|
import { UIContext } from '@kit.ArkUI';
|
||||||
|
import { DesktopEvent } from './DesktopHost';
|
||||||
|
|
||||||
|
// OS authorization only; session policy stays in the shared desktop runtime.
|
||||||
|
export class DesktopAccess {
|
||||||
|
private static context: common.UIAbilityContext;
|
||||||
|
private static enabled: boolean = false;
|
||||||
|
private static hostEnabled: boolean = false;
|
||||||
|
private static hostRequested: boolean = false;
|
||||||
|
private static running: boolean = false;
|
||||||
|
private static generation: number = 0;
|
||||||
|
private static requested: Set<Permissions> = new Set();
|
||||||
|
private static requests: Promise<void> = Promise.resolve();
|
||||||
|
private static notify: (event: DesktopEvent) => void = () => {};
|
||||||
|
private static manager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
|
||||||
|
private static permissions: Permissions[] = [];
|
||||||
|
private static changed: (info: abilityAccessCtrl.PermissionStateChangeInfo) => void = () => DesktopAccess.refresh();
|
||||||
|
|
||||||
|
static start(context: common.UIAbilityContext, enabled: boolean, notify: (event: DesktopEvent) => void,
|
||||||
|
hostEnabled: boolean = false): void {
|
||||||
|
if (DesktopAccess.running) DesktopAccess.stop();
|
||||||
|
DesktopAccess.context = context; DesktopAccess.enabled = enabled;
|
||||||
|
DesktopAccess.hostEnabled = hostEnabled;
|
||||||
|
DesktopAccess.hostRequested = false;
|
||||||
|
DesktopAccess.running = true; DesktopAccess.notify = notify;
|
||||||
|
++DesktopAccess.generation; DesktopAccess.requested.clear();
|
||||||
|
DesktopAccess.permissions = [];
|
||||||
|
if (enabled) DesktopAccess.permissions.push('ohos.permission.HOOK_KEY_EVENT', 'ohos.permission.READ_PASTEBOARD');
|
||||||
|
if (hostEnabled) DesktopAccess.permissions.push('ohos.permission.CUSTOM_SCREEN_RECORDING', 'ohos.permission.CONTROL_DEVICE');
|
||||||
|
if (DesktopAccess.permissions.length > 0) {
|
||||||
|
try { DesktopAccess.manager.on('selfPermissionStateChange', DesktopAccess.permissions, DesktopAccess.changed); }
|
||||||
|
catch (error) { console.warn(`Slint permission observation: ${error.message}`); }
|
||||||
|
}
|
||||||
|
DesktopAccess.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
static stop(): void {
|
||||||
|
if (!DesktopAccess.running) return;
|
||||||
|
DesktopAccess.notify({ op: 'hostPermissions', controlDevice: false, screenRecording: false });
|
||||||
|
DesktopAccess.running = false;
|
||||||
|
++DesktopAccess.generation;
|
||||||
|
if (DesktopAccess.permissions.length > 0) {
|
||||||
|
try { DesktopAccess.manager.off('selfPermissionStateChange', DesktopAccess.permissions, DesktopAccess.changed); }
|
||||||
|
catch (error) { console.warn(`Slint permission cleanup: ${error.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static granted(permission: Permissions): boolean {
|
||||||
|
try {
|
||||||
|
return DesktopAccess.manager.checkAccessTokenSync(DesktopAccess.context.applicationInfo.accessTokenId, permission) ===
|
||||||
|
abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static refresh(): void {
|
||||||
|
if (!DesktopAccess.running) return;
|
||||||
|
DesktopAccess.notify({ op: 'keyboardPermission', ok: DesktopAccess.granted('ohos.permission.HOOK_KEY_EVENT') });
|
||||||
|
DesktopAccess.notify({ op: 'clipboardChanged' });
|
||||||
|
DesktopAccess.notify({ op: 'hostPermissions',
|
||||||
|
controlDevice: DesktopAccess.hostEnabled && DesktopAccess.granted('ohos.permission.CONTROL_DEVICE'),
|
||||||
|
screenRecording: DesktopAccess.hostEnabled && DesktopAccess.granted('ohos.permission.CUSTOM_SCREEN_RECORDING') });
|
||||||
|
}
|
||||||
|
|
||||||
|
static requestKeyboard(): void {
|
||||||
|
if (DesktopAccess.enabled) DesktopAccess.request('ohos.permission.HOOK_KEY_EVENT', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static requestClipboard(): void {
|
||||||
|
if (DesktopAccess.enabled) DesktopAccess.request('ohos.permission.READ_PASTEBOARD', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static requestHost(ui: UIContext): void {
|
||||||
|
if (!DesktopAccess.running || !DesktopAccess.hostEnabled || DesktopAccess.hostRequested) return;
|
||||||
|
DesktopAccess.hostRequested = true;
|
||||||
|
const generation = DesktopAccess.generation;
|
||||||
|
DesktopAccess.requests = DesktopAccess.requests.then(async () => {
|
||||||
|
if (!DesktopAccess.running || generation !== DesktopAccess.generation) return;
|
||||||
|
const switches = DesktopAccess.missingHostSwitches();
|
||||||
|
if (switches.length === 0) return;
|
||||||
|
const prompt = ui.getPromptAction();
|
||||||
|
const result = await prompt.showDialog({
|
||||||
|
title: '设置远程访问权限',
|
||||||
|
message: '请在接下来打开的应用设置页开启:\n\n' + switches.join('\n') +
|
||||||
|
'\n\n仅用于已通过连接密码验证的远程会话。你可以随时在该页面关闭权限。',
|
||||||
|
buttons: [{ text: '暂不开启', color: '#666666' }, { text: '前往设置', color: '#007DFF' }]
|
||||||
|
});
|
||||||
|
if (result.index !== 1 || !DesktopAccess.running || generation !== DesktopAccess.generation ||
|
||||||
|
DesktopAccess.missingHostSwitches().length === 0) return;
|
||||||
|
try {
|
||||||
|
// The system manual-permission dialog describes a different settings
|
||||||
|
// route on PC. Open this app's actual switches together instead.
|
||||||
|
await DesktopAccess.context.startAbility({
|
||||||
|
bundleName: 'com.huawei.hmos.settings',
|
||||||
|
abilityName: 'com.huawei.hmos.settings.MainAbility',
|
||||||
|
uri: 'application_info_entry',
|
||||||
|
parameters: { pushParams: DesktopAccess.context.applicationInfo.name }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!DesktopAccess.running || generation !== DesktopAccess.generation) return;
|
||||||
|
console.warn(`Slint host permission settings: ${error.message}`);
|
||||||
|
await prompt.showDialog({
|
||||||
|
title: '未能打开应用设置',
|
||||||
|
message: '请在系统设置的应用列表中找到本应用,再开启:\n\n' +
|
||||||
|
DesktopAccess.missingHostSwitches().join('\n'),
|
||||||
|
buttons: [{ text: '知道了', color: '#007DFF' }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch((error: Error) => console.warn(`Slint host permission guide: ${error.message}`))
|
||||||
|
.then(() => { if (generation === DesktopAccess.generation) DesktopAccess.refresh(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static missingHostSwitches(): string[] {
|
||||||
|
const switches: string[] = [];
|
||||||
|
if (!DesktopAccess.granted('ohos.permission.CUSTOM_SCREEN_RECORDING'))
|
||||||
|
switches.push('“屏幕共享”:允许远程设备查看本机屏幕。');
|
||||||
|
if (!DesktopAccess.granted('ohos.permission.CONTROL_DEVICE'))
|
||||||
|
switches.push('“辅助操作”:允许远程设备使用本机键盘和鼠标。');
|
||||||
|
return switches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static request(permission: Permissions, manual: boolean): void {
|
||||||
|
if (!DesktopAccess.running || DesktopAccess.requested.has(permission) || DesktopAccess.granted(permission)) return;
|
||||||
|
DesktopAccess.requested.add(permission);
|
||||||
|
const generation = DesktopAccess.generation;
|
||||||
|
DesktopAccess.requests = DesktopAccess.requests.then(async () => {
|
||||||
|
if (!DesktopAccess.running || generation !== DesktopAccess.generation || DesktopAccess.granted(permission)) return;
|
||||||
|
if (manual) await DesktopAccess.manager.openPermissionOnSetting(DesktopAccess.context, permission);
|
||||||
|
else await DesktopAccess.manager.requestPermissionsFromUser(DesktopAccess.context, [permission]);
|
||||||
|
}).catch((error: Error) => console.warn(`Slint permission ${permission}: ${error.message}`))
|
||||||
|
.then(() => { if (generation === DesktopAccess.generation) DesktopAccess.refresh(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
import native from 'libslint_desktop.so';
|
||||||
|
import { TextInputHost } from './TextInputHost';
|
||||||
|
import { TrayHost } from './TrayHost';
|
||||||
|
import { FileHost } from './FileHost';
|
||||||
|
import { DisplayHost, DesktopDisplay } from './DisplayHost';
|
||||||
|
import { DesktopAccess } from './DesktopAccess';
|
||||||
|
import { AutostartHost, AutostartEvent } from './AutostartHost';
|
||||||
|
import { desktopPermissions, hostPermissions } from './DesktopCapabilities';
|
||||||
|
import { pointer } from '@kit.InputKit';
|
||||||
|
import { common, abilityAccessCtrl } from '@kit.AbilityKit';
|
||||||
|
import { display, window } from '@kit.ArkUI';
|
||||||
|
import { pasteboard } from '@kit.BasicServicesKit';
|
||||||
|
import { certificateManager } from '@kit.DeviceCertificateKit';
|
||||||
|
|
||||||
|
export interface DesktopEvent {
|
||||||
|
op: string;
|
||||||
|
displays?: DesktopDisplay[];
|
||||||
|
decor?: number;
|
||||||
|
noFrame?: boolean;
|
||||||
|
topmost?: boolean;
|
||||||
|
cursor?: number;
|
||||||
|
anchor?: number;
|
||||||
|
inputType?: number;
|
||||||
|
commit?: boolean;
|
||||||
|
id?: number;
|
||||||
|
request?: number;
|
||||||
|
ok?: boolean;
|
||||||
|
controlDevice?: boolean;
|
||||||
|
screenRecording?: boolean;
|
||||||
|
text?: string;
|
||||||
|
message?: string;
|
||||||
|
url?: string;
|
||||||
|
path?: string;
|
||||||
|
title?: string;
|
||||||
|
show?: string;
|
||||||
|
settings?: string;
|
||||||
|
exit?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
dx?: number;
|
||||||
|
dy?: number;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
maximized?: boolean;
|
||||||
|
minimized?: boolean;
|
||||||
|
surface?: string;
|
||||||
|
density?: number;
|
||||||
|
active?: boolean;
|
||||||
|
kind?: number;
|
||||||
|
button?: number;
|
||||||
|
code?: number;
|
||||||
|
down?: boolean;
|
||||||
|
control?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
unicode?: number;
|
||||||
|
capsLock?: boolean;
|
||||||
|
numLock?: boolean;
|
||||||
|
imeSession?: number;
|
||||||
|
stateRevision?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window/OS service host only. The desktop C++ application owns every product
|
||||||
|
// action and keeps running when another window gains focus or is minimized.
|
||||||
|
export class DesktopHost {
|
||||||
|
private static stage: window.WindowStage;
|
||||||
|
private static context: common.UIAbilityContext;
|
||||||
|
private static windows: Map<number, window.Window> = new Map();
|
||||||
|
private static queues: Map<number, Promise<void>> = new Map();
|
||||||
|
private static clipboardQueue: Promise<void> = Promise.resolve();
|
||||||
|
private static clipboardRevision: number = 0;
|
||||||
|
private static clipboardCacheRevision: number = -1;
|
||||||
|
private static clipboardCache: string = '';
|
||||||
|
private static clipboardObserved: boolean = false;
|
||||||
|
private static clipboardChanged: () => void = () => {
|
||||||
|
if (!DesktopHost.started || DesktopHost.terminating) return;
|
||||||
|
++DesktopHost.clipboardRevision;
|
||||||
|
DesktopHost.clipboardCache = '';
|
||||||
|
DesktopHost.send({ op: 'clipboardChanged' });
|
||||||
|
};
|
||||||
|
private static properties: Map<number, DesktopEvent> = new Map();
|
||||||
|
private static timer: number = -1;
|
||||||
|
private static cursorSupported: boolean = true;
|
||||||
|
private static cursorHidden: boolean = false;
|
||||||
|
private static trayRemoval: Promise<void> = Promise.resolve();
|
||||||
|
private static started: boolean = false;
|
||||||
|
private static terminating: boolean = false;
|
||||||
|
private static activeWindow: number = 1;
|
||||||
|
private static mainShown: boolean = false;
|
||||||
|
private static stateRevisions: Map<number, number> = new Map();
|
||||||
|
|
||||||
|
static send(event: DesktopEvent): void { native.send(JSON.stringify(event)); }
|
||||||
|
|
||||||
|
static requestExit(): boolean {
|
||||||
|
if (!DesktopHost.started || DesktopHost.terminating) return false;
|
||||||
|
DesktopHost.send({ op: 'tray', text: 'exit' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static restore(): void {
|
||||||
|
const target = DesktopHost.windows.get(1);
|
||||||
|
if (target === undefined) return;
|
||||||
|
DesktopHost.send({ op: 'restore', id: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
static foreground(): void {
|
||||||
|
if (!DesktopHost.started || DesktopHost.terminating) return;
|
||||||
|
AutostartHost.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
static start(stage: window.WindowStage, context: common.UIAbilityContext): void {
|
||||||
|
DesktopHost.stage = stage; DesktopHost.context = context;
|
||||||
|
const screen = display.getDefaultDisplaySync();
|
||||||
|
const storage = new LocalStorage();
|
||||||
|
storage.setOrCreate('slintWindowId', 1);
|
||||||
|
DesktopHost.windows.set(1, stage.getMainWindowSync());
|
||||||
|
stage.loadContent('pages/Index', storage).then(async () => {
|
||||||
|
await FileHost.prepare(context);
|
||||||
|
const screens = await DisplayHost.snapshot();
|
||||||
|
DesktopHost.observe(1, stage.getMainWindowSync());
|
||||||
|
// Publish actual grants before native auto-connect can start capture.
|
||||||
|
DesktopAccess.start(context, desktopPermissions, (event: DesktopEvent) => {
|
||||||
|
if (event.op === 'clipboardChanged') DesktopHost.clipboardChanged();
|
||||||
|
else DesktopHost.send(event);
|
||||||
|
}, hostPermissions);
|
||||||
|
native.start(JSON.stringify({ filesDir: context.filesDir, cacheDir: context.cacheDir,
|
||||||
|
width: screen.width, height: screen.height, density: screen.densityPixels,
|
||||||
|
displayId: screen.id, displays: screens, name: 'Harmony PC',
|
||||||
|
certificates: certificateManager.getCertificateStorePath({ certType: certificateManager.CertType.CA_CERT_SYSTEM }) }));
|
||||||
|
DesktopHost.started = true;
|
||||||
|
AutostartHost.start((event: AutostartEvent) => native.send(JSON.stringify(event)));
|
||||||
|
try {
|
||||||
|
pasteboard.getSystemPasteboard().on('update', DesktopHost.clipboardChanged);
|
||||||
|
DesktopHost.clipboardObserved = true;
|
||||||
|
} catch (error) { console.warn(`Slint clipboard observation: ${error.message}`); }
|
||||||
|
DisplayHost.observe((screens: DesktopDisplay[]) => {
|
||||||
|
DesktopHost.send({ op: 'displays', displays: screens });
|
||||||
|
DesktopHost.windows.forEach((target: window.Window, id: number) => DesktopHost.metrics(id, target));
|
||||||
|
});
|
||||||
|
DesktopHost.timer = setInterval(() => DesktopHost.poll(), 8);
|
||||||
|
}).catch((error: Error) => console.error(`Slint startup: ${error.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static observe(id: number, target: window.Window): void {
|
||||||
|
const current = (): boolean => DesktopHost.windows.get(id) === target && !DesktopHost.terminating;
|
||||||
|
const watch = (subscribe: () => void): void => {
|
||||||
|
try { subscribe(); }
|
||||||
|
catch (error) { console.warn(`Slint window observation: ${error.message}`); }
|
||||||
|
};
|
||||||
|
watch(() => target.on('windowEvent', (event: window.WindowEventType) => {
|
||||||
|
if (!current()) return;
|
||||||
|
if (event === window.WindowEventType.WINDOW_ACTIVE) {
|
||||||
|
DesktopHost.activeWindow = id;
|
||||||
|
DesktopAccess.refresh();
|
||||||
|
DesktopHost.foreground();
|
||||||
|
}
|
||||||
|
if (event === window.WindowEventType.WINDOW_ACTIVE || event === window.WindowEventType.WINDOW_INACTIVE)
|
||||||
|
DesktopHost.send({ op: 'active', id, active: event === window.WindowEventType.WINDOW_ACTIVE });
|
||||||
|
}));
|
||||||
|
watch(() => target.on('windowRectChange', () => { if (current()) DesktopHost.metrics(id, target); }));
|
||||||
|
watch(() => target.on('displayIdChange', () => { if (current()) DesktopHost.metrics(id, target); }));
|
||||||
|
DesktopHost.metrics(id, target);
|
||||||
|
watch(() => target.on('windowStatusDidChange', (status: window.WindowStatusType) => {
|
||||||
|
if (!current()) return;
|
||||||
|
const revision = (DesktopHost.stateRevisions.get(id) ?? 0) + 1;
|
||||||
|
DesktopHost.stateRevisions.set(id, revision);
|
||||||
|
// Minimization does not change the underlying fullscreen/maximized mode.
|
||||||
|
const state: DesktopEvent = status === window.WindowStatusType.MINIMIZE ?
|
||||||
|
{ op: 'state', id, stateRevision: revision, minimized: true } :
|
||||||
|
{ op: 'state', id, stateRevision: revision, fullscreen: status === window.WindowStatusType.FULL_SCREEN,
|
||||||
|
maximized: status === window.WindowStatusType.MAXIMIZE, minimized: false };
|
||||||
|
const saved = DesktopHost.properties.get(id) ?? { op: 'properties', id };
|
||||||
|
saved.fullscreen = state.fullscreen ?? saved.fullscreen;
|
||||||
|
saved.maximized = state.maximized ?? saved.maximized;
|
||||||
|
saved.minimized = state.minimized;
|
||||||
|
saved.stateRevision = revision;
|
||||||
|
DesktopHost.properties.set(id, saved);
|
||||||
|
DesktopHost.send(state);
|
||||||
|
}));
|
||||||
|
watch(() => target.on('windowWillClose', async () => {
|
||||||
|
if (!current()) return false;
|
||||||
|
DesktopHost.send({ op: 'close', id });
|
||||||
|
return true; // The shared desktop callback decides hide/disconnect/exit.
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static forget(id: number, target: window.Window): void {
|
||||||
|
DesktopHost.windows.delete(id);
|
||||||
|
TextInputHost.release(id);
|
||||||
|
// Some SDKs do not expose all listeners. Cleanup must still reach destroy.
|
||||||
|
try { target.off('windowEvent'); } catch (_) {}
|
||||||
|
try { target.off('windowRectChange'); } catch (_) {}
|
||||||
|
try { target.off('displayIdChange'); } catch (_) {}
|
||||||
|
try { target.off('windowStatusDidChange'); } catch (_) {}
|
||||||
|
try { target.off('windowWillClose'); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static metrics(id: number, target: window.Window): void {
|
||||||
|
try {
|
||||||
|
const point = DisplayHost.global(target);
|
||||||
|
DesktopHost.send({ op: 'metrics', id, x: point.x, y: point.y,
|
||||||
|
density: DisplayHost.screen(target).densityPixels, decor: DesktopHost.decorHeight(target) });
|
||||||
|
} catch (error) { console.warn(`Slint window metrics: ${error.message}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static poll(): void {
|
||||||
|
const events: DesktopEvent[] = JSON.parse(native.poll()) as DesktopEvent[];
|
||||||
|
events.forEach((event: DesktopEvent) => {
|
||||||
|
if (event.request !== undefined) {
|
||||||
|
const service = (): Promise<void> => DesktopHost.service(event).catch((error: Error) =>
|
||||||
|
DesktopHost.send({ op: 'reply', request: event.request, ok: false, message: error.message }));
|
||||||
|
// Slint edits and the shared clipboard monitor run on different native
|
||||||
|
// threads. The OS permits only one copy/paste operation at a time.
|
||||||
|
if (event.op.startsWith('clipboard')) DesktopHost.clipboardQueue = DesktopHost.clipboardQueue.then(service);
|
||||||
|
else service();
|
||||||
|
} else if (event.id !== undefined) {
|
||||||
|
const id = event.id;
|
||||||
|
const prior = DesktopHost.queues.get(id) ?? Promise.resolve();
|
||||||
|
const next = prior.then(() => DesktopHost.windowCommand(event)).catch((error: Error) => console.error(`Slint window ${id}: ${error.message}`));
|
||||||
|
DesktopHost.queues.set(id, next);
|
||||||
|
next.then(() => { if (DesktopHost.queues.get(id) === next) DesktopHost.queues.delete(id); });
|
||||||
|
} else if (event.op === 'exit') {
|
||||||
|
DesktopHost.terminating = true;
|
||||||
|
DesktopAccess.stop();
|
||||||
|
AutostartHost.stop();
|
||||||
|
if (DesktopHost.clipboardObserved) {
|
||||||
|
try { pasteboard.getSystemPasteboard().off('update', DesktopHost.clipboardChanged); }
|
||||||
|
catch (error) { console.warn(`Slint clipboard cleanup: ${error.message}`); }
|
||||||
|
DesktopHost.clipboardObserved = false;
|
||||||
|
}
|
||||||
|
clearInterval(DesktopHost.timer);
|
||||||
|
DesktopHost.trayRemoval.finally(() => DesktopHost.context.terminateSelf());
|
||||||
|
} else if (event.op === 'keyboardPermission') {
|
||||||
|
DesktopAccess.requestKeyboard();
|
||||||
|
} else if (event.op === 'autostartRefresh') {
|
||||||
|
AutostartHost.refresh();
|
||||||
|
} else if (event.op === 'trayRemove') {
|
||||||
|
DesktopHost.trayRemoval = TrayHost.remove(DesktopHost.context)
|
||||||
|
.catch((error: Error) => console.warn(`Slint tray removal: ${error.message}`));
|
||||||
|
} else if (event.op === 'error') {
|
||||||
|
console.error(`Slint desktop: ${event.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async windowCommand(event: DesktopEvent): Promise<void> {
|
||||||
|
const id = event.id ?? 0;
|
||||||
|
if (event.op === 'properties') {
|
||||||
|
// Ignore mode commands based on a state older than the latest native
|
||||||
|
// transition. Keep independent title/size/stacking updates applicable.
|
||||||
|
if ((event.stateRevision ?? 0) < (DesktopHost.stateRevisions.get(id) ?? 0)) {
|
||||||
|
event.fullscreen = undefined; event.maximized = undefined; event.minimized = undefined;
|
||||||
|
}
|
||||||
|
const prior = DesktopHost.properties.get(id);
|
||||||
|
DesktopHost.properties.set(id, { op: 'properties', id, title: event.title, noFrame: event.noFrame, topmost: event.topmost,
|
||||||
|
minWidth: event.minWidth, minHeight: event.minHeight, maxWidth: event.maxWidth, maxHeight: event.maxHeight,
|
||||||
|
fullscreen: event.fullscreen ?? prior?.fullscreen,
|
||||||
|
maximized: event.maximized ?? prior?.maximized, minimized: event.minimized ?? prior?.minimized,
|
||||||
|
stateRevision: DesktopHost.stateRevisions.get(id) ?? 0 });
|
||||||
|
}
|
||||||
|
if (event.op === 'destroy') {
|
||||||
|
DesktopHost.properties.delete(id);
|
||||||
|
DesktopHost.stateRevisions.delete(id);
|
||||||
|
}
|
||||||
|
let target = DesktopHost.windows.get(id);
|
||||||
|
if (event.op === 'visible' && event.visible && target === undefined) {
|
||||||
|
target = await DesktopHost.stage.getMainWindowSync().createSubWindowWithOptions(`crossdesk-slint-${id}`,
|
||||||
|
{ title: DesktopHost.properties.get(id)?.title ?? 'CrossDesk', decorEnabled: !DesktopHost.properties.get(id)?.noFrame, isModal: false,
|
||||||
|
zLevelAboveParentLoosened: !DesktopHost.properties.get(id)?.topmost, maximizeSupported: true });
|
||||||
|
DesktopHost.windows.set(id, target);
|
||||||
|
try {
|
||||||
|
const storage = new LocalStorage();
|
||||||
|
storage.setOrCreate('slintWindowId', id);
|
||||||
|
await target.loadContent('pages/Index', storage);
|
||||||
|
await DesktopHost.resizeContent(target, event.width ?? 1216, event.height ?? 903);
|
||||||
|
if (event.x !== undefined && event.y !== undefined) await DisplayHost.move(target, event.x, event.y);
|
||||||
|
const saved = DesktopHost.properties.get(id);
|
||||||
|
if (saved !== undefined) await DesktopHost.applyProperties(target, saved);
|
||||||
|
// Publish geometry only after restoring it; the default placement must
|
||||||
|
// not overwrite the shared desktop's saved position during recreation.
|
||||||
|
DesktopHost.observe(id, target);
|
||||||
|
} catch (error) {
|
||||||
|
DesktopHost.forget(id, target);
|
||||||
|
try { await target.destroyWindow(); } catch (_) {}
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target === undefined) return;
|
||||||
|
if (event.op === 'ime') { TextInputHost.request(event, target, (reply: DesktopEvent) => DesktopHost.send(reply)); return; }
|
||||||
|
if (event.op === 'cursor') {
|
||||||
|
if (!DesktopHost.cursorSupported) return;
|
||||||
|
// Slint 1.17.1 MouseCursor order is pinned by os-services.patch.
|
||||||
|
const styles: pointer.PointerStyle[] = [pointer.PointerStyle.DEFAULT, pointer.PointerStyle.DEFAULT,
|
||||||
|
pointer.PointerStyle.HELP, pointer.PointerStyle.HAND_POINTING, pointer.PointerStyle.RUNNING,
|
||||||
|
pointer.PointerStyle.LOADING, pointer.PointerStyle.CROSS, pointer.PointerStyle.TEXT_CURSOR,
|
||||||
|
pointer.PointerStyle.CURSOR_COPY, pointer.PointerStyle.CURSOR_COPY, pointer.PointerStyle.MOVE,
|
||||||
|
pointer.PointerStyle.CURSOR_FORBID, pointer.PointerStyle.CURSOR_FORBID, pointer.PointerStyle.HAND_OPEN,
|
||||||
|
pointer.PointerStyle.HAND_GRABBING, pointer.PointerStyle.WEST_EAST, pointer.PointerStyle.NORTH_SOUTH,
|
||||||
|
pointer.PointerStyle.NORTH, pointer.PointerStyle.EAST, pointer.PointerStyle.SOUTH, pointer.PointerStyle.WEST,
|
||||||
|
pointer.PointerStyle.NORTH_EAST, pointer.PointerStyle.NORTH_WEST, pointer.PointerStyle.SOUTH_EAST,
|
||||||
|
pointer.PointerStyle.SOUTH_WEST, pointer.PointerStyle.WEST_EAST, pointer.PointerStyle.NORTH_SOUTH,
|
||||||
|
pointer.PointerStyle.NORTH_EAST_SOUTH_WEST, pointer.PointerStyle.NORTH_WEST_SOUTH_EAST];
|
||||||
|
try {
|
||||||
|
await pointer.setPointerStyle(target.getWindowProperties().id, styles[event.kind ?? 0] ?? pointer.PointerStyle.DEFAULT);
|
||||||
|
if (DesktopHost.cursorHidden !== (event.kind === 1)) {
|
||||||
|
await pointer.setPointerVisible(event.kind !== 1);
|
||||||
|
DesktopHost.cursorHidden = event.kind === 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
DesktopHost.cursorSupported = false;
|
||||||
|
console.warn(`Slint cursor service unavailable: ${error.message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.op === 'visible') {
|
||||||
|
if (event.visible) {
|
||||||
|
if (id === 1 && !DesktopHost.mainShown) {
|
||||||
|
const status = target.getWindowStatus();
|
||||||
|
if (status !== window.WindowStatusType.MAXIMIZE && status !== window.WindowStatusType.FULL_SCREEN)
|
||||||
|
await DesktopHost.resizeContent(target, event.width ?? 1216, event.height ?? 903);
|
||||||
|
DesktopHost.mainShown = true;
|
||||||
|
}
|
||||||
|
await DesktopHost.show(id, target);
|
||||||
|
if (id === 1) DesktopAccess.requestHost(target.getUIContext());
|
||||||
|
} else if (id === 1) await target.minimize();
|
||||||
|
else { DesktopHost.forget(id, target); await target.destroyWindow(); }
|
||||||
|
} else if (event.op === 'resize') {
|
||||||
|
await DesktopHost.resizeContent(target, event.width ?? 1216, event.height ?? 903);
|
||||||
|
} else if (event.op === 'move') {
|
||||||
|
await DisplayHost.move(target, event.x ?? 0, event.y ?? 0);
|
||||||
|
} else if (event.op === 'properties') await DesktopHost.applyProperties(target, event);
|
||||||
|
else if (event.op === 'destroy' && id !== 1) {
|
||||||
|
DesktopHost.forget(id, target); DesktopHost.properties.delete(id);
|
||||||
|
await target.destroyWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decorHeight(target: window.Window): number {
|
||||||
|
try { return target.getWindowDecorVisible() ? Math.round(target.getWindowDecorHeight() * DisplayHost.screen(target).densityPixels) : 0; }
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async resizeContent(target: window.Window, width: number, height: number): Promise<void> {
|
||||||
|
await target.resize(Math.max(1, Math.round(width)), Math.max(1, Math.round(height) + DesktopHost.decorHeight(target)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async show(id: number, target: window.Window): Promise<void> {
|
||||||
|
// restore() restores a minimized main window on 2in1; showWindow() only
|
||||||
|
// restores child windows. Avoid resizing an already-visible/maximized one.
|
||||||
|
if (id === 1 && target.getWindowStatus() === window.WindowStatusType.MINIMIZE) await target.restore();
|
||||||
|
else await target.showWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async applyProperties(target: window.Window, event: DesktopEvent): Promise<void> {
|
||||||
|
if (event.noFrame !== undefined && target.getWindowDecorVisible() === event.noFrame)
|
||||||
|
target.setWindowDecorVisible(!event.noFrame);
|
||||||
|
// A decor-free subwindow has no native title component. Harmony rejects
|
||||||
|
// setWindowTitle for it, which must not prevent the Slint status bar from
|
||||||
|
// receiving its size/stacking properties or becoming visible.
|
||||||
|
if (event.title !== undefined && target.getWindowDecorVisible()) {
|
||||||
|
try { await target.setWindowTitle(event.title); }
|
||||||
|
catch (error) { console.warn(`Slint window title: ${error.message}`); }
|
||||||
|
}
|
||||||
|
if (event.id !== 1 && event.topmost !== undefined) {
|
||||||
|
try { await target.setSubWindowZLevel(event.topmost ? 10000 : 0); }
|
||||||
|
catch (error) { console.warn(`Slint window stacking: ${error.message}`); }
|
||||||
|
}
|
||||||
|
const decor = DesktopHost.decorHeight(target);
|
||||||
|
const limits: window.WindowLimits = { minWidth: Math.max(1, Math.round(event.minWidth ?? 1)),
|
||||||
|
minHeight: Math.max(1, Math.round(event.minHeight ?? 1) + decor) };
|
||||||
|
if (event.maxWidth !== undefined) limits.maxWidth = Math.max(limits.minWidth ?? 1, Math.round(event.maxWidth));
|
||||||
|
if (event.maxHeight !== undefined) limits.maxHeight = Math.max(limits.minHeight ?? 1, Math.round(event.maxHeight) + decor);
|
||||||
|
await target.setWindowLimits(limits);
|
||||||
|
if (event.fullscreen === undefined && event.maximized === undefined && event.minimized === undefined) return;
|
||||||
|
if ((event.stateRevision ?? 0) < (DesktopHost.stateRevisions.get(event.id ?? 0) ?? 0)) return;
|
||||||
|
const saved = DesktopHost.properties.get(event.id ?? 0);
|
||||||
|
const minimized = event.minimized ?? saved?.minimized ?? false;
|
||||||
|
const fullscreen = event.fullscreen ?? saved?.fullscreen ?? false;
|
||||||
|
const maximized = event.maximized ?? saved?.maximized ?? false;
|
||||||
|
let status = target.getWindowStatus();
|
||||||
|
if (minimized) {
|
||||||
|
if (status !== window.WindowStatusType.MINIMIZE) await target.minimize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === window.WindowStatusType.MINIMIZE) {
|
||||||
|
await DesktopHost.show(event.id ?? 0, target);
|
||||||
|
status = target.getWindowStatus();
|
||||||
|
}
|
||||||
|
if (fullscreen) {
|
||||||
|
if (status !== window.WindowStatusType.FULL_SCREEN) await target.maximize(window.MaximizePresentation.ENTER_IMMERSIVE);
|
||||||
|
} else if (maximized) {
|
||||||
|
if (status !== window.WindowStatusType.MAXIMIZE) await target.maximize(window.MaximizePresentation.EXIT_IMMERSIVE);
|
||||||
|
} else if (status === window.WindowStatusType.MAXIMIZE || status === window.WindowStatusType.FULL_SCREEN) await target.recover();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async service(event: DesktopEvent): Promise<void> {
|
||||||
|
const response: DesktopEvent = { op: 'reply', request: event.request, ok: true };
|
||||||
|
const board = pasteboard.getSystemPasteboard();
|
||||||
|
// A Ctrl+V gesture grants one read. Background synchronization must never
|
||||||
|
// consume that grant before the editor does. It requires READ_PASTEBOARD;
|
||||||
|
// the explicit paste uses the system's normal gesture authorization.
|
||||||
|
if ((event.op === 'clipboardRead' || event.op === 'clipboardHas') &&
|
||||||
|
abilityAccessCtrl.createAtManager().checkAccessTokenSync(DesktopHost.context.applicationInfo.accessTokenId,
|
||||||
|
'ohos.permission.READ_PASTEBOARD') !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
|
||||||
|
response.ok = false;
|
||||||
|
DesktopHost.clipboardCache = '';
|
||||||
|
DesktopHost.clipboardCacheRevision = -1;
|
||||||
|
DesktopAccess.requestClipboard();
|
||||||
|
response.message = 'Clipboard synchronization requires READ_PASTEBOARD permission';
|
||||||
|
} else if (event.op === 'clipboardRead' || event.op === 'clipboardPaste') {
|
||||||
|
const revision = DesktopHost.clipboardRevision;
|
||||||
|
const explicitPaste = event.op === 'clipboardPaste';
|
||||||
|
if (explicitPaste || !DesktopHost.clipboardObserved || DesktopHost.clipboardCacheRevision !== revision) {
|
||||||
|
const text = board.hasDataType(pasteboard.MIMETYPE_TEXT_PLAIN) ? (await board.getData()).getPrimaryText() : '';
|
||||||
|
if (!explicitPaste && abilityAccessCtrl.createAtManager().checkAccessTokenSync(
|
||||||
|
DesktopHost.context.applicationInfo.accessTokenId, 'ohos.permission.READ_PASTEBOARD') !==
|
||||||
|
abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
|
||||||
|
DesktopHost.clipboardCache = ''; DesktopHost.clipboardCacheRevision = -1;
|
||||||
|
DesktopHost.send({ op: 'reply', request: event.request, ok: false, message: 'Clipboard permission revoked' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.text = text;
|
||||||
|
// A change during getData must be read again. Explicit paste never
|
||||||
|
// populates the background cache from a one-use gesture grant.
|
||||||
|
if (!explicitPaste && revision === DesktopHost.clipboardRevision) {
|
||||||
|
DesktopHost.clipboardCache = text; DesktopHost.clipboardCacheRevision = revision;
|
||||||
|
}
|
||||||
|
} else response.text = DesktopHost.clipboardCache;
|
||||||
|
} else if (event.op === 'clipboardHas') response.ok = board.hasDataType(pasteboard.MIMETYPE_TEXT_PLAIN);
|
||||||
|
else if (event.op === 'clipboardWrite') {
|
||||||
|
await board.setData(pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, event.text ?? ''));
|
||||||
|
DesktopHost.clipboardChanged();
|
||||||
|
}
|
||||||
|
else if (event.op === 'openUrl') await DesktopHost.context.openLink(event.url ?? '');
|
||||||
|
else if (event.op === 'trayCreate') response.ok = await TrayHost.create(DesktopHost.context, event,
|
||||||
|
(reply: DesktopEvent) => DesktopHost.send(reply));
|
||||||
|
else if (event.op === 'selectFile' || event.op === 'selectFolder') {
|
||||||
|
const owner = DesktopHost.windows.get(DesktopHost.activeWindow) ?? DesktopHost.stage.getMainWindowSync();
|
||||||
|
response.path = await FileHost.select(DesktopHost.context, event.op === 'selectFolder', owner);
|
||||||
|
} else response.ok = false;
|
||||||
|
DesktopHost.send(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { display, window } from '@kit.ArkUI';
|
||||||
|
|
||||||
|
export interface DesktopDisplay {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
primary: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
density: number;
|
||||||
|
usableX: number;
|
||||||
|
usableY: number;
|
||||||
|
usableWidth: number;
|
||||||
|
usableHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DisplayHost {
|
||||||
|
private static revision: number = 0;
|
||||||
|
private static observed: Map<number, display.Display> = new Map();
|
||||||
|
|
||||||
|
static screen(target: window.Window): display.Display {
|
||||||
|
return display.getDisplayByIdSync(target.getWindowProperties().displayId ?? display.getDefaultDisplaySync().id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static global(target: window.Window): display.Position {
|
||||||
|
const properties = target.getWindowProperties();
|
||||||
|
return display.convertRelativeToGlobalCoordinate({
|
||||||
|
displayId: properties.displayId ?? display.getDefaultDisplaySync().id,
|
||||||
|
position: { x: properties.windowRect.left, y: properties.windowRect.top }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async move(target: window.Window, x: number, y: number): Promise<void> {
|
||||||
|
const point = display.convertGlobalToRelativeCoordinate({ x: Math.round(x), y: Math.round(y) });
|
||||||
|
await target.moveWindowToAsync(point.position.x, point.position.y, { displayId: point.displayId });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async snapshot(): Promise<DesktopDisplay[]> {
|
||||||
|
const primary = display.getDefaultDisplaySync().id;
|
||||||
|
const screens = await display.getAllDisplays();
|
||||||
|
const result: DesktopDisplay[] = [];
|
||||||
|
for (const screen of screens) {
|
||||||
|
const origin = display.convertRelativeToGlobalCoordinate({ displayId: screen.id, position: { x: 0, y: 0 } });
|
||||||
|
let area: display.Rect = { left: 0, top: 0, width: screen.width, height: screen.height };
|
||||||
|
try { area = await screen.getAvailableArea(); }
|
||||||
|
catch (error) { console.warn(`Slint available display area: ${error.message}`); }
|
||||||
|
result.push({ id: screen.id, name: screen.name, primary: screen.id === primary,
|
||||||
|
x: origin.x, y: origin.y, width: screen.width, height: screen.height, density: screen.densityPixels,
|
||||||
|
usableX: origin.x + area.left, usableY: origin.y + area.top,
|
||||||
|
usableWidth: area.width, usableHeight: area.height });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static observe(changed: (screens: DesktopDisplay[]) => void): void {
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
const revision = ++DisplayHost.revision;
|
||||||
|
try {
|
||||||
|
const screens = await DisplayHost.snapshot();
|
||||||
|
if (revision !== DisplayHost.revision) return;
|
||||||
|
DisplayHost.observed.forEach((screen: display.Display, id: number) => {
|
||||||
|
if (!screens.some((item: DesktopDisplay) => item.id === id)) {
|
||||||
|
try { screen.off('availableAreaChange'); } catch (_) {}
|
||||||
|
DisplayHost.observed.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
screens.forEach((item: DesktopDisplay) => {
|
||||||
|
if (DisplayHost.observed.has(item.id)) return;
|
||||||
|
const screen = display.getDisplayByIdSync(item.id);
|
||||||
|
try { screen.on('availableAreaChange', () => { refresh(); }); }
|
||||||
|
catch (error) { console.warn(`Slint display area events: ${error.message}`); }
|
||||||
|
DisplayHost.observed.set(item.id, screen);
|
||||||
|
});
|
||||||
|
changed(screens);
|
||||||
|
} catch (error) { console.warn(`Slint display refresh: ${error.message}`); }
|
||||||
|
};
|
||||||
|
display.on('add', () => { refresh(); });
|
||||||
|
display.on('remove', () => { refresh(); });
|
||||||
|
display.on('change', () => { refresh(); });
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { UIAbility } from '@kit.AbilityKit';
|
||||||
|
import { window } from '@kit.ArkUI';
|
||||||
|
import { DesktopHost } from '../pages/DesktopHost';
|
||||||
|
|
||||||
|
export default class EntryAbility extends UIAbility {
|
||||||
|
onPrepareToTerminate(): boolean { return DesktopHost.requestExit(); }
|
||||||
|
onNewWant(): void { DesktopHost.restore(); }
|
||||||
|
onForeground(): void { DesktopHost.foreground(); }
|
||||||
|
onWindowStageCreate(stage: window.WindowStage): void {
|
||||||
|
DesktopHost.start(stage, this.context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { picker, fileIo, fileUri, fileShare } from '@kit.CoreFileKit';
|
||||||
|
import { preferences } from '@kit.ArkData';
|
||||||
|
import { window } from '@kit.ArkUI';
|
||||||
|
|
||||||
|
// Only URI grants and native file access live here. Transfer/progress/cancel
|
||||||
|
// remain in the shared desktop FileTransferController and FileReceiver.
|
||||||
|
export class FileHost {
|
||||||
|
private static grants: preferences.Preferences;
|
||||||
|
private static sequence: number = 0;
|
||||||
|
static async prepare(context: common.UIAbilityContext): Promise<void> {
|
||||||
|
FileHost.grants = await preferences.getPreferences(context, { name: 'slint-file-grants' });
|
||||||
|
const folders = await FileHost.grants.get('folders', []) as string[];
|
||||||
|
for (const uri of folders) {
|
||||||
|
try { await fileShare.activatePermission([FileHost.policy(uri)]); }
|
||||||
|
catch (error) { console.warn(`Slint folder permission is no longer available: ${error.code}`); }
|
||||||
|
}
|
||||||
|
// These are snapshots from previous processes, never live transfers.
|
||||||
|
const staging = `${context.cacheDir}/slint-outgoing`;
|
||||||
|
if (fileIo.accessSync(staging)) await fileIo.rmdir(staging);
|
||||||
|
await fileIo.mkdir(staging);
|
||||||
|
}
|
||||||
|
private static policy(uri: string): fileShare.PolicyInfo {
|
||||||
|
return { uri, operationMode: fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE };
|
||||||
|
}
|
||||||
|
static async select(context: common.UIAbilityContext, folder: boolean, owner: window.Window): Promise<string> {
|
||||||
|
const selected = await new picker.DocumentViewPicker(context, owner).select({ maxSelectNumber: 1,
|
||||||
|
selectMode: folder ? picker.DocumentSelectMode.FOLDER : picker.DocumentSelectMode.FILE });
|
||||||
|
if (selected.length === 0) return '';
|
||||||
|
const uri = new fileUri.FileUri(selected[0]);
|
||||||
|
if (folder) {
|
||||||
|
const policy = FileHost.policy(selected[0]);
|
||||||
|
await fileShare.persistPermission([policy]);
|
||||||
|
await fileShare.activatePermission([policy]);
|
||||||
|
const folders = await FileHost.grants.get('folders', []) as string[];
|
||||||
|
if (!folders.includes(selected[0])) folders.push(selected[0]);
|
||||||
|
await FileHost.grants.put('folders', folders);
|
||||||
|
await FileHost.grants.flush();
|
||||||
|
return uri.path;
|
||||||
|
}
|
||||||
|
if (!uri.name || uri.name === '.' || uri.name === '..' || uri.name.includes('/'))
|
||||||
|
throw new Error('Invalid selected filename');
|
||||||
|
const directory = `${context.cacheDir}/slint-outgoing/${Date.now()}-${++FileHost.sequence}`;
|
||||||
|
await fileIo.mkdir(directory);
|
||||||
|
const path = `${directory}/${uri.name}`;
|
||||||
|
const source = await fileIo.open(selected[0], fileIo.OpenMode.READ_ONLY);
|
||||||
|
try { await fileIo.copyFile(source.fd, path); }
|
||||||
|
finally { await fileIo.close(source); }
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { DesktopEvent, DesktopHost } from './DesktopHost';
|
||||||
|
import { KeyboardHost } from './KeyboardHost';
|
||||||
|
|
||||||
|
|
||||||
|
// This host owns only the native surface and event delivery. All visible
|
||||||
|
// application controls are compiled from apps/desktop's existing Slint files.
|
||||||
|
@Entry({ useSharedStorage: true })
|
||||||
|
@Component
|
||||||
|
struct Index {
|
||||||
|
private surface: XComponentController = new XComponentController();
|
||||||
|
private keyboard: KeyboardHost = new KeyboardHost();
|
||||||
|
private surfaceId: string = '';
|
||||||
|
|
||||||
|
private send(event: DesktopEvent): void {
|
||||||
|
event.surface = this.surfaceId;
|
||||||
|
DesktopHost.send(event);
|
||||||
|
}
|
||||||
|
@LocalStorageProp('slintWindowId') private windowId: number = 1;
|
||||||
|
|
||||||
|
aboutToDisappear(): void {
|
||||||
|
this.keyboard.reset();
|
||||||
|
if (this.surfaceId.length === 0) return;
|
||||||
|
this.send({ op: 'detach', id: this.windowId });
|
||||||
|
this.surfaceId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Stack() {
|
||||||
|
XComponent({ id: 'slint-desktop', type: XComponentType.SURFACE, controller: this.surface })
|
||||||
|
.width('100%').height('100%')
|
||||||
|
.onSizeChange((_old: SizeOptions, size: SizeOptions) => {
|
||||||
|
this.surface.setXComponentSurfaceSize({ surfaceWidth: Math.round(vp2px(Number(size.width))),
|
||||||
|
surfaceHeight: Math.round(vp2px(Number(size.height))) });
|
||||||
|
this.send({ op: 'redraw', id: this.windowId });
|
||||||
|
})
|
||||||
|
.onLoad(() => {
|
||||||
|
this.surfaceId = this.surface.getXComponentSurfaceId();
|
||||||
|
this.send({ op: 'attach', id: this.windowId, surface: this.surface.getXComponentSurfaceId(),
|
||||||
|
density: this.getUIContext().vp2px(1) });
|
||||||
|
})
|
||||||
|
.onDestroy(() => this.aboutToDisappear())
|
||||||
|
// A focusable leaf is required: an empty Column is a focus scope and
|
||||||
|
// receives touch events but cannot retain hardware keyboard focus.
|
||||||
|
Text('').id('slintInput').width('100%').height('100%').focusable(true).defaultFocus(true).focusOnTouch(true)
|
||||||
|
.onBlur(() => this.keyboard.reset())
|
||||||
|
.onTouch((event: TouchEvent) => {
|
||||||
|
if (event.type === TouchType.Down) this.getUIContext().getFocusController().requestFocus('slintInput');
|
||||||
|
if (event.source === SourceType.Mouse) return;
|
||||||
|
if (event.changedTouches.length === 0) return;
|
||||||
|
const p = event.changedTouches[0];
|
||||||
|
const kind = event.type === TouchType.Down ? 0 : event.type === TouchType.Up ? 1 :
|
||||||
|
event.type === TouchType.Cancel ? 3 : 2;
|
||||||
|
this.send({ op: 'pointer', id: this.windowId, kind, x: p.x, y: p.y, button: 0 });
|
||||||
|
})
|
||||||
|
.onMouse((event: MouseEvent) => {
|
||||||
|
if (event.action === MouseAction.Press) this.getUIContext().getFocusController().requestFocus('slintInput');
|
||||||
|
const kind = event.action === MouseAction.Press ? 0 : event.action === MouseAction.Release ? 1 : 2;
|
||||||
|
this.send({ op: 'pointer', id: this.windowId, kind, x: event.x, y: event.y,
|
||||||
|
button: event.button === MouseButton.Right ? 1 : event.button === MouseButton.Middle ? 2 : 0 });
|
||||||
|
event.stopPropagation();
|
||||||
|
})
|
||||||
|
.onHover((hovered: boolean) => {
|
||||||
|
if (!hovered) this.send({ op: 'pointerExit', id: this.windowId });
|
||||||
|
})
|
||||||
|
.onKeyPreIme((event: KeyEvent) => this.keyboard.preIme(event, this.windowId, (reply: DesktopEvent) => this.send(reply)))
|
||||||
|
.onKeyEvent((event: KeyEvent) => {
|
||||||
|
this.send(KeyboardHost.event(event, this.windowId));
|
||||||
|
event.stopPropagation();
|
||||||
|
})
|
||||||
|
.onAxisEvent((event: AxisEvent) => {
|
||||||
|
this.send({ op: 'scroll', id: this.windowId, x: event.x, y: event.y,
|
||||||
|
dx: -event.getHorizontalAxisValue(), dy: -event.getVerticalAxisValue() });
|
||||||
|
})
|
||||||
|
}.width('100%').height('100%').backgroundColor(Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { DesktopEvent } from './DesktopHost';
|
||||||
|
|
||||||
|
export class KeyboardHost {
|
||||||
|
private captured: Set<number> = new Set();
|
||||||
|
private modifiers: Set<number> = new Set();
|
||||||
|
reset(): void { this.captured.clear(); this.modifiers.clear(); }
|
||||||
|
|
||||||
|
static event(event: KeyEvent, id: number): DesktopEvent {
|
||||||
|
return { op: 'key', id, code: event.keyCode, down: event.type === KeyType.Down,
|
||||||
|
unicode: event.unicode, capsLock: event.isCapsLockOn, numLock: event.isNumLockOn };
|
||||||
|
}
|
||||||
|
|
||||||
|
preIme(event: KeyEvent, id: number, send: (event: DesktopEvent) => void): boolean {
|
||||||
|
const code = event.keyCode;
|
||||||
|
const modifier = code === 2072 || code === 2073 || code === 2045 || code === 2046 ||
|
||||||
|
code === 2047 || code === 2048 || code === 2076 || code === 2077;
|
||||||
|
if (modifier) {
|
||||||
|
if (event.type === KeyType.Down) this.modifiers.add(code);
|
||||||
|
else this.modifiers.delete(code);
|
||||||
|
}
|
||||||
|
// ArkUI's query accepts only Ctrl/Alt/Shift, not Meta. Track Meta through
|
||||||
|
// its actual key transitions instead of issuing an invalid SDK query.
|
||||||
|
const control = (event.getModifierKeyState?.(['Ctrl']) ?? false) ||
|
||||||
|
this.modifiers.has(2072) || this.modifiers.has(2073);
|
||||||
|
const shortcutModifier = code === 2072 || code === 2073 || code === 2076 || code === 2077;
|
||||||
|
const shortcut = shortcutModifier || (!modifier && code !== 2050 &&
|
||||||
|
(control || event.metaKey === 1 || this.modifiers.has(2076) || this.modifiers.has(2077)));
|
||||||
|
// IMEs may turn Ctrl+A into insertText("a") instead of an edit command.
|
||||||
|
// Slint must receive shortcuts as keys before the IME consumes them. Keep
|
||||||
|
// the matching release even when the modifier is released first. Space
|
||||||
|
// remains available for the input method's own toggle. Its native event
|
||||||
|
// still carries the OS modifier state. Forward Ctrl/Meta transitions too:
|
||||||
|
// some IMEs otherwise consume the down event but pass the key-up along.
|
||||||
|
if (!shortcut && !this.captured.has(code)) return false;
|
||||||
|
if (event.type === KeyType.Down) this.captured.add(code);
|
||||||
|
else this.captured.delete(code);
|
||||||
|
send(KeyboardHost.event(event, id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# HarmonyOS Slint 桌面移植
|
||||||
|
|
||||||
|
目标是与 Windows、macOS、Linux 使用同一套界面和产品行为。此目录直接编译 `apps/desktop/src/gui/application/gui_application.cpp`、`gui/runtime`、`gui/features` 和原始 `crossdesk_ui.slint`。鸿蒙 PC 只维护这一套入口,旧 ArkUI PC 会话和 Slint 验证程序已移除。
|
||||||
|
|
||||||
|
**当前是可运行的移植开发版,尚未达到全部 PC 行为一致的验收标准。** PC 与移动端共用应用信息,包名统一为 `com.chunkdash.crossdesk`,桌面名称统一为 **CrossDesk**。移动端使用独立 ArkUI 工程;旧 PC 验证包 `cn.crossdesk.slintdesktop` 的数据不会自动迁移到统一包名的新应用。
|
||||||
|
|
||||||
|
## 一致性约束
|
||||||
|
|
||||||
|
- 界面、布局、菜单、密码确认、设置保存/取消、历史记录、分组、桌面墙、会话标签、文件传输状态机、剪贴板同步和网络连接全部来自桌面公共代码。业务改动应在公共代码修复,不能在 ArkTS 再写一套行为。
|
||||||
|
- ArkTS 只负责系统窗口、XComponent、键鼠事件、输入法、剪贴板、文件选择器、目录授权、系统托盘和打开链接。
|
||||||
|
- C++ 平台层实现窗口/渲染、音频、屏幕采集及输入注入。私有 `include/SDL3` 只适配公共代码实际使用的 SDL 服务,不是完整的 SDL 移植,不能用于其他目标。
|
||||||
|
- 屏幕、窗口和输入法坐标使用系统的实际显示器、工作区和各窗口缩放比例。Slint 的无边框属性直接映射到原生窗口,置顶属性目前只保证应用内部子窗口层级。
|
||||||
|
- 所有 Slint 对象、事件、定时器和渲染在同一 GUI 线程运行。系统服务通过 ArkTS 线程处理;退出会取消等待,切换窗口保持已有会话。
|
||||||
|
- Harmony 也定义 `__linux__`。共享代码仅在使用 X11/Wayland、Linux 原生窗口等位置排除 `__OHOS__`;POSIX 和 TLS 代码继续复用。
|
||||||
|
|
||||||
|
## 本机构建和运行
|
||||||
|
|
||||||
|
当前桌面适配使用 API 26 的[独立子窗口](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/subwindow-guide)行为,要求 HarmonyOS API 26;移动端仍兼容 API 20。当前产物为 arm64,已在本机 2in1 模拟器验证。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建 MiniRTC、Slint runtime、公共桌面程序和 HAP,安装到正在运行的模拟器
|
||||||
|
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --install
|
||||||
|
|
||||||
|
# 后续增量构建;runtime 补丁变更仍会自动重建
|
||||||
|
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --reuse-runtime --install
|
||||||
|
|
||||||
|
# 原生采集、输入 / wire 协议 / Slint 平台适配回归
|
||||||
|
python3 apps/harmony/tests/run.py --desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
`--device` 可指定 HDC 设备。`--version` 设置与其他桌面构建相同的版本号,默认 `0.0.0`,因此开发包会出现现有的新版提示。未提供鸿蒙发布文件时,更新入口打开官网,不能下载 Linux 安装包。
|
||||||
|
|
||||||
|
需要本机 Slint compiler 1.17.1、Rust OHOS 标准库和 DevEco SDK。工具链定位及首次下载要求见 [鸿蒙构建说明](../README.md),签名选项见下文。Slint 和 httplib 源码均固定版本及 SHA-256。生成的工程位于 `.native/slint-poc/desktop-project`,可用 DevEco Studio 打开。HAP 位于其 `entry/build/default/outputs/default/entry-default-unsigned.hap`;真机安装仍需自己的调试签名。
|
||||||
|
|
||||||
|
## 本次验证(2026-10-15)
|
||||||
|
|
||||||
|
工程精简后重新通过 `tests/run.py --desktop --mobile`、默认 PC HAP、全部可选权限声明的 PC HAP 以及移动端完整构建。包检查确认 PC 仅支持 `2in1`、移动端仅支持 `phone/tablet`,两端均只包含各自所需的三个原生库;PC 不含旧验证库或移动视频渲染器。以下设备交互记录来自此前验证,本次精简未重复真机或模拟器端到端会话。
|
||||||
|
|
||||||
|
| 范围 | 证据 / 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| 共享桌面程序 | 完整 C++ 源码交叉编译、无未解析符号;HAP 的 arm64 ELF、SONAME 和动态依赖检查通过 |
|
||||||
|
| 启动和登录 | 本机 2in1 模拟器启动;系统 CA 校验下连接信令服务器;重启恢复设备身份和近期连接 |
|
||||||
|
| 真正远程会话 | 与测试 MiniRTC 被控端建立连接;共享密码对话框、会话窗口、工具栏和动态 NV12 测试画面正常;远端实际收到 control / keyboard / mouse 数据流,关闭会话后收到断开事件 |
|
||||||
|
| 中文输入 | 连接别名文本框全选替换;鸿蒙系统拼音候选 `nihao` 选择“你好”并提交到原 Slint 文本框 |
|
||||||
|
| 键盘 | 补齐扫描码、扩展位、左右修饰键及 F13–F24;复用公共按键状态对账,按下时先修饰键、释放时先普通键。左右 Ctrl、双 Shift、乱序快照、重复按键、释放失败重试和失焦清理的主机测试通过 |
|
||||||
|
| 被控输入实测 | Mac MiniRTC 测试主控经网络发送真实输入:拖动鸿蒙窗口、Ctrl+A 替换文本、右 Shift+方向键选择后删除、右 Ctrl、双 Ctrl 先放开左侧仍保持组合键均通过;按住右 Ctrl 时断开,之后本地输入正常,无修饰键滞留 |
|
||||||
|
| 剪贴板快捷键 | 最终 HAP 中经远程 Ctrl+A/C、End、Ctrl+V、Ctrl+A/X、Ctrl+V,`987` 完整复制为 `987 987`,剪切后再次粘贴恢复;补充后台同步不能抢占用户粘贴授权的回归测试 |
|
||||||
|
| 窗口 | 桌面墙独立窗口、全屏进入/退出恢复;主窗口关闭后从应用入口重新打开,保持原进程与登录状态 |
|
||||||
|
| 窗口重建 | 模拟器桌面墙移动后关闭、重开保留位置和尺寸;修复重建 XComponent 后因局部重绘缓冲被清空而出现黑块的问题 |
|
||||||
|
| 显示器适配 | 接入显示器增删、分辨率及工作区变化;负坐标、非连续屏幕 ID、主屏 ID 为 0、可用工作区和最近屏幕选择的主机测试通过。外接多屏与混合缩放仍需真机验收 |
|
||||||
|
| 被控端适配测试 | 直接编译生产 `devices.cpp`,以模拟系统接口验证 Display1/Display2 切换、失败回退、拔屏后编号稳定及恢复、采集帧率、私密场景暂停和授权失效时拒绝输入 |
|
||||||
|
| 原生采集回归 | 直接编译生产 `capture.cpp`,模拟异步授权;验证启动前不发送数据、GPU 路径优先及失败回退、CPU Surface 合并通知后排空队列、同步 fence 等待/超时/释放、私密场景和暂停/恢复、音频范围检查、重复停止及初始化失败清理;麦克风静音不影响视频和输入 |
|
||||||
|
| 模拟器被控实测 | 改用系统 Surface 输出及 GLES 纹理读取后,Mac 测试主控实际解码出 3120×2080 桌面;检查了壁纸、Slint 窗口、文字、颜色和方向。移动窗口时画面持续更新。同一鸿蒙进程断开、重新授权、重连再次收到正常画面;断开后被控栏消失,桌面墙仍能创建和绘制;授权弹窗选择“返回”收到取消状态且解码帧数为 0,授权等待中断开也能正常清理 |
|
||||||
|
| 鼠标状态 | 多键同时按下、拖出边界、取消和重复失焦的释放测试通过;切屏释放旧屏按键/鼠标,之后映射到目标屏像素范围 |
|
||||||
|
| 桌面墙 | 共享空状态、从近期连接导入设备、密码确认、单设备连接和动态视频预览通过;批量多设备会话尚未完整验收 |
|
||||||
|
| 系统托盘 | 接入 PC 原生托盘;关闭主窗口后保留原进程,左键恢复、菜单打开共享设置及退出通过;退出执行公共连接清理并移除图标 |
|
||||||
|
| 音频 | 合成 48 kHz 单声道测试音经 MiniRTC 解码后进入 OH_AudioRenderer;连接后自动播放、静音停止、取消静音恢复;尚未验收真机扬声器听感 |
|
||||||
|
| 文件 | 系统选择器发送 1 MiB 二进制文件,远端逐字节校验一致;接收 UTF-8 文件到默认应用目录和用户选择的公共目录;进程重启后无需重新选择目录即可接收 |
|
||||||
|
| 对话框及焦点 | 修复空初始目录导致的闪退;文件选择器绑定发起操作的窗口,取消后返回原会话;选择器尚未关闭时从托盘退出,也能取消等待并完成会话清理 |
|
||||||
|
| macOS 回归 | 公共 Slint UI 冒烟程序完整编译、链接和运行通过;新增间隔输入后 Ctrl+A 替换的回归,修复前失败、修复后通过。未完成整个 macOS 客户端链接回归 |
|
||||||
|
|
||||||
|
密码框沿用共享桌面的明文文本控件。输入英文密码时,请切换系统输入法到英文或先提交组合文本;自动化回归也必须控制输入法状态,不能把尚未提交的拼音候选当成已输入的密码。
|
||||||
|
|
||||||
|
文件测试使用独立的合成 MiniRTC 测试端及测试文件,不采集本机桌面。默认目录接收和公共目录接收均检查了 UTF-8 内容,发送测试文件大小为 1,048,576 字节,SHA-256 为 `fbbab289f7f94b25736c58be46a994c441fd02552cc6022352e3d86d2fab7c83`。测试记录和临时文件已清理,原有连接记录保留。
|
||||||
|
|
||||||
|
被控端联调仅共享鸿蒙模拟器桌面,临时保存主控解码帧以检查实际像素,测试结束清理;没有采集工作站桌面。原始数据回调在本机模拟器只产生音频,CPU 映射的 Surface 视频缓冲也几乎全黑;`StartScreenCaptureWithSurface` 配合 NativeImage 外部纹理、V2 变换矩阵、GLES 读取和 NV12 转换后,主控可显示实际桌面。保留 CPU Surface 和原始回调作为初始化失败时的后备路径。采集帧率设置遵循 [AVScreenCapture 接口约束](https://github.com/openharmony/docs/blob/master/en/application-dev/reference/apis-media-kit/capi-native-avscreen-capture-h.md),在异步启动成功通知之后执行。
|
||||||
|
|
||||||
|
采集纹理及 EGL context 由专用线程创建、使用和释放,生产者窗口由 NativeImage 持有,避免重复释放。停止采集只销毁自身资源,不终止与 Slint/ArkUI 共用的 EGL display。界面、会话、编码发送及连接清理继续使用公共桌面逻辑。远程拖动原先因 MOVE 事件携带 `BUTTON_NONE` 被窗口管理器拒绝,改为携带实际按下的按钮后,模拟器窗口实际移动并恢复原位。系统全局指针位置查询仍返回能力错误,与已验证的输入注入分别处理。
|
||||||
|
|
||||||
|
鸿蒙输入法可能消费硬件 Ctrl 组合键。`KeyboardHost` 在 `onKeyPreIme` 中把 Ctrl/Meta 及组合键交给原 Slint 按键分发;普通文本与 Ctrl+Space 保留系统输入法路径。输入法自身的全选、剪切、复制、粘贴、移动和选区回调也交给 Slint TextInput。公共 ID 格式化定时器改为通过 `running` 属性启停,避免原生重复定时器持续清除选区,修复同时适用于其他桌面平台。
|
||||||
|
|
||||||
|
系统 Ctrl+V 提供的剪贴板读取授权只能使用一次。显式粘贴使用独立的 `clipboardPaste` 请求,后台同步先检查 `READ_PASTEBOARD` 权限,避免公共剪贴板轮询抢先消费粘贴授权。剪贴板服务按顺序执行,读取失败不会伪装成成功的空字符串。已接入 `SystemPasteboard.update` 变更通知,隐藏/失焦窗口也向公共桌面投递 `SDL_EVENT_CLIPBOARD_UPDATE`,同步去重、大小限制及会话过滤继续复用 `ClipboardController`。后台读取使用按变更版本失效的缓存,读取期间再次变更会重读,撤权会清空缓存并拒绝结果。显式粘贴不进入后台缓存。当前普通调试包没有 `READ_PASTEBOARD` 的签名 ACL,因此跨应用后台同步的授权实机验收尚未完成。权限限制参见[系统剪贴板说明](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-v5/use_pasteboard_to_copy_and_paste-V5)。
|
||||||
|
|
||||||
|
## 全局输入和后台剪贴板权限
|
||||||
|
|
||||||
|
`KeyboardHook` 使用 API 21 的 `OH_Input_AddKeyEventHook`,而不是局部窗口监听或热键订阅。共享 `SessionDeviceManager` 决定捕获生命周期,原生回调仅进入公共按键队列;窗口失焦、撤权和取消时释放捕获状态,未知按键/未捕获的释放事件交回系统。卸载失败后回调只放行,不再访问已销毁的会话。未授权时继续使用原 Slint 窗口事件;收到授权变化后自动重试,不因周期性失败重试打断用户正按住的回退组合键。
|
||||||
|
|
||||||
|
本机 API 26 SDK 的 `PermissionDefinitions.json` 将 `HOOK_KEY_EVENT` 定义为 `system_basic / manual_settings`(tablet、2in1),将 `READ_PASTEBOARD` 定义为 `system_basic / user_grant`,二者均需签名 profile 中获批的 ACL。代码声明不能替代签名授权。`DesktopAccess` 串行处理系统授权界面,每次启动只引导一次;用户取消后不循环弹窗,并监听自身权限变化。键盘使用 `openPermissionOnSetting`,剪贴板使用 `requestPermissionsFromUser`。普通开发包不声明这两项权限,也不打开无法使用的授权页。
|
||||||
|
|
||||||
|
获得获批 profile 后,导出一项 DevEco `signingConfigs` 配置到仓库外的私有 JSON 文件,`material.certpath`、`material.storeFile`、`material.profile` 使用绝对路径:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --reuse-runtime \
|
||||||
|
--desktop-permissions --signing-config /absolute/private/signing.json --install
|
||||||
|
```
|
||||||
|
|
||||||
|
`--desktop-permissions` 生成权限声明、用途说明和授权流程开关;`--signing-config` 生成签名配置并选择 `entry-default-signed.hap` 安装。生成的配置仅位于忽略的 `.native` 缓存,文件权限为 `0600`。无签名参数时仍输出普通 `entry-default-unsigned.hap`。可单独用 `--desktop-permissions` 做带权限声明的编译检查,不能据此认为已获得权限或可安装到真机。
|
||||||
|
|
||||||
|
远程 `lock_workstation` 命令已接到经过采集/输入授权检查的 Meta+L 路径,先释放残留键鼠,发送后配对释放,失败保留待清理状态。Windows `send_sas` 没有等价的鸿蒙公开接口,明确返回不支持;所有 PC 客户端共用的 Slint 快捷键菜单在目标为鸿蒙时不显示 Ctrl+Alt+Del,仍保留 Win+L。普通 Ctrl/Alt/Meta 组合键由原生钩子按原顺序转发,系统最终允许拦截哪些保留键仍需授权后的真机验证。
|
||||||
|
|
||||||
|
本轮验证:普通 HAP 与带权限声明的 HAP 构建通过;普通 HAP 安装本机 2in1 模拟器后,全局钩子实际返回 `201`,无授权弹窗循环,仍收到共享窗口回退发送的 `keyboard` 数据流。真实 Ctrl+A/C、End、Ctrl+V 把 `246` 复制为 `246 246`。生产 C++/ArkTS 的模拟 SDK 回归覆盖 Alt+Tab、Meta+L、重复键、小键盘 Enter、取消、失焦、撤权、重复注册、卸载失败、后台通知、缓存失效、读取中变更/撤权,以及权限拒绝后不重复引导。macOS 公共 Slint 冒烟测试新增快捷键菜单的真实点击回归,验证移除 SAS 后首项为锁屏、弹层命中范围随菜单缩小、切回 Windows 菜单仍正常。成功授权的钩子和后台跨应用读取没有在无 ACL 包中冒充验证通过。
|
||||||
|
|
||||||
|
## 被控端持久授权(API 26)
|
||||||
|
|
||||||
|
已接入一次系统设置授权、后续连接复用权限的路径。它解决的是应用运行中重复连接的录屏和键鼠确认;重启后自启动、系统登录前控制、锁屏及休眠恢复仍需分别验收。已使用包含这两项 ACL 的调试 Profile 完成签名和模拟器安装,尚未完成本机首次设置授权及真机免确认重连验证。
|
||||||
|
|
||||||
|
| 权限 | 用途 | SDK 要求 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ohos.permission.CUSTOM_SCREEN_RECORDING` | 录屏持久授权,指定显示器采集时无需重复确认 | API 22 起,`system_basic / manual_settings` |
|
||||||
|
| `ohos.permission.CONTROL_DEVICE` | 直接注入键鼠,无需每次请求临时输入授权 | API 26 起,`system_basic / manual_settings`,PC/2in1 |
|
||||||
|
|
||||||
|
两项权限都需要签名 Profile 的 ACL 和用户在系统设置中的授权。`CUSTOM_SCREEN_CAPTURE` 是截图权限,不能替代上述录屏权限。`HOOK_KEY_EVENT` 是控制端捕获快捷键的权限,也不能替代被控端的 `CONTROL_DEVICE`。
|
||||||
|
|
||||||
|
使用独立的 `--host-permissions` 选项生成声明、用途说明及设置引导,不要求同时申请全局键盘钩子或剪贴板权限:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 生成 DevEco 工程并编译未签名包,供申请权限和检查构建使用。
|
||||||
|
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --reuse-runtime --host-permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 申请与签名
|
||||||
|
|
||||||
|
1. 在 DevEco Studio 打开 `apps/harmony/.native/slint-poc/desktop-project`。PC 与移动端的 bundle name 均为 `com.chunkdash.crossdesk`;AGC 应用、签名 Profile 和包名必须一致。构建从共享的 `AppScope/app.json5` 读取包名,不支持单独为 PC 覆盖包名。
|
||||||
|
2. 在 **File > Project Structure > Project > Signing Configs** 登录自己的华为开发者账号,关联 AGC 注册应用。在支持该流程的 DevEco Studio 中,通过 **Enable ACL Permissions** 为 `entry / EntryAbility` 申请上述两项权限。调用时机选择 `always`,因为最小化到后台后仍需接受经过认证的连接。若权限列表中没有 `CONTROL_DEVICE`,核对 IDE/SDK 的 API 26 支持,并通过 AGC 项目的“ACL权限”页签确认该权限的开放范围;网页端需先取得 ACL,再将其加入新生成的 Profile。
|
||||||
|
3. 分别填写用途和申请理由,并提交实际功能演示材料。可使用以下说明作为草稿,按真实发布场景修改:
|
||||||
|
|
||||||
|
- `CUSTOM_SCREEN_RECORDING`:CrossDesk 为鸿蒙 PC 提供远程桌面被控功能。设备所有者在本机设置中主动授权后,应用仅在远程连接通过密码验证且会话有效时采集指定显示器并传输画面,断开后停止采集。申请持久权限以支持所有者离开设备后的远程访问,避免每次连接都要求现场确认。
|
||||||
|
- `CONTROL_DEVICE`:在已认证且屏幕采集有效的远程会话中,将远端操作转换为本机键鼠输入。应用停止共享、系统进入私密场景或授权失效时不接受远程输入;用户可在系统设置中撤销授权。
|
||||||
|
|
||||||
|
4. 检查生成 Profile 的 ACL 确实包含两项权限。调试阶段官方自动签名流程可能提供临时 Profile,其有效期和权限以实际签发结果为准;发布阶段须完成正式权限及发布 Profile 申请。登录、提交申请及用户设置授权需要由账号/设备所有者完成。
|
||||||
|
5. 将工程 `build-profile.json5` 的一项 `app.signingConfigs` 对象保存为仓库外的私有 JSON 文件(不要包含外层数组)。保持原有签名字段,`material.certpath`、`material.storeFile`、`material.profile` 均使用绝对路径。构建脚本会重新生成工程配置,因此在再次运行脚本前先导出该对象。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 apps/harmony/scripts/build_slint_desktop.py --desktop --reuse-runtime \
|
||||||
|
--host-permissions --signing-config /absolute/private/signing.json \
|
||||||
|
--device YOUR_DEVICE_ID --install
|
||||||
|
```
|
||||||
|
|
||||||
|
Profile 必须对应 `com.chunkdash.crossdesk`,无需额外传入 `--bundle-name`;该选项仅为兼容已有命令保留,且只接受共享包名。目标设备已有同包名应用时将尝试更新,需满足签名和版本要求。脚本只使用显式传入的签名配置,不会自动继承源工程的签名;DevEco 写入源工程的 ACL 声明也按本次构建开关重新生成,避免重复声明或普通构建意外启用权限。
|
||||||
|
|
||||||
|
如果也需要控制端快捷键和后台剪贴板同步,可同时加 `--desktop-permissions`,但 Profile 还需包含对应的两项 ACL。未签名包的构建成功不代表有权限或能安装到真机。
|
||||||
|
|
||||||
|
申请入口和流程依据:[华为受限权限申请](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/declare-permissions-in-acl)、[DevEco 自动签名及 ACL 配置](https://developer.huawei.com/consumer/cn/doc/doccenter-deveco-studio/ide-signing-auto)。
|
||||||
|
|
||||||
|
### 运行行为与验收
|
||||||
|
|
||||||
|
首次显示主窗口时,启用 `--host-permissions` 的构建合并提示缺少的“屏幕共享”和“辅助操作”开关;点击“前往设置”后打开本应用的系统设置详情页。两项均已授予时不弹窗,只缺一项时只解释该项。“暂不开启”或关闭引导后本次运行不重复打开,可自行进入系统设置开启或重启应用再次引导。普通构建不声明这两项权限、不打开对应设置。
|
||||||
|
|
||||||
|
PC 系统 `openPermissionOnSetting` 弹窗中的“隐私和安全 > 辅助操作”路径与实际应用设置页不一致,且该系统文案没有应用侧修改接口。因此被控权限改用窗口所属 `UIContext` 的一次引导,再按[华为官方应用设置跳转示例](https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-access-control-16)打开 `application_info_entry`,`pushParams` 取运行中应用的实际包名,兼容默认验证包和已签名包。跳转失败时显示手动查找本应用及开关的说明,不退回错误路径的系统提示。仅打开设置不视为获得权限,仍读取系统授权状态。
|
||||||
|
|
||||||
|
启动前和设置变化后,ArkTS 都读取系统实际权限并同步给 C++。输入允许条件为“采集有效,且持有 `CONTROL_DEVICE` 或已获得临时输入授权”。不能仅查询 `OH_Input_QueryAuthorizedStatus`,因为 API 26 明确说明该接口只反映弹窗授权,不反映 `CONTROL_DEVICE`。参见 [Input Kit 接口说明](https://github.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-input-kit/capi-oh-input-manager-h.md#oh_input_requestinjection)。
|
||||||
|
|
||||||
|
持有 `CUSTOM_SCREEN_RECORDING` 时,指定屏幕采集设置不弹出内容选择器,并由系统检查权限是否允许免确认启动;未持有权限时保留原有授权流程。此行为依据本机 SDK 和 [OpenHarmony 录屏服务的 `IsSkipPrivacyWindow`](https://github.com/openharmony/multimedia_player_framework/blob/master/services/services/screen_capture/server/screen_capture_server.cpp),需要目标 HarmonyOS 固件验证。无论哪条路径,都等待 `STARTED` 后才发送画面。断开仍停止录屏、释放键鼠和撤销临时输入授权,不通过持续录屏维持权限。
|
||||||
|
|
||||||
|
主机回归覆盖持久权限下临时授权状态仍为 `UNAUTHORIZED` 的输入、断开重连、权限撤销、私密场景、设置串行化、取消后不重复引导、退出后不再打开待处理设置,以及录屏策略失败回退。真机验收需使用带相应 ACL 的签名包,依次验证首次授权、两次重连、退出重开、分别撤销两项权限及恢复授权;锁屏/唤醒和重启后场景单独记录,不能用模拟 SDK 测试代替。
|
||||||
|
|
||||||
|
2026-10-15 验证:`python3 apps/harmony/tests/run.py --desktop` 通过;普通构建与 `--host-permissions` 构建均通过 ArkTS、原生链接和 HAP 检查。构建脚本检查打包后的权限声明与所选开关一致。
|
||||||
|
|
||||||
|
2026-10-15 签名验证:使用本机 DevEco 生成的 `com.chunkdash.crossdesk` 调试 Profile,确认其 ACL 包含上述两项权限、已登记当前模拟器。签名 HAP 通过官方 `hap-sign-tool verify-app`(含代码和权限签名校验),在 API 26 的 2in1 模拟器安装并启动成功。系统读取到两项权限,但首次设置授权尚未开启,未据此宣称免确认重连已通过。交付包保存在 `.native/artifacts/acl-host/crossdesk-acl-signed.hap`。同时验证普通构建不会继承源工程签名或可选 ACL 声明。
|
||||||
|
|
||||||
|
2026-10-15 引导修正:回归覆盖两个开关合并、仅缺一项、已授权免提示、取消不跳转、不重复引导、实际包名路由、退出后的过期回调、跳转失败及设置打开不等于授权。完整 `--desktop` 主机回归和带 ACL 的签名构建通过;实际开关授权和远程重连仍需设备端验收。
|
||||||
|
|
||||||
|
## 无 ACL 的窗口与输入行为(2026-10-15)
|
||||||
|
|
||||||
|
继续使用同一套公共 Slint 界面、桌面墙、会话及编辑逻辑;本轮修复集中在鸿蒙窗口和输入事件适配层,不增加平台专用的产品交互。
|
||||||
|
|
||||||
|
- 原生标题栏的最大化/还原状态作为一次完整确认传回 Slint。Slint 的状态设置器会同步回调平台;`WindowMode` 阻止确认过程中产生的中间状态再次控制系统窗口,修复点击还原后立即重新最大化的循环。属性事件只携带实际变化的模式,状态版本过滤延迟命令,包括等待标题/尺寸服务期间发生的原生状态变化。
|
||||||
|
- 最小化保留原有全屏/最大化模式。主窗口恢复使用 `restore()`,子窗口使用 `showWindow()`;重复显示不重设主窗口的还原尺寸。由全屏返回普通最大化明确使用 `EXIT_IMMERSIVE`。窗口 API 行为参见[官方窗口参考](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-window)。
|
||||||
|
- 子窗口隐藏/重建时注销监听,按原生窗口实例过滤旧的关闭、失焦、尺寸和状态回调。XComponent 事件携带表面标识,旧表面的延迟销毁和输入不能作用于新表面。创建失败清理半成品,窗口命令队列完成后释放;标题/层级服务失败不再阻断尺寸和模式处理。
|
||||||
|
- 输入法增加独立会话编号。相同窗口内切换输入框时,旧回调、旧更新和正在等待的 attach 不能写入新编辑器;原生分发再次检查会话编号。窗口销毁会立即使输入法回调失效,异步完成后释放连接。
|
||||||
|
- 硬件事件传递系统 CapsLock、NumLock 快照和 SDK 提供的可打印 ASCII Unicode 值;小键盘在 NumLock 关闭时映射导航键,右 Alt 单独映射为 Slint AltGr。重复键和释放继续使用首次按下的符号。此项没有声称覆盖 SDK 未提供的非 ASCII 布局或全部输入法行为。
|
||||||
|
|
||||||
|
验证:新增生产 ArkTS 窗口适配回归,覆盖恢复/全屏、过期命令、重建、晚到回调、可选服务失败和创建失败清理;C++ 回归覆盖 Slint 同步状态回写,输入法回归覆盖同窗口换字段和 attach 期间销毁;扩展锁键、布局符号、右 Alt 和小键盘测试。`python3 apps/harmony/tests/run.py --desktop` 及普通无 ACL HAP 构建通过。2in1 模拟器实测主窗口最大化后还原到原尺寸、最小化后重新激活、桌面墙进入/退出全屏及关闭后重建;重建后的表面继续绘制,没有再出现状态往返。最终安装包验证普通输入及 Ctrl+A/C、End、Ctrl+V,将 `246` 复制为 `246 246`,随后清空测试输入并恢复原窗口位置。
|
||||||
|
|
||||||
|
## 网络状态面板展开回归(2026-10-15)
|
||||||
|
|
||||||
|
已复现点击网络状态图标后,鼠标留在按钮上时面板不展开、移开后才部分展开的问题。公共 `GuiApplication` 每 16 ms 对统计模型调用 `set_vector`,即使行数不变也会重置行数依赖,反复重启控制栏的高度动画。改为只在行数变化时重置模型,其余时间仅更新变化的数据行,保留各 PC 共用的 Slint 布局和 60 ms 动画。
|
||||||
|
|
||||||
|
新增 `slint_stream_render_test` 使用真实 Slint 定时器与保留缓冲区的软件渲染,持续刷新统计值,在左右停靠布局下连续点击展开/收起且不移动鼠标,同时检查完整面板像素和输入范围。修复前可复现失败,修复后与 `slint_ui_smoke_test` 均通过。普通无 ACL HAP 已构建、安装,在本机 2in1 模拟器连接合成视频源后确认点击展开和收起均不再等待鼠标移开。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xmake build slint_stream_render_test
|
||||||
|
xmake run slint_stream_render_test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 其他按钮与弹窗回归(2026-10-15)
|
||||||
|
|
||||||
|
继续排查公共桌面的模型更新和弹窗输入路径,修复以下问题,所有 PC 仍复用同一套 Slint 组件和回调:
|
||||||
|
|
||||||
|
- 显示器和文件传输模型也改为行数变化时才整体重置,其余时间只更新变化的行,避免每帧刷新重建菜单及列表。
|
||||||
|
- 文件传输浮层的标题、边缘和空白区域原先会把点击、滚轮传到远端。浮层现在完整占用本地输入区域,拖出边界以及关闭瞬间都不会向远端漏发鼠标释放事件。
|
||||||
|
- 编码下拉框打开后,连接状态切换为会话中会禁用设置,但原菜单仍可修改编码。公共 `CompactComboBox` 现在在禁用时关闭菜单、取消展开定时器,选项回调也检查当前有效性。
|
||||||
|
|
||||||
|
新增 `slint_popup_render_test`,直接运行共享 Slint 窗口,使用保留像素缓冲区、真实鼠标/键盘事件和 16 ms 模型更新定时器。覆盖显示器、快捷键、视频设置、文件传输、分组筛选、设置下拉框和标签、消息、关于、桌面墙网络状态及添加弹窗、被控端控制者选择及断开确认。检查鼠标留在触发位置时的绘制、按住期间的数据刷新、Esc/取消关闭,以及本地输入不泄漏到远端。文件浮层与禁用下拉框的用例均在修复前失败、修复后通过。
|
||||||
|
|
||||||
|
验证:`slint_popup_render_test`、`slint_stream_render_test`、`slint_ui_smoke_test` 在本机 macOS 通过;普通无 ACL HAP 构建、安装成功。2in1 模拟器实测主菜单、设置下拉框、设置标签、桌面墙网络状态、添加菜单和添加弹窗,点击后鼠标保持原位也可立即显示,取消/关闭后正常恢复。模拟器本轮未连接真实设备;会话内持续刷新及被控面板状态转换由共享 Slint 回归覆盖,不代表所有平台的全部端到端验收已完成。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xmake build slint_popup_render_test
|
||||||
|
xmake run slint_popup_render_test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 被控端鼠标同步(2026-10-15)
|
||||||
|
|
||||||
|
鸿蒙被控端原先忽略公共 `Start(show_cursor)` 参数,始终请求将鼠标录入视频,并仅发送 `embedded` 状态。现在原生 PC 会话关闭录屏中的鼠标,通过 `OH_Input_GetCursorInfo` 同步箭头、文本、链接、拖拽、忙碌和窗口缩放等语义形状及可见状态,复用 macOS/Windows 控制端已有的 `cursor_state` 协议和显示逻辑。需要视频内鼠标的会话继续使用 `show_cursor=true`。录屏只有确认移除鼠标后才报告独立绘制;移除失败会尝试视频内鼠标,均失败则报告采样不可用。切屏、暂停恢复及私密场景恢复保留该策略。
|
||||||
|
|
||||||
|
位置查询优先使用 `OH_Input_GetPointerLocation`;后台查询不可用时,使用录屏期间 `OH_Input_AddMouseEventMonitor` 收到的真实鼠标事件。两种来源均将屏内像素坐标和物理显示器 ID 转换为已协商的显示器编号和归一化位置,兼容负坐标排列及重叠屏幕。监听仅在有效的独立鼠标录屏会话期间注册,暂停、私密场景、切屏和停止时清空位置并移除监听;移除失败的回调保持停用。没有有效查询或监听事件时,不伪造位置,仍保留已获取的形状/可见状态。
|
||||||
|
|
||||||
|
后台位置同步使用 **`ohos.permission.INPUT_MONITORING`**,其授权方式是 `system_basic / system_grant`,需要签名 Profile 中对应 ACL。该权限出现在[华为自动签名支持的 ACL 清单](https://developer.huawei.com/consumer/cn/doc/doccenter-deveco-studio/ide-signing-auto)中;仍需满足应用场景及审批要求。获得该 ACL 后,构建时增加 `--cursor-position-permission` 生成并校验声明。它独立于录屏和输入注入授权,普通构建及已有两项被控权限的构建不会自动加入该 ACL。接口依据:[OpenHarmony Input Kit 的鼠标监听接口](https://github.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-input-kit/capi-oh-input-manager-h.md#oh_input_addmouseeventmonitor)。
|
||||||
|
|
||||||
|
更正:本机 API 26 SDK 确实定义 `INPUT_DEVICE_CONFIGURATOR`,并注明它可允许后台直接查询位置,但 SDK 定义和证书授权标记不能证明该权限已在 HarmonyOS 开发者 ACL 申请页面开放。此前据此建议直接申请不充分;当前构建不再声明或依赖它。`INPUT_MONITORING` 用于事件监听,不能给 `OH_Input_GetPointerLocation` 直接查询授权。未获得监听权限时,后台位置仍不能保证反馈;鼠标形状和可见状态同步不依赖该权限。
|
||||||
|
|
||||||
|
回归测试调用生产采集适配和鼠标采样代码,覆盖形状映射、隐藏/无设备、后台查询拒绝后的监听位置、监听权限拒绝、暂停/切屏清理、迟到回调、移除失败与重试、系统已移除监听、采样失败、屏幕 ID 映射、无效坐标、协议往返、公共显示策略及录屏模式回退。`python3 apps/harmony/tests/run.py --desktop` 通过,带监听权限声明的 API 26 AArch64 桌面 HAP 构建通过,并检查打包后只声明 `INPUT_MONITORING`、未声明 `INPUT_DEVICE_CONFIGURATOR`。尚需获批签名权限的鸿蒙 PC 真机与 macOS/Windows 控制端进行端到端验收,模拟 SDK 测试不替代实际鼠标服务和签名权限验证。
|
||||||
|
|
||||||
|
## 开机自启动设置(2026-10-15)
|
||||||
|
|
||||||
|
共享设置页的「启动与后台」保留原有自启动开关样式。鸿蒙上开关按系统查询结果显示,点击开关弹出设置引导,不直接翻转状态;引导中显示详细状态,并提供「刷新状态」。打开设置、应用回到前台或原生窗口重新激活时会重新查询;查询失败、设备不支持和未开启在引导中分别显示。查询超过 5 秒可重试,旧查询、超时后的迟到结果及退出后的回调不会覆盖新状态。其他 PC 平台继续使用原有自启动开关行为。
|
||||||
|
|
||||||
|
鸿蒙 PC 用户需要在 **系统设置 → 应用和元服务 → 应用启动管理 → 添加应用** 中选择 **CrossDesk**,点击「完成」;关闭时在该列表移除。应用内提供完整操作说明,不使用未经验证的设置深链接,也不会把保存本地配置当作系统已经开启。步骤依据[华为电脑自启动说明](https://consumer.huawei.com/cn/support/content/zh-cn16048401/);本机 API 26 SDK 的公开接口仅提供 `isAutoStartupSupported()` 和 `getAutoStartupStatusForSelf()`,不需要增加自启动 ACL。系统接口 `setApplicationAutoStartup()` 不适用于普通应用。
|
||||||
|
|
||||||
|
程序启动后继续由公共 `GuiApplication::InitializeModules()` 和 `GuiRuntime::CreateConnectionPeer()` 恢复设备身份并连接服务器,托盘也复用原有启动流程。自启动设置只决定系统是否启动应用,不等于获得录屏、输入注入权限或登录前控制能力。
|
||||||
|
|
||||||
|
验证:`python3 apps/harmony/tests/run.py --desktop` 通过,新增生产 ArkTS 回归覆盖状态变化、查询失败、超时、过期结果、进程退出,以及启动/前台/窗口激活和原生刷新命令。`slint_popup_render_test` 和 `slint_ui_smoke_test` 在 macOS 编译运行通过,覆盖开关开启/关闭时点击弹出引导且不改变状态、刷新按钮、异步状态变化、最小窗口布局、Esc/确认关闭及其他平台开关仍可直接切换。普通无 ACL HAP 构建及包检查通过,已安装到 API 26 的 2in1 模拟器,启动后自动连接服务器,自启动开关显示关闭,点击后弹出引导并显示「未开启」,手动刷新及从系统设置返回均正常。未修改模拟器的系统自启动名单;开启/关闭的状态转换由模拟 SDK 回归覆盖。真机系统自启动、重启/切换用户后自动上线仍需设备验收。
|
||||||
|
|
||||||
|
## 未完成的验收项
|
||||||
|
|
||||||
|
以下项目不能因为公共逻辑已编译就标记为支持完整:
|
||||||
|
|
||||||
|
- 真机被控端:录屏和输入注入的授权、拒绝、撤销、私密场景恢复;系统声音采集;已实现启动时连接的多显示器枚举、切换和输入映射,但模拟器只有一个屏幕。输入注入仅在采集有效且系统授权时执行。
|
||||||
|
- 真机主控音频听感、长时间连接、多会话及桌面墙批量操作;目前远程画面通过合成测试源验证。
|
||||||
|
- 文件传输中途取消、大文件与磁盘满、用户撤销目录授权、后台多任务传输;基础发送、接收及重启授权已经通过。
|
||||||
|
- 系统自启动后的真机重启/登录验收、系统服务和隐私遮罩;自启动状态、设置引导及返回刷新已接入,开启/关闭由用户在系统设置管理。无人值守的持久录屏/输入授权路径仍需带 ACL 的真机验收,详见上文。原生托盘已接入,仅在系统报告支持时启用。
|
||||||
|
- 多屏被控:MiniRTC 在初始化时协商 DisplayN;已协商屏幕拔出后保留编号、恢复到仍可用的屏幕,重新插入可恢复。运行中新增屏幕需要重新初始化连接端(当前重启应用),不能直接向已有会话发送未协商的流。切屏通过重新启动系统采集实现,系统可能再次要求授权,尚不能视为与其他 PC 无感切换完全一致。
|
||||||
|
- 全局键盘钩子的授权实机验收、系统保留键在不同 PC 固件上的路由、Meta+L 实际锁屏效果及跨应用后台剪贴板同步。无 ACL 开发包只能验证拒绝/回退路径;模拟 SDK 的成功测试不能替代系统授权后的端到端验收。
|
||||||
|
- 窗口和输入:外接多屏、混合缩放、窗口位置/尺寸的长周期恢复、所有输入法组合范围、非 US 键盘布局、系统保留快捷键及跨应用置顶浮层。模拟器不具备的鼠标服务会报告能力不足。
|
||||||
|
- GPU 视频直接导入、性能与耗电。当前视频走公共 Slint CPU 图像后备路径,再用 GLES3 呈现;手机和平板使用独立移动端。
|
||||||
|
|
||||||
|
## 代码定位
|
||||||
|
|
||||||
|
- `CMakeLists.txt`:公共桌面源码和原生依赖集合,只构建 `slint_desktop` 桥接。
|
||||||
|
- `font-discovery.patch`:关闭 OHOS 运行库不适用的 Fontconfig 自动发现;字体仍由后端显式加载。
|
||||||
|
- `../native/audio.*`:PC 与移动端共享的 OHAudio 播放;PC 不编译移动视频渲染器。
|
||||||
|
- `DisplayHost.ets` / `displays.h`:系统显示器与工作区、全局/屏内坐标转换、窗口所属屏幕及热插拔事件。
|
||||||
|
- `window-properties.patch`:把原 Slint 窗口的无边框和置顶属性交给鸿蒙窗口层;补丁只影响 OHOS。
|
||||||
|
- `pointer.h`:逐窗口跟踪鼠标按下状态,失焦/取消时完整释放。
|
||||||
|
- `desktop.cpp` / `surface.inc`:Slint GUI 线程、窗口适配、EGL/GLES3 呈现。
|
||||||
|
- `DesktopHost.ets` / `Index.ets`:鸿蒙原生窗口、表面及事件分发。
|
||||||
|
- `TextInputHost.ets` / `os-services.patch`:Slint 1.17.1 C++ 后端缺少的输入法/鼠标形状桥接,补丁只改变 OHOS 目标。
|
||||||
|
- `keyboard_hook.cpp` / `DesktopAccess.ets`:系统键盘钩子、权限变化及公共捕获回退。
|
||||||
|
- `KeyboardHost.ets`:输入法之前的硬件快捷键路由及配对释放,调用原 Slint 按键处理。
|
||||||
|
- `services.cpp` / `devices.cpp`:文件、剪贴板、音频、采集和输入等平台服务。
|
||||||
|
- `../native/capture.cpp`:原生采集生命周期、异步授权状态、音视频缓冲处理。
|
||||||
|
- `../native/capture_surface.cpp`:系统录屏 Surface 的 NativeImage/GLES 读取、方向变换及线程资源管理。
|
||||||
|
- `../native/input.cpp` / `key_mapping.h`:被控键鼠注入、扩展扫描码映射、按键快照对账及授权/断开后的释放。
|
||||||
|
- `FileHost.ets`:系统文件选择器、URI 读取快照、目录持久授权及进程启动时激活。未选公共目录时接收文件保存在应用 `files/desktop/Received Files`;可在共享设置中选择公开目录。
|
||||||
|
- `TrayHost.ets` / `tray.cpp`:PC 系统托盘事件接回公共桌面的显示、设置和退出回调。
|
||||||
|
- `AutostartHost.ets` / `platform/harmony/harmony_autostart.h`(桌面源码目录):异步查询系统自启动状态,提供共享设置页的只读状态及刷新桥接。
|
||||||
|
- `platform/harmony/gui/window_state.h`(桌面源码目录):原生窗口激活状态用于公共音频、剪贴板和输入释放逻辑。
|
||||||
|
- `keyboard.h`:鸿蒙按键到 Slint 按键的状态化映射。
|
||||||
|
|
||||||
|
日志:`hdc shell hilog -T CrossDeskDesktop`(共享桌面应用)和 `hdc shell hilog -T SlintDesktop`(表面/采集)。登录凭据不应复制到问题报告;公共桌面版本的本机密码默认可见,录屏/截图前请隐藏。
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { inputMethod } from '@kit.IMEKit';
|
||||||
|
import { window } from '@kit.ArkUI';
|
||||||
|
import { DisplayHost } from './DisplayHost';
|
||||||
|
import type { DesktopEvent } from './DesktopHost';
|
||||||
|
|
||||||
|
// The OS owns composition/candidates; Slint owns the text, selection and edits.
|
||||||
|
export class TextInputHost {
|
||||||
|
private static editor: inputMethod.InputMethodController = inputMethod.getController();
|
||||||
|
private static id: number = 0;
|
||||||
|
private static preview: string = '';
|
||||||
|
private static queue: Promise<void> = Promise.resolve();
|
||||||
|
private static revision: number = 0;
|
||||||
|
private static requestedId: number = 0;
|
||||||
|
private static requestedSession: number = 0;
|
||||||
|
|
||||||
|
static request(event: DesktopEvent, target: window.Window, send: (event: DesktopEvent) => void): void {
|
||||||
|
const id = event.id ?? 0;
|
||||||
|
if (event.kind === 1) {
|
||||||
|
TextInputHost.requestedId = id;
|
||||||
|
TextInputHost.requestedSession = event.imeSession ?? 0;
|
||||||
|
++TextInputHost.revision;
|
||||||
|
} else if (event.kind === 0) {
|
||||||
|
if (TextInputHost.requestedId !== id) return;
|
||||||
|
TextInputHost.requestedId = 0;
|
||||||
|
++TextInputHost.revision;
|
||||||
|
} else if (TextInputHost.requestedId !== id || TextInputHost.requestedSession !== (event.imeSession ?? 0)) return;
|
||||||
|
const revision = TextInputHost.revision;
|
||||||
|
TextInputHost.queue = TextInputHost.queue.then(() => TextInputHost.apply(event, target, send, revision))
|
||||||
|
.catch((error: Error) => console.error(`Slint input method: ${error.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
static release(id: number): void {
|
||||||
|
if (TextInputHost.requestedId === id) {
|
||||||
|
TextInputHost.requestedId = 0;
|
||||||
|
++TextInputHost.revision;
|
||||||
|
}
|
||||||
|
TextInputHost.queue = TextInputHost.queue.then(async () => {
|
||||||
|
if (TextInputHost.id !== id || TextInputHost.requestedId === id) return;
|
||||||
|
TextInputHost.id = 0; TextInputHost.preview = '';
|
||||||
|
await TextInputHost.editor.detach();
|
||||||
|
}).catch((error: Error) => console.warn(`Slint input cleanup: ${error.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async apply(event: DesktopEvent, target: window.Window, send: (event: DesktopEvent) => void,
|
||||||
|
revision: number): Promise<void> {
|
||||||
|
if (revision !== TextInputHost.revision) return;
|
||||||
|
const id = event.id ?? 0;
|
||||||
|
const editor = TextInputHost.editor;
|
||||||
|
if (event.kind === 0) {
|
||||||
|
if (TextInputHost.id === id) { TextInputHost.id = 0; TextInputHost.preview = ''; await editor.detach(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = target.getWindowProperties().windowRect;
|
||||||
|
const scale = DisplayHost.screen(target).densityPixels;
|
||||||
|
const cursor: inputMethod.CursorInfo = { left: rect.left + (event.x ?? 0) * scale,
|
||||||
|
top: rect.top + ((event.y ?? 0) + (target.getWindowDecorVisible() ? target.getWindowDecorHeight() : 0)) * scale,
|
||||||
|
width: Math.max(1, (event.width ?? 1) * scale), height: Math.max(1, (event.height ?? 1) * scale) };
|
||||||
|
const selection: inputMethod.Range = { start: event.anchor ?? 0, end: event.cursor ?? 0 };
|
||||||
|
const inputType = event.inputType === 1 ? inputMethod.TextInputType.VISIBLE_PASSWORD :
|
||||||
|
event.inputType === 2 ? inputMethod.TextInputType.NUMBER :
|
||||||
|
event.inputType === 3 ? inputMethod.TextInputType.NUMBER_DECIMAL : inputMethod.TextInputType.TEXT;
|
||||||
|
if (TextInputHost.id !== id || event.kind === 1) {
|
||||||
|
if (TextInputHost.id !== 0) { TextInputHost.id = 0; await editor.detach(); }
|
||||||
|
if (revision !== TextInputHost.revision) return;
|
||||||
|
// Bind the input session to the UI context of its owning window.
|
||||||
|
await editor.attachWithUIContext(target.getUIContext(), { inputAttribute: { textInputType: inputType,
|
||||||
|
enterKeyType: inputMethod.EnterKeyType.DONE, consumeKeyEvents: true }, cursorInfo: cursor, selection,
|
||||||
|
windowId: target.getWindowProperties().id }, { showKeyboard: false });
|
||||||
|
if (revision !== TextInputHost.revision) { await editor.detach(); return; }
|
||||||
|
TextInputHost.id = id; TextInputHost.preview = '';
|
||||||
|
const current = (): boolean => TextInputHost.id === id && revision === TextInputHost.revision;
|
||||||
|
const deliver = (command: DesktopEvent): void => {
|
||||||
|
if (!current()) return;
|
||||||
|
command.imeSession = event.imeSession ?? 0;
|
||||||
|
send(command);
|
||||||
|
};
|
||||||
|
const key = (code: number): void => {
|
||||||
|
deliver({ op: 'key', id, code, down: true }); deliver({ op: 'key', id, code, down: false });
|
||||||
|
};
|
||||||
|
// off() is required when the same process attaches a new text field.
|
||||||
|
editor.off('insertText'); editor.off('deleteLeft'); editor.off('deleteRight');
|
||||||
|
editor.off('sendFunctionKey'); editor.off('setPreviewText'); editor.off('finishTextPreview');
|
||||||
|
editor.off('handleExtendAction'); editor.off('moveCursor');
|
||||||
|
editor.off('selectByRange'); editor.off('selectByMovement');
|
||||||
|
editor.on('insertText', (text: string) => {
|
||||||
|
if (!current()) return;
|
||||||
|
TextInputHost.preview = ''; deliver({ op: 'composition', id, text, commit: true });
|
||||||
|
});
|
||||||
|
editor.on('setPreviewText', (text: string, _range: inputMethod.Range) => {
|
||||||
|
if (!current()) return;
|
||||||
|
TextInputHost.preview = text; deliver({ op: 'composition', id, text, commit: false });
|
||||||
|
});
|
||||||
|
editor.on('finishTextPreview', () => {
|
||||||
|
if (!current()) return;
|
||||||
|
if (TextInputHost.preview.length > 0) deliver({ op: 'composition', id, text: TextInputHost.preview, commit: true });
|
||||||
|
TextInputHost.preview = '';
|
||||||
|
});
|
||||||
|
editor.on('deleteLeft', (length: number) => { for (let i = 0; i < Math.min(length, 65536); ++i) key(2055); });
|
||||||
|
editor.on('deleteRight', (length: number) => { for (let i = 0; i < Math.min(length, 65536); ++i) key(2071); });
|
||||||
|
editor.on('sendFunctionKey', () => key(2054));
|
||||||
|
// The input method consumes these hardware shortcuts while attached.
|
||||||
|
// Deliver editing commands to the focused Slint TextInput, without
|
||||||
|
// synthesizing modifier presses that could disturb held physical keys.
|
||||||
|
editor.on('handleExtendAction', (action: inputMethod.ExtendAction) => {
|
||||||
|
let code = 0;
|
||||||
|
if (action === inputMethod.ExtendAction.SELECT_ALL) code = 2017;
|
||||||
|
else if (action === inputMethod.ExtendAction.CUT) code = 2040;
|
||||||
|
else if (action === inputMethod.ExtendAction.COPY) code = 2019;
|
||||||
|
else if (action === inputMethod.ExtendAction.PASTE) code = 2038;
|
||||||
|
if (code !== 0) deliver({ op: 'imeKey', id, code, control: true });
|
||||||
|
});
|
||||||
|
const move = (direction: inputMethod.Direction, shift: boolean): void => {
|
||||||
|
const code = direction === inputMethod.Direction.CURSOR_UP ? 2012 :
|
||||||
|
direction === inputMethod.Direction.CURSOR_DOWN ? 2013 :
|
||||||
|
direction === inputMethod.Direction.CURSOR_LEFT ? 2014 :
|
||||||
|
direction === inputMethod.Direction.CURSOR_RIGHT ? 2015 : 0;
|
||||||
|
if (code !== 0) deliver({ op: 'imeKey', id, code, shift });
|
||||||
|
};
|
||||||
|
editor.on('moveCursor', (direction: inputMethod.Direction) => move(direction, false));
|
||||||
|
editor.on('selectByMovement', (movement: inputMethod.Movement) => move(movement.direction, true));
|
||||||
|
editor.on('selectByRange', (range: inputMethod.Range) => {
|
||||||
|
deliver({ op: 'imeSelection', id, anchor: range.start, cursor: range.end });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await editor.changeSelection(event.text ?? '', selection.start, selection.end);
|
||||||
|
if (revision !== TextInputHost.revision) return;
|
||||||
|
await editor.updateCursor(cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { statusBarManager } from '@kit.DeskTopExtensionKit';
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { image } from '@kit.ImageKit';
|
||||||
|
import { emitter } from '@kit.BasicServicesKit';
|
||||||
|
import type { DesktopEvent } from './DesktopHost';
|
||||||
|
|
||||||
|
export class TrayHost {
|
||||||
|
private static active: boolean = false;
|
||||||
|
private static icon: image.PixelMap | undefined;
|
||||||
|
static async create(context: common.UIAbilityContext, event: DesktopEvent,
|
||||||
|
send: (event: DesktopEvent) => void): Promise<boolean> {
|
||||||
|
if (TrayHost.active) return true;
|
||||||
|
if (!await statusBarManager.isStatusBarCapabilitySupported(context)) return false;
|
||||||
|
const data = await context.resourceManager.getMediaContent($r('app.media.app_icon').id);
|
||||||
|
const source = image.createImageSource(data.buffer as ArrayBuffer);
|
||||||
|
try { TrayHost.icon = await source.createPixelMap({ desiredSize: { width: 48, height: 48 } }); }
|
||||||
|
finally { source.release(); }
|
||||||
|
const action = (code: string): void => send({ op: 'tray', text: code });
|
||||||
|
statusBarManager.on('statusBarIconClick', () => {
|
||||||
|
action('show');
|
||||||
|
// A minimized main window cannot foreground its UIAbility with
|
||||||
|
// showWindow alone. This is an explicit click on our system tray icon.
|
||||||
|
context.startAbility({ bundleName: context.abilityInfo.bundleName,
|
||||||
|
abilityName: context.abilityInfo.name, moduleName: context.abilityInfo.moduleName })
|
||||||
|
.catch((error: Error) => console.error(`Slint tray restore: ${error.message}`));
|
||||||
|
});
|
||||||
|
statusBarManager.on('rightMenuClick', (event: emitter.EventData) => {
|
||||||
|
const code: string | undefined = (event.data as Record<string, string> | undefined)?.['menuCode'];
|
||||||
|
if (typeof code === 'string') action(code);
|
||||||
|
});
|
||||||
|
const item: statusBarManager.StatusBarItem = {
|
||||||
|
icons: { white: TrayHost.icon, black: TrayHost.icon },
|
||||||
|
hoverTips: event.title ?? 'CrossDesk',
|
||||||
|
quickOperation: { abilityName: '', moduleName: 'entry', title: 'CrossDesk', height: 30 },
|
||||||
|
statusBarGroupMenu: [[
|
||||||
|
{ title: event.show ?? '', menuCode: 'show', menuAction: { abilityName: 'EntryAbility', moduleName: 'entry', notifyOnly: true } },
|
||||||
|
{ title: event.settings ?? '', menuCode: 'settings', menuAction: { abilityName: 'EntryAbility', moduleName: 'entry', notifyOnly: true } }
|
||||||
|
]]
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => statusBarManager.addToStatusBar(context, item,
|
||||||
|
(error) => { if (error) reject(error); else resolve(); }));
|
||||||
|
TrayHost.active = true;
|
||||||
|
return true;
|
||||||
|
} catch (error) { TrayHost.release(); throw new Error(error.message); }
|
||||||
|
}
|
||||||
|
static async remove(context: common.UIAbilityContext): Promise<void> {
|
||||||
|
if (!TrayHost.active) return;
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => statusBarManager.removeFromStatusBar(context,
|
||||||
|
(error) => { if (error) reject(error); else resolve(); }));
|
||||||
|
} finally { TrayHost.active = false; TrayHost.release(); }
|
||||||
|
}
|
||||||
|
private static release(): void {
|
||||||
|
statusBarManager.off('statusBarIconClick'); statusBarManager.off('rightMenuClick');
|
||||||
|
TrayHost.icon?.release(); TrayHost.icon = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _BRIDGE_H_
|
||||||
|
#define _BRIDGE_H_
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
void Emit(Json command);
|
||||||
|
// Calls a platform service on the ArkTS thread. The Slint thread can wait while
|
||||||
|
// ArkUI keeps pumping window and picker events; shutdown cancels pending calls.
|
||||||
|
Json Request(Json command);
|
||||||
|
Json Environment();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-15
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _CURSOR_SHAPE_H_
|
||||||
|
#define _CURSOR_SHAPE_H_
|
||||||
|
|
||||||
|
#include <multimodalinput/oh_pointer_style.h>
|
||||||
|
#include <remote_cursor_shape.h>
|
||||||
|
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
inline RemoteCursorShape ShapeFromPointerStyle(Input_PointerStyle style) {
|
||||||
|
using Shape = RemoteCursorShape;
|
||||||
|
switch (style) {
|
||||||
|
case EAST: case MIDDLE_BTN_EAST: return Shape::e_resize;
|
||||||
|
case WEST: case MIDDLE_BTN_WEST: return Shape::w_resize;
|
||||||
|
case SOUTH: case MIDDLE_BTN_SOUTH: return Shape::s_resize;
|
||||||
|
case NORTH: case MIDDLE_BTN_NORTH: return Shape::n_resize;
|
||||||
|
case WEST_EAST: case MIDDLE_BTN_EAST_WEST: return Shape::ew_resize;
|
||||||
|
case NORTH_SOUTH: case MIDDLE_BTN_NORTH_SOUTH: return Shape::ns_resize;
|
||||||
|
case NORTH_EAST: case MIDDLE_BTN_NORTH_EAST: return Shape::ne_resize;
|
||||||
|
case NORTH_WEST: case MIDDLE_BTN_NORTH_WEST: return Shape::nw_resize;
|
||||||
|
case SOUTH_EAST: case MIDDLE_BTN_SOUTH_EAST: return Shape::se_resize;
|
||||||
|
case SOUTH_WEST: case MIDDLE_BTN_SOUTH_WEST: return Shape::sw_resize;
|
||||||
|
case NORTH_EAST_SOUTH_WEST: return Shape::nesw_resize;
|
||||||
|
case NORTH_WEST_SOUTH_EAST: return Shape::nwse_resize;
|
||||||
|
case CROSS: case CURSOR_CROSS: case COLOR_SUCKER:
|
||||||
|
case SCREENSHOT_CHOOSE: case SCREENSHOT_CURSOR: return Shape::crosshair;
|
||||||
|
case CURSOR_COPY: return Shape::copy;
|
||||||
|
case CURSOR_FORBID: return Shape::not_allowed;
|
||||||
|
case HAND_GRABBING: return Shape::grabbing;
|
||||||
|
case HAND_OPEN: return Shape::grab;
|
||||||
|
case HAND_POINTING: return Shape::pointer;
|
||||||
|
case HELP: return Shape::help;
|
||||||
|
case MOVE: case MIDDLE_BTN_NORTH_SOUTH_WEST_EAST: return Shape::move;
|
||||||
|
case RESIZE_LEFT_RIGHT: return Shape::col_resize;
|
||||||
|
case RESIZE_UP_DOWN: return Shape::row_resize;
|
||||||
|
case TEXT_CURSOR: case HORIZONTAL_TEXT_CURSOR: return Shape::text;
|
||||||
|
case LOADING: return Shape::wait;
|
||||||
|
case RUNNING: case RUNNING_LEFT: case RUNNING_RIGHT: return Shape::progress;
|
||||||
|
// As on the other PC platforms, unsupported/custom artwork uses an arrow.
|
||||||
|
default: return Shape::default_cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
#include "bridge.h"
|
||||||
|
#include "keyboard.h"
|
||||||
|
#include "keyboard_hook.h"
|
||||||
|
#include "host_access.h"
|
||||||
|
#include "pointer.h"
|
||||||
|
#include "window_mode.h"
|
||||||
|
#include "platform/harmony/gui/tray/harmony_tray.h"
|
||||||
|
#include "platform/harmony/gui/window_state.h"
|
||||||
|
#include "platform/harmony/harmony_autostart.h"
|
||||||
|
#include "application/gui_application.h"
|
||||||
|
#include "SDL3/SDL.h"
|
||||||
|
#include "rd_log.h"
|
||||||
|
#include "platform/path_backend.h"
|
||||||
|
#include <EGL/egl.h>
|
||||||
|
#include <GLES3/gl3.h>
|
||||||
|
#include <hilog/log.h>
|
||||||
|
#include <napi/native_api.h>
|
||||||
|
#include <native_window/external_window.h>
|
||||||
|
#include <slint-platform.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <deque>
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
namespace {
|
||||||
|
std::mutex bridge_mutex;
|
||||||
|
std::condition_variable wake;
|
||||||
|
Json environment;
|
||||||
|
std::deque<Json> incoming, outgoing;
|
||||||
|
std::map<uint64_t, Json> replies;
|
||||||
|
std::deque<slint::platform::Platform::Task> tasks;
|
||||||
|
std::atomic<bool> stopping{false};
|
||||||
|
std::thread gui_thread;
|
||||||
|
uint64_t request_id = 0;
|
||||||
|
bool services_cancelled = false;
|
||||||
|
}
|
||||||
|
Json Environment() { std::lock_guard lock(bridge_mutex); return environment; }
|
||||||
|
void Emit(Json command) { std::lock_guard lock(bridge_mutex); outgoing.push_back(std::move(command)); }
|
||||||
|
Json Request(Json command) {
|
||||||
|
std::unique_lock lock(bridge_mutex);
|
||||||
|
if (stopping || services_cancelled) return Json::object();
|
||||||
|
const auto id = ++request_id;
|
||||||
|
command["request"] = id; outgoing.push_back(std::move(command));
|
||||||
|
// Picker dismissal and shutdown both reply/cancel; never block ArkUI.
|
||||||
|
wake.wait(lock, [id] { return stopping || services_cancelled || replies.count(id); });
|
||||||
|
if (stopping || services_cancelled) return Json::object();
|
||||||
|
auto result = std::move(replies.at(id)); replies.erase(id); return result;
|
||||||
|
}
|
||||||
|
namespace {
|
||||||
|
struct Surface;
|
||||||
|
class DesktopWindow final : public slint::platform::WindowAdapter {
|
||||||
|
public:
|
||||||
|
int id;
|
||||||
|
slint::platform::SoftwareRenderer software{slint::platform::SoftwareRenderer::RepaintBufferType::ReusedBuffer};
|
||||||
|
slint::PhysicalSize extent;
|
||||||
|
slint::PhysicalPosition origin{{0, 0}};
|
||||||
|
bool dirty = true, visible = false, active = false, explicitly_sized = false;
|
||||||
|
bool positioned = false;
|
||||||
|
float density = 1;
|
||||||
|
int decor = 0;
|
||||||
|
bool no_frame = false, topmost = false;
|
||||||
|
KeyboardState keyboard;
|
||||||
|
PointerState pointer;
|
||||||
|
const void* ime_adapter = nullptr;
|
||||||
|
uint64_t ime_session = 0;
|
||||||
|
std::string surface_id;
|
||||||
|
WindowMode window_mode;
|
||||||
|
uint64_t state_revision = 0;
|
||||||
|
bool scaled = false;
|
||||||
|
std::unique_ptr<Surface> surface;
|
||||||
|
explicit DesktopWindow(int value);
|
||||||
|
~DesktopWindow() override;
|
||||||
|
slint::PhysicalSize size() override { return extent; }
|
||||||
|
slint::platform::AbstractRenderer& renderer() override { return software; }
|
||||||
|
void request_redraw() override { dirty = true; }
|
||||||
|
void set_visible(bool value) override {
|
||||||
|
if (!scaled) SetDensity(Environment().value("density", 1.f));
|
||||||
|
if (!value) ReleaseInput();
|
||||||
|
visible = value;
|
||||||
|
Json command{{"op", "visible"}, {"id", id}, {"visible", value}, {"width", extent.width}, {"height", extent.height}};
|
||||||
|
if (positioned) { command["x"] = origin.x; command["y"] = origin.y; }
|
||||||
|
Emit(std::move(command));
|
||||||
|
}
|
||||||
|
void set_size(slint::PhysicalSize value) override {
|
||||||
|
explicitly_sized = true; extent = value;
|
||||||
|
Emit({{"op", "resize"}, {"id", id}, {"width", value.width}, {"height", value.height}});
|
||||||
|
}
|
||||||
|
std::optional<slint::PhysicalPosition> position() override { return origin; }
|
||||||
|
void set_position(slint::PhysicalPosition value) override {
|
||||||
|
positioned = true; origin = value;
|
||||||
|
Emit({{"op", "move"}, {"id", id}, {"x", value.x}, {"y", value.y}});
|
||||||
|
}
|
||||||
|
void update_window_properties(const WindowProperties& properties) override {
|
||||||
|
const auto constraints = properties.layout_constraints();
|
||||||
|
if (!scaled) SetDensity(Environment().value("density", 1.f));
|
||||||
|
if (!explicitly_sized && !visible)
|
||||||
|
extent = slint::PhysicalSize({uint32_t(std::max(1.f, constraints.preferred.width * density)),
|
||||||
|
uint32_t(std::max(1.f, constraints.preferred.height * density))});
|
||||||
|
Json command{{"op", "properties"}, {"id", id}, {"title", std::string(properties.title())},
|
||||||
|
{"stateRevision", state_revision}, {"noFrame", no_frame}, {"topmost", topmost}};
|
||||||
|
const Json requested{{"fullscreen", properties.is_fullscreen()}, {"maximized", properties.is_maximized()},
|
||||||
|
{"minimized", properties.is_minimized()}};
|
||||||
|
// Layout/title updates must not reapply an old window mode while the OS
|
||||||
|
// is asynchronously reporting a titlebar maximize/restore transition.
|
||||||
|
command.update(window_mode.Request(requested));
|
||||||
|
if (constraints.min) { command["minWidth"] = constraints.min->width * density; command["minHeight"] = constraints.min->height * density; }
|
||||||
|
if (constraints.max) { command["maxWidth"] = constraints.max->width * density; command["maxHeight"] = constraints.max->height * density; }
|
||||||
|
Emit(std::move(command));
|
||||||
|
}
|
||||||
|
void SetDensity(float value);
|
||||||
|
void ReleaseInput();
|
||||||
|
};
|
||||||
|
std::map<int, DesktopWindow*> windows;
|
||||||
|
#include "surface.inc"
|
||||||
|
DesktopWindow::DesktopWindow(int value) : id(value), extent{{1216, 903}}, surface(std::make_unique<Surface>(*this)) { windows[id] = this; }
|
||||||
|
DesktopWindow::~DesktopWindow() { windows.erase(id); Emit({{"op", "destroy"}, {"id", id}}); }
|
||||||
|
void DesktopWindow::SetDensity(float value) {
|
||||||
|
if (!std::isfinite(value) || value <= 0 || (scaled && density == value)) return;
|
||||||
|
density = value; scaled = true;
|
||||||
|
surface->density = value;
|
||||||
|
surface->rendered_width = surface->rendered_height = 0;
|
||||||
|
window().dispatch_scale_factor_change_event(value);
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
slint::PointerEventButton PointerButton(int button) {
|
||||||
|
return button == 1 ? slint::PointerEventButton::Right : button == 2 ? slint::PointerEventButton::Middle : slint::PointerEventButton::Left;
|
||||||
|
}
|
||||||
|
void DesktopWindow::ReleaseInput() {
|
||||||
|
pointer.Cancel([this](int button, float x, float y) {
|
||||||
|
window().dispatch_pointer_release_event(slint::LogicalPosition({x, y}), PointerButton(button));
|
||||||
|
});
|
||||||
|
window().dispatch_pointer_exit_event();
|
||||||
|
for (const auto& [code, key] : keyboard.Pressed()) if (!key.empty()) window().dispatch_key_release_event(slint::SharedString(key));
|
||||||
|
keyboard.Reset();
|
||||||
|
std::lock_guard lock(bridge_mutex);
|
||||||
|
environment["buttons"] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OsRequest(void* data, const void* token, int kind, const uint8_t* text, size_t length,
|
||||||
|
size_t cursor, size_t anchor, float x, float y, float width, float height, int type) {
|
||||||
|
auto& adapter = *static_cast<DesktopWindow*>(data);
|
||||||
|
if (kind == 4) { adapter.no_frame = type & 1; adapter.topmost = type & 2; return; }
|
||||||
|
if (kind == 3) Emit({{"op", "cursor"}, {"id", adapter.id}, {"kind", type}});
|
||||||
|
else {
|
||||||
|
if (kind == 0 || kind == 1) ++adapter.ime_session;
|
||||||
|
adapter.ime_adapter = kind == 0 ? nullptr : token;
|
||||||
|
Emit({{"op", "ime"}, {"id", adapter.id}, {"imeSession", adapter.ime_session}, {"kind", kind}, {"text", std::string(reinterpret_cast<const char*>(text), length)},
|
||||||
|
{"cursor", cursor}, {"anchor", anchor}, {"x", x}, {"y", y}, {"width", width}, {"height", height}, {"inputType", type}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void Key(DesktopWindow& adapter, const Json& command) {
|
||||||
|
const bool down = command.value("down", false);
|
||||||
|
const int code = command.value("code", 0);
|
||||||
|
const bool repeat = down && adapter.keyboard.IsPressed(code);
|
||||||
|
const auto lock = [&command](const char* name) -> std::optional<bool> {
|
||||||
|
return command.contains(name) ? std::optional<bool>{command.at(name).get<bool>()} : std::nullopt;
|
||||||
|
};
|
||||||
|
const auto key = adapter.keyboard.Translate(code, down, command.value("unicode", 0u), lock("capsLock"), lock("numLock"));
|
||||||
|
if (key.empty()) return;
|
||||||
|
if (repeat) adapter.window().dispatch_key_press_repeat_event(slint::SharedString(key));
|
||||||
|
else if (down) adapter.window().dispatch_key_press_event(slint::SharedString(key));
|
||||||
|
else adapter.window().dispatch_key_release_event(slint::SharedString(key));
|
||||||
|
}
|
||||||
|
void Dispatch(const Json& command) {
|
||||||
|
if (command.value("op", "") == "tray") { HarmonyTray::Dispatch(command.value("text", "")); return; }
|
||||||
|
const auto found = windows.find(command.value("id", 0));
|
||||||
|
if (found == windows.end()) return;
|
||||||
|
auto& adapter = *found->second;
|
||||||
|
const auto op = command.value("op", "");
|
||||||
|
// ArkUI can finish a callback after Slint has focused another editor in the
|
||||||
|
// same window. Window identity alone is not an input-session identity.
|
||||||
|
if (command.contains("imeSession") && command.value("imeSession", uint64_t{0}) != adapter.ime_session) return;
|
||||||
|
if (op != "attach" && command.contains("surface") && command.value("surface", "") != adapter.surface_id) return;
|
||||||
|
if (op == "attach") {
|
||||||
|
adapter.surface_id = command.at("surface").get<std::string>();
|
||||||
|
if (!adapter.surface->AttachSurface(std::stoull(command.at("surface").get<std::string>()), command.at("density")))
|
||||||
|
Emit({{"op", "error"}, {"message", "Slint surface initialization failed"}});
|
||||||
|
adapter.dirty = true;
|
||||||
|
} else if (op == "detach") { adapter.surface_id.clear(); adapter.ReleaseInput(); adapter.surface->DetachSurface(); }
|
||||||
|
else if (op == "redraw") adapter.dirty = true;
|
||||||
|
else if (op == "restore") { adapter.window().set_minimized(false); adapter.window().show(); adapter.dirty = true; }
|
||||||
|
else if (op == "close") adapter.window().dispatch_close_requested_event();
|
||||||
|
else if (op == "active") {
|
||||||
|
adapter.active = command.value("active", false);
|
||||||
|
if (!adapter.active) adapter.ReleaseInput();
|
||||||
|
if (command.value("active", false) && adapter.scaled && !adapter.visible) {
|
||||||
|
adapter.window().set_minimized(false);
|
||||||
|
adapter.window().show();
|
||||||
|
}
|
||||||
|
adapter.window().dispatch_window_active_changed_event(command.value("active", false));
|
||||||
|
} else if (op == "state") {
|
||||||
|
adapter.state_revision = command.value("stateRevision", uint64_t{0});
|
||||||
|
auto actual = Json::object();
|
||||||
|
for (const auto* name : {"fullscreen", "maximized", "minimized"})
|
||||||
|
if (command.contains(name)) actual[name] = command.at(name);
|
||||||
|
// Each setter synchronously calls update_window_properties. These are OS
|
||||||
|
// acknowledgements, not three independent requests back to the compositor.
|
||||||
|
adapter.window_mode.Acknowledge(actual, [&] {
|
||||||
|
if (command.contains("fullscreen")) adapter.window().set_fullscreen(command.at("fullscreen").get<bool>());
|
||||||
|
if (command.contains("maximized")) adapter.window().set_maximized(command.at("maximized").get<bool>());
|
||||||
|
if (command.contains("minimized")) adapter.window().set_minimized(command.at("minimized").get<bool>());
|
||||||
|
});
|
||||||
|
} else if (op == "metrics") {
|
||||||
|
adapter.positioned = true;
|
||||||
|
adapter.origin = slint::PhysicalPosition({command.value("x", 0), command.value("y", 0)});
|
||||||
|
adapter.decor = command.value("decor", 0);
|
||||||
|
adapter.SetDensity(command.value("density", 1.f));
|
||||||
|
adapter.dirty = true;
|
||||||
|
} else if (op == "pointerExit") {
|
||||||
|
adapter.window().dispatch_pointer_exit_event();
|
||||||
|
} else if (op == "pointer") {
|
||||||
|
const int kind = command.value("kind", 2), button = command.value("button", 0);
|
||||||
|
const slint::LogicalPosition point{{command.value("x", 0.f), command.value("y", 0.f)}};
|
||||||
|
{
|
||||||
|
std::lock_guard lock(bridge_mutex);
|
||||||
|
const auto density = adapter.density;
|
||||||
|
environment["pointerX"] = adapter.origin.x + point.x * density;
|
||||||
|
environment["pointerY"] = adapter.origin.y + adapter.decor + point.y * density;
|
||||||
|
adapter.pointer.Update(kind, button, point.x, point.y);
|
||||||
|
environment["buttons"] = adapter.pointer.Buttons();
|
||||||
|
}
|
||||||
|
const auto b = PointerButton(button);
|
||||||
|
if (kind == 0) adapter.window().dispatch_pointer_press_event(point, b);
|
||||||
|
else if (kind == 1) adapter.window().dispatch_pointer_release_event(point, b);
|
||||||
|
else if (kind == 2) adapter.window().dispatch_pointer_move_event(point);
|
||||||
|
else adapter.ReleaseInput();
|
||||||
|
} else if (op == "scroll") adapter.window().dispatch_pointer_scroll_event(
|
||||||
|
slint::LogicalPosition({command.value("x", 0.f), command.value("y", 0.f)}), command.value("dx", 0.f), command.value("dy", 0.f));
|
||||||
|
else if (op == "key") Key(adapter, command);
|
||||||
|
else if (op == "imeKey" && adapter.ime_adapter) {
|
||||||
|
KeyboardState symbols;
|
||||||
|
const auto text = symbols.Translate(command.value("code", 0), true);
|
||||||
|
const int modifiers = (command.value("shift", false) ? 1 : 0) | (command.value("control", false) ? 2 : 0);
|
||||||
|
slint::cbindgen_private::crossdesk_slint_edit(adapter.ime_adapter,
|
||||||
|
reinterpret_cast<const uint8_t*>(text.data()), text.size(), modifiers);
|
||||||
|
} else if (op == "imeSelection" && adapter.ime_adapter) {
|
||||||
|
slint::cbindgen_private::crossdesk_slint_selection(adapter.ime_adapter,
|
||||||
|
command.value("anchor", 0), command.value("cursor", 0));
|
||||||
|
}
|
||||||
|
else if (op == "composition" && adapter.ime_adapter) {
|
||||||
|
const auto text = command.value("text", "");
|
||||||
|
slint::cbindgen_private::crossdesk_slint_composition(adapter.ime_adapter, command.value("commit", true), reinterpret_cast<const uint8_t*>(text.data()), text.size());
|
||||||
|
} else if (op == "text") {
|
||||||
|
const slint::SharedString text(command.value("text", ""));
|
||||||
|
adapter.window().dispatch_key_press_event(text); adapter.window().dispatch_key_release_event(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class DesktopPlatform final : public slint::platform::Platform {
|
||||||
|
int next_id = 0;
|
||||||
|
public:
|
||||||
|
std::unique_ptr<slint::platform::WindowAdapter> create_window_adapter() override { return std::make_unique<DesktopWindow>(++next_id); }
|
||||||
|
void run_in_event_loop(Task task) override {
|
||||||
|
std::lock_guard lock(bridge_mutex); tasks.push_back(std::move(task)); wake.notify_all();
|
||||||
|
}
|
||||||
|
void quit_event_loop() override { stopping = true; wake.notify_all(); }
|
||||||
|
void set_clipboard_text(const slint::SharedString& text, Clipboard kind) override {
|
||||||
|
if (kind == Clipboard::DefaultClipboard) SDL_SetClipboardText(text.data());
|
||||||
|
}
|
||||||
|
std::optional<slint::SharedString> clipboard_text(Clipboard kind) override {
|
||||||
|
if (kind != Clipboard::DefaultClipboard) return {};
|
||||||
|
const auto result = Request({{"op", "clipboardPaste"}});
|
||||||
|
if (!result.value("ok", false)) return {};
|
||||||
|
return slint::SharedString(result.value("text", ""));
|
||||||
|
}
|
||||||
|
void run_event_loop() override {
|
||||||
|
while (!stopping) {
|
||||||
|
std::deque<Json> commands;
|
||||||
|
std::deque<Task> pending;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(bridge_mutex); commands.swap(incoming); pending.swap(tasks);
|
||||||
|
}
|
||||||
|
for (auto& task : pending) std::move(task).run();
|
||||||
|
for (auto& command : commands) {
|
||||||
|
try { Dispatch(command); }
|
||||||
|
catch (const std::exception& error) { Emit({{"op", "error"}, {"message", error.what()}}); }
|
||||||
|
}
|
||||||
|
slint::platform::update_timers_and_animations();
|
||||||
|
for (const auto& [id, adapter] : windows) if (adapter->visible) adapter->surface->Render();
|
||||||
|
std::unique_lock lock(bridge_mutex);
|
||||||
|
wake.wait_for(lock, std::chrono::milliseconds(8), [] { return stopping || !incoming.empty() || !tasks.empty(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::string String(napi_env env, napi_value value) {
|
||||||
|
size_t length = 0;
|
||||||
|
if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok || length > 1024 * 1024) return {};
|
||||||
|
std::string result(length + 1, '\0');
|
||||||
|
napi_get_value_string_utf8(env, value, result.data(), result.size(), &length);
|
||||||
|
result.resize(length); return result;
|
||||||
|
}
|
||||||
|
napi_value Undefined(napi_env env) { napi_value out; napi_get_undefined(env, &out); return out; }
|
||||||
|
napi_value Start(napi_env env, napi_callback_info info) {
|
||||||
|
if (gui_thread.joinable()) return Undefined(env);
|
||||||
|
napi_value arg; size_t count = 1; napi_get_cb_info(env, info, &count, &arg, nullptr, nullptr);
|
||||||
|
try {
|
||||||
|
environment = Json::parse(String(env, arg));
|
||||||
|
if (environment.value("filesDir", "").empty()) throw std::runtime_error("Missing application storage directory");
|
||||||
|
} catch (const std::exception& error) { napi_throw_error(env, nullptr, error.what()); return nullptr; }
|
||||||
|
stopping = false;
|
||||||
|
gui_thread = std::thread([] {
|
||||||
|
try {
|
||||||
|
setenv("SLINT_DEFAULT_FONT", "/system/fonts/HarmonyOS_Sans.ttf", 1);
|
||||||
|
setenv("SLINT_FONT_PATH", "/system/fonts/HarmonyOS_Sans_SC.ttf", 1);
|
||||||
|
const auto certificates = Environment().value("certificates", "");
|
||||||
|
if (certificates.empty()) throw std::runtime_error("Missing system CA certificate directory");
|
||||||
|
setenv("SSL_CERT_DIR", certificates.c_str(), 1);
|
||||||
|
slint::cbindgen_private::crossdesk_slint_set_os_callback(OsRequest);
|
||||||
|
slint::platform::set_platform(std::make_unique<DesktopPlatform>());
|
||||||
|
// GuiRuntime constructs worker controllers before Run(). They may log
|
||||||
|
// immediately, so select an app-writable directory before construction.
|
||||||
|
crossdesk::InitLogger(crossdesk::platform::GetLogPath("CrossDesk").string());
|
||||||
|
crossdesk::GuiApplication app;
|
||||||
|
app.Run();
|
||||||
|
Emit({{"op", "exit"}});
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "SlintDesktop", "Application failed: %{public}s", error.what());
|
||||||
|
Emit({{"op", "error"}, {"message", error.what()}});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Undefined(env);
|
||||||
|
}
|
||||||
|
napi_value Send(napi_env env, napi_callback_info info) {
|
||||||
|
napi_value arg; size_t count = 1; napi_get_cb_info(env, info, &count, &arg, nullptr, nullptr);
|
||||||
|
try {
|
||||||
|
auto command = Json::parse(String(env, arg));
|
||||||
|
if (command.value("op", "") == "autostartState") {
|
||||||
|
SetHarmonyAutostartStatus(command.value("pending", false) ? HarmonyAutostartStatus::checking :
|
||||||
|
!command.value("ok", false) ? HarmonyAutostartStatus::failed :
|
||||||
|
!command.value("supported", false) ? HarmonyAutostartStatus::unsupported :
|
||||||
|
command.value("active", false) ? HarmonyAutostartStatus::enabled : HarmonyAutostartStatus::disabled);
|
||||||
|
return Undefined(env);
|
||||||
|
}
|
||||||
|
if (command.value("op", "") == "active")
|
||||||
|
SetKeyboardFocus(command.value("id", 0), command.value("active", false));
|
||||||
|
else if (command.value("op", "") == "keyboardPermission")
|
||||||
|
SetKeyboardHookPermission(command.value("ok", false));
|
||||||
|
else if (command.value("op", "") == "hostPermissions") {
|
||||||
|
SetHostPermissions(command.value("controlDevice", false), command.value("screenRecording", false));
|
||||||
|
return Undefined(env);
|
||||||
|
}
|
||||||
|
else if (command.value("op", "") == "clipboardChanged") {
|
||||||
|
SDL_Event event{SDL_EVENT_CLIPBOARD_UPDATE}; SDL_PushEvent(&event);
|
||||||
|
return Undefined(env);
|
||||||
|
}
|
||||||
|
std::lock_guard lock(bridge_mutex);
|
||||||
|
// A synchronous native picker can be pending on the GUI thread when the
|
||||||
|
// system tray requests exit. Unblock it so the shared exit callback runs.
|
||||||
|
if (command.value("op", "") == "tray" && command.value("text", "") == "exit")
|
||||||
|
services_cancelled = true;
|
||||||
|
if (command.value("op", "") == "displays") environment["displays"] = command.at("displays");
|
||||||
|
else if (command.value("op", "") == "reply") replies[command.at("request").get<uint64_t>()] = command;
|
||||||
|
else incoming.push_back(std::move(command));
|
||||||
|
wake.notify_all();
|
||||||
|
} catch (...) { napi_throw_error(env, nullptr, "Invalid desktop event"); return nullptr; }
|
||||||
|
return Undefined(env);
|
||||||
|
}
|
||||||
|
napi_value Poll(napi_env env, napi_callback_info) {
|
||||||
|
Json result = Json::array();
|
||||||
|
{ std::lock_guard lock(bridge_mutex); for (auto& command : outgoing) result.push_back(std::move(command)); outgoing.clear(); }
|
||||||
|
const auto text = result.dump(); napi_value out; napi_create_string_utf8(env, text.c_str(), text.size(), &out); return out;
|
||||||
|
}
|
||||||
|
napi_value Init(napi_env env, napi_value exports) {
|
||||||
|
napi_property_descriptor properties[] = {
|
||||||
|
{"start", nullptr, Start, nullptr, nullptr, nullptr, napi_default, nullptr},
|
||||||
|
{"send", nullptr, Send, nullptr, nullptr, nullptr, napi_default, nullptr},
|
||||||
|
{"poll", nullptr, Poll, nullptr, nullptr, nullptr, napi_default, nullptr}};
|
||||||
|
napi_define_properties(env, exports, 3, properties);
|
||||||
|
napi_add_env_cleanup_hook(env, [](void*) { stopping = true; wake.notify_all(); if (gui_thread.joinable()) gui_thread.join(); }, nullptr);
|
||||||
|
return exports;
|
||||||
|
}
|
||||||
|
napi_module module = {1, 0, nullptr, Init, "slint_desktop", nullptr, {0}};
|
||||||
|
}
|
||||||
|
bool WindowActive(const slint::Window& window) {
|
||||||
|
const auto& handle = window.window_handle().handle();
|
||||||
|
for (const auto& [id, adapter] : windows) {
|
||||||
|
const auto& candidate = adapter->window().window_handle().handle();
|
||||||
|
if (handle._0 == candidate._0 && handle._1 == candidate._1)
|
||||||
|
return adapter->active && adapter->visible && !window.is_minimized();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespace crossdesk {
|
||||||
|
bool IsHarmonyWindowActive(const slint::Window& window) {
|
||||||
|
return harmony::desktop::WindowActive(window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extern "C" __attribute__((constructor)) void RegisterSlintDesktop() { napi_module_register(&crossdesk::harmony::desktop::module); }
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
#include "bridge.h"
|
||||||
|
#include "displays.h"
|
||||||
|
#include "capture.h"
|
||||||
|
#include "input.h"
|
||||||
|
#include "keyboard_hook.h"
|
||||||
|
#include "host_access.h"
|
||||||
|
#include "screen_capturer_factory.h"
|
||||||
|
#include "speaker_capturer_factory.h"
|
||||||
|
#include "device_controller_factory.h"
|
||||||
|
#include "platform/video_renderer.h"
|
||||||
|
#include "runtime/cursor_state_provider.h"
|
||||||
|
#include "runtime/cursor_position.h"
|
||||||
|
#include "cursor_shape.h"
|
||||||
|
#include "platform/harmony/input/system_shortcuts.h"
|
||||||
|
#include <display_stream_id.h>
|
||||||
|
#include <multimodalinput/oh_input_manager.h>
|
||||||
|
#include <hilog/log.h>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cmath>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
namespace {
|
||||||
|
std::mutex input_mutex;
|
||||||
|
harmony::Input input;
|
||||||
|
std::atomic<bool> capture_active{false};
|
||||||
|
std::atomic<harmony::CaptureCursorMode> cursor_mode{harmony::CaptureCursorMode::unknown};
|
||||||
|
std::atomic<bool> control_device_granted{false};
|
||||||
|
std::atomic<bool> screen_recording_granted{false};
|
||||||
|
std::mutex cursor_monitor_lifecycle, cursor_monitor_mutex;
|
||||||
|
bool cursor_monitor_requested = false, cursor_monitor_installed = false;
|
||||||
|
bool cursor_monitor_accepting = false;
|
||||||
|
struct MonitoredCursorPosition {
|
||||||
|
bool valid = false;
|
||||||
|
int32_t display = -1;
|
||||||
|
double x = 0, y = 0;
|
||||||
|
};
|
||||||
|
MonitoredCursorPosition monitored_cursor;
|
||||||
|
|
||||||
|
void ReceiveCursorPosition(const Input_MouseEvent* event) {
|
||||||
|
if (!event) return;
|
||||||
|
std::lock_guard lock(cursor_monitor_mutex);
|
||||||
|
if (!cursor_monitor_accepting || !capture_active) return;
|
||||||
|
if (OH_Input_GetMouseEventAction(event) == MOUSE_ACTION_CANCEL) {
|
||||||
|
monitored_cursor = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto display = OH_Input_GetMouseEventDisplayId(event);
|
||||||
|
const auto x = OH_Input_GetMouseEventDisplayX(event);
|
||||||
|
const auto y = OH_Input_GetMouseEventDisplayY(event);
|
||||||
|
monitored_cursor = {display >= 0 && x >= 0 && y >= 0, display, double(x), double(y)};
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetCursorMonitoring(bool active) {
|
||||||
|
std::lock_guard operation(cursor_monitor_lifecycle);
|
||||||
|
if (cursor_monitor_requested == active) return;
|
||||||
|
cursor_monitor_requested = active;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(cursor_monitor_mutex);
|
||||||
|
cursor_monitor_accepting = false;
|
||||||
|
monitored_cursor = {};
|
||||||
|
}
|
||||||
|
// The callback only uses process-owned state. A failed removal remains
|
||||||
|
// inert, and must be removed before the next session can register again.
|
||||||
|
if (cursor_monitor_installed) {
|
||||||
|
const auto result = OH_Input_RemoveMouseEventMonitor(ReceiveCursorPosition);
|
||||||
|
// With this non-null callback, PARAMETER_ERROR means no listener remains
|
||||||
|
// (for example, the recording service already removed it on shutdown).
|
||||||
|
if (result != INPUT_SUCCESS && result != INPUT_PARAMETER_ERROR) {
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_WARN, 0, "SlintDesktop", "Cursor monitor removal failed: %{public}d", int(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cursor_monitor_installed = false;
|
||||||
|
}
|
||||||
|
if (!active) return;
|
||||||
|
// Input Kit allows this listener only during recording. INPUT_MONITORING
|
||||||
|
// provides background positions without INPUT_DEVICE_CONFIGURATOR.
|
||||||
|
{
|
||||||
|
std::lock_guard lock(cursor_monitor_mutex);
|
||||||
|
cursor_monitor_accepting = true;
|
||||||
|
}
|
||||||
|
const auto result = OH_Input_AddMouseEventMonitor(ReceiveCursorPosition);
|
||||||
|
if (result == INPUT_SUCCESS) {
|
||||||
|
cursor_monitor_installed = true;
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(cursor_monitor_mutex);
|
||||||
|
cursor_monitor_accepting = false;
|
||||||
|
monitored_cursor = {};
|
||||||
|
}
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_WARN, 0, "SlintDesktop", "Cursor monitor unavailable: %{public}d", int(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool InjectionAuthorized() {
|
||||||
|
if (!capture_active) return false;
|
||||||
|
// API 26's dialog status does not reflect CONTROL_DEVICE authorization.
|
||||||
|
if (control_device_granted) return true;
|
||||||
|
Input_InjectionStatus status = UNAUTHORIZED;
|
||||||
|
return OH_Input_QueryAuthorizedStatus(&status) == INPUT_SUCCESS && status == AUTHORIZED;
|
||||||
|
}
|
||||||
|
std::mutex speaker_mutex;
|
||||||
|
SpeakerCapturer::speaker_data_cb speaker_callback;
|
||||||
|
bool speaker_running = false;
|
||||||
|
std::vector<DisplayInfo> Displays() {
|
||||||
|
std::vector<DisplayInfo> result;
|
||||||
|
for (const auto& d : harmony::desktop::ReadDisplays(harmony::desktop::Environment()))
|
||||||
|
result.emplace_back(reinterpret_cast<void*>(uintptr_t(d.id)), d.name, d.primary,
|
||||||
|
d.x, d.y, d.x + d.width, d.y + d.height);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
class HarmonyScreen final : public ScreenCapturer {
|
||||||
|
cb_desktop_data callback_;
|
||||||
|
harmony::Capture capture_;
|
||||||
|
mutable std::mutex mutex_;
|
||||||
|
std::vector<DisplayInfo> displays_;
|
||||||
|
DisplayInfo current_{"", 0, 0, 0, 0};
|
||||||
|
int selected_ = 0, fps_ = 60;
|
||||||
|
bool running_ = false, requested_ = false;
|
||||||
|
bool show_cursor_ = false;
|
||||||
|
|
||||||
|
void StopCapture() {
|
||||||
|
capture_active = false;
|
||||||
|
cursor_mode = harmony::CaptureCursorMode::unknown;
|
||||||
|
SetCursorMonitoring(false);
|
||||||
|
capture_.Stop();
|
||||||
|
{ std::lock_guard lock(input_mutex); input.Release(); }
|
||||||
|
OH_Input_CancelInjection();
|
||||||
|
}
|
||||||
|
void Refresh() {
|
||||||
|
const auto latest = Displays();
|
||||||
|
// MiniRTC negotiates DisplayN at initialization. Preserve its slot/physical
|
||||||
|
// identity across hot-unplug; new displays become available on reinit.
|
||||||
|
for (auto& known : displays_) {
|
||||||
|
const auto found = std::find_if(latest.begin(), latest.end(), [&](const auto& d) { return d.handle == known.handle; });
|
||||||
|
if (found != latest.end()) known = *found;
|
||||||
|
else { known.width = known.height = 0; known.right = known.left; known.bottom = known.top; }
|
||||||
|
}
|
||||||
|
int target = selected_;
|
||||||
|
if (target < 0 || target >= int(displays_.size()) || displays_[target].width <= 0 || displays_[target].height <= 0) {
|
||||||
|
const auto first = std::find_if(displays_.begin(), displays_.end(), [](const auto& d) { return d.width > 0 && d.height > 0; });
|
||||||
|
target = first == displays_.end() ? -1 : int(first - displays_.begin());
|
||||||
|
}
|
||||||
|
const bool changed = target != selected_ || (target >= 0 &&
|
||||||
|
(displays_[target].width != current_.width || displays_[target].height != current_.height));
|
||||||
|
selected_ = target;
|
||||||
|
if (requested_ && changed) {
|
||||||
|
StopCapture();
|
||||||
|
running_ = StartCapture() == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int StartCapture() {
|
||||||
|
if (selected_ < 0 || selected_ >= int(displays_.size()) ||
|
||||||
|
displays_[selected_].width <= 0 || displays_[selected_].height <= 0) return -1;
|
||||||
|
current_ = displays_[selected_];
|
||||||
|
const auto stream = MakeDisplayStreamId(selected_);
|
||||||
|
const bool started = capture_.Start(current_.width, current_.height, uintptr_t(current_.handle),
|
||||||
|
[this, stream](const MiniRtcVideoFrame& frame) {
|
||||||
|
if (callback_) callback_(reinterpret_cast<unsigned char*>(const_cast<char*>(frame.data)),
|
||||||
|
frame.size, frame.width, frame.height, stream.c_str(), nullptr);
|
||||||
|
}, [this](int state) {
|
||||||
|
const bool was_active = capture_active.exchange(harmony::CaptureActiveForState(state, capture_active));
|
||||||
|
cursor_mode = capture_active ? capture_.CursorMode() : harmony::CaptureCursorMode::unknown;
|
||||||
|
SetCursorMonitoring(capture_active && cursor_mode == harmony::CaptureCursorMode::separate);
|
||||||
|
if (capture_active && !was_active && !InjectionAuthorized()) {
|
||||||
|
OH_Input_RequestInjection([](Input_InjectionStatus status) {
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Host input authorization: %{public}d", int(status));
|
||||||
|
// Retry releases retained while authorization was unavailable
|
||||||
|
// before accepting a newly authorized controller's presses.
|
||||||
|
{ std::lock_guard lock(input_mutex); input.Release(); }
|
||||||
|
});
|
||||||
|
} else if (!capture_active) { std::lock_guard lock(input_mutex); input.Release(); }
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Host capture state: %{public}d", state);
|
||||||
|
},
|
||||||
|
[](const char* data, size_t size) {
|
||||||
|
std::lock_guard lock(speaker_mutex);
|
||||||
|
if (speaker_running && speaker_callback)
|
||||||
|
speaker_callback(reinterpret_cast<unsigned char*>(const_cast<char*>(data)), size, "audio");
|
||||||
|
}, fps_, screen_recording_granted, show_cursor_);
|
||||||
|
cursor_mode = started ? capture_.CursorMode() : harmony::CaptureCursorMode::unknown;
|
||||||
|
return started ? 0 : -1;
|
||||||
|
}
|
||||||
|
int Select(int index) {
|
||||||
|
if (index < 0 || index >= int(displays_.size()) || displays_[index].width <= 0 || displays_[index].height <= 0) return -1;
|
||||||
|
if (index == selected_) return 0;
|
||||||
|
const int previous = selected_;
|
||||||
|
if (running_) StopCapture();
|
||||||
|
selected_ = index;
|
||||||
|
if (!requested_ || StartCapture() == 0) { running_ = requested_; return 0; }
|
||||||
|
// Do not acknowledge a failed switch or send input to the failed target.
|
||||||
|
selected_ = previous;
|
||||||
|
running_ = StartCapture() == 0;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
public:
|
||||||
|
~HarmonyScreen() override { Stop(); }
|
||||||
|
int Init(int fps, cb_desktop_data cb) override {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
fps_ = std::clamp(fps, 1, 60); callback_ = std::move(cb); displays_ = Displays(); return 0;
|
||||||
|
}
|
||||||
|
int Destroy() override { return Stop(); }
|
||||||
|
int Start(bool show_cursor) override {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
if (running_ && show_cursor_ == show_cursor) return 0;
|
||||||
|
if (running_) StopCapture();
|
||||||
|
requested_ = running_ = false;
|
||||||
|
show_cursor_ = show_cursor;
|
||||||
|
Refresh();
|
||||||
|
running_ = StartCapture() == 0;
|
||||||
|
requested_ = running_;
|
||||||
|
return running_ ? 0 : -1;
|
||||||
|
}
|
||||||
|
int Stop() override {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
requested_ = running_ = false; StopCapture(); return 0;
|
||||||
|
}
|
||||||
|
int Pause(int) override { return Stop(); }
|
||||||
|
int Resume(int index) override {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
Refresh();
|
||||||
|
if (Select(index) != 0) return -1;
|
||||||
|
if (!running_) running_ = StartCapture() == 0;
|
||||||
|
requested_ = running_;
|
||||||
|
return running_ ? 0 : -1;
|
||||||
|
}
|
||||||
|
std::vector<DisplayInfo> GetDisplayInfoList() override {
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
Refresh();
|
||||||
|
return displays_;
|
||||||
|
}
|
||||||
|
int SwitchTo(int index) override { std::lock_guard lock(mutex_); return Select(index); }
|
||||||
|
int ResetToInitialMonitor() override { return SwitchTo(0); }
|
||||||
|
int GetCurrentMonitorIndex() const override { std::lock_guard lock(mutex_); return selected_; }
|
||||||
|
};
|
||||||
|
class HarmonyMouse final : public MouseController {
|
||||||
|
std::vector<DisplayInfo> displays_;
|
||||||
|
void* display_ = nullptr;
|
||||||
|
int width_ = 0, height_ = 0;
|
||||||
|
bool Configure(int index) {
|
||||||
|
const auto& d = displays_[index];
|
||||||
|
if (d.handle == display_ && d.width == width_ && d.height == height_) return true;
|
||||||
|
if (!input.Configure(d.width, d.height, uintptr_t(d.handle))) return false;
|
||||||
|
display_ = d.handle; width_ = d.width; height_ = d.height;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public:
|
||||||
|
int Init(std::vector<DisplayInfo> displays) override {
|
||||||
|
if (displays.empty()) return -1;
|
||||||
|
std::lock_guard lock(input_mutex);
|
||||||
|
displays_ = std::move(displays); return Configure(0) ? 0 : -1;
|
||||||
|
}
|
||||||
|
void UpdateDisplayInfoList(const std::vector<DisplayInfo>& displays) override {
|
||||||
|
std::lock_guard lock(input_mutex);
|
||||||
|
if (!std::any_of(displays.begin(), displays.end(), [this](const auto& d) {
|
||||||
|
return d.handle == display_ && d.width > 0 && d.height > 0;
|
||||||
|
})) input.Release();
|
||||||
|
displays_ = displays;
|
||||||
|
}
|
||||||
|
int Destroy() override {
|
||||||
|
std::lock_guard lock(input_mutex); input.Release(); displays_.clear(); width_ = height_ = 0; return 0;
|
||||||
|
}
|
||||||
|
int SendMouseCommand(RemoteAction action, int index) override {
|
||||||
|
std::lock_guard lock(input_mutex);
|
||||||
|
if (index < 0 || index >= int(displays_.size()) || displays_[index].width <= 0 || displays_[index].height <= 0 || !InjectionAuthorized()) return -1;
|
||||||
|
if (!Configure(index)) return -1;
|
||||||
|
return input.Apply(action) ? 0 : -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
class HarmonyKeyboard final : public KeyboardCapturer {
|
||||||
|
harmony::desktop::KeyboardHook hook_;
|
||||||
|
int last_hook_result_ = -1;
|
||||||
|
public:
|
||||||
|
int Hook(OnKeyAction callback, void* context) override {
|
||||||
|
const int result = hook_.Start(callback, context);
|
||||||
|
if (result != last_hook_result_) {
|
||||||
|
last_hook_result_ = result;
|
||||||
|
OH_LOG_Print(LOG_APP, result == INPUT_SUCCESS ? LOG_INFO : LOG_WARN, 0, "SlintDesktop",
|
||||||
|
"Global keyboard hook result: %{public}d", result);
|
||||||
|
}
|
||||||
|
if (result == INPUT_PERMISSION_DENIED)
|
||||||
|
harmony::desktop::Emit({{"op", "keyboardPermission"}});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// Local keyboard capture and remote input injection are independent. A
|
||||||
|
// controller-window focus change must not release another peer's host keys.
|
||||||
|
int Unhook() override { return hook_.Stop(); }
|
||||||
|
bool IsHookActive() const override { return hook_.Active(); }
|
||||||
|
bool ShouldRetryHook() const override {
|
||||||
|
return last_hook_result_ != INPUT_DEVICE_NOT_SUPPORTED &&
|
||||||
|
(last_hook_result_ != INPUT_PERMISSION_DENIED || harmony::desktop::KeyboardHookPermissionGranted());
|
||||||
|
}
|
||||||
|
int SendKeyboardCommand(int key, bool down, uint32_t scan, bool extended) override {
|
||||||
|
RemoteAction action{}; action.type = keyboard;
|
||||||
|
action.k.key_value = key; action.k.flag = down ? key_down : key_up;
|
||||||
|
action.k.scan_code = scan; action.k.extended = extended;
|
||||||
|
std::lock_guard lock(input_mutex); return InjectionAuthorized() && input.Apply(action) ? 0 : -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The common presenter owns its CPU image fallback. A GPU renderer can replace
|
||||||
|
// this without introducing a separate session, input, or window implementation.
|
||||||
|
class SoftwareVideo final : public VideoRenderer {
|
||||||
|
std::string selected_;
|
||||||
|
public:
|
||||||
|
bool IsReady() const override { return false; }
|
||||||
|
bool IsActive() const override { return false; }
|
||||||
|
bool SetSelectedStream(std::string id) override { bool changed = id != selected_; selected_ = std::move(id); return changed; }
|
||||||
|
void DiscardStream(std::string_view id) override { if (id == selected_) selected_.clear(); }
|
||||||
|
SubmitResult SubmitNv12(std::string_view, const uint8_t*, size_t, int, int, VideoLatencyFrame) override { return SubmitResult::failed; }
|
||||||
|
SubmitResult SubmitCachedNv12(std::string_view, const uint8_t*, size_t, int, int, VideoLatencyFrame) override { return SubmitResult::failed; }
|
||||||
|
bool CopyLatestNv12(std::string_view, std::vector<unsigned char>*, int*, int*) const override { return false; }
|
||||||
|
};
|
||||||
|
class HarmonySpeaker final : public SpeakerCapturer {
|
||||||
|
public:
|
||||||
|
int Init(speaker_data_cb callback) override { std::lock_guard lock(speaker_mutex); speaker_callback = std::move(callback); return 0; }
|
||||||
|
int Destroy() override { std::lock_guard lock(speaker_mutex); speaker_running = false; speaker_callback = {}; return 0; }
|
||||||
|
int Start() override { std::lock_guard lock(speaker_mutex); speaker_running = true; return 0; }
|
||||||
|
int Stop() override { std::lock_guard lock(speaker_mutex); speaker_running = false; return 0; }
|
||||||
|
bool IsRunning() const override { std::lock_guard lock(speaker_mutex); return speaker_running; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
void harmony::desktop::SetHostPermissions(bool control_device, bool screen_recording) {
|
||||||
|
std::lock_guard lock(input_mutex);
|
||||||
|
if (control_device_granted.exchange(control_device) != control_device) input.Release();
|
||||||
|
screen_recording_granted = screen_recording;
|
||||||
|
}
|
||||||
|
ScreenCapturer* ScreenCapturerFactory::Create() { return new HarmonyScreen(); }
|
||||||
|
bool SendHarmonySystemShortcut(ServiceCommandFlag command) {
|
||||||
|
// Windows secure attention has no public Harmony equivalent. Do not claim
|
||||||
|
// it succeeded, or turn a SAS request into an unrelated physical chord.
|
||||||
|
if (command != ServiceCommandFlag::lock_workstation) return false;
|
||||||
|
std::lock_guard lock(input_mutex);
|
||||||
|
return InjectionAuthorized() && input.LockWorkstation();
|
||||||
|
}
|
||||||
|
SpeakerCapturer* SpeakerCapturerFactory::Create() { return new HarmonySpeaker(); }
|
||||||
|
DeviceController* DeviceControllerFactory::Create(Device device) {
|
||||||
|
return device == Mouse ? static_cast<DeviceController*>(new HarmonyMouse()) : new HarmonyKeyboard();
|
||||||
|
}
|
||||||
|
std::unique_ptr<VideoRenderer> CreateVideoRenderer() { return std::make_unique<SoftwareVideo>(); }
|
||||||
|
struct CursorStateProvider::Impl {
|
||||||
|
Input_CursorInfo* cursor = OH_Input_CursorInfo_Create();
|
||||||
|
~Impl() { if (cursor) OH_Input_CursorInfo_Destroy(&cursor); }
|
||||||
|
};
|
||||||
|
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
|
||||||
|
CursorStateProvider::~CursorStateProvider() = default;
|
||||||
|
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays, int, CursorState* state) {
|
||||||
|
if (!state) return false;
|
||||||
|
*state = {};
|
||||||
|
ResetCursorPosition(state);
|
||||||
|
state->shape = RemoteCursorShape::none;
|
||||||
|
if (!capture_active) return false;
|
||||||
|
const auto mode = cursor_mode.load();
|
||||||
|
if (mode == harmony::CaptureCursorMode::embedded) {
|
||||||
|
state->render_mode = CursorRenderMode::embedded;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mode != harmony::CaptureCursorMode::separate || !impl_->cursor ||
|
||||||
|
OH_Input_GetCursorInfo(impl_->cursor, nullptr) != INPUT_SUCCESS ||
|
||||||
|
OH_Input_CursorInfo_IsVisible(impl_->cursor, &state->visible) != INPUT_SUCCESS) return false;
|
||||||
|
|
||||||
|
if (state->visible) {
|
||||||
|
Input_PointerStyle style = DEFAULT;
|
||||||
|
if (OH_Input_CursorInfo_GetStyle(impl_->cursor, &style) != INPUT_SUCCESS) return false;
|
||||||
|
state->shape = harmony::desktop::ShapeFromPointerStyle(style);
|
||||||
|
}
|
||||||
|
state->render_mode = state->visible ? CursorRenderMode::separate : CursorRenderMode::hidden;
|
||||||
|
state->hidden_reason = state->visible ? CursorHiddenReason::unspecified : CursorHiddenReason::system_hidden;
|
||||||
|
|
||||||
|
int32_t display = -1;
|
||||||
|
double x = 0, y = 0;
|
||||||
|
auto location = OH_Input_GetPointerLocation(&display, &x, &y);
|
||||||
|
if (location != INPUT_SUCCESS) {
|
||||||
|
std::lock_guard lock(cursor_monitor_mutex);
|
||||||
|
if (location == INPUT_DEVICE_NO_POINTER) {
|
||||||
|
monitored_cursor = {};
|
||||||
|
} else if (cursor_monitor_accepting && monitored_cursor.valid) {
|
||||||
|
display = monitored_cursor.display;
|
||||||
|
x = monitored_cursor.x;
|
||||||
|
y = monitored_cursor.y;
|
||||||
|
location = INPUT_SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (location == INPUT_SUCCESS && display >= 0 && std::isfinite(x) && std::isfinite(y)) {
|
||||||
|
// SDK coordinates are relative to a physical display; the protocol uses
|
||||||
|
// negotiated display slots. Do not confuse a physical ID with a slot or
|
||||||
|
// select a different monitor when desktop rectangles overlap.
|
||||||
|
for (size_t index = 0; index < displays.size(); ++index) {
|
||||||
|
const auto& d = displays[index];
|
||||||
|
if (uintptr_t(d.handle) != static_cast<uintptr_t>(display) || d.width <= 0 || d.height <= 0 ||
|
||||||
|
x < 0 || y < 0 || x >= d.width || y >= d.height) continue;
|
||||||
|
state->display_id = static_cast<int>(index);
|
||||||
|
state->x = static_cast<float>(x / d.width);
|
||||||
|
state->y = static_cast<float>(y / d.height);
|
||||||
|
state->position_valid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (location == INPUT_DEVICE_NO_POINTER && !state->visible) {
|
||||||
|
state->hidden_reason = CursorHiddenReason::no_pointing_device;
|
||||||
|
}
|
||||||
|
// Without a successful query or a monitored event, report no position.
|
||||||
|
// This must not discard a successfully sampled shape/visibility.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-15
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _DISPLAYS_H_
|
||||||
|
#define _DISPLAYS_H_
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <limits>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
struct DesktopDisplay {
|
||||||
|
int id;
|
||||||
|
std::string name;
|
||||||
|
bool primary;
|
||||||
|
int x, y, width, height;
|
||||||
|
int usable_x, usable_y, usable_width, usable_height;
|
||||||
|
// SDL reserves zero for failure; Harmony's primary display can be zero.
|
||||||
|
uint32_t SdlId() const { return uint32_t(id) + 1; }
|
||||||
|
};
|
||||||
|
|
||||||
|
inline std::vector<DesktopDisplay> ReadDisplays(const nlohmann::json& environment) {
|
||||||
|
std::vector<DesktopDisplay> result;
|
||||||
|
for (const auto& item : environment.value("displays", nlohmann::json::array())) {
|
||||||
|
const int64_t id = item.value("id", int64_t(-1));
|
||||||
|
const int width = item.value("width", 0), height = item.value("height", 0);
|
||||||
|
if (id < 0 || id > INT32_MAX || width <= 0 || height <= 0) continue;
|
||||||
|
if (std::any_of(result.begin(), result.end(), [id](const auto& d) { return d.id == id; })) continue;
|
||||||
|
const int x = item.value("x", 0), y = item.value("y", 0);
|
||||||
|
result.push_back({int(id), item.value("name", "Harmony Display"), item.value("primary", false),
|
||||||
|
x, y, width, height, item.value("usableX", x), item.value("usableY", y),
|
||||||
|
item.value("usableWidth", width), item.value("usableHeight", height)});
|
||||||
|
}
|
||||||
|
std::stable_sort(result.begin(), result.end(), [](const auto& a, const auto& b) {
|
||||||
|
return a.primary != b.primary ? a.primary : a.id < b.id;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const DesktopDisplay* DisplayAt(const std::vector<DesktopDisplay>& displays, int x, int y) {
|
||||||
|
const DesktopDisplay* nearest = nullptr;
|
||||||
|
int64_t distance = std::numeric_limits<int64_t>::max();
|
||||||
|
for (const auto& d : displays) {
|
||||||
|
const int64_t dx = int64_t(x) - std::clamp<int64_t>(x, d.x, int64_t(d.x) + d.width - 1);
|
||||||
|
const int64_t dy = int64_t(y) - std::clamp<int64_t>(y, d.y, int64_t(d.y) + d.height - 1);
|
||||||
|
const int64_t next = dx * dx + dy * dy;
|
||||||
|
if (next < distance) { distance = next; nearest = &d; }
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
--- a/Cargo.toml
|
||||||
|
+++ b/Cargo.toml
|
||||||
|
@@ -200,7 +200,7 @@
|
||||||
|
wgpu-29 = { package = "wgpu", version = "29.0.4", default-features = false }
|
||||||
|
input = { version = "0.10.0", default-features = false }
|
||||||
|
tr = { version = "0.1", default-features = false }
|
||||||
|
-fontique = { version = "0.10.0" }
|
||||||
|
+fontique = { version = "0.10.0", default-features = false, features = ["std"] }
|
||||||
|
# Pinned to match parley's version
|
||||||
|
read-fonts = { version = "0.39" }
|
||||||
|
# Pinned to match parley's version
|
||||||
|
--- a/internal/core/Cargo.toml
|
||||||
|
+++ b/internal/core/Cargo.toml
|
||||||
|
@@ -130,7 +130,7 @@
|
||||||
|
unicode-script = { version = "0.5.7", optional = true }
|
||||||
|
icu_normalizer = { workspace = true, optional = true }
|
||||||
|
sys-locale = { version = "0.3.2", optional = true, features = ["js"] }
|
||||||
|
-parley = { version = "0.10.0", optional = true, features = ["complex-scripts"] }
|
||||||
|
+parley = { version = "0.10.0", optional = true, default-features = false, features = ["std", "complex-scripts"] }
|
||||||
|
|
||||||
|
image = { workspace = true, optional = true, default-features = false }
|
||||||
|
clru = { workspace = true, optional = true }
|
||||||
|
--- a/Cargo.lock
|
||||||
|
+++ b/Cargo.lock
|
||||||
|
@@ -3960,16 +3960,9 @@
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"linebender_resource_handle",
|
||||||
|
"memmap2",
|
||||||
|
- "objc2 0.6.4",
|
||||||
|
- "objc2-core-foundation",
|
||||||
|
- "objc2-core-text",
|
||||||
|
- "objc2-foundation 0.3.2",
|
||||||
|
"parlance",
|
||||||
|
"read-fonts",
|
||||||
|
- "roxmltree",
|
||||||
|
"smallvec",
|
||||||
|
- "windows 0.62.2",
|
||||||
|
- "windows-core 0.62.2",
|
||||||
|
"yeslogic-fontconfig-sys",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-15
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _HOST_ACCESS_H_
|
||||||
|
#define _HOST_ACCESS_H_
|
||||||
|
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
// Values come from AbilityKit's actual permission checks, never saved settings.
|
||||||
|
void SetHostPermissions(bool control_device, bool screen_recording);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SDL_H_
|
||||||
|
#define _SDL_H_
|
||||||
|
|
||||||
|
// The desktop's small SDL service surface, implemented with Harmony APIs.
|
||||||
|
// This header is private to the Harmony target; desktop SDL builds are unchanged.
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
using Uint8 = uint8_t;
|
||||||
|
using SDL_DisplayID = uint32_t;
|
||||||
|
struct SDL_Point { int x, y; };
|
||||||
|
struct SDL_Rect { int x, y, w, h; };
|
||||||
|
struct SDL_DisplayMode { int w, h; };
|
||||||
|
struct SDL_Event { uint32_t type; };
|
||||||
|
struct SDL_AudioSpec { int format, channels, freq; };
|
||||||
|
struct SDL_AudioStream;
|
||||||
|
constexpr uint32_t SDL_INIT_VIDEO = 1, SDL_INIT_AUDIO = 2;
|
||||||
|
constexpr uint32_t SDL_EVENT_FIRST = 0, SDL_EVENT_LAST = UINT32_MAX;
|
||||||
|
constexpr uint32_t SDL_EVENT_QUIT = 0x100, SDL_EVENT_CLIPBOARD_UPDATE = 0x900;
|
||||||
|
constexpr int SDL_GETEVENT = 2, SDL_AUDIO_S16 = 0x8010;
|
||||||
|
constexpr uint32_t SDL_BUTTON_LMASK = 1, SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK = 0;
|
||||||
|
bool SDL_Init(uint32_t);
|
||||||
|
bool SDL_InitSubSystem(uint32_t);
|
||||||
|
void SDL_Quit();
|
||||||
|
void SDL_QuitSubSystem(uint32_t);
|
||||||
|
const char* SDL_GetError();
|
||||||
|
uint64_t SDL_GetTicks();
|
||||||
|
void SDL_Delay(uint32_t);
|
||||||
|
bool SDL_PollEvent(SDL_Event*);
|
||||||
|
int SDL_PeepEvents(SDL_Event*, int, int, uint32_t, uint32_t);
|
||||||
|
bool SDL_PushEvent(SDL_Event*);
|
||||||
|
char* SDL_GetClipboardText();
|
||||||
|
bool SDL_SetClipboardText(const char*);
|
||||||
|
bool SDL_HasClipboardText();
|
||||||
|
inline void SDL_free(void* p) { std::free(p); }
|
||||||
|
const char* SDL_GetCurrentVideoDriver();
|
||||||
|
const SDL_DisplayMode* SDL_GetCurrentDisplayMode(SDL_DisplayID);
|
||||||
|
SDL_DisplayID SDL_GetPrimaryDisplay();
|
||||||
|
SDL_DisplayID SDL_GetDisplayForPoint(const SDL_Point*);
|
||||||
|
SDL_DisplayID* SDL_GetDisplays(int*);
|
||||||
|
bool SDL_GetDisplayUsableBounds(SDL_DisplayID, SDL_Rect*);
|
||||||
|
uint32_t SDL_GetGlobalMouseState(float*, float*);
|
||||||
|
bool SDL_OpenURL(const char*);
|
||||||
|
uint32_t SDL_OpenAudioDevice(uint32_t, const SDL_AudioSpec*);
|
||||||
|
bool SDL_ResumeAudioDevice(uint32_t);
|
||||||
|
void SDL_CloseAudioDevice(uint32_t);
|
||||||
|
SDL_AudioStream* SDL_CreateAudioStream(const SDL_AudioSpec*, const SDL_AudioSpec*);
|
||||||
|
void SDL_DestroyAudioStream(SDL_AudioStream*);
|
||||||
|
bool SDL_BindAudioStream(uint32_t, SDL_AudioStream*);
|
||||||
|
bool SDL_PutAudioStreamData(SDL_AudioStream*, const void*, int);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SDL_AUDIO_H_
|
||||||
|
#define _SDL_AUDIO_H_
|
||||||
|
|
||||||
|
#include "SDL.h"
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SDL_CLIPBOARD_H_
|
||||||
|
#define _SDL_CLIPBOARD_H_
|
||||||
|
|
||||||
|
#include "SDL.h"
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SDL_ERROR_H_
|
||||||
|
#define _SDL_ERROR_H_
|
||||||
|
|
||||||
|
#include "SDL.h"
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SDL_EVENTS_H_
|
||||||
|
#define _SDL_EVENTS_H_
|
||||||
|
|
||||||
|
#include "SDL.h"
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SDL_INIT_H_
|
||||||
|
#define _SDL_INIT_H_
|
||||||
|
|
||||||
|
#include "SDL.h"
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _SDL_TIMER_H_
|
||||||
|
#define _SDL_TIMER_H_
|
||||||
|
|
||||||
|
#include "SDL.h"
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _TINYFILEDIALOGS_H_
|
||||||
|
#define _TINYFILEDIALOGS_H_
|
||||||
|
|
||||||
|
const char* tinyfd_selectFolderDialog(const char* title, const char* path);
|
||||||
|
const char* tinyfd_openFileDialog(const char* title, const char* path, int,
|
||||||
|
const char* const*, const char*, int);
|
||||||
|
|
||||||
|
#endif
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export const start: (environment: string) => void;
|
||||||
|
export const send: (event: string) => void;
|
||||||
|
export const poll: () => string;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-14
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _KEYBOARD_H_
|
||||||
|
#define _KEYBOARD_H_
|
||||||
|
|
||||||
|
#include <slint-platform.h>
|
||||||
|
#include <map>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
// Remember the symbol chosen on key-down. Releasing Shift before a letter must
|
||||||
|
// not turn the matching key-up into a different remote key.
|
||||||
|
class KeyboardState {
|
||||||
|
std::map<int, std::string> pressed_;
|
||||||
|
bool caps_ = false;
|
||||||
|
bool num_ = true;
|
||||||
|
static std::string Utf8(std::u8string_view key) { return {reinterpret_cast<const char*>(key.data()), key.size()}; }
|
||||||
|
public:
|
||||||
|
void Reset() { pressed_.clear(); }
|
||||||
|
bool IsPressed(int code) const { return pressed_.count(code); }
|
||||||
|
const std::map<int, std::string>& Pressed() const { return pressed_; }
|
||||||
|
std::string Translate(int code, bool down, unsigned unicode = 0,
|
||||||
|
std::optional<bool> caps = {}, std::optional<bool> num = {}) {
|
||||||
|
// Lock keys may have changed in another application. Prefer the OS event
|
||||||
|
// snapshot, while keeping a down/up pair's original symbol unchanged.
|
||||||
|
if (caps) caps_ = *caps;
|
||||||
|
if (num) num_ = *num;
|
||||||
|
if (auto it = pressed_.find(code); it != pressed_.end()) {
|
||||||
|
auto key = it->second;
|
||||||
|
if (!down) pressed_.erase(it);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
using namespace slint::platform::key_codes;
|
||||||
|
const bool shift = IsPressed(2047) || IsPressed(2048);
|
||||||
|
std::string key;
|
||||||
|
if (!num_ && code >= 2103 && code <= 2112) {
|
||||||
|
const std::u8string_view navigation[] = {Insert, End, DownArrow, PageDown, LeftArrow, u8"", RightArrow, Home, UpArrow, PageUp};
|
||||||
|
key = Utf8(navigation[code - 2103]);
|
||||||
|
} else if (!num_ && code == 2117) key = Utf8(Delete);
|
||||||
|
else if (unicode >= 0x21 && unicode <= 0x7e &&
|
||||||
|
((code >= 2000 && code <= 2009) || (code >= 2017 && code <= 2044) ||
|
||||||
|
(code >= 2056 && code <= 2064) || (code >= 2103 && code <= 2122)))
|
||||||
|
key = std::string(1, char(unicode));
|
||||||
|
else if (code >= 2000 && code <= 2009) key = std::string(1, shift ? ")!@#$%^&*("[code - 2000] : char('0' + code - 2000));
|
||||||
|
else if (code >= 2017 && code <= 2042) key = std::string(1, char(((shift != caps_) ? 'A' : 'a') + code - 2017));
|
||||||
|
else if (code >= 2103 && code <= 2112) key = std::string(1, char('0' + code - 2103));
|
||||||
|
else if (code >= 2090 && code <= 2101) {
|
||||||
|
const std::u8string_view functions[] = {F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12};
|
||||||
|
key = Utf8(functions[code - 2090]);
|
||||||
|
} else if (code >= 2816 && code <= 2827) {
|
||||||
|
const std::u8string_view functions[] = {F13, F14, F15, F16, F17, F18, F19, F20, F21, F22, F23, F24};
|
||||||
|
key = Utf8(functions[code - 2816]);
|
||||||
|
} else switch (code) {
|
||||||
|
case 2055: key = Utf8(Backspace); break; case 2054: case 2119: key = Utf8(Return); break;
|
||||||
|
case 2049: key = Utf8(Tab); break; case 2070: key = Utf8(Escape); break;
|
||||||
|
case 2012: key = Utf8(UpArrow); break; case 2013: key = Utf8(DownArrow); break;
|
||||||
|
case 2014: key = Utf8(LeftArrow); break; case 2015: key = Utf8(RightArrow); break;
|
||||||
|
case 2072: key = Utf8(Control); break; case 2073: key = Utf8(ControlR); break;
|
||||||
|
case 2047: key = Utf8(Shift); break; case 2048: key = Utf8(ShiftR); break;
|
||||||
|
case 2045: key = Utf8(Alt); break; case 2046: key = Utf8(AltGr); break;
|
||||||
|
case 2076: key = Utf8(Meta); break; case 2077: key = Utf8(MetaR); break;
|
||||||
|
case 2067: key = Utf8(Menu); break;
|
||||||
|
case 2050: key = " "; break; case 2071: key = Utf8(Delete); break;
|
||||||
|
case 2081: key = Utf8(Home); break; case 2082: key = Utf8(End); break;
|
||||||
|
case 2068: key = Utf8(PageUp); break; case 2069: key = Utf8(PageDown); break;
|
||||||
|
case 2083: key = Utf8(Insert); break;
|
||||||
|
case 2074: if (down && !caps) caps_ = !caps_; key = Utf8(CapsLock); break;
|
||||||
|
case 2102: if (down && !num) num_ = !num_; break;
|
||||||
|
case 2075: key = Utf8(ScrollLock); break; case 2080: key = Utf8(Pause); break;
|
||||||
|
case 2079: key = Utf8(SysReq); break; case 2113: key = "/"; break;
|
||||||
|
case 2114: key = "*"; break; case 2115: key = "-"; break;
|
||||||
|
case 2116: key = "+"; break; case 2117: key = "."; break;
|
||||||
|
case 2118: key = ","; break; case 2120: key = "="; break;
|
||||||
|
case 2121: key = "("; break; case 2122: key = ")"; break;
|
||||||
|
case 2043: key = shift ? "<" : ","; break; case 2044: key = shift ? ">" : "."; break;
|
||||||
|
case 2056: key = shift ? "~" : "`"; break; case 2057: key = shift ? "_" : "-"; break;
|
||||||
|
case 2058: key = shift ? "+" : "="; break; case 2059: key = shift ? "{" : "["; break;
|
||||||
|
case 2060: key = shift ? "}" : "]"; break; case 2061: key = shift ? "|" : "\\"; break;
|
||||||
|
case 2062: key = shift ? ":" : ";"; break; case 2063: key = shift ? "\"" : "'"; break;
|
||||||
|
case 2064: key = shift ? "?" : "/"; break;
|
||||||
|
}
|
||||||
|
if (down && (!key.empty() || code == 2102)) pressed_[code] = key;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#include "keyboard_hook.h"
|
||||||
|
#include "key_mapping.h"
|
||||||
|
#include "keyboard_state.h"
|
||||||
|
#include "windows_key_metadata.h"
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
namespace {
|
||||||
|
std::mutex lifecycle, delivery;
|
||||||
|
KeyboardHook* owner = nullptr;
|
||||||
|
OnKeyAction sink = nullptr;
|
||||||
|
void* sink_context = nullptr;
|
||||||
|
int focused_window = 0, capture_window = 0;
|
||||||
|
bool installed = false, permission = false;
|
||||||
|
PressedKeys pressed;
|
||||||
|
|
||||||
|
void ReleaseCaptured() {
|
||||||
|
if (sink) for (const auto& key : ReconcileKeyboardState(pressed, {}))
|
||||||
|
sink(key.key_code, false, key.scan_code, key.extended, sink_context);
|
||||||
|
pressed.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void SetKeyboardFocus(int window, bool active) {
|
||||||
|
std::lock_guard lock(delivery);
|
||||||
|
if (active) focused_window = window;
|
||||||
|
else if (focused_window == window) focused_window = 0;
|
||||||
|
if (capture_window != focused_window) ReleaseCaptured();
|
||||||
|
}
|
||||||
|
void SetKeyboardHookPermission(bool granted) {
|
||||||
|
std::lock_guard lock(delivery);
|
||||||
|
permission = granted;
|
||||||
|
if (!granted) ReleaseCaptured();
|
||||||
|
}
|
||||||
|
bool KeyboardHookPermissionGranted() { std::lock_guard lock(delivery); return permission; }
|
||||||
|
KeyboardHook::~KeyboardHook() { Stop(); }
|
||||||
|
int KeyboardHook::Start(OnKeyAction callback, void* context) {
|
||||||
|
if (!callback) return INPUT_PARAMETER_ERROR;
|
||||||
|
std::lock_guard operation(lifecycle);
|
||||||
|
int target_window = 0;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(delivery);
|
||||||
|
if (owner) return owner == this ? INPUT_SUCCESS : INPUT_REPEAT_INTERCEPTOR;
|
||||||
|
if (!focused_window) return INPUT_APP_NOT_FOCUSED;
|
||||||
|
target_window = focused_window;
|
||||||
|
}
|
||||||
|
// A failed removal leaves a pass-through callback installed. Retry removal
|
||||||
|
// before registering again; never interpret REPEAT_INTERCEPTOR as success.
|
||||||
|
if (installed) {
|
||||||
|
const auto result = OH_Input_RemoveKeyEventHook(Receive);
|
||||||
|
if (result != INPUT_SUCCESS) return result;
|
||||||
|
installed = false;
|
||||||
|
}
|
||||||
|
const auto result = OH_Input_AddKeyEventHook(Receive);
|
||||||
|
if (result != INPUT_SUCCESS) return result;
|
||||||
|
installed = true;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(delivery);
|
||||||
|
owner = this; sink = callback; sink_context = context;
|
||||||
|
capture_window = target_window; permission = true; pressed.clear();
|
||||||
|
}
|
||||||
|
return INPUT_SUCCESS;
|
||||||
|
}
|
||||||
|
int KeyboardHook::Stop() {
|
||||||
|
std::lock_guard operation(lifecycle);
|
||||||
|
{
|
||||||
|
std::lock_guard lock(delivery);
|
||||||
|
if (owner && owner != this) return INPUT_SUCCESS;
|
||||||
|
// Wait for an in-flight delivery, then detach the context before invoking
|
||||||
|
// the OS. Failed removal or a queued callback cannot access a dead owner.
|
||||||
|
owner = nullptr; sink = nullptr; sink_context = nullptr;
|
||||||
|
capture_window = 0; pressed.clear();
|
||||||
|
}
|
||||||
|
if (!installed) return INPUT_SUCCESS;
|
||||||
|
const auto result = OH_Input_RemoveKeyEventHook(Receive);
|
||||||
|
if (result == INPUT_SUCCESS) installed = false;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
bool KeyboardHook::Active() const {
|
||||||
|
std::lock_guard lock(delivery);
|
||||||
|
return owner == this && permission && capture_window != 0 && capture_window == focused_window;
|
||||||
|
}
|
||||||
|
void KeyboardHook::Receive(const Input_KeyEvent* event) {
|
||||||
|
if (!event) return;
|
||||||
|
bool consumed = false;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(delivery);
|
||||||
|
if (owner && sink && permission && capture_window != 0 && capture_window == focused_window) {
|
||||||
|
const auto action = OH_Input_GetKeyEventAction(event);
|
||||||
|
const int code = OH_Input_GetKeyEventKeyCode(event);
|
||||||
|
const int key = ToVirtualKey(code);
|
||||||
|
if (action == KEY_ACTION_CANCEL) {
|
||||||
|
consumed = !pressed.empty(); ReleaseCaptured();
|
||||||
|
} else if (key && (action == KEY_ACTION_DOWN || action == KEY_ACTION_UP)) {
|
||||||
|
const bool down = action == KEY_ACTION_DOWN;
|
||||||
|
// Do not swallow a release whose press belonged to the local app.
|
||||||
|
if (down || pressed.count(key)) {
|
||||||
|
uint32_t scan = 0; bool extended = false;
|
||||||
|
LookupWindowsKeyMetadataFromVk(key, &scan, &extended);
|
||||||
|
if (code == 2119) { scan = 0x1C; extended = true; }
|
||||||
|
if (down) pressed[key] = {key, scan, extended};
|
||||||
|
else pressed.erase(key);
|
||||||
|
sink(key, down, scan, extended, sink_context); consumed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The SDK owns event storage. Return unhandled events in original order,
|
||||||
|
// without reinjecting them (which would re-enter the hook).
|
||||||
|
if (!consumed) {
|
||||||
|
int32_t id = 0;
|
||||||
|
if (OH_Input_GetKeyEventId(event, &id) == INPUT_SUCCESS)
|
||||||
|
OH_Input_DispatchToNextHandler(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-15
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _KEYBOARD_HOOK_H_
|
||||||
|
#define _KEYBOARD_HOOK_H_
|
||||||
|
|
||||||
|
#include "device_controller.h"
|
||||||
|
#include <multimodalinput/oh_input_manager.h>
|
||||||
|
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
// Process-wide OS hook; the shared SessionDeviceManager owns when it runs.
|
||||||
|
// ArkUI updates focus immediately, even while the Slint thread waits for an OS
|
||||||
|
// service. A late callback must never capture input for another application.
|
||||||
|
void SetKeyboardFocus(int window, bool active);
|
||||||
|
void SetKeyboardHookPermission(bool granted);
|
||||||
|
bool KeyboardHookPermissionGranted();
|
||||||
|
class KeyboardHook {
|
||||||
|
public:
|
||||||
|
~KeyboardHook();
|
||||||
|
int Start(OnKeyAction callback, void* context);
|
||||||
|
int Stop();
|
||||||
|
bool Active() const;
|
||||||
|
private:
|
||||||
|
static void Receive(const Input_KeyEvent* event);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
--- a/api/cpp/platform.rs
|
||||||
|
+++ b/api/cpp/platform.rs
|
||||||
|
@@ -47,6 +47,11 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowAdapter for CppWindowAdapter {
|
||||||
|
+ #[cfg(target_env = "ohos")]
|
||||||
|
+ fn internal(&self, _: i_slint_core::InternalToken) -> Option<&dyn i_slint_core::window::WindowAdapterInternal> {
|
||||||
|
+ Some(self)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
fn window(&self) -> &Window {
|
||||||
|
&self.window
|
||||||
|
}
|
||||||
|
@@ -186,6 +191,103 @@
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+
|
||||||
|
+// Harmony's native editor and cursor services are provided by the C++ host.
|
||||||
|
+// Keep this extension target-local; the public C++ adapter ABI stays unchanged.
|
||||||
|
+#[cfg(target_env = "ohos")]
|
||||||
|
+mod harmony_services {
|
||||||
|
+ use super::*;
|
||||||
|
+ use core::cell::Cell;
|
||||||
|
+ use i_slint_core::window::{InputMethodRequest, WindowAdapterInternal, WindowInner};
|
||||||
|
+ type HarmonyOsCallback = unsafe extern "C" fn(*mut c_void, *const c_void, i32,
|
||||||
|
+ *const u8, usize, usize, usize, f32, f32, f32, f32, i32);
|
||||||
|
+ std::thread_local! { static CALLBACK: Cell<Option<HarmonyOsCallback>> = const { Cell::new(None) }; }
|
||||||
|
+ #[unsafe(no_mangle)]
|
||||||
|
+ pub extern "C" fn crossdesk_slint_set_os_callback(callback: HarmonyOsCallback) {
|
||||||
|
+ CALLBACK.set(Some(callback));
|
||||||
|
+ }
|
||||||
|
+ impl WindowAdapterInternal for CppWindowAdapter {
|
||||||
|
+ fn input_method_request(&self, request: InputMethodRequest) {
|
||||||
|
+ let Some(callback) = CALLBACK.get() else { return };
|
||||||
|
+ let (kind, properties) = match request {
|
||||||
|
+ InputMethodRequest::Enable(p) => (1, Some(p)),
|
||||||
|
+ InputMethodRequest::Update(p) => (2, Some(p)),
|
||||||
|
+ InputMethodRequest::Disable => (0, None),
|
||||||
|
+ _ => return,
|
||||||
|
+ };
|
||||||
|
+ let p = properties.unwrap_or_default();
|
||||||
|
+ let cursor = p.text[..p.cursor_position].encode_utf16().count();
|
||||||
|
+ let anchor = p.text[..p.anchor_position.unwrap_or(p.cursor_position)].encode_utf16().count();
|
||||||
|
+ unsafe { callback(self.user_data, self as *const Self as *const c_void, kind, p.text.as_ptr(), p.text.len(), cursor, anchor,
|
||||||
|
+ p.cursor_rect_origin.x, p.cursor_rect_origin.y, p.cursor_rect_size.width,
|
||||||
|
+ p.cursor_rect_size.height, p.input_type as i32); }
|
||||||
|
+ }
|
||||||
|
+ fn set_mouse_cursor(&self, cursor: i_slint_core::items::MouseCursor) {
|
||||||
|
+ if let Some(callback) = CALLBACK.get() {
|
||||||
|
+ unsafe { callback(self.user_data, self as *const Self as *const c_void, 3, core::ptr::null(), 0, 0, 0,
|
||||||
|
+ 0., 0., 0., 0., cursor as i32); }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ // IME commands target the editor that owns the native input session. Use
|
||||||
|
+ // Slint's normal key handling (selection, clipboard, validation and undo)
|
||||||
|
+ // without mutating the window's physical modifier state.
|
||||||
|
+ #[unsafe(no_mangle)]
|
||||||
|
+ pub unsafe extern "C" fn crossdesk_slint_edit(adapter: *const c_void,
|
||||||
|
+ text: *const u8, length: usize, modifiers: i32) {
|
||||||
|
+ use i_slint_core::input::{InternalKeyEvent, KeyEvent, KeyEventType};
|
||||||
|
+ let Ok(text) = core::str::from_utf8(unsafe { core::slice::from_raw_parts(text, length) }) else { return };
|
||||||
|
+ let window = WindowInner::from_pub(&unsafe { &*(adapter as *const CppWindowAdapter) }.window);
|
||||||
|
+ let Some(item) = window.focus_item.borrow().upgrade() else { return };
|
||||||
|
+ if item.downcast::<i_slint_core::items::TextInput>().is_none() { return; }
|
||||||
|
+ let mut key_event = KeyEvent::default();
|
||||||
|
+ key_event.text = text.into();
|
||||||
|
+ key_event.modifiers.shift = modifiers & 1 != 0;
|
||||||
|
+ key_event.modifiers.control = modifiers & 2 != 0;
|
||||||
|
+ for event_type in [KeyEventType::KeyPressed, KeyEventType::KeyReleased] {
|
||||||
|
+ item.borrow().as_ref().key_event(&InternalKeyEvent {
|
||||||
|
+ key_event: key_event.clone(), event_type, ..Default::default()
|
||||||
|
+ }, &window.window_adapter(), &item);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ #[unsafe(no_mangle)]
|
||||||
|
+ pub unsafe extern "C" fn crossdesk_slint_selection(adapter: *const c_void, anchor: i32, cursor: i32) {
|
||||||
|
+ let window = WindowInner::from_pub(&unsafe { &*(adapter as *const CppWindowAdapter) }.window);
|
||||||
|
+ let Some(item) = window.focus_item.borrow().upgrade() else { return };
|
||||||
|
+ let Some(input) = item.downcast::<i_slint_core::items::TextInput>() else { return };
|
||||||
|
+ let input = input.as_pin_ref();
|
||||||
|
+ let text = input.text();
|
||||||
|
+ // Harmony uses UTF-16 indices; Slint uses UTF-8 byte offsets. Clamp
|
||||||
|
+ // malformed ranges to character boundaries, including surrogate pairs.
|
||||||
|
+ let byte_offset = |offset: i32| -> i32 {
|
||||||
|
+ let mut utf16 = 0;
|
||||||
|
+ for (byte, ch) in text.char_indices() {
|
||||||
|
+ if utf16 + ch.len_utf16() > offset.max(0) as usize { return byte as i32; }
|
||||||
|
+ utf16 += ch.len_utf16();
|
||||||
|
+ }
|
||||||
|
+ text.len() as i32
|
||||||
|
+ };
|
||||||
|
+ input.set_selection_offsets(&window.window_adapter(), &item, byte_offset(anchor), byte_offset(cursor));
|
||||||
|
+ }
|
||||||
|
+ // Called only on the Slint thread while the owning C++ window is alive.
|
||||||
|
+ #[unsafe(no_mangle)]
|
||||||
|
+ pub unsafe extern "C" fn crossdesk_slint_composition(adapter: *const c_void,
|
||||||
|
+ commit: bool, text: *const u8, length: usize) {
|
||||||
|
+ use i_slint_core::input::{InternalKeyEvent, KeyEvent, KeyEventType};
|
||||||
|
+ let Ok(text) = core::str::from_utf8(unsafe { core::slice::from_raw_parts(text, length) }) else { return };
|
||||||
|
+ let mut key_event = KeyEvent::default();
|
||||||
|
+ key_event.text = text.into();
|
||||||
|
+ let event = if commit {
|
||||||
|
+ InternalKeyEvent { event_type: KeyEventType::CommitComposition,
|
||||||
|
+ key_event, ..Default::default() }
|
||||||
|
+ } else {
|
||||||
|
+ InternalKeyEvent { event_type: KeyEventType::UpdateComposition, preedit_text: text.into(),
|
||||||
|
+ preedit_selection: Some(text.len() as i32..text.len() as i32), ..Default::default() }
|
||||||
|
+ };
|
||||||
|
+ WindowInner::from_pub(&unsafe { &*(adapter as *const CppWindowAdapter) }.window).process_key_input(event);
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
type PlatformUserData = *mut c_void;
|
||||||
|
|
||||||
|
struct CppPlatform {
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-15
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _POINTER_H_
|
||||||
|
#define _POINTER_H_
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
// Preserve each button's last position. Cancellation/focus loss must release
|
||||||
|
// every held button, while merely leaving the surface must preserve a drag.
|
||||||
|
class PointerState {
|
||||||
|
std::array<bool, 3> pressed_{};
|
||||||
|
float x_ = 0, y_ = 0;
|
||||||
|
public:
|
||||||
|
void Update(int kind, int button, float x, float y) {
|
||||||
|
x_ = x; y_ = y;
|
||||||
|
if (button >= 0 && button < 3 && (kind == 0 || kind == 1)) pressed_[button] = kind == 0;
|
||||||
|
}
|
||||||
|
unsigned Buttons() const {
|
||||||
|
return (pressed_[0] ? 1u : 0u) | (pressed_[1] ? 4u : 0u) | (pressed_[2] ? 2u : 0u);
|
||||||
|
}
|
||||||
|
template<class Release> void Cancel(Release release) {
|
||||||
|
const auto pressed = std::exchange(pressed_, {});
|
||||||
|
for (int i = 0; i < 3; ++i) if (pressed[i]) release(i, x_, y_);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#include "bridge.h"
|
||||||
|
#include "displays.h"
|
||||||
|
#include "SDL3/SDL.h"
|
||||||
|
#include "audio.h"
|
||||||
|
#include "platform/path_backend.h"
|
||||||
|
#include "platform/autostart_backend.h"
|
||||||
|
#include "platform/harmony/harmony_autostart.h"
|
||||||
|
#include "platform.h"
|
||||||
|
#include <multimodalinput/oh_input_manager.h>
|
||||||
|
#include <chrono>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstring>
|
||||||
|
#include <deque>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
using namespace crossdesk::harmony::desktop;
|
||||||
|
namespace {
|
||||||
|
std::mutex event_mutex;
|
||||||
|
std::deque<SDL_Event> events;
|
||||||
|
thread_local std::string service_error;
|
||||||
|
std::atomic<crossdesk::HarmonyAutostartStatus> autostart_status{crossdesk::HarmonyAutostartStatus::checking};
|
||||||
|
bool AudioFormat(const SDL_AudioSpec* spec) {
|
||||||
|
return spec && spec->freq == 48000 && spec->channels == 1 && spec->format == SDL_AUDIO_S16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool SDL_Init(uint32_t) { return true; }
|
||||||
|
bool SDL_InitSubSystem(uint32_t) { return true; }
|
||||||
|
void SDL_Quit() { std::lock_guard lock(event_mutex); events.clear(); }
|
||||||
|
void SDL_QuitSubSystem(uint32_t) {}
|
||||||
|
const char* SDL_GetError() { return service_error.c_str(); }
|
||||||
|
uint64_t SDL_GetTicks() {
|
||||||
|
static const auto start = std::chrono::steady_clock::now();
|
||||||
|
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
|
||||||
|
}
|
||||||
|
void SDL_Delay(uint32_t delay) { std::this_thread::sleep_for(std::chrono::milliseconds(delay)); }
|
||||||
|
bool SDL_PushEvent(SDL_Event* event) {
|
||||||
|
if (!event) return false;
|
||||||
|
std::lock_guard lock(event_mutex); events.push_back(*event); return true;
|
||||||
|
}
|
||||||
|
bool SDL_PollEvent(SDL_Event* event) { return SDL_PeepEvents(event, 1, SDL_GETEVENT, 0, UINT32_MAX) == 1; }
|
||||||
|
int SDL_PeepEvents(SDL_Event* out, int count, int, uint32_t first, uint32_t last) {
|
||||||
|
std::lock_guard lock(event_mutex); int copied = 0;
|
||||||
|
for (auto it = events.begin(); it != events.end() && copied < count;) {
|
||||||
|
if (it->type >= first && it->type <= last) { out[copied++] = *it; it = events.erase(it); }
|
||||||
|
else ++it;
|
||||||
|
}
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
char* SDL_GetClipboardText() {
|
||||||
|
auto result = Request({{"op", "clipboardRead"}});
|
||||||
|
if (!result.value("ok", false)) {
|
||||||
|
service_error = result.value("message", "Harmony clipboard read failed");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return strdup(result.value("text", "").c_str());
|
||||||
|
}
|
||||||
|
bool SDL_SetClipboardText(const char* text) {
|
||||||
|
auto result = Request({{"op", "clipboardWrite"}, {"text", text ? text : ""}});
|
||||||
|
if (result.value("ok", false)) return true;
|
||||||
|
service_error = result.value("message", "Harmony clipboard write failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool SDL_HasClipboardText() { return Request({{"op", "clipboardHas"}}).value("ok", false); }
|
||||||
|
const char* SDL_GetCurrentVideoDriver() { return "harmony"; }
|
||||||
|
const SDL_DisplayMode* SDL_GetCurrentDisplayMode(SDL_DisplayID id) {
|
||||||
|
static thread_local SDL_DisplayMode mode;
|
||||||
|
for (const auto& d : ReadDisplays(Environment())) if (d.SdlId() == id) {
|
||||||
|
mode = {d.width, d.height}; return &mode;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
SDL_DisplayID SDL_GetPrimaryDisplay() {
|
||||||
|
const auto displays = ReadDisplays(Environment());
|
||||||
|
return displays.empty() ? 0 : displays.front().SdlId();
|
||||||
|
}
|
||||||
|
SDL_DisplayID SDL_GetDisplayForPoint(const SDL_Point* point) {
|
||||||
|
if (!point) return 0;
|
||||||
|
const auto displays = ReadDisplays(Environment());
|
||||||
|
const auto* found = DisplayAt(displays, point->x, point->y);
|
||||||
|
return found ? found->SdlId() : 0;
|
||||||
|
}
|
||||||
|
SDL_DisplayID* SDL_GetDisplays(int* count) {
|
||||||
|
const auto displays = ReadDisplays(Environment());
|
||||||
|
auto* ids = static_cast<SDL_DisplayID*>(std::calloc(displays.size() + 1, sizeof(SDL_DisplayID)));
|
||||||
|
if (count) *count = ids ? int(displays.size()) : 0;
|
||||||
|
if (ids) for (size_t i = 0; i < displays.size(); ++i) ids[i] = displays[i].SdlId();
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
bool SDL_GetDisplayUsableBounds(SDL_DisplayID id, SDL_Rect* bounds) {
|
||||||
|
if (!bounds) return false;
|
||||||
|
for (const auto& d : ReadDisplays(Environment())) if (d.SdlId() == id) {
|
||||||
|
*bounds = {d.usable_x, d.usable_y, d.usable_width, d.usable_height}; return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint32_t SDL_GetGlobalMouseState(float* x, float* y) {
|
||||||
|
auto env = Environment();
|
||||||
|
double px = env.value("pointerX", 0.), py = env.value("pointerY", 0.);
|
||||||
|
// Touch-only devices and unfocused apps may not expose a global pointer.
|
||||||
|
// Keep the last window event coordinates intact and retry after hot-plug or
|
||||||
|
// focus changes without issuing a failing system call every render frame.
|
||||||
|
static thread_local std::chrono::steady_clock::time_point retry_after;
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
if (now >= retry_after) {
|
||||||
|
int32_t display = 0;
|
||||||
|
double native_x = px, native_y = py;
|
||||||
|
if (OH_Input_GetPointerLocation(&display, &native_x, &native_y) == INPUT_SUCCESS) {
|
||||||
|
px = native_x; py = native_y;
|
||||||
|
for (const auto& d : ReadDisplays(env)) if (d.id == display) { px += d.x; py += d.y; break; }
|
||||||
|
} else retry_after = now + std::chrono::seconds(1);
|
||||||
|
}
|
||||||
|
if (x) *x = px;
|
||||||
|
if (y) *y = py;
|
||||||
|
return env.value("buttons", 0u);
|
||||||
|
}
|
||||||
|
bool SDL_OpenURL(const char* url) { return Request({{"op", "openUrl"}, {"url", url}}).value("ok", false); }
|
||||||
|
struct SDL_AudioStream { crossdesk::harmony::AudioPlayer player; };
|
||||||
|
uint32_t SDL_OpenAudioDevice(uint32_t, const SDL_AudioSpec* spec) {
|
||||||
|
if (AudioFormat(spec)) return 1;
|
||||||
|
service_error = "Harmony playback requires 48 kHz mono PCM16"; return 0;
|
||||||
|
}
|
||||||
|
bool SDL_ResumeAudioDevice(uint32_t id) { return id == 1; }
|
||||||
|
void SDL_CloseAudioDevice(uint32_t) {}
|
||||||
|
SDL_AudioStream* SDL_CreateAudioStream(const SDL_AudioSpec* in, const SDL_AudioSpec* out) {
|
||||||
|
if (!AudioFormat(in) || !AudioFormat(out)) return nullptr;
|
||||||
|
return new SDL_AudioStream();
|
||||||
|
}
|
||||||
|
void SDL_DestroyAudioStream(SDL_AudioStream* stream) { delete stream; }
|
||||||
|
bool SDL_BindAudioStream(uint32_t id, SDL_AudioStream* stream) {
|
||||||
|
if (id == 1 && stream && stream->player.Start()) return true;
|
||||||
|
service_error = "OH_AudioRenderer initialization failed"; return false;
|
||||||
|
}
|
||||||
|
bool SDL_PutAudioStreamData(SDL_AudioStream* stream, const void* data, int size) {
|
||||||
|
if (!stream || !data || size <= 0) return false;
|
||||||
|
stream->player.Push(static_cast<const char*>(data), size); return true;
|
||||||
|
}
|
||||||
|
const char* tinyfd_selectFolderDialog(const char* title, const char* path) {
|
||||||
|
static thread_local std::string result;
|
||||||
|
result = Request({{"op", "selectFolder"}, {"title", title ? title : ""},
|
||||||
|
{"path", path ? path : ""}}).value("path", "");
|
||||||
|
return result.empty() ? nullptr : result.c_str();
|
||||||
|
}
|
||||||
|
const char* tinyfd_openFileDialog(const char* title, const char*, int, const char* const*, const char*, int) {
|
||||||
|
static thread_local std::string result;
|
||||||
|
result = Request({{"op", "selectFile"}, {"title", title ? title : ""}}).value("path", "");
|
||||||
|
return result.empty() ? nullptr : result.c_str();
|
||||||
|
}
|
||||||
|
namespace crossdesk {
|
||||||
|
HarmonyAutostartStatus GetHarmonyAutostartStatus() { return autostart_status.load(); }
|
||||||
|
void SetHarmonyAutostartStatus(HarmonyAutostartStatus status) { autostart_status = status; }
|
||||||
|
void RefreshHarmonyAutostartStatus() {
|
||||||
|
autostart_status = HarmonyAutostartStatus::checking;
|
||||||
|
Emit({{"op", "autostartRefresh"}});
|
||||||
|
}
|
||||||
|
std::string GetHostName() { return Environment().value("name", "Harmony PC"); }
|
||||||
|
std::string GetMac() { return {}; } // App sandbox does not expose a hardware MAC.
|
||||||
|
bool IsWaylandSession() { return false; }
|
||||||
|
namespace platform {
|
||||||
|
std::filesystem::path GetExecutableDirectory() { return Environment().value("filesDir", ""); }
|
||||||
|
std::filesystem::path GetConfigPath(const std::string&) { return GetCachePath(""); }
|
||||||
|
std::filesystem::path GetCachePath(const std::string&) {
|
||||||
|
auto path = GetExecutableDirectory() / "desktop";
|
||||||
|
std::filesystem::create_directories(path); return path;
|
||||||
|
}
|
||||||
|
std::filesystem::path GetLogPath(const std::string&) {
|
||||||
|
auto path = GetCachePath("") / "logs";
|
||||||
|
std::filesystem::create_directories(path); return path;
|
||||||
|
}
|
||||||
|
std::string GetAutostartExecutablePath() { return {}; }
|
||||||
|
bool IsAutostartEnabled(const std::string&) {
|
||||||
|
return GetHarmonyAutostartStatus() == HarmonyAutostartStatus::enabled;
|
||||||
|
}
|
||||||
|
// The public Harmony API can query this setting, but only the user can change
|
||||||
|
// it in system settings. Never report a registration change we did not make.
|
||||||
|
bool EnableAutostart(const std::string& app, const std::string&) { return IsAutostartEnabled(app); }
|
||||||
|
bool DisableAutostart(const std::string& app) {
|
||||||
|
return GetHarmonyAutostartStatus() == HarmonyAutostartStatus::disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// GLES presentation only; all UI and callbacks belong to GuiApplication.
|
||||||
|
struct Surface {
|
||||||
|
DesktopWindow& owner;
|
||||||
|
explicit Surface(DesktopWindow& window) : owner(window) {}
|
||||||
|
~Surface() { DetachSurface(); }
|
||||||
|
EGLDisplay display = EGL_NO_DISPLAY;
|
||||||
|
EGLContext context = EGL_NO_CONTEXT;
|
||||||
|
EGLSurface surface = EGL_NO_SURFACE;
|
||||||
|
OHNativeWindow* native_window = nullptr;
|
||||||
|
GLuint program = 0, texture = 0, vao = 0;
|
||||||
|
float density = 1;
|
||||||
|
std::vector<slint::Rgb8Pixel> pixels;
|
||||||
|
uint64_t frames = 0;
|
||||||
|
uint32_t rendered_width = 0, rendered_height = 0;
|
||||||
|
void DetachSurface() {
|
||||||
|
if (display != EGL_NO_DISPLAY) {
|
||||||
|
if (surface != EGL_NO_SURFACE && context != EGL_NO_CONTEXT) {
|
||||||
|
eglMakeCurrent(display, surface, surface, context);
|
||||||
|
glDeleteTextures(1, &texture); glDeleteProgram(program); glDeleteVertexArrays(1, &vao);
|
||||||
|
}
|
||||||
|
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||||
|
if (surface != EGL_NO_SURFACE) eglDestroySurface(display, surface);
|
||||||
|
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
|
||||||
|
// EGL display is shared by the application windows.
|
||||||
|
}
|
||||||
|
if (native_window) OH_NativeWindow_DestroyNativeWindow(native_window);
|
||||||
|
display = EGL_NO_DISPLAY; context = EGL_NO_CONTEXT; surface = EGL_NO_SURFACE;
|
||||||
|
native_window = nullptr; program = texture = vao = 0;
|
||||||
|
// ReusedBuffer's retained contents belong to the Slint window, not the EGL
|
||||||
|
// surface. Keep them when a hidden window gets a new XComponent; otherwise
|
||||||
|
// Slint repaints only dirty regions over a cleared (black) buffer.
|
||||||
|
frames = 0;
|
||||||
|
rendered_width = rendered_height = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
GLuint Shader(GLenum type, const char* source) {
|
||||||
|
GLuint value = glCreateShader(type);
|
||||||
|
glShaderSource(value, 1, &source, nullptr); glCompileShader(value);
|
||||||
|
GLint ok = 0; glGetShaderiv(value, GL_COMPILE_STATUS, &ok);
|
||||||
|
if (!ok) {
|
||||||
|
char log[1024]{}; glGetShaderInfoLog(value, sizeof(log), nullptr, log);
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "SlintDesktop", "Shader failed: %{public}s", log);
|
||||||
|
glDeleteShader(value); return 0;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AttachSurface(uint64_t id, float scale) {
|
||||||
|
DetachSurface();
|
||||||
|
if (!id || !std::isfinite(scale) || scale <= 0) return false;
|
||||||
|
owner.SetDensity(scale);
|
||||||
|
density = scale;
|
||||||
|
if (OH_NativeWindow_CreateNativeWindowFromSurfaceId(id, &native_window) != 0) return false;
|
||||||
|
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||||
|
if (!eglInitialize(display, nullptr, nullptr) || !eglBindAPI(EGL_OPENGL_ES_API)) return false;
|
||||||
|
const EGLint attributes[] = {EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE,
|
||||||
|
EGL_OPENGL_ES3_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
|
||||||
|
EGLConfig config; EGLint count = 0;
|
||||||
|
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return false;
|
||||||
|
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
|
||||||
|
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
|
||||||
|
surface = eglCreateWindowSurface(display, config, reinterpret_cast<EGLNativeWindowType>(native_window), nullptr);
|
||||||
|
if (context == EGL_NO_CONTEXT || surface == EGL_NO_SURFACE ||
|
||||||
|
!eglMakeCurrent(display, surface, surface, context)) return false;
|
||||||
|
const char* vertex = "#version 300 es\n"
|
||||||
|
"out vec2 uv; void main(){ vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);"
|
||||||
|
"uv=vec2(p.x,1.-p.y); gl_Position=vec4(p*2.-1.,0.,1.);}";
|
||||||
|
const char* fragment = "#version 300 es\nprecision mediump float;"
|
||||||
|
"in vec2 uv; uniform sampler2D frame; out vec4 color; void main(){color=texture(frame,uv);}";
|
||||||
|
GLuint v = Shader(GL_VERTEX_SHADER, vertex), f = Shader(GL_FRAGMENT_SHADER, fragment);
|
||||||
|
if (!v || !f) { glDeleteShader(v); glDeleteShader(f); return false; }
|
||||||
|
program = glCreateProgram(); glAttachShader(program, v); glAttachShader(program, f);
|
||||||
|
glLinkProgram(program); glDeleteShader(v); glDeleteShader(f);
|
||||||
|
GLint linked = 0; glGetProgramiv(program, GL_LINK_STATUS, &linked);
|
||||||
|
if (!linked) return false;
|
||||||
|
glGenVertexArrays(1, &vao); glBindVertexArray(vao);
|
||||||
|
glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||||
|
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||||
|
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Surface attached scale=%{public}.3f", density);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Render() {
|
||||||
|
if (surface == EGL_NO_SURFACE) return;
|
||||||
|
EGLint width = 0, height = 0;
|
||||||
|
eglQuerySurface(display, surface, EGL_WIDTH, &width);
|
||||||
|
eglQuerySurface(display, surface, EGL_HEIGHT, &height);
|
||||||
|
if (width <= 0 || height <= 0 || width > 8192 || height > 8192) return;
|
||||||
|
if (rendered_width != uint32_t(width) || rendered_height != uint32_t(height)) {
|
||||||
|
rendered_width = width; rendered_height = height;
|
||||||
|
owner.extent = slint::PhysicalSize({uint32_t(width), uint32_t(height)});
|
||||||
|
owner.window().dispatch_resize_event(slint::LogicalSize({width / density, height / density}));
|
||||||
|
pixels.resize(size_t(width) * height); owner.dirty = true;
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Window %{public}d resize %{public}dx%{public}d", owner.id, width, height);
|
||||||
|
}
|
||||||
|
if (!owner.dirty && !owner.window().has_active_animations()) return;
|
||||||
|
if (pixels.size() != size_t(width) * height) pixels.resize(size_t(width) * height);
|
||||||
|
const auto started = std::chrono::steady_clock::now();
|
||||||
|
owner.dirty = false;
|
||||||
|
owner.software.render(pixels, width);
|
||||||
|
if (!eglMakeCurrent(display, surface, surface, context)) return;
|
||||||
|
glViewport(0, 0, width, height); glUseProgram(program);
|
||||||
|
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texture);
|
||||||
|
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||||
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, pixels.data());
|
||||||
|
glUniform1i(glGetUniformLocation(program, "frame"), 0);
|
||||||
|
glBindVertexArray(vao); glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||||
|
const GLenum error = glGetError();
|
||||||
|
const bool swapped = eglSwapBuffers(display, surface);
|
||||||
|
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||||
|
if (error || !swapped) {
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_ERROR, 0, "SlintDesktop", "Present failed gl=%{public}x egl=%{public}x", error, eglGetError());
|
||||||
|
} else if (++frames == 1 || frames % 120 == 0) {
|
||||||
|
double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - started).count();
|
||||||
|
OH_LOG_Print(LOG_APP, LOG_INFO, 0, "SlintDesktop", "Presented frames=%{public}llu frame_ms=%{public}.2f", (unsigned long long)frames, ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#include "platform/harmony/gui/tray/harmony_tray.h"
|
||||||
|
#include "bridge.h"
|
||||||
|
#include "localization.h"
|
||||||
|
#include "rd_log.h"
|
||||||
|
|
||||||
|
namespace crossdesk {
|
||||||
|
namespace { HarmonyTray* current = nullptr; }
|
||||||
|
HarmonyTray::HarmonyTray(std::function<void()> show, std::function<void()> hide,
|
||||||
|
std::function<void()> settings, std::function<void()> exit,
|
||||||
|
const std::string& tooltip, int language)
|
||||||
|
: show_(std::move(show)), hide_(std::move(hide)),
|
||||||
|
settings_(std::move(settings)), exit_(std::move(exit)) {
|
||||||
|
current = this;
|
||||||
|
language = localization::detail::ClampLanguageIndex(language);
|
||||||
|
auto result = harmony::desktop::Request({{"op", "trayCreate"}, {"title", tooltip},
|
||||||
|
{"show", localization::show_main_window[language]},
|
||||||
|
{"settings", localization::settings[language]},
|
||||||
|
{"exit", localization::exit_program[language]}});
|
||||||
|
active_ = result.value("ok", false);
|
||||||
|
if (!active_) LOG_WARN("Harmony tray unavailable: {}", result.value("message", "unsupported"));
|
||||||
|
}
|
||||||
|
HarmonyTray::~HarmonyTray() { RemoveTrayIcon(); if (current == this) current = nullptr; }
|
||||||
|
bool HarmonyTray::MinimizeToTray() {
|
||||||
|
if (!active_) return false;
|
||||||
|
hide_();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void HarmonyTray::RemoveTrayIcon() {
|
||||||
|
if (!active_) return;
|
||||||
|
active_ = false;
|
||||||
|
harmony::desktop::Emit({{"op", "trayRemove"}});
|
||||||
|
}
|
||||||
|
void HarmonyTray::Dispatch(const std::string& action) {
|
||||||
|
if (!current) return;
|
||||||
|
if (action == "show") current->show_();
|
||||||
|
else if (action == "settings") current->settings_();
|
||||||
|
else if (action == "exit") current->exit_();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
--- a/api/cpp/platform.rs
|
||||||
|
+++ b/api/cpp/platform.rs
|
||||||
|
@@ -98,6 +98,8 @@
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_window_properties(&self, properties: WindowProperties<'_>) {
|
||||||
|
+ #[cfg(target_env = "ohos")]
|
||||||
|
+ harmony_services::window_chrome(self);
|
||||||
|
unsafe { (self.update_window_properties)(self.user_data, &properties) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -206,6 +208,16 @@
|
||||||
|
pub extern "C" fn crossdesk_slint_set_os_callback(callback: HarmonyOsCallback) {
|
||||||
|
CALLBACK.set(Some(callback));
|
||||||
|
}
|
||||||
|
+ // Read these properties inside the normal property tracker so subsequent
|
||||||
|
+ // Slint changes propagate without a separate UI/window state machine.
|
||||||
|
+ pub(super) fn window_chrome(adapter: &CppWindowAdapter) {
|
||||||
|
+ let Some(callback) = CALLBACK.get() else { return };
|
||||||
|
+ let Some(item) = WindowInner::from_pub(&adapter.window).window_item() else { return };
|
||||||
|
+ let item = item.as_pin_ref();
|
||||||
|
+ let flags = i32::from(item.no_frame()) | (i32::from(item.always_on_top()) << 1);
|
||||||
|
+ unsafe { callback(adapter.user_data, adapter as *const _ as *const c_void, 4,
|
||||||
|
+ core::ptr::null(), 0, 0, 0, 0., 0., 0., 0., flags); }
|
||||||
|
+ }
|
||||||
|
impl WindowAdapterInternal for CppWindowAdapter {
|
||||||
|
fn input_method_request(&self, request: InputMethodRequest) {
|
||||||
|
let Some(callback) = CALLBACK.get() else { return };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* @Author: DI JUNKUN
|
||||||
|
* @Date: 2026-10-15
|
||||||
|
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef _WINDOW_MODE_H_
|
||||||
|
#define _WINDOW_MODE_H_
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
namespace crossdesk::harmony::desktop {
|
||||||
|
// Slint setters synchronously notify the platform one property at a time.
|
||||||
|
// A native state snapshot must be acknowledged atomically without echoing
|
||||||
|
// those intermediate combinations back to the native compositor.
|
||||||
|
class WindowMode {
|
||||||
|
nlohmann::json known_ = nlohmann::json::object();
|
||||||
|
bool acknowledging_ = false;
|
||||||
|
public:
|
||||||
|
nlohmann::json Request(const nlohmann::json& desired) {
|
||||||
|
auto changes = nlohmann::json::object();
|
||||||
|
if (acknowledging_) return changes;
|
||||||
|
for (const auto& [name, value] : desired.items())
|
||||||
|
if (!known_.contains(name) || known_.at(name) != value) changes[name] = value;
|
||||||
|
known_ = desired;
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
template <typename Apply>
|
||||||
|
void Acknowledge(const nlohmann::json& actual, Apply apply) {
|
||||||
|
known_.update(actual);
|
||||||
|
struct Reset { bool& flag; ~Reset() { flag = false; } } reset{acknowledging_};
|
||||||
|
acknowledging_ = true;
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"apiType": "stageMode",
|
||||||
|
"buildOption": {
|
||||||
|
"externalNativeOptions": {
|
||||||
|
"path": "./src/main/cpp/CMakeLists.txt",
|
||||||
|
"arguments": "",
|
||||||
|
"cppFlags": "-std=c++17",
|
||||||
|
"abiFilters": [
|
||||||
|
"arm64-v8a"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { hapTasks } from '@ohos/hvigor-ohos-plugin';
|
||||||
|
export default { system: hapTasks, plugins: [] };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"stableOrder": true,
|
||||||
|
"enableUnifiedLockfile": false
|
||||||
|
},
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||||
|
"specifiers": {
|
||||||
|
"libcrossdesk.so@src/main/cpp/types/libcrossdesk": "libcrossdesk.so@src/main/cpp/types/libcrossdesk"
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"libcrossdesk.so@src/main/cpp/types/libcrossdesk": {
|
||||||
|
"name": "libcrossdesk.so",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "src/main/cpp/types/libcrossdesk",
|
||||||
|
"registryType": "local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "entry",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"libcrossdesk.so": "file:./src/main/cpp/types/libcrossdesk"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
project(crossdesk_harmony LANGUAGES CXX)
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
get_filename_component(HARMONY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../.." ABSOLUTE)
|
||||||
|
set(CORE_LIBRARY "${HARMONY_ROOT}/entry/libs/${OHOS_ARCH}/libcrossdesk_harmony_core.so")
|
||||||
|
if(NOT EXISTS "${CORE_LIBRARY}")
|
||||||
|
message(FATAL_ERROR "Build MiniRTC first: python3 apps/harmony/scripts/build.py --mobile --native-only")
|
||||||
|
endif()
|
||||||
|
add_library(crossdesk_core SHARED IMPORTED)
|
||||||
|
set_target_properties(crossdesk_core PROPERTIES
|
||||||
|
IMPORTED_LOCATION "${CORE_LIBRARY}"
|
||||||
|
IMPORTED_SONAME "libcrossdesk_harmony_core.so")
|
||||||
|
add_library(crossdesk SHARED napi_init.cpp)
|
||||||
|
# HAP libraries are resolved by the app's linker namespace. The host build
|
||||||
|
# directory is not a useful runtime search path on a HarmonyOS device.
|
||||||
|
set_target_properties(crossdesk PROPERTIES SKIP_BUILD_RPATH TRUE)
|
||||||
|
target_include_directories(crossdesk PRIVATE "${HARMONY_ROOT}/native")
|
||||||
|
target_link_libraries(crossdesk PRIVATE crossdesk_core libace_napi.z.so)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#include <napi/native_api.h>
|
||||||
|
#include "mobile_session.h"
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
napi_value Command(napi_env env, napi_callback_info info) {
|
||||||
|
size_t count = 1; napi_value argument;
|
||||||
|
if (napi_get_cb_info(env, info, &count, &argument, nullptr, nullptr) != napi_ok || count != 1) {
|
||||||
|
napi_throw_type_error(env, nullptr, "Expected a JSON command string"); return nullptr;
|
||||||
|
}
|
||||||
|
size_t size = 0;
|
||||||
|
if (napi_get_value_string_utf8(env, argument, nullptr, 0, &size) != napi_ok || size > 1024 * 1024) {
|
||||||
|
napi_throw_type_error(env, nullptr, "Invalid command"); return nullptr;
|
||||||
|
}
|
||||||
|
std::string value(size + 1, '\0');
|
||||||
|
napi_get_value_string_utf8(env, argument, value.data(), value.size(), &size); value.resize(size);
|
||||||
|
crossdesk::harmony::MobileCommand(value);
|
||||||
|
napi_value result; napi_get_undefined(env, &result); return result;
|
||||||
|
}
|
||||||
|
napi_value Poll(napi_env env, napi_callback_info) {
|
||||||
|
const std::string data = crossdesk::harmony::MobilePoll();
|
||||||
|
napi_value result;
|
||||||
|
napi_create_string_utf8(env, data.data(), data.size(), &result); return result;
|
||||||
|
}
|
||||||
|
napi_value Init(napi_env env, napi_value exports) {
|
||||||
|
napi_property_descriptor properties[] = {
|
||||||
|
{"mobileCommand", nullptr, Command, nullptr, nullptr, nullptr, napi_default, nullptr},
|
||||||
|
{"mobilePoll", nullptr, Poll, nullptr, nullptr, nullptr, napi_default, nullptr}};
|
||||||
|
napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties);
|
||||||
|
napi_add_env_cleanup_hook(env, [](void*) {
|
||||||
|
crossdesk::harmony::MobileShutdown();
|
||||||
|
}, nullptr);
|
||||||
|
return exports;
|
||||||
|
}
|
||||||
|
napi_module module = {1, 0, nullptr, Init, "crossdesk", nullptr, {0}};
|
||||||
|
}
|
||||||
|
extern "C" __attribute__((constructor)) void RegisterCrossDesk() { napi_module_register(&module); }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const mobileCommand: (json: string) => void;
|
||||||
|
export const mobilePoll: () => string;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "libcrossdesk.so",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"types": "index.d.ts"
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { announcementByteLength } from './AnnouncementText';
|
||||||
|
|
||||||
|
export interface AnnouncementRevision { id: number; revision: number; }
|
||||||
|
export interface AnnouncementItem extends AnnouncementRevision { title: string; body: string; updated_at: number; read?: boolean; }
|
||||||
|
export interface AnnouncementResponse {
|
||||||
|
type: string; request_id: string; summary_only: boolean; offset: number; total: number;
|
||||||
|
catalog_revision: number; items: AnnouncementItem[]; error?: string;
|
||||||
|
}
|
||||||
|
export interface AnnouncementRequest { type: string; request_id: string; summary_only: boolean; offset: number; }
|
||||||
|
interface LocalRevision { id: number; revision: number; }
|
||||||
|
interface LocalState { scope: string; reads: LocalRevision[]; dismissed: LocalRevision[]; }
|
||||||
|
// Revisions, pagination, stale replies and local dismissal follow AnnouncementInbox.java.
|
||||||
|
export class AnnouncementInbox {
|
||||||
|
items: AnnouncementItem[] = [];
|
||||||
|
total: number = 0;
|
||||||
|
unread: number = 0;
|
||||||
|
loaded: boolean = false;
|
||||||
|
loading: boolean = false;
|
||||||
|
failed: boolean = false;
|
||||||
|
saveFailed: boolean = false;
|
||||||
|
connected: boolean = false;
|
||||||
|
changed: () => void = () => {};
|
||||||
|
private scope: string = '';
|
||||||
|
private reads: Map<number, number> = new Map();
|
||||||
|
private dismissed: Map<number, number> = new Map();
|
||||||
|
private catalog: Map<number, number> = new Map();
|
||||||
|
private pending: Map<number, number> = new Map();
|
||||||
|
private pageIds: Map<number, number[]> = new Map();
|
||||||
|
private bodies: Map<number, AnnouncementItem[]> = new Map();
|
||||||
|
private revision: number = -1;
|
||||||
|
private pendingRevision: number = -1;
|
||||||
|
private pendingTotal: number = 0;
|
||||||
|
private pageOffset: number = 0;
|
||||||
|
private limit: number = 20;
|
||||||
|
private summary: boolean = true;
|
||||||
|
private requestId: string = '';
|
||||||
|
private sequence: number = 0;
|
||||||
|
private timeout: number = -1;
|
||||||
|
private send: (request: AnnouncementRequest) => void;
|
||||||
|
private load: (scope: string) => string;
|
||||||
|
private save: (scope: string, body: string) => void;
|
||||||
|
constructor(send: (request: AnnouncementRequest) => void, load: (scope: string) => string, save: (scope: string, body: string) => void) {
|
||||||
|
this.send = send; this.load = load; this.save = save;
|
||||||
|
}
|
||||||
|
configure(scope: string): void {
|
||||||
|
if (this.scope === scope) return;
|
||||||
|
this.reset(); this.scope = scope;
|
||||||
|
try {
|
||||||
|
const local = JSON.parse(this.load(scope)) as LocalState;
|
||||||
|
if (local.scope === scope) {
|
||||||
|
local.reads.forEach((v: LocalRevision) => { if (v.id > 0 && v.revision > 0) this.reads.set(v.id, v.revision); });
|
||||||
|
local.dismissed.forEach((v: LocalRevision) => { if (v.id > 0 && v.revision > 0) this.dismissed.set(v.id, v.revision); });
|
||||||
|
}
|
||||||
|
} catch { /* First use or corrupt local metadata. Content always comes from the server. */ }
|
||||||
|
}
|
||||||
|
reset(): void {
|
||||||
|
this.cancel(); this.connected = false; this.scope = ''; this.items = []; this.catalog.clear(); this.pending.clear();
|
||||||
|
this.reads.clear(); this.dismissed.clear(); this.pageIds.clear(); this.bodies.clear(); this.revision = this.pendingRevision = -1;
|
||||||
|
this.total = this.unread = 0; this.limit = 20; this.loaded = this.failed = this.saveFailed = false; this.changed();
|
||||||
|
}
|
||||||
|
connect(value: boolean): void {
|
||||||
|
if (value === this.connected) return;
|
||||||
|
this.connected = value; if (value) this.refresh(); else this.cancel(); this.changed();
|
||||||
|
}
|
||||||
|
refresh(): void { this.cancel(); this.pending.clear(); this.pendingRevision = -1; this.pendingTotal = 0; this.summary = true; this.request(0); this.changed(); }
|
||||||
|
more(): void { if (!this.connected || this.loading || this.failed || this.limit >= this.total) return; this.limit += Math.min(20, this.total - this.limit); this.rebuild(); this.changed(); }
|
||||||
|
markRead(item: AnnouncementItem): void {
|
||||||
|
if (!this.items.some((v: AnnouncementItem) => v.id === item.id && v.revision === item.revision)) return;
|
||||||
|
const next = new Map(this.reads); next.set(item.id, item.revision);
|
||||||
|
if (this.persist(next, this.dismissed)) { this.reads = next; this.reading(); } this.changed();
|
||||||
|
}
|
||||||
|
dismiss(item: AnnouncementItem): void {
|
||||||
|
if (!this.items.some((v: AnnouncementItem) => v.id === item.id && v.revision === item.revision)) return;
|
||||||
|
const next = new Map(this.dismissed); next.set(item.id, item.revision);
|
||||||
|
if (this.persist(this.reads, next)) { this.dismissed = next; this.cancel(); this.rebuild(); } this.changed();
|
||||||
|
}
|
||||||
|
private persist(reads: Map<number, number>, dismissed: Map<number, number>): boolean {
|
||||||
|
const body: LocalState = { scope: this.scope, reads: [], dismissed: [] };
|
||||||
|
reads.forEach((revision: number, id: number) => body.reads.push({ id: id, revision: revision }));
|
||||||
|
dismissed.forEach((revision: number, id: number) => body.dismissed.push({ id: id, revision: revision }));
|
||||||
|
try { this.save(this.scope, JSON.stringify(body)); this.saveFailed = false; return true; }
|
||||||
|
catch { this.saveFailed = true; return false; }
|
||||||
|
}
|
||||||
|
fail(id: string): void { if (id && id === this.requestId) { this.cancel(); this.failed = true; this.changed(); } }
|
||||||
|
receive(message: AnnouncementResponse): void {
|
||||||
|
if (!this.connected || !this.requestId || message.request_id !== this.requestId) return;
|
||||||
|
const request = this.requestId;
|
||||||
|
try {
|
||||||
|
if (message.type !== 'announcements' || message.error || message.summary_only !== this.summary ||
|
||||||
|
!Number.isSafeInteger(message.offset) || !Number.isSafeInteger(message.total) || !Number.isSafeInteger(message.catalog_revision) ||
|
||||||
|
message.offset < 0 || message.total < message.offset || message.total > 1000000 || message.catalog_revision < 0 ||
|
||||||
|
!Array.isArray(message.items) || message.items.length !== Math.min(this.summary ? 200 : 20, message.total - message.offset)) throw new Error('Invalid announcement page');
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const item of message.items) {
|
||||||
|
if (!Number.isSafeInteger(item.id) || !Number.isSafeInteger(item.revision) || item.id <= 0 || item.revision <= 0 || ids.includes(item.id)) throw new Error('Invalid revision');
|
||||||
|
ids.push(item.id);
|
||||||
|
}
|
||||||
|
const expected = this.summary ? this.pendingRevision : this.revision;
|
||||||
|
if (expected >= 0 && expected !== message.catalog_revision) { this.refresh(); return; }
|
||||||
|
if (this.summary) {
|
||||||
|
if (message.offset !== this.pending.size || (this.pendingRevision >= 0 && this.pendingTotal !== message.total)) throw new Error('Invalid offset');
|
||||||
|
for (const item of message.items) { if (this.pending.has(item.id)) throw new Error('Duplicate item'); this.pending.set(item.id, item.revision); }
|
||||||
|
this.pendingRevision = message.catalog_revision; this.pendingTotal = message.total; this.cancel();
|
||||||
|
if (this.pending.size < this.pendingTotal) { this.request(this.pending.size); this.changed(); return; }
|
||||||
|
if (this.revision !== this.pendingRevision) { this.pageIds.clear(); this.bodies.clear(); }
|
||||||
|
this.catalog = new Map(this.pending); this.revision = this.pendingRevision;
|
||||||
|
} else {
|
||||||
|
if (message.offset !== this.pageOffset || message.total !== this.catalog.size) throw new Error('Invalid body page');
|
||||||
|
for (const item of message.items) {
|
||||||
|
if (this.catalog.get(item.id) !== item.revision || typeof item.title !== 'string' || typeof item.body !== 'string' ||
|
||||||
|
announcementByteLength(item.title) > 240 || announcementByteLength(item.body) > 8000 || !Number.isSafeInteger(item.updated_at) || item.updated_at <= 0) throw new Error('Invalid body');
|
||||||
|
this.pageIds.forEach((entries: number[], offset: number) => { if (offset !== message.offset && entries.includes(item.id)) throw new Error('Duplicate page'); });
|
||||||
|
}
|
||||||
|
this.cancel(); this.pageIds.set(message.offset, ids); this.bodies.set(message.offset, message.items);
|
||||||
|
}
|
||||||
|
this.failed = false; this.rebuild(); this.changed();
|
||||||
|
} catch { this.fail(request); }
|
||||||
|
}
|
||||||
|
private reading(): void {
|
||||||
|
this.total = this.unread = 0;
|
||||||
|
this.catalog.forEach((revision: number, id: number) => {
|
||||||
|
if (this.dismissed.get(id) === revision) return;
|
||||||
|
this.total++; if (this.reads.get(id) !== revision) this.unread++;
|
||||||
|
});
|
||||||
|
this.items.forEach((item: AnnouncementItem) => item.read = this.reads.get(item.id) === item.revision);
|
||||||
|
}
|
||||||
|
private rebuild(): void {
|
||||||
|
this.reading(); const wanted = Math.min(this.limit, this.total); const visible: AnnouncementItem[] = [];
|
||||||
|
this.summary = false; let next = -1;
|
||||||
|
for (let offset = 0; offset < this.catalog.size && visible.length < wanted; offset += 20) {
|
||||||
|
const ids = this.pageIds.get(offset); if (!ids) { next = offset; break; }
|
||||||
|
const selected = ids.filter((id: number) => this.dismissed.get(id) !== this.catalog.get(id));
|
||||||
|
if (!selected.length) { this.bodies.delete(offset); continue; }
|
||||||
|
const body = this.bodies.get(offset); if (!body) { next = offset; break; }
|
||||||
|
for (const item of body) if (selected.includes(item.id) && visible.length < wanted) visible.push(item);
|
||||||
|
}
|
||||||
|
this.items = visible; this.loaded = true; this.reading();
|
||||||
|
if (next >= 0) { this.pageOffset = next; this.request(next); }
|
||||||
|
}
|
||||||
|
private request(offset: number): void {
|
||||||
|
if (!this.connected || !this.scope) return;
|
||||||
|
this.requestId = `hm-${++this.sequence}-${Date.now()}`; this.loading = true; this.failed = false;
|
||||||
|
const id = this.requestId; this.timeout = setTimeout(() => this.fail(id), 12000);
|
||||||
|
this.send({ type: 'announcements_list', request_id: id, summary_only: this.summary, offset: offset });
|
||||||
|
}
|
||||||
|
private cancel(): void { if (this.timeout >= 0) clearTimeout(this.timeout); this.timeout = -1; this.requestId = ''; this.loading = false; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export interface AnnouncementSpan { text: string; url: string; }
|
||||||
|
|
||||||
|
// Same restricted plain-text/link grammar as Android AnnouncementText.java.
|
||||||
|
export function isAnnouncementURL(address: string): boolean {
|
||||||
|
return !/[\x00-\x20\x7f\\<>\"]/.test(address) &&
|
||||||
|
/^https?:\/\/(?:[^\/?#]*@)?(?:\[[0-9a-f:.]+\]|[^\/?#:@\[\]]+)(?::[0-9]+)?(?:[\/?#]|$)/i.test(address);
|
||||||
|
}
|
||||||
|
function hasScheme(text: string, start: number): boolean {
|
||||||
|
return /^https?:\/\//i.test(text.substring(start, start + 8));
|
||||||
|
}
|
||||||
|
function urlEnd(text: string, start: number): number {
|
||||||
|
let parentheses = 0, brackets = 0, end = start;
|
||||||
|
for (; end < text.length; end++) {
|
||||||
|
const c = text.charAt(end);
|
||||||
|
if (c.charCodeAt(0) <= 0x20 || c.charCodeAt(0) === 0x7f || c === '\\' || c === '`' ||
|
||||||
|
'<>\"\'{},。;:!?、()【】《》「」『』“”‘’'.includes(c)) break;
|
||||||
|
if (c === '(') parentheses++;
|
||||||
|
if (c === ')') { if (parentheses === 0) break; parentheses--; }
|
||||||
|
if (c === '[') brackets++;
|
||||||
|
if (c === ']') { if (brackets === 0) break; brackets--; }
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
export function announcementSpans(body: string): AnnouncementSpan[] {
|
||||||
|
const text = body.replace(/\r\n/g, '\n'), result: AnnouncementSpan[] = [];
|
||||||
|
let plain = '';
|
||||||
|
for (let position = 0; position < text.length;) {
|
||||||
|
let label = '', address = '', next = position;
|
||||||
|
if (text.charAt(position) === '[') {
|
||||||
|
const labelEnd = text.indexOf(']', position + 1), start = labelEnd + 2;
|
||||||
|
if (labelEnd > position + 1 && start < text.length && text.charAt(labelEnd + 1) === '(' &&
|
||||||
|
!/[\[\]\r\n]/.test(text.substring(position + 1, labelEnd)) && hasScheme(text, start)) {
|
||||||
|
const end = urlEnd(text, start), url = text.substring(start, end);
|
||||||
|
if (text.charAt(end) === ')' && isAnnouncementURL(url)) {
|
||||||
|
label = text.substring(position + 1, labelEnd); address = url; next = end + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!address && hasScheme(text, position)) {
|
||||||
|
let end = urlEnd(text, position);
|
||||||
|
while (end > position && '.,;:!?'.includes(text.charAt(end - 1))) end--;
|
||||||
|
const url = text.substring(position, end);
|
||||||
|
if (isAnnouncementURL(url)) { label = address = url; next = end; }
|
||||||
|
}
|
||||||
|
if (address) {
|
||||||
|
if (plain) { result.push({ text: plain, url: '' }); plain = ''; }
|
||||||
|
result.push({ text: label, url: address }); position = next;
|
||||||
|
} else plain += text.charAt(position++);
|
||||||
|
}
|
||||||
|
if (plain) result.push({ text: plain, url: '' });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function announcementByteLength(text: string): number {
|
||||||
|
let bytes = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const c = text.charCodeAt(i);
|
||||||
|
if (c < 0x80) bytes++;
|
||||||
|
else if (c < 0x800) bytes += 2;
|
||||||
|
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length &&
|
||||||
|
text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) { bytes += 4; i++; }
|
||||||
|
else bytes += c >= 0xd800 && c <= 0xdfff ? 1 : 3; // Java UTF-8 replaces lone surrogates with '?'.
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// Platform-independent controller rules ported from the Android client.
|
||||||
|
// Keep gesture geometry, acknowledgement ordering and expiry behavior identical.
|
||||||
|
export function clamp(value: number, low: number, high: number): number {
|
||||||
|
return Math.max(low, Math.min(high, value));
|
||||||
|
}
|
||||||
|
export class RemoteViewport {
|
||||||
|
baseWidth: number = 1;
|
||||||
|
baseHeight: number = 1;
|
||||||
|
baseLeft: number = 0;
|
||||||
|
baseTop: number = 0;
|
||||||
|
scale: number = 1;
|
||||||
|
offsetX: number = 0;
|
||||||
|
offsetY: number = 0;
|
||||||
|
fit(width: number, height: number, videoWidth: number, videoHeight: number): void {
|
||||||
|
if (Math.min(width, height, videoWidth, videoHeight) <= 0) return;
|
||||||
|
const scale = Math.min(width / videoWidth, height / videoHeight);
|
||||||
|
this.baseWidth = Math.max(1, videoWidth * scale); this.baseHeight = Math.max(1, videoHeight * scale);
|
||||||
|
this.baseLeft = (width - this.baseWidth) / 2; this.baseTop = (height - this.baseHeight) / 2;
|
||||||
|
this.constrain();
|
||||||
|
}
|
||||||
|
width(): number { return this.baseWidth * this.scale; }
|
||||||
|
height(): number { return this.baseHeight * this.scale; }
|
||||||
|
left(): number { return this.baseLeft + this.baseWidth * (1 - this.scale) / 2 + this.offsetX; }
|
||||||
|
top(): number { return this.baseTop + this.baseHeight * (1 - this.scale) / 2 + this.offsetY; }
|
||||||
|
contains(x: number, y: number): boolean { return x >= this.left() && x < this.left() + this.width() && y >= this.top() && y < this.top() + this.height(); }
|
||||||
|
x(value: number): number { return clamp((value - this.left()) / this.width(), 0, 1); }
|
||||||
|
y(value: number): number { return clamp((value - this.top()) / this.height(), 0, 1); }
|
||||||
|
zoom(factor: number, x: number, y: number): void {
|
||||||
|
if (!Number.isFinite(factor) || factor <= 0) return;
|
||||||
|
const next = clamp(this.scale * factor, 1, 10), ratio = next / this.scale;
|
||||||
|
const fx = x - this.baseLeft - this.baseWidth / 2, fy = y - this.baseTop - this.baseHeight / 2;
|
||||||
|
this.offsetX = fx - (fx - this.offsetX) * ratio; this.offsetY = fy - (fy - this.offsetY) * ratio;
|
||||||
|
this.scale = next; this.constrain();
|
||||||
|
}
|
||||||
|
pan(x: number, y: number): void { this.offsetX += x; this.offsetY += y; this.constrain(); }
|
||||||
|
reset(): void { this.scale = 1; this.offsetX = this.offsetY = 0; }
|
||||||
|
finish(): void { if (this.scale <= 1.001) this.reset(); }
|
||||||
|
private constrain(): void {
|
||||||
|
this.offsetX = clamp(this.offsetX, -this.baseWidth * (this.scale - 1) / 2, this.baseWidth * (this.scale - 1) / 2);
|
||||||
|
this.offsetY = clamp(this.offsetY, -this.baseHeight * (this.scale - 1) / 2, this.baseHeight * (this.scale - 1) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class MouseInput {
|
||||||
|
x: number = 0.5;
|
||||||
|
y: number = 0.5;
|
||||||
|
gesture: boolean = false;
|
||||||
|
left: boolean = false;
|
||||||
|
right: boolean = false;
|
||||||
|
revision: number = 0;
|
||||||
|
private sender: (x: number, y: number, flag: number, scroll: number) => void;
|
||||||
|
constructor(sender: (x: number, y: number, flag: number, scroll: number) => void) { this.sender = sender; }
|
||||||
|
held(): boolean { return this.left || this.right; }
|
||||||
|
move(x: number, y: number): void { this.x = clamp(x, 0, 1); this.y = clamp(y, 0, 1); this.send(0); }
|
||||||
|
drag(dx: number, dy: number, width: number, height: number): void {
|
||||||
|
if (this.held() && width > 0 && height > 0) this.move(this.x + dx / width, this.y + dy / height);
|
||||||
|
}
|
||||||
|
gestureLeft(down: boolean): void {
|
||||||
|
if ((down && this.held()) || this.gesture === down) return;
|
||||||
|
this.gesture = down; this.send(down ? 1 : 2);
|
||||||
|
}
|
||||||
|
button(left: boolean, down: boolean): void {
|
||||||
|
if ((left ? this.left : this.right) === down) return;
|
||||||
|
if (down) { this.gestureLeft(false); this.revision++; }
|
||||||
|
if (left) this.left = down; else this.right = down;
|
||||||
|
this.send(left ? (down ? 1 : 2) : (down ? 3 : 4));
|
||||||
|
}
|
||||||
|
click(right: boolean = false): void { if (!this.held()) { this.send(right ? 3 : 1); this.send(right ? 4 : 2); } }
|
||||||
|
scroll(delta: number): void { if (delta !== 0) this.sender(this.x, this.y, 7, delta > 0 ? 1 : -1); }
|
||||||
|
release(): void { this.gestureLeft(false); this.button(true, false); this.button(false, false); this.revision++; }
|
||||||
|
private send(flag: number): void { this.sender(this.x, this.y, flag, 0); }
|
||||||
|
}
|
||||||
|
export interface VideoValues { quality: number; frameRate: number; preference: number; }
|
||||||
|
export class VideoSettings {
|
||||||
|
supported: boolean = false;
|
||||||
|
pending: boolean = false;
|
||||||
|
failed: boolean = false;
|
||||||
|
selection: VideoValues;
|
||||||
|
applied: VideoValues;
|
||||||
|
requestId: number = 0;
|
||||||
|
appliedRequestId: number = 0;
|
||||||
|
constructor(preference: number = 1, longEdge: number = 1920) {
|
||||||
|
this.selection = { quality: longEdge > 0 && longEdge <= 1280 ? 0 : 1, frameRate: 30, preference: clamp(preference, 0, 2) };
|
||||||
|
this.applied = this.selection;
|
||||||
|
}
|
||||||
|
valid(v: VideoValues): boolean { return Number.isInteger(v.quality) && v.quality >= 0 && v.quality <= 2 &&
|
||||||
|
(v.frameRate === 30 || v.frameRate === 60) && Number.isInteger(v.preference) && v.preference >= 0 && v.preference <= 2; }
|
||||||
|
support(value: boolean): void { this.supported = value; if (!value) { this.selection = this.applied; this.pending = this.failed = false; } }
|
||||||
|
begin(value: VideoValues): number {
|
||||||
|
if (!this.supported || !this.valid(value)) return -1;
|
||||||
|
this.requestId = (this.requestId + 1) >>> 0; this.selection = value; this.pending = true; this.failed = false; return this.requestId;
|
||||||
|
}
|
||||||
|
receive(value: VideoValues, id: number, accepted: boolean): void {
|
||||||
|
if (!this.supported || !this.valid(value) || !Number.isInteger(id) || id < 0 || id > 0xFFFFFFFF || ((this.requestId - id) | 0) < 0) return;
|
||||||
|
if (accepted && ((id - this.appliedRequestId) | 0) >= 0) { this.applied = value; this.appliedRequestId = id; }
|
||||||
|
if (id !== this.requestId) return;
|
||||||
|
this.pending = false; this.failed = !accepted; this.selection = this.applied;
|
||||||
|
}
|
||||||
|
expire(id: number): void { if (this.pending && id === this.requestId) { this.pending = false; this.failed = true; this.selection = this.applied; } }
|
||||||
|
feedback(): string { return !this.supported ? '连接建立后可调整,需被控端支持。' : this.failed ? '调整失败,请重试。' : this.pending ? '等待电脑端确认…' : ''; }
|
||||||
|
}
|
||||||
|
export interface Traffic { inbound: number; outbound: number; loss: number; }
|
||||||
|
export interface NetworkReport { rtt: number; mode: number; srtp: boolean; video: Traffic; audio: Traffic; data: Traffic; total: Traffic; }
|
||||||
|
export interface FrameSample { time: number; count: number; latency: number; latencyCount: number; }
|
||||||
|
export class NetworkStatistics {
|
||||||
|
report?: NetworkReport;
|
||||||
|
reportTime: number = -Infinity;
|
||||||
|
rttTime: number = -Infinity;
|
||||||
|
rtt: number = -1;
|
||||||
|
width: number = 0;
|
||||||
|
height: number = 0;
|
||||||
|
lastFrame: number = 0;
|
||||||
|
frames: FrameSample[] = [];
|
||||||
|
receive(report: NetworkReport, now: number): void {
|
||||||
|
this.report = report; this.reportTime = now;
|
||||||
|
if (Number.isFinite(report.rtt) && report.rtt >= 0 && report.rtt <= 2000) {
|
||||||
|
this.rtt = this.rtt >= 0 && now - this.rttTime < 3000 ? this.rtt + 0.25 * (report.rtt - this.rtt) : report.rtt;
|
||||||
|
this.rttTime = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame(id: number, latency: number, width: number, height: number, now: number, latencyCount: number = -1): void {
|
||||||
|
if (id <= this.lastFrame || width <= 0 || height <= 0) return;
|
||||||
|
const count = Math.min(240, id - this.lastFrame);
|
||||||
|
this.frames.push({ time: now, count: count, latency: latency, latencyCount: latencyCount < 0 ? count : latencyCount });
|
||||||
|
this.lastFrame = id; this.width = width; this.height = height; this.prune(now);
|
||||||
|
}
|
||||||
|
reset(): void { this.frames = []; this.width = this.height = 0; }
|
||||||
|
prune(now: number): void { this.frames = this.frames.filter((f: FrameSample) => now - f.time < 1000).slice(-240); }
|
||||||
|
fps(now: number): number { this.prune(now); return this.frames.reduce((n: number, f: FrameSample) => n + f.count, 0); }
|
||||||
|
latency(now: number): number {
|
||||||
|
this.prune(now); const valid = this.frames.filter((f: FrameSample) => Number.isFinite(f.latency) && f.latency >= 0);
|
||||||
|
const count = valid.reduce((n: number, f: FrameSample) => n + f.latencyCount, 0);
|
||||||
|
return count ? valid.reduce((n: number, f: FrameSample) => n + f.latency * f.latencyCount, 0) / count : -1;
|
||||||
|
}
|
||||||
|
current(now: number): NetworkReport | undefined { return now - this.reportTime < 3000 ? this.report : undefined; }
|
||||||
|
mode(now: number): string { const report = this.current(now); return !report ? '—' : report.mode === 0 ? 'P2P 直连' : report.mode === 1 ? 'TURN 中继' : '—'; }
|
||||||
|
static rate(n: number): string { return !Number.isFinite(n) || n < 0 ? '—' : n >= 1000000 ? `${(n / 1000000).toFixed(1)} Mbps` : n >= 1000 ? `${(n / 1000).toFixed(0)} Kbps` : `${n} bps`; }
|
||||||
|
static loss(n: number): string { return Number.isFinite(n) && n >= 0 ? `${(n * 100).toFixed(1)}%` : '—'; }
|
||||||
|
static delay(n: number): string { return !Number.isFinite(n) || n < 0 ? '—' : n < 1 ? '<1 ms' : `${n.toFixed(0)} ms`; }
|
||||||
|
}
|
||||||
|
export interface PresenceDevice { id: string; online: boolean; }
|
||||||
|
export interface PresenceMessage { type: string; devices?: PresenceDevice[]; id?: string; online?: boolean; }
|
||||||
|
export class RecentPresence {
|
||||||
|
watched: string[] = [];
|
||||||
|
connected: boolean = false;
|
||||||
|
awaiting: boolean = true;
|
||||||
|
lastRefresh: number = 0;
|
||||||
|
values: Map<string, boolean> = new Map();
|
||||||
|
times: Map<string, number> = new Map();
|
||||||
|
private sender: (ids: string[]) => void;
|
||||||
|
constructor(sender: (ids: string[]) => void) { this.sender = sender; }
|
||||||
|
watch(ids: string[], now: number): void {
|
||||||
|
if (JSON.stringify(ids) === JSON.stringify(this.watched)) return;
|
||||||
|
this.watched = ids; this.values.clear(); this.times.clear(); this.awaiting = true; this.refresh(now);
|
||||||
|
}
|
||||||
|
connect(value: boolean, now: number): void {
|
||||||
|
if (value === this.connected) return;
|
||||||
|
this.connected = value; this.values.clear(); this.times.clear(); this.awaiting = true; this.refresh(now);
|
||||||
|
}
|
||||||
|
online(id: string, now: number): boolean { return this.connected && !this.awaiting && this.values.get(id) === true && now - (this.times.get(id) ?? -Infinity) < 60000; }
|
||||||
|
receive(message: PresenceMessage, now: number): void {
|
||||||
|
if (!this.connected) return;
|
||||||
|
let devices: PresenceDevice[] = [];
|
||||||
|
if (message.type === 'presence' && Array.isArray(message.devices)) {
|
||||||
|
this.values.clear(); this.times.clear(); this.awaiting = false; devices = message.devices;
|
||||||
|
} else if (message.type === 'presence_update' && !this.awaiting && typeof message.id === 'string' && typeof message.online === 'boolean') {
|
||||||
|
devices = [{ id: message.id, online: message.online }];
|
||||||
|
}
|
||||||
|
devices.forEach((d: PresenceDevice) => {
|
||||||
|
if (this.watched.includes(d.id) && typeof d.online === 'boolean') { this.values.set(d.id, d.online); this.times.set(d.id, now); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tick(now: number): void { if (this.connected && now - this.lastRefresh >= 30000) this.refresh(now); }
|
||||||
|
private refresh(now: number): void { if (this.connected) { this.lastRefresh = now; this.sender(this.watched.slice()); } }
|
||||||
|
}
|
||||||
|
export function asciiKey(character: string): number {
|
||||||
|
if (character.length !== 1) return 0;
|
||||||
|
const c = character.charCodeAt(0);
|
||||||
|
if (c >= 97 && c <= 122) return c - 32;
|
||||||
|
if (c >= 65 && c <= 90) return c | 0x100;
|
||||||
|
if (c >= 48 && c <= 57) return c;
|
||||||
|
if (c === 32 || c === 9 || c === 13) return c;
|
||||||
|
if (c === 10) return 13;
|
||||||
|
const plain = ';=,-./`[\\]\'', shifted = ':+<_>?~{|}"';
|
||||||
|
const codes: number[] = [186, 187, 188, 189, 190, 191, 192, 219, 220, 221, 222];
|
||||||
|
const index = plain.indexOf(character); if (index >= 0) return codes[index];
|
||||||
|
const upper = shifted.indexOf(character); if (upper >= 0) return codes[upper] | 0x100;
|
||||||
|
const digit = ')!@#$%^&*('.indexOf(character); return digit >= 0 ? (48 + digit) | 0x100 : 0;
|
||||||
|
}
|
||||||
|
export interface TouchPoint { x: number; y: number; }
|
||||||
|
export class TouchInput {
|
||||||
|
relative: boolean = true;
|
||||||
|
private active: boolean = false;
|
||||||
|
private multi: boolean = false;
|
||||||
|
private moved: boolean = false;
|
||||||
|
private long: boolean = false;
|
||||||
|
private pinching: boolean = false;
|
||||||
|
private revision: number = 0;
|
||||||
|
private startX: number = 0;
|
||||||
|
private startY: number = 0;
|
||||||
|
private lastX: number = 0;
|
||||||
|
private lastY: number = 0;
|
||||||
|
private focusX: number = 0;
|
||||||
|
private focusY: number = 0;
|
||||||
|
private span: number = 0;
|
||||||
|
private startSpan: number = 0;
|
||||||
|
private scrollY: number = 0;
|
||||||
|
readonly viewport: RemoteViewport;
|
||||||
|
readonly mouse: MouseInput;
|
||||||
|
constructor(viewport: RemoteViewport, mouse: MouseInput) { this.viewport = viewport; this.mouse = mouse; }
|
||||||
|
cancel(): void { this.active = false; this.multi = true; this.mouse.release(); this.viewport.finish(); }
|
||||||
|
hold(): void { if (this.active && !this.multi && !this.moved && this.revision === this.mouse.revision && !this.mouse.held()) { this.mouse.click(true); this.long = true; } }
|
||||||
|
touch(points: TouchPoint[], kind: string): void {
|
||||||
|
if (kind === 'cancel') { this.cancel(); return; }
|
||||||
|
if (points.length >= 2) {
|
||||||
|
const x = (points[0].x + points[1].x) / 2, y = (points[0].y + points[1].y) / 2;
|
||||||
|
const span = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y);
|
||||||
|
if (!this.multi) {
|
||||||
|
this.mouse.gestureLeft(false); this.multi = true; this.startSpan = this.span = span;
|
||||||
|
this.focusX = x; this.scrollY = this.focusY = y; this.pinching = false;
|
||||||
|
} else if (kind === 'move' && !this.mouse.held()) {
|
||||||
|
if (!this.pinching && Math.abs(span - this.startSpan) > 6 && this.viewport.contains(x, y)) this.pinching = true;
|
||||||
|
if (this.pinching && this.span > 0) this.viewport.zoom(span / this.span, x, y);
|
||||||
|
if (this.viewport.scale > 1.001 || this.pinching) this.viewport.pan(x - this.focusX, y - this.focusY);
|
||||||
|
else if (this.viewport.contains(x, y) && Math.abs(y - this.scrollY) > 16) { this.mouse.scroll(y - this.scrollY); this.scrollY = y; }
|
||||||
|
this.span = span; this.focusX = x; this.focusY = y;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.multi) {
|
||||||
|
if (kind === 'up' && points.length === 0) { this.active = false; this.viewport.finish(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === 'down' && points.length === 1) {
|
||||||
|
const p = points[0]; this.active = true; this.moved = this.long = false; this.pinching = false;
|
||||||
|
this.revision = this.mouse.revision; this.startX = this.lastX = p.x; this.startY = this.lastY = p.y;
|
||||||
|
if (!this.relative) { this.active = this.viewport.contains(p.x, p.y); if (this.active) this.mouse.move(this.viewport.x(p.x), this.viewport.y(p.y)); }
|
||||||
|
} else if (kind === 'move' && this.active && points.length) {
|
||||||
|
const p = points[0];
|
||||||
|
if (Math.hypot(p.x - this.startX, p.y - this.startY) > 4) this.moved = true;
|
||||||
|
if (this.moved && !this.relative && !this.long && this.revision === this.mouse.revision && !this.mouse.held()) this.mouse.gestureLeft(true);
|
||||||
|
if (this.relative) this.mouse.move(this.mouse.x + (p.x - this.lastX) / this.viewport.width(), this.mouse.y + (p.y - this.lastY) / this.viewport.height());
|
||||||
|
else this.mouse.move(this.viewport.x(p.x), this.viewport.y(p.y));
|
||||||
|
this.lastX = p.x; this.lastY = p.y;
|
||||||
|
} else if (kind === 'up') {
|
||||||
|
if (this.active && !this.moved && !this.long && this.revision === this.mouse.revision) this.mouse.click();
|
||||||
|
this.mouse.gestureLeft(false); this.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
begin(): void { if (!this.active) this.multi = false; }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user