mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 18:03:08 +08:00
Compare commits
17
Commits
3043b5f110
...
deskhub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02c3952be0 | ||
|
|
34eb2b1fe3 | ||
|
|
0901d61533 | ||
|
|
40670de954 | ||
|
|
33cbfa5b8a | ||
|
|
81430d78d9 | ||
|
|
aed6ad99c2 | ||
|
|
1e2520d472 | ||
|
|
697843cccd | ||
|
|
45bf6ec4ee | ||
|
|
43a0789c1e | ||
|
|
c4756b298b | ||
|
|
47e6385f9a | ||
|
|
9f610bfc9b | ||
|
|
006526389a | ||
|
|
e76d2bf903 | ||
|
|
2cf0d79543 |
+17
-9
@@ -1,18 +1,18 @@
|
||||
# CrossDesk Privacy Policy
|
||||
|
||||
Last updated: October 10, 2026
|
||||
Last updated: October 15, 2026
|
||||
|
||||
[中文](#中文) | [English](#english)
|
||||
|
||||
## 中文
|
||||
|
||||
更新日期:2026 年 10 月 10 日
|
||||
更新日期:2026 年 10 月 15 日
|
||||
|
||||
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
|
||||
|
||||
### 一、适用范围
|
||||
|
||||
本政策适用于 CrossDesk 桌面客户端、iOS 与 Android 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
|
||||
本政策适用于 CrossDesk 桌面客户端、iOS、Android 与鸿蒙移动客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
|
||||
|
||||
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
|
||||
|
||||
@@ -50,11 +50,13 @@ Android 中的设备身份及您选择保存的访问密码使用 Android Keysto
|
||||
|
||||
iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。
|
||||
|
||||
Android 默认信令连接同样进行 TLS 证书校验。
|
||||
Android 与鸿蒙移动端默认信令连接同样进行 TLS 证书校验。
|
||||
|
||||
鸿蒙移动端使用系统 Asset Store 加密保存设备身份及您选择保存的访问密码,明确禁止凭据跨设备同步。设置和连接记录按服务器隔离。远程预览默认关闭,单独确认后从下一次连接起保存一张 640×360 预览;预览保存在应用私有缓存中,不上传,可随时清除,也可能被系统回收。关闭预览或撤回联网授权会清除预览。接收文件保存在应用私有目录,您可在会话中通过系统文件选择器导出。
|
||||
|
||||
### 五、移动端权限与授权选择
|
||||
|
||||
**隐私授权。** iOS 与 Android 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
|
||||
**隐私授权。** iOS、Android 与鸿蒙移动端 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
|
||||
|
||||
**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。
|
||||
|
||||
@@ -64,6 +66,8 @@ Android 客户端同样作为控制端接收远程画面与音频,不采集本
|
||||
|
||||
Android 远程会话优先使用系统画中画在切换应用后继续显示画面。画中画不可用时,通过前台服务和连接通知保持您已建立的远程连接;后台暂停本地画面渲染和音频播放,不申请悬浮窗权限。通知权限用于提供返回及断开入口,CPU 唤醒锁用于会话后台期间维持连接,返回前台或会话结束时释放。您可以在应用或连接通知中断开,移除应用任务也会结束会话及保活服务。
|
||||
|
||||
鸿蒙手机和平板只作为控制端,不申请本机录屏、输入注入、摄像头或麦克风权限;文件选择和剪贴板操作由您主动触发。已建立会话在切入后台时优先使用系统画中画,提供静音控制;画中画不可用时申请数据传输后台任务并显示系统通知。若系统不允许或停止后台任务且画中画不可用,会话会断开。后台切换释放远端按键;空闲后台不维持信令连接。完成联网授权后,应用在前台检查鸿蒙本机版本,并在连接后检查远端版本;版本请求不携带设备身份或远端版本。
|
||||
|
||||
应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。
|
||||
|
||||
### 六、查询与删除请求
|
||||
@@ -86,13 +90,13 @@ Android 远程会话优先使用系统画中画在切换应用后继续显示画
|
||||
|
||||
## English
|
||||
|
||||
Updated: October 2, 2026
|
||||
Updated: October 15, 2026
|
||||
|
||||
CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software.
|
||||
|
||||
### 1. Scope
|
||||
|
||||
This policy applies to the CrossDesk desktop, iOS and Android clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
|
||||
This policy applies to the CrossDesk desktop, iOS, Android and HarmonyOS mobile clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
|
||||
|
||||
If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply.
|
||||
|
||||
@@ -130,11 +134,13 @@ On Android, device identities and access passwords you choose to save are encryp
|
||||
|
||||
On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people.
|
||||
|
||||
Default signaling connections on Android also validate TLS certificates.
|
||||
Default signaling connections on Android and HarmonyOS mobile also validate TLS certificates.
|
||||
|
||||
HarmonyOS mobile uses the system Asset Store to encrypt device identities and saved access passwords, with cross-device credential synchronization disabled. Settings and connection records are scoped to the server. Remote previews are off by default. After separate confirmation, one 640×360 preview is saved per connection starting with the next connection. Previews remain in private app cache, are never uploaded, can be cleared and may be reclaimed by the system. Disabling previews or withdrawing network consent clears them. Received files remain in private app storage and can be exported through the system document picker during the session.
|
||||
|
||||
### 5. Mobile permissions and consent choices
|
||||
|
||||
**Privacy authorization.** On first use of iOS or Android, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
|
||||
**Privacy authorization.** On first use of iOS, Android or HarmonyOS mobile, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
|
||||
|
||||
**Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data.
|
||||
|
||||
@@ -144,6 +150,8 @@ The Android client also receives remote screens and audio as a controller and do
|
||||
|
||||
Android remote sessions prefer system picture-in-picture to continue displaying video when you switch apps. If picture-in-picture is unavailable, a foreground service and connection notification maintain the remote connection you have established. Local video rendering and audio playback pause in the background; no overlay permission is requested. Notification permission provides return and disconnect actions. A CPU wake lock helps maintain the connection while the session is backgrounded and is released on foreground return or session end. You can disconnect in the app or its connection notification; removing the app task also ends the session and its foreground service.
|
||||
|
||||
HarmonyOS phones and tablets act only as controllers and do not request local screen recording, input injection, camera or microphone permissions. File selection and clipboard actions are user initiated. An established session prefers system picture-in-picture with a mute control when backgrounded. If unavailable, the app requests a data-transfer background task with a system notification. The session ends if the system disallows or stops that task while picture-in-picture is unavailable. Backgrounding releases remote keys; idle background sessions do not retain signaling. After network consent, the foreground app checks the local HarmonyOS version and checks the remote version after connection. These requests contain neither device identity nor the remote version.
|
||||
|
||||
The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking.
|
||||
|
||||
### 6. Access and deletion requests
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<div align="center">
|
||||
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
|
||||
<h1>CrossDesk</h1>
|
||||
<p>轻量、跨平台的远程桌面,让电脑、浏览器和 iPhone / iPad 连接到同一张桌面。</p>
|
||||
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
|
||||
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](#download)
|
||||
[](#download)
|
||||
[](https://github.com/kunkundi/crossdesk/releases)
|
||||
[](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
|
||||
[](LICENSE)
|
||||
@@ -15,11 +15,11 @@
|
||||
|
||||
</div>
|
||||
|
||||
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS / Android 客户端控制电脑。项目基于 [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 客户端的构建、签名方式见下文。
|
||||
|
||||
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [会话操作](#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>
|
||||
|
||||
@@ -27,10 +27,18 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
||||
|
||||
### 桌面客户端
|
||||
|
||||
主窗口集中展示本机 ID、连接密码、远程连接入口和近期连接记录。
|
||||
主窗口以“本桌面”和“远程桌面”两张卡片展示本机凭据与连接入口,下方是可按分组筛选的近期连接。左上角打开桌面墙,右上角菜单进入设置、消息与关于。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-main-zh.png" width="800" alt="当前桌面客户端示例:已连接服务器,三台在线设备均显示页面缩略图" />
|
||||
<img src="docs/images/desktop-main-zh.png" width="800" alt="新版主窗口:桌面墙入口、本机凭据、远程连接卡片和近期连接分组筛选;数据为演示" />
|
||||
</p>
|
||||
|
||||
### 桌面墙(DeskHub)
|
||||
|
||||
在独立窗口中同时查看多台设备,支持分组、可调整大小的网格、精简模式、全屏与批量操作。点击画面可放大查看,再按需接管操控。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-deskhub-zh.png" width="960" alt="桌面墙网格:六台演示设备、分组筛选、添加、选择、精简模式和网络状态入口" />
|
||||
</p>
|
||||
|
||||
### Web 客户端
|
||||
@@ -38,7 +46,7 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
||||
在浏览器中输入远程设备 ID 和密码,无需在控制端安装桌面客户端。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端当前连接首页" />
|
||||
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端连接首页(2026-09-25 截图)" />
|
||||
</p>
|
||||
|
||||
图片来源与更新方式见 [截图说明](docs/images/README.md)。
|
||||
@@ -47,16 +55,17 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
||||
|
||||
| 能力 | 当前支持 |
|
||||
| --- | --- |
|
||||
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS / Android 作为控制端 |
|
||||
| 跨平台控制 | Windows / macOS / Linux / 鸿蒙 PC 桌面互控;浏览器与原生 iOS / Android / 鸿蒙手机和平板作为控制端;鸿蒙目前为开发版 |
|
||||
| 实时画面与声音 | H.264 / AV1、硬件编解码、远端声音播放;会话中可调整画质、30 / 60 fps 与画面偏好 |
|
||||
| 多设备与多显示器 | 近期连接、设备别名、会话标签与远端显示器切换 |
|
||||
| 多设备与多显示器 | 近期连接、设备别名与分组筛选、会话标签与远端显示器切换 |
|
||||
| 桌面墙(DeskHub) | 多桌面实时预览、分组与拖动排序、放大接管、群控、批量连接 / 断开 / 静音 / 文件分发;可配置分辨率、帧率与视频总带宽 |
|
||||
| 输入与协作 | 键鼠输入、远端光标同步、组合键、文本剪贴板同步、文件传输 |
|
||||
| 网络与部署 | P2P 直连、TURN 中继与强制中继、SRTP 媒体加密、自托管信令与中继服务 |
|
||||
| 隐私屏 | 被连接时自动开启,或在会话工具栏切换;可用性取决于被控端系统与采集方式 |
|
||||
| Windows 锁屏控制 | 通过 CrossDesk Service 支持锁屏、登录界面及安全桌面的输入转发 |
|
||||
| Linux 无头运行 | 复用已有桌面和应用,或创建独立 Xvfb 虚拟桌面;终端控制台可查看状态、修改密码和设置,见 [运行说明](docs/HEADLESS.md) |
|
||||
|
||||
各客户端的入口和能力有所不同;桌面会话操作见下文,iOS 的具体功能见 [开发说明](apps/ios/README.md)。
|
||||
各客户端的入口和能力有所不同;桌面会话操作见下文,移动端具体功能见 [iOS](apps/ios/README.md)、[Android](apps/android/README.md) 与 [鸿蒙](apps/harmony/README.md) 开发说明。
|
||||
|
||||
<a id="download"></a>
|
||||
|
||||
@@ -71,6 +80,7 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
|
||||
| Linux | Ubuntu 20.04+,amd64 / arm64,glibc 2.31 基线 | 普通版 / Xfce 虚拟桌面版 `.deb` |
|
||||
| iOS / iPadOS | iOS 16.0+,arm64 真机 | 原生客户端;使用 Xcode 构建并签名,CI 产物为未签名应用 |
|
||||
| Android | Android 8.0+,arm64 | 原生控制端;使用 Gradle 构建 APK,见 [开发说明](apps/android/README.md) |
|
||||
| 鸿蒙 HarmonyOS(开发版) | PC:API 26;手机 / 平板:API 20+;arm64,待真机验收 | 使用 DevEco 构建并签名 HAP;见 [鸿蒙支持](#harmonyos) 与 [构建说明](apps/harmony/README.md) |
|
||||
| Web | 支持 WebRTC 的浏览器 | 访问 [web.crossdesk.cn](https://web.crossdesk.cn/) |
|
||||
|
||||
Linux 每个架构提供两个可独立安装的包,**选择其中一个**:
|
||||
@@ -103,9 +113,9 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
### 从另一台电脑连接
|
||||
|
||||
1. **准备被控电脑。** 安装并运行 CrossDesk,等待底部显示“已连接服务器”。在左侧“本桌面”查看 **本机 ID** 和 **密码**,提供给控制端。
|
||||
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 ID,点击 **→**。
|
||||
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 **9 位 ID**,点击 **连接**。
|
||||
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
|
||||
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连,也可编辑别名或删除记录。
|
||||
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连。悬停卡片可编辑别名、修改分组、删除记录或连接 / 断开;标题右侧可按分组筛选设备。
|
||||
|
||||
本机密码旁的眼睛按钮用于显示 / 隐藏密码,刷新按钮用于生成新的随机密码,铅笔按钮用于手动修改。密码统一为 **6 位数字或英文字母**。修改或刷新时需保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
|
||||
|
||||
@@ -117,6 +127,27 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
|
||||
如果使用自建服务器,需要让控制端与被控端接入同一服务;Web 部署配置见 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
|
||||
|
||||
<a id="deskhub"></a>
|
||||
|
||||
## 桌面墙:集中查看与操控多台设备
|
||||
|
||||
点击主窗口左上角 **桌面墙**,打开独立窗口;连接窗口仍可用于普通远程连接和全局设置。桌面墙会保存设备成员、分组、排序与窗口布局,重启后需要手动连接设备。
|
||||
|
||||
1. **添加设备。** 点击 **添加 → 通过设备 ID 添加**,填写 ID、密码及可选分组,点击“添加并连接”;只有勾选“记住密码”才保存凭据。也可选择 **从近期连接添加**,多选已有记录并导入;导入后用“选择 → 连接”发起连接。
|
||||
2. **查看与整理。** 从“全部设备”切换到指定分组;拖动网格分隔线统一调整画面大小,拖动设备到另一台设备上交换位置。精简模式隐藏名称和管理工具栏,全屏用于扩大查看区域;翻页保留连接。
|
||||
3. **放大与操控。** 点击设备画面进入只读放大视图,上方缩略图可切换设备,拖动中间分隔条可调整上下区域。点击 **接管操控** 后才发送键鼠;点击“释放操控”或按 **Ctrl / ⌘ + Shift + Esc** 释放。切换设备或窗口失焦也会释放操控。
|
||||
4. **批量操作。** 点击 **选择**,勾选设备或“全选”,执行连接、断开,或从“更多”中静音 / 取消静音、分发文件。“全选”覆盖当前分组的全部分页;切换分组会清空选择。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-deskhub-focus-zh.png" width="960" alt="桌面墙放大视图:上方设备缩略图、下方主画面,以及只读预览、开启群控和接管操控入口;画面为演示" />
|
||||
</p>
|
||||
|
||||
**群控:** 在放大视图点击“开启群控”,将当前设备的键鼠操作同步到 **所有已连接设备**,包含其他分组、分页及未显示在墙内的连接;范围不受批量勾选限制。各设备接收相同坐标与按键,屏幕尺寸不同时不会另做比例适配。释放操控会同时结束群控。
|
||||
|
||||
桌面墙连接默认静音,需要声音时通过“选择 → 更多 → 取消静音”开启。底部 **网络状态** 可查看所有已连接设备的直连 / 中继、加密状态、收发速率、视频延时、丢包率、分辨率与帧率。需要显示器切换、隐私屏等完整单会话工具时,可在主窗口“近期连接”中点击已连接设备,打开独立会话窗口。
|
||||
|
||||
**关闭桌面墙会断开全部远程设备连接,并关闭共用连接的独立会话窗口;最小化不会断开。** 分组、分页和选择范围不会限制关闭时的断开范围。更多操作、预览策略与版本兼容性见 [桌面墙说明](docs/deskhub.md)。
|
||||
|
||||
<a id="session"></a>
|
||||
|
||||
## 会话中的常用操作
|
||||
@@ -136,7 +167,7 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
| 全屏 | 进入 / 退出全屏 |
|
||||
| 断开连接 | 结束当前远程会话 |
|
||||
|
||||
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
|
||||
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 常规 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
|
||||
|
||||
### 在会话中调整画面
|
||||
|
||||
@@ -150,10 +181,10 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
|
||||
## 客户端设置与更新
|
||||
|
||||
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。画质、帧率和画面偏好在远程会话工具栏中调整。
|
||||
点击主窗口右上角 **☰ → 设置**,顶部切换 **常规 / 桌面墙**,修改后点击 **保存**。切换选项卡会保留未保存的修改,点击“取消”放弃修改。“常规”按界面、视频、网络连接等类别组织选项,可向下滚动查看其余设置。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。普通会话的画质、帧率和画面偏好在远程会话工具栏中调整。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前客户端设置:编码格式、硬件编解码、中继、强制中继与自动隐私屏" />
|
||||
<img src="docs/images/desktop-settings-zh.png" width="720" alt="新版常规设置:常规与桌面墙选项卡、语言、视频编码、硬件编解码及网络连接设置" />
|
||||
</p>
|
||||
|
||||
| 设置 | 说明 |
|
||||
@@ -169,6 +200,22 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
|
||||
| 文件保存路径 | 选择接收文件的目录 |
|
||||
|
||||
### 桌面墙画面传输
|
||||
|
||||
在 **设置 → 桌面墙** 中统一配置墙内设备,保存后立即应用到当前桌面墙会话,并用于后续连接;普通连接不受影响。
|
||||
|
||||
| 设置 | 说明 |
|
||||
| --- | --- |
|
||||
| 分辨率上限 | 自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160 或原始分辨率;自动模式按网格 / 放大视图调整,固定上限保持比例且不放大源画面 |
|
||||
| 帧率 | 跟随设备设置,或指定 15 / 30 / 60 FPS |
|
||||
| 视频总带宽 | 默认 20 Mbps,可选 2 / 5 / 10 / 20 / 50 / 100 Mbps;由支持该能力的桌面墙会话共享,放大画面优先分配,不包含音频、文件传输与普通连接,也不是瞬时流量的硬上限 |
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-deskhub-zh.png" width="720" alt="桌面墙传输设置:自动分辨率、跟随设备帧率和 20 Mbps 视频总带宽" />
|
||||
</p>
|
||||
|
||||
支持预览订阅的设备在不可见时可暂停视频传输,重新可见时恢复;旧版被控端按其能力回退,不保证所有分辨率、帧率或带宽选项都生效。
|
||||
|
||||
### 检查更新
|
||||
|
||||
客户端启动后会在后台检查版本,发现新版本时自动弹出更新提示,可查看更新说明并前往下载页面。关闭提示后,本次运行中的后台检查不会再次弹窗,主窗口菜单入口仍会显示更新提示;重新启动客户端后,如仍有新版本则再次提示。打开 **☰ → 关于** 可查看版本;有新版本时,点击版本信息下方的 **更新日志** 可再次打开更新提示,也可点击 **检查更新** 手动检查。
|
||||
@@ -200,6 +247,21 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
|
||||
|
||||
按 [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>
|
||||
|
||||
## Windows 锁屏与登录界面
|
||||
@@ -256,7 +318,7 @@ Windows 的“开机自启”在当前用户登录后启动客户端。安装版
|
||||
|
||||
## 自托管服务器
|
||||
|
||||
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
|
||||
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 常规 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
|
||||
|
||||
- [自托管指南](docs/SELF_HOSTING.md):当前 Compose 部署入口、客户端设置、证书信任与排查。
|
||||
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server):服务端源码与发布配置。
|
||||
@@ -267,6 +329,7 @@ Windows 的“开机自启”在当前用户登录后启动客户端。安装版
|
||||
- [源码构建](docs/BUILD.md):Windows、macOS、Linux、可选编译参数与打包。
|
||||
- [iOS 开发说明](apps/ios/README.md):原生端构建和真机验证。
|
||||
- [Android 开发说明](apps/android/README.md):APK 构建、安装与模拟器验证。
|
||||
- [鸿蒙开发说明](apps/harmony/README.md):PC 与手机 / 平板 HAP 构建、签名、权限及验证。
|
||||
- [GUI 架构](docs/gui-architecture.md):桌面 UI、平台实现与工程结构。
|
||||
- [常见问题](docs/FAQ.md):连接失败、黑屏、文件位置与编译问题。
|
||||
- [提交问题](https://github.com/kunkundi/crossdesk/issues):请附上两端系统、客户端版本、连接方式和可复现步骤。
|
||||
@@ -275,7 +338,9 @@ Windows 的“开机自启”在当前用户登录后启动客户端。安装版
|
||||
|
||||
感谢 [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
|
||||
|
||||
|
||||
+82
-17
@@ -1,13 +1,13 @@
|
||||
<div align="center">
|
||||
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
|
||||
<h1>CrossDesk</h1>
|
||||
<p>A lightweight, cross-platform remote desktop for computers, browsers, and iPhone / iPad.</p>
|
||||
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
|
||||
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](#download)
|
||||
[](#download)
|
||||
[](https://github.com/kunkundi/crossdesk/releases)
|
||||
[](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
|
||||
[](LICENSE)
|
||||
@@ -15,11 +15,11 @@
|
||||
|
||||
</div>
|
||||
|
||||
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS / Android clients for controlling computers. Built on [MiniRTC](https://github.com/kunkundi/minirtc), it supports real-time video and audio, keyboard and mouse control, file transfer, and self-hosting. The project is under active development.
|
||||
CrossDesk connects 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.
|
||||
|
||||
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english)
|
||||
[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>
|
||||
|
||||
@@ -27,10 +27,18 @@ CrossDesk connects Windows, macOS, and Linux desktops, with browser and native i
|
||||
|
||||
### Desktop client
|
||||
|
||||
The main window brings together your device ID, connection password, remote connection entry, and recent devices.
|
||||
The main window presents your credentials and connection controls in Local Desktop and Remote Desktop cards, with recent devices and a group filter below. Open DeskHub at the top left, or use the top-right menu for settings, messages, and About.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-main-en.png" width="800" alt="Current desktop UI example showing a connected server and three online devices with page thumbnails" />
|
||||
<img src="docs/images/desktop-main-en.png" width="800" alt="Updated main window with DeskHub access, local credentials, remote connection controls, and recent-device group filtering; data is illustrative" />
|
||||
</p>
|
||||
|
||||
### DeskHub
|
||||
|
||||
View multiple devices in a separate window with groups, a resizable grid, compact and fullscreen views, and batch actions. Click a preview to enlarge it, then take control when needed.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-deskhub-en.png" width="960" alt="DeskHub grid with six demonstration devices, group filtering, Add, Select, Compact, and Network controls" />
|
||||
</p>
|
||||
|
||||
### Web client
|
||||
@@ -38,7 +46,7 @@ The main window brings together your device ID, connection password, remote conn
|
||||
Enter a remote device ID and password in your browser without installing the desktop app on the controlling device.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/web-client.png" width="800" alt="Current CrossDesk Web Client connection page in Chinese" />
|
||||
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web Client connection page in Chinese, captured on 2026-09-25" />
|
||||
</p>
|
||||
|
||||
See the [screenshot notes](docs/images/README.md) for image sources and refresh instructions.
|
||||
@@ -47,16 +55,17 @@ See the [screenshot notes](docs/images/README.md) for image sources and refresh
|
||||
|
||||
| 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 |
|
||||
| Devices and displays | Recent connections, device aliases, 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 |
|
||||
| Input and sharing | Keyboard/mouse input, remote cursor synchronization, shortcuts, text clipboard synchronization, and file transfer |
|
||||
| Networking and deployment | Direct P2P connections, TURN relay and forced relay, SRTP media encryption, and self-hosted signaling/relay services |
|
||||
| Privacy screen | Enable automatically when connected to, or toggle from the session toolbar; availability depends on the host OS and capture method |
|
||||
| Windows protected desktops | CrossDesk Service forwards input on lock screens, sign-in screens, and secure desktops |
|
||||
| Linux headless operation | Reuse existing desktops and applications or create separate Xvfb desktops; use the terminal console to check status and change passwords or settings; see [setup](docs/HEADLESS_EN.md) |
|
||||
|
||||
Controls and capabilities vary by client. Desktop controls are described below; see the [iOS guide](apps/ios/README.md) for native mobile features.
|
||||
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>
|
||||
|
||||
@@ -71,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` |
|
||||
| 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)) |
|
||||
| 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/) |
|
||||
|
||||
Linux provides two standalone packages per architecture. **Choose one**:
|
||||
@@ -103,9 +113,9 @@ Both contain the same complete application, replace each other and preserve user
|
||||
### Connect from another computer
|
||||
|
||||
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the bottom status bar to show that the server is connected. Share the **Local ID** and **Password** shown under **Local Desktop** with the controller.
|
||||
2. **Enter the remote ID.** On the controlling computer, enter the host's ID in **Remote Desktop → Remote ID**, then click **→**.
|
||||
2. **Enter the remote ID.** On the controlling computer, enter the host's **9-digit ID** in **Remote Desktop → Remote ID**, then click **Connect**.
|
||||
3. **Verify the password.** Enter the host's current connection password when prompted and confirm. Select **Remember password** if you want to save it.
|
||||
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, edit its alias, or remove its record.
|
||||
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, or hover to rename it, edit its groups, remove its record, or connect / disconnect. Use the group filter beside the heading to narrow the list.
|
||||
|
||||
The eye button shows or hides the local password, the refresh button generates a new random password, and the pencil button lets you edit it. Passwords use **6 ASCII letters or digits**. Stay connected to the server when editing or refreshing a password. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
|
||||
|
||||
@@ -117,6 +127,27 @@ The eye button shows or hides the local password, the refresh button generates a
|
||||
|
||||
For self-hosting, connect both sides to the same service. Browser deployment is documented in [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client).
|
||||
|
||||
<a id="deskhub"></a>
|
||||
|
||||
## DeskHub: view and control multiple devices
|
||||
|
||||
Click **DeskHub** at the top left of the main window to open a separate window. The connection window remains available for ordinary remote sessions and global settings. DeskHub saves its device membership, groups, order, and window layout; connections must be started manually after relaunching.
|
||||
|
||||
1. **Add devices.** Choose **Add → Add by device ID**, enter an ID, password, and optional group, then click **Add and connect**. Credentials are saved only when you select **Remember password**. Alternatively, choose **Add from recent** and import multiple records; use **Select → Connect** to connect after importing.
|
||||
2. **Arrange the view.** Switch from **All devices** to a group, drag grid dividers to resize all previews, or drag a device onto another to swap positions. **Compact** hides device names and management controls; fullscreen provides more viewing space. Changing pages keeps connections open.
|
||||
3. **Enlarge and control.** Click a preview to open a read-only enlarged view. Switch devices with the thumbnails above it, and drag the divider to resize the two areas. Click **Control** to send keyboard and mouse input. Click **Release** or press **Ctrl / ⌘ + Shift + Esc** to release; switching devices or losing window focus also releases control.
|
||||
4. **Apply batch actions.** Click **Select**, choose devices or **Select all**, and connect, disconnect with **Close**, or use **More** to mute / unmute or send a file. **Select all** includes every page in the current group; switching groups clears the selection.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-deskhub-focus-en.png" width="960" alt="DeskHub enlarged view with thumbnails above the main preview and View only status, Group control, and Control buttons; screens are illustrative" />
|
||||
</p>
|
||||
|
||||
**Group control:** click **Group control** in the enlarged view to mirror input from the current device to **all connected devices**, including other groups, pages, and connections not displayed in the wall. The scope is independent of the batch selection. Devices receive the same coordinates and keys without additional scaling for different screen sizes. Releasing control also ends group control.
|
||||
|
||||
DeskHub connections are muted by default. Enable sound with **Select → More → Unmute**. The bottom **Network** button shows direct / relay mode, encryption, receive/send rates, video latency, packet loss, resolution, and frame rate for all connected devices. For the full single-session toolbar, including display switching and privacy screens, click a connected device under **Recent Connections** in the main window to open its separate session window.
|
||||
|
||||
**Closing DeskHub disconnects all remote devices and closes session windows sharing those connections; minimizing it keeps connections open.** The disconnect scope includes all groups and pages, regardless of selection. See the [DeskHub guide](docs/deskhub.md) (Chinese) for further controls, preview behavior, and compatibility details.
|
||||
|
||||
<a id="session"></a>
|
||||
|
||||
## Session controls
|
||||
@@ -136,7 +167,7 @@ Switch devices using session tabs. Expand the control bar if it is collapsed, an
|
||||
| Fullscreen | Enter / exit fullscreen |
|
||||
| Disconnect | End the current remote session |
|
||||
|
||||
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
|
||||
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → General → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
|
||||
|
||||
### Adjust video during a session
|
||||
|
||||
@@ -150,10 +181,10 @@ After connecting, open **Video settings** (the sliders icon) in the toolbar. Cho
|
||||
|
||||
## Client settings and updates
|
||||
|
||||
Open **☰ → Settings** in the top-right corner and click **OK** to save. Configure codecs and connections before connecting; some settings are disabled during an active session. Scroll down for self-hosting, startup behavior, and the file save path. Adjust quality, frame rate, and video preference from the remote session toolbar.
|
||||
Open **☰ → Settings** in the top-right corner, choose **General / DeskHub**, and click **Save** to apply changes. Switching tabs preserves pending changes; **Cancel** discards them. General settings are grouped into interface, video, networking, and further sections available by scrolling. Configure codecs and connections before connecting; some settings are disabled during an active session. Adjust ordinary session quality, frame rate, and video preference from its toolbar.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-en.png" width="720" alt="Current client settings with codec, hardware codecs, TURN, forced relay, and automatic privacy screen options" />
|
||||
<img src="docs/images/desktop-settings-en.png" width="720" alt="Updated General settings with General and DeskHub tabs, language, video codec, hardware codecs, and networking options" />
|
||||
</p>
|
||||
|
||||
| Setting | Purpose |
|
||||
@@ -169,6 +200,22 @@ Open **☰ → Settings** in the top-right corner and click **OK** to save. Conf
|
||||
| Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
|
||||
| File Save Path | Choose where received files are saved |
|
||||
|
||||
### DeskHub video transmission
|
||||
|
||||
Use **Settings → DeskHub** to configure wall sessions together. Saving applies changes immediately to current DeskHub sessions and future connections; ordinary connections are unaffected.
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| Resolution limit | Auto, 320 × 180, 640 × 360, 1280 × 720, 1920 × 1080, 2560 × 1440, 3840 × 2160, or Original resolution; Auto adapts to grid / enlarged views, while fixed limits preserve aspect ratio without upscaling the source |
|
||||
| Frame rate | Follow device settings, or choose 15 / 30 / 60 FPS |
|
||||
| Video budget | Defaults to 20 Mbps; choose 2 / 5 / 10 / 20 / 50 / 100 Mbps, shared by compatible DeskHub sessions with priority for enlarged views. Audio, file transfers, and ordinary connections are excluded; this is not a hard limit on instantaneous traffic |
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-deskhub-en.png" width="720" alt="DeskHub video settings with Auto resolution, Follow device settings frame rate, and a 20 Mbps video budget" />
|
||||
</p>
|
||||
|
||||
Devices supporting preview subscriptions can pause video while their views are hidden and resume when visible. Older hosts fall back according to their capabilities, so not every resolution, frame-rate, or bandwidth setting will apply.
|
||||
|
||||
### Check for updates
|
||||
|
||||
The client checks for updates in the background after launch and automatically opens the update dialog when a new version is found, showing release notes and a link to the download page. After you dismiss the dialog, background checks will not reopen it during the current run, while the main window menu keeps its update indicator. Restarting the client shows the dialog again if an update is still available. Open **☰ → About** to view the version. When an update is available, click **Release notes** below the new version to reopen the update dialog, or click **Check for updates** to check manually.
|
||||
@@ -192,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.
|
||||
|
||||
<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>
|
||||
|
||||
## Windows lock screens and sign-in
|
||||
@@ -248,7 +310,7 @@ If **Remote Windows service unavailable** appears, check installation and servic
|
||||
|
||||
## Self-hosting
|
||||
|
||||
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → Self-Hosted Config**. Enable the same server configuration on both the controller and host.
|
||||
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → General → Self-Hosted Config**. Enable the same server configuration on both the controller and host.
|
||||
|
||||
- [Self-hosting guide](docs/SELF_HOSTING_EN.md): current Compose deployment, client settings, certificate trust, and troubleshooting.
|
||||
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server): server source and release configuration.
|
||||
@@ -259,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.
|
||||
- [iOS development](apps/ios/README.md): native build and physical-device 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.
|
||||
- [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.
|
||||
@@ -267,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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -7,35 +7,11 @@
|
||||
#ifndef _CONTROLLER_PROTOCOL_H_
|
||||
#define _CONTROLLER_PROTOCOL_H_
|
||||
|
||||
#include <minirtc.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <remote_action.h>
|
||||
|
||||
#include "../../../libs/controller/include/mobile_controller_protocol.h"
|
||||
namespace crossdesk::android_controller {
|
||||
inline std::string SecureAttentionMessage() {
|
||||
RemoteAction action{};
|
||||
action.type = service_command;
|
||||
action.c.flag = send_sas;
|
||||
return action.to_json();
|
||||
using mobile_controller::SecureAttentionMessage;
|
||||
using mobile_controller::VideoSettingsMessage;
|
||||
using mobile_controller::NetworkReport;
|
||||
}
|
||||
inline std::string VideoSettingsMessage(int quality, int frame_rate, int preference,
|
||||
uint32_t request_id) {
|
||||
RemoteAction action{};
|
||||
action.type = video_settings;
|
||||
action.vs = {quality, frame_rate, preference, request_id, false};
|
||||
return action.to_json();
|
||||
}
|
||||
|
||||
inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, TraversalMode mode) {
|
||||
const auto traffic = [](const MiniRtcInboundStats& in, const MiniRtcOutboundStats& out) {
|
||||
return nlohmann::json{{"inbound", in.bitrate}, {"outbound", out.bitrate}, {"loss", in.loss_rate}};
|
||||
};
|
||||
return {{"rtt", stats.rtt_ms}, {"mode", mode}, {"srtp", stats.srtp_active},
|
||||
{"video", traffic(stats.video_inbound_stats, stats.video_outbound_stats)},
|
||||
{"audio", traffic(stats.audio_inbound_stats, stats.audio_outbound_stats)},
|
||||
{"data", traffic(stats.data_inbound_stats, stats.data_outbound_stats)},
|
||||
{"total", traffic(stats.total_inbound_stats, stats.total_outbound_stats)}};
|
||||
}
|
||||
} // namespace crossdesk::android_controller
|
||||
|
||||
#endif
|
||||
|
||||
@@ -7,265 +7,9 @@
|
||||
#ifndef _FILE_TRANSFER_SESSION_H_
|
||||
#define _FILE_TRANSFER_SESSION_H_
|
||||
|
||||
#include <file_transfer_format.h>
|
||||
#include <stream_names.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
#include "../../../libs/controller/include/mobile_file_transfer_session.h"
|
||||
namespace crossdesk::android_controller {
|
||||
using mobile_controller::FileTransferSession;
|
||||
}
|
||||
|
||||
// File I/O is bounded to one chunk per pump. Feedback is delivered by MiniRTC
|
||||
// callback threads; no callback waits for the RTC executor or for a remote ACK.
|
||||
class FileTransferSession {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Send = std::function<int(const char*, const char*, size_t)>;
|
||||
using Progress = std::function<void(const std::string&, double, bool,
|
||||
const std::string&)>;
|
||||
FileTransferSession(Send send, Progress progress)
|
||||
: send_(std::move(send)), progress_(std::move(progress)) {}
|
||||
~FileTransferSession() {
|
||||
if (outgoing_) {
|
||||
outgoing_->input.close();
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(outgoing_->path, ec);
|
||||
}
|
||||
for (auto& entry : incoming_) Remove(entry.second);
|
||||
}
|
||||
|
||||
void SetDirectory(std::filesystem::path directory) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
directory_ = std::move(directory);
|
||||
}
|
||||
|
||||
bool Start(const std::filesystem::path& path, const std::string& name,
|
||||
Clock::time_point now = Clock::now()) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (outgoing_ || name.empty() || name.size() > UINT16_MAX) return false;
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::is_regular_file(path, ec)) return false;
|
||||
const auto size = std::filesystem::file_size(path, ec);
|
||||
if (ec) return false;
|
||||
Outgoing file;
|
||||
file.input.open(path, std::ios::binary);
|
||||
if (!file.input) return false;
|
||||
file.id = ++next_id_;
|
||||
file.name = name;
|
||||
file.path = path;
|
||||
file.total = size;
|
||||
file.updated = now;
|
||||
outgoing_ = std::move(file);
|
||||
progress_(name, 0, true, "");
|
||||
return true;
|
||||
}
|
||||
|
||||
void Pump(Clock::time_point now = Clock::now()) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (auto it = incoming_.begin(); it != incoming_.end();) {
|
||||
if (now - it->second.updated < timeout_) { ++it; continue; }
|
||||
Reject(it->first, it->second.total, it->second.received);
|
||||
progress_(it->second.name, -1, false, "");
|
||||
Remove(it->second);
|
||||
it = incoming_.erase(it);
|
||||
}
|
||||
if (!outgoing_) return;
|
||||
auto& file = *outgoing_;
|
||||
if (now - file.updated >= timeout_) { FinishSend(false); return; }
|
||||
if (file.last || file.sent - file.acked >= 1024 * 1024) return;
|
||||
std::array<char, kFileChunkSize> payload{};
|
||||
const auto size = static_cast<uint32_t>(std::min<uint64_t>(payload.size(), file.total - file.sent));
|
||||
if (size) {
|
||||
file.input.read(payload.data(), size);
|
||||
if (file.input.gcount() != size) { FinishSend(false); return; }
|
||||
}
|
||||
const bool first = file.sent == 0;
|
||||
const bool last = file.sent + size == file.total;
|
||||
const auto chunk = EncodeFileChunk(file.id, file.sent, file.total,
|
||||
payload.data(), size, first ? &file.name : nullptr, first, last);
|
||||
if (chunk.empty() || send_(kFileStream, chunk.data(), chunk.size()) != 0) {
|
||||
FinishSend(false); return;
|
||||
}
|
||||
file.sent += size;
|
||||
file.last = last;
|
||||
}
|
||||
|
||||
void Ack(const char* data, size_t size, Clock::time_point now = Clock::now()) {
|
||||
FileTransferAck ack{};
|
||||
if (!DecodeFileTransferAck(data, size, &ack)) return;
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!outgoing_ || ack.file_id != outgoing_->id) return;
|
||||
auto& file = *outgoing_;
|
||||
if (ack.total_size != file.total || ack.acked_offset > file.sent ||
|
||||
(ack.flags & ~3u)) return;
|
||||
if (ack.flags & 2) { FinishSend(false); return; }
|
||||
if (ack.flags & 1) {
|
||||
if (file.last && ack.acked_offset == file.total) FinishSend(true);
|
||||
return;
|
||||
}
|
||||
if (ack.acked_offset <= file.acked) return;
|
||||
file.acked = ack.acked_offset;
|
||||
file.updated = now;
|
||||
Report(file.name, file.acked, file.total, true, file.percent);
|
||||
}
|
||||
|
||||
void Receive(const char* data, size_t size, Clock::time_point now = Clock::now()) {
|
||||
FileChunkView chunk;
|
||||
if (!DecodeFileChunk(data, size, &chunk)) return;
|
||||
const auto& h = chunk.header;
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto it = incoming_.find(h.file_id);
|
||||
const bool first = h.flags & 1, last = h.flags & 2;
|
||||
const bool invalid = (h.flags & ~3u) || h.total_size > INT64_MAX ||
|
||||
(first && h.offset != 0) || chunk.payload_size > kFileChunkSize ||
|
||||
size != sizeof(FileChunkHeader) + h.name_len + chunk.payload_size ||
|
||||
(!first && h.name_len) || (!chunk.payload_size && !last) ||
|
||||
last != (h.offset + chunk.payload_size == h.total_size);
|
||||
if (invalid || (it == incoming_.end() && incoming_.size() >= 4)) {
|
||||
Reject(h.file_id, h.total_size, h.offset);
|
||||
if (it != incoming_.end()) { progress_(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it); }
|
||||
return;
|
||||
}
|
||||
if (it == incoming_.end()) {
|
||||
Incoming file;
|
||||
file.name = SafeName(chunk.file_name);
|
||||
file.total = h.total_size;
|
||||
std::error_code ec;
|
||||
if (!directory_.empty()) std::filesystem::create_directories(directory_, ec);
|
||||
// A per-transfer directory avoids overwrites, even for duplicate names.
|
||||
auto folder = directory_ / (std::to_string(h.file_id) + "-" + std::to_string(++receive_id_));
|
||||
if (directory_.empty() || ec || !std::filesystem::create_directory(folder, ec)) {
|
||||
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); return;
|
||||
}
|
||||
file.path = folder / file.name;
|
||||
file.partial = folder / ".partial";
|
||||
file.output.open(file.partial, std::ios::binary | std::ios::trunc);
|
||||
if (!file.output) {
|
||||
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); Remove(file); return;
|
||||
}
|
||||
it = incoming_.emplace(h.file_id, std::move(file)).first;
|
||||
}
|
||||
auto& file = it->second;
|
||||
if (h.total_size != file.total || (first && file.first) ||
|
||||
!AddRange(file, h.offset, h.offset + chunk.payload_size)) {
|
||||
Reject(h.file_id, h.total_size, h.offset);
|
||||
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
|
||||
}
|
||||
if (first) {
|
||||
file.first = true;
|
||||
file.name = SafeName(chunk.file_name);
|
||||
file.path = file.path.parent_path() / file.name;
|
||||
}
|
||||
file.last |= last;
|
||||
// KCP removes messages in order, but delivers batches outside its lock on
|
||||
// both RTP and timer threads. Track coverage instead of assuming callbacks
|
||||
// arrive in order; ACK only the contiguous prefix and never expose holes.
|
||||
file.received = !file.ranges.empty() && file.ranges.begin()->first == 0
|
||||
? file.ranges.begin()->second : 0;
|
||||
const bool complete = file.first && file.last && file.received == file.total;
|
||||
file.output.seekp(static_cast<std::streamoff>(h.offset));
|
||||
file.output.write(chunk.payload, chunk.payload_size);
|
||||
if (complete) { file.output.flush(); file.output.close(); }
|
||||
std::error_code ec;
|
||||
if (complete && !file.output.fail()) std::filesystem::rename(file.partial, file.path, ec);
|
||||
if (file.output.fail() || ec) {
|
||||
Reject(h.file_id, h.total_size, h.offset);
|
||||
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
|
||||
}
|
||||
file.updated = now;
|
||||
const FileTransferAck ack{kFileAckMagic, h.file_id, file.received, file.total, complete ? 1u : 0u};
|
||||
const auto packet = EncodeFileTransferAck(ack);
|
||||
send_(kFileFeedbackStream, packet.data(), packet.size());
|
||||
if (complete) {
|
||||
progress_(file.name, 1, false, file.path.string());
|
||||
incoming_.erase(it);
|
||||
} else Report(file.name, file.received, file.total, false, file.percent);
|
||||
}
|
||||
|
||||
bool Sending() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return outgoing_.has_value();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Outgoing {
|
||||
uint32_t id = 0;
|
||||
std::string name;
|
||||
std::filesystem::path path;
|
||||
std::ifstream input;
|
||||
uint64_t total = 0, sent = 0, acked = 0;
|
||||
bool last = false;
|
||||
int percent = -1;
|
||||
Clock::time_point updated;
|
||||
};
|
||||
struct Incoming {
|
||||
std::string name;
|
||||
std::filesystem::path path, partial;
|
||||
std::ofstream output;
|
||||
uint64_t total = 0, received = 0;
|
||||
bool first = false, last = false;
|
||||
std::map<uint64_t, uint64_t> ranges;
|
||||
int percent = -1;
|
||||
Clock::time_point updated;
|
||||
};
|
||||
static bool AddRange(Incoming& file, uint64_t start, uint64_t end) {
|
||||
if (start == end) return true;
|
||||
auto next = file.ranges.lower_bound(start);
|
||||
if (next != file.ranges.end() && next->first < end) return false;
|
||||
if (next != file.ranges.begin()) {
|
||||
auto previous = std::prev(next);
|
||||
if (previous->second > start) return false;
|
||||
if (previous->second == start) { start = previous->first; file.ranges.erase(previous); }
|
||||
}
|
||||
if (next != file.ranges.end() && next->first == end) { end = next->second; file.ranges.erase(next); }
|
||||
file.ranges.emplace(start, end);
|
||||
return file.ranges.size() <= 1024;
|
||||
}
|
||||
static std::string SafeName(std::string name) {
|
||||
const auto slash = name.find_last_of("/\\");
|
||||
if (slash != std::string::npos) name.erase(0, slash + 1);
|
||||
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == ':') ch = '_';
|
||||
if (name.empty() || name == "." || name == ".." || name == ".partial" || name.size() > 240) return "received_file";
|
||||
return name;
|
||||
}
|
||||
static void Remove(Incoming& file) {
|
||||
file.output.close();
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(file.partial, ec);
|
||||
std::filesystem::remove(file.path.parent_path(), ec);
|
||||
}
|
||||
void Reject(uint32_t id, uint64_t total, uint64_t offset) {
|
||||
const auto packet = EncodeFileTransferAck({kFileAckMagic, id, offset, total, 2});
|
||||
send_(kFileFeedbackStream, packet.data(), packet.size());
|
||||
}
|
||||
void Report(const std::string& name, uint64_t offset, uint64_t total, bool sending, int& previous) {
|
||||
const double progress = total ? static_cast<double>(offset) / total : 0;
|
||||
const int percent = static_cast<int>(progress * 100);
|
||||
if (percent != previous) { previous = percent; progress_(name, std::min(progress, .99), sending, ""); }
|
||||
}
|
||||
void FinishSend(bool success) {
|
||||
progress_(outgoing_->name, success ? 1 : -1, true, "");
|
||||
outgoing_->input.close();
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(outgoing_->path, ec);
|
||||
outgoing_.reset();
|
||||
}
|
||||
const std::chrono::seconds timeout_{30};
|
||||
std::mutex mutex_;
|
||||
Send send_;
|
||||
Progress progress_;
|
||||
std::filesystem::path directory_;
|
||||
std::optional<Outgoing> outgoing_;
|
||||
std::map<uint32_t, Incoming> incoming_;
|
||||
uint32_t next_id_ = 0;
|
||||
uint64_t receive_id_ = 0;
|
||||
};
|
||||
} // namespace crossdesk::android_controller
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -7,21 +7,9 @@
|
||||
#ifndef _VIDEO_TIMING_H_
|
||||
#define _VIDEO_TIMING_H_
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "../../../libs/controller/include/mobile_video_timing.h"
|
||||
namespace crossdesk::android_controller {
|
||||
inline double VideoLatencyMilliseconds(uint64_t captured_us,
|
||||
uint64_t received_us,
|
||||
int64_t submitted_us) {
|
||||
// MiniRTC calibrates capture time when the frame is reassembled. Validate
|
||||
// that mapping at reception, not after decoding/Surface waits: a real local
|
||||
// backlog over five seconds must remain visible in the latency measurement.
|
||||
if (!captured_us || received_us < captured_us || submitted_us <= 0 ||
|
||||
received_us > static_cast<uint64_t>(submitted_us) ||
|
||||
received_us - captured_us > 5'000'000)
|
||||
return -1;
|
||||
return (static_cast<uint64_t>(submitted_us) - captured_us) / 1000.0;
|
||||
using mobile_controller::VideoLatencyMilliseconds;
|
||||
}
|
||||
} // namespace crossdesk::android_controller
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include "config_center.h"
|
||||
#include "daemon.h"
|
||||
#include "path_manager.h"
|
||||
#include "platform/instance_activation.h"
|
||||
#include "platform/single_instance.h"
|
||||
#include "rd_log.h"
|
||||
#include "render.h"
|
||||
@@ -307,6 +308,25 @@ static crossdesk::platform::InstanceResult AcquireInstance(
|
||||
return result;
|
||||
}
|
||||
|
||||
static int ExitForExistingInstance(
|
||||
crossdesk::platform::InstanceResult result, bool interactive_launch) {
|
||||
if (interactive_launch &&
|
||||
result == crossdesk::platform::InstanceResult::kAlreadyRunning) {
|
||||
std::string error;
|
||||
if (!crossdesk::platform::RequestInstanceActivation(error)) {
|
||||
std::cerr << error << '\n';
|
||||
#ifdef _WIN32
|
||||
MessageBoxW(nullptr,
|
||||
L"CrossDesk is already running, but its window could not be "
|
||||
L"activated.\n\nIt may still be starting or be running in "
|
||||
L"another Windows session. Please check the system tray.",
|
||||
L"CrossDesk", MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
return result == crossdesk::platform::InstanceResult::kAlreadyRunning ? 0 : 1;
|
||||
}
|
||||
|
||||
static int RunApplication(
|
||||
int argc, char* argv[],
|
||||
crossdesk::platform::SingleInstanceGuard& client_instance) {
|
||||
@@ -314,6 +334,12 @@ static int RunApplication(
|
||||
auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk");
|
||||
crossdesk::InitLogger(path_manager->GetLogPath().string());
|
||||
|
||||
#ifdef _WIN32
|
||||
crossdesk::get_logger()->info(
|
||||
"CrossDesk executable: {}",
|
||||
std::filesystem::path(GetCurrentExecutablePathW()).u8string());
|
||||
#endif
|
||||
|
||||
if (IsDaemonChild(argc, argv)) {
|
||||
// child process: run render directly
|
||||
crossdesk::Render render;
|
||||
@@ -452,11 +478,11 @@ int main(int argc, char* argv[]) {
|
||||
if (!IsDaemonChild(argc, argv)) {
|
||||
const auto result = AcquireInstance(launcher_instance, InstanceRole::kLauncher);
|
||||
if (result != InstanceResult::kAcquired)
|
||||
return result == InstanceResult::kAlreadyRunning ? 0 : 1;
|
||||
return ExitForExistingInstance(result, true);
|
||||
}
|
||||
const auto result = AcquireInstance(client_instance, InstanceRole::kClient);
|
||||
if (result != InstanceResult::kAcquired)
|
||||
return result == InstanceResult::kAlreadyRunning ? 0 : 1;
|
||||
return ExitForExistingInstance(result, !IsDaemonChild(argc, argv));
|
||||
|
||||
#ifdef __linux__
|
||||
const int exit_code = crossdesk::RunWithLinuxDisplay(
|
||||
|
||||
@@ -45,6 +45,9 @@ class KeyboardCapturer : public DeviceController {
|
||||
// Backends with an asynchronous capture loop override this so the UI can
|
||||
// fall back to window events if that loop stops unexpectedly.
|
||||
virtual bool IsHookActive() const { return true; }
|
||||
// Permission-denied backends can wait for an OS grant notification rather
|
||||
// than repeatedly releasing held fallback keys to retry an impossible hook.
|
||||
virtual bool ShouldRetryHook() const { return true; }
|
||||
virtual int SendKeyboardCommand(int key_code, bool is_down,
|
||||
uint32_t scan_code = 0,
|
||||
bool extended = false) = 0;
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
#include "features/gallery/preview_bandwidth.h"
|
||||
#include "localization.h"
|
||||
#include "platform.h"
|
||||
#ifndef __OHOS__
|
||||
#include "platform/instance_activation.h"
|
||||
#endif
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rendering/slint_video_presenter.h"
|
||||
#include "rendering/video_geometry.h"
|
||||
@@ -53,7 +56,12 @@
|
||||
#elif defined(__APPLE__)
|
||||
#include "platform/macos/gui/tray/mac_tray.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 <X11/Xatom.h>
|
||||
#include <X11/Xlib.h>
|
||||
@@ -61,6 +69,7 @@
|
||||
|
||||
#include "platform/linux/gui/cuda_graphics.h"
|
||||
#include "platform/linux/gui/tray/linux_tray.h"
|
||||
#include "platform/linux/gui/wayland_activation.h"
|
||||
#include "platform/linux/headless/headless_console.h"
|
||||
// Xlib defines Status as a macro, which collides with UpdateChecker::Status.
|
||||
#undef Status
|
||||
@@ -70,6 +79,7 @@
|
||||
#include "signal_server_settings.h"
|
||||
#include "signal_server_address.h"
|
||||
#include "ui/announcement_text.h"
|
||||
#include "ui/model_update.h"
|
||||
#include "ui/release_notes_text.h"
|
||||
#include "ui/ui_localization.h"
|
||||
#include "update_checker.h"
|
||||
@@ -111,7 +121,7 @@ std::string CreatePasswordChangeRequestId(uint64_t sequence) {
|
||||
std::to_string(sequence);
|
||||
}
|
||||
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
bool HasNonEmptyEnvironmentVariable(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value != nullptr && value[0] != '\0';
|
||||
@@ -242,8 +252,9 @@ bool X11WindowMatches(Display* display, ::Window x11_window,
|
||||
kGeometryTolerance;
|
||||
}
|
||||
|
||||
void X11ActivateGalleryWindow(Display* display) {
|
||||
if (!display) return;
|
||||
bool X11ActivateWindow(Display* display, const slint::Window& target,
|
||||
bool gallery_window) {
|
||||
if (!display) return false;
|
||||
Atom type = None;
|
||||
int format = 0;
|
||||
unsigned long count = 0, remaining = 0;
|
||||
@@ -253,13 +264,15 @@ void X11ActivateGalleryWindow(Display* display) {
|
||||
0, 4096, False, XA_WINDOW, &type, &format, &count,
|
||||
&remaining, &data) != Success || !data) {
|
||||
if (data) XFree(data);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
bool activated = false;
|
||||
if (type == XA_WINDOW && format == 32) {
|
||||
const auto* windows = reinterpret_cast<const ::Window*>(data);
|
||||
for (unsigned long i = 0; i < count; ++i) {
|
||||
if (X11GetWindowProcessId(display, windows[i]) !=
|
||||
static_cast<unsigned long>(getpid())) continue;
|
||||
if (!gallery_window && !X11WindowMatches(display, windows[i], target)) continue;
|
||||
unsigned char* name = nullptr;
|
||||
unsigned long length = 0;
|
||||
Atom name_type = None;
|
||||
@@ -267,8 +280,11 @@ void X11ActivateGalleryWindow(Display* display) {
|
||||
XGetWindowProperty(display, windows[i], XInternAtom(display, "_NET_WM_NAME", False),
|
||||
0, 256, False, XInternAtom(display, "UTF8_STRING", False),
|
||||
&name_type, &name_format, &length, &remaining, &name);
|
||||
const bool matches = name && name_format == 8 &&
|
||||
std::string_view(reinterpret_cast<char*>(name), length).starts_with("CrossDesk ");
|
||||
const std::string_view title = name && name_format == 8
|
||||
? std::string_view(reinterpret_cast<char*>(name), length)
|
||||
: std::string_view{};
|
||||
const bool matches = gallery_window ? title.starts_with("CrossDesk ")
|
||||
: title == "CrossDesk";
|
||||
if (name) XFree(name);
|
||||
if (!matches) continue;
|
||||
XEvent event{};
|
||||
@@ -278,12 +294,15 @@ void X11ActivateGalleryWindow(Display* display) {
|
||||
event.xclient.format = 32;
|
||||
event.xclient.data.l[0] = 1;
|
||||
event.xclient.data.l[1] = CurrentTime;
|
||||
XSendEvent(display, root, False, SubstructureRedirectMask | SubstructureNotifyMask, &event);
|
||||
activated = XSendEvent(display, root, False,
|
||||
SubstructureRedirectMask | SubstructureNotifyMask,
|
||||
&event) != 0;
|
||||
XFlush(display);
|
||||
break;
|
||||
}
|
||||
}
|
||||
XFree(data);
|
||||
return activated;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -639,14 +658,14 @@ struct WindowDragState {
|
||||
float global_to_physical_scale = 1;
|
||||
slint::PhysicalPosition window_start;
|
||||
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,
|
||||
XCloseDisplay};
|
||||
slint::Timer pointer_timer;
|
||||
#endif
|
||||
|
||||
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();
|
||||
if (driver && std::strcmp(driver, "x11") == 0) {
|
||||
// Slint can dispatch motion before SDL pumps its XInput2 events, and
|
||||
@@ -683,7 +702,7 @@ bool CanUseGlobalPointerPosition() {
|
||||
if (driver == nullptr) {
|
||||
return false;
|
||||
}
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
// Wayland deliberately does not expose global pointer coordinates or allow
|
||||
// clients to position top-level windows.
|
||||
return std::strcmp(driver, "wayland") != 0;
|
||||
@@ -821,7 +840,7 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
||||
state.global_to_physical_scale = 1.0f;
|
||||
#endif
|
||||
}
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
if (state.use_global_pointer && state.x11_display) {
|
||||
// Moving the window can leave consecutive pointer events at identical
|
||||
// local coordinates, so Slint need not emit another TouchArea::moved.
|
||||
@@ -841,7 +860,7 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
|
||||
return;
|
||||
}
|
||||
if (phase == 0) {
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
state.pointer_timer.stop();
|
||||
if (state.use_global_pointer && state.x11_display) {
|
||||
DragWindow(component, 2, mouse_x, mouse_y, state);
|
||||
@@ -1013,18 +1032,25 @@ struct GuiApplication::SlintUi {
|
||||
int localized_language = -1;
|
||||
std::chrono::steady_clock::time_point last_clipboard_poll{};
|
||||
slint::Timer video_timer;
|
||||
#ifndef __OHOS__
|
||||
std::unique_ptr<platform::InstanceActivationReceiver> activation_receiver;
|
||||
#endif
|
||||
#if _WIN32
|
||||
std::unique_ptr<WinTray> tray;
|
||||
#elif defined(__APPLE__)
|
||||
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;
|
||||
Display* x11_focus_display = nullptr;
|
||||
int main_activation_attempts = 0;
|
||||
std::string main_activation_token;
|
||||
std::chrono::steady_clock::time_point last_x11_activation_poll{};
|
||||
#endif
|
||||
|
||||
~SlintUi() {
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
if (x11_focus_display) {
|
||||
XCloseDisplay(x11_focus_display);
|
||||
}
|
||||
@@ -1036,6 +1062,14 @@ GuiApplication::GuiApplication() = default;
|
||||
GuiApplication::~GuiApplication() = default;
|
||||
|
||||
int GuiApplication::Run() {
|
||||
#ifndef __OHOS__
|
||||
// Publish the GUI endpoint before configuration/media initialization, so a
|
||||
// second launch can queue activation even while this window is starting.
|
||||
auto activation_receiver =
|
||||
std::make_unique<platform::InstanceActivationReceiver>();
|
||||
std::string activation_error;
|
||||
activation_receiver->Start(activation_error);
|
||||
#endif
|
||||
path_manager_ = std::make_unique<PathManager>("CrossDesk");
|
||||
if (!path_manager_) {
|
||||
return -1;
|
||||
@@ -1043,6 +1077,9 @@ int GuiApplication::Run() {
|
||||
exec_log_path_ = path_manager_->GetLogPath().string();
|
||||
dll_log_path_ = exec_log_path_;
|
||||
InitializeLogger();
|
||||
#ifndef __OHOS__
|
||||
if (!activation_error.empty()) LOG_WARN("{}", activation_error);
|
||||
#endif
|
||||
cache_path_ = path_manager_->GetCachePath().string();
|
||||
announcements_.SetStorageDirectory(path_manager_->GetCachePath() /
|
||||
"announcement_reads");
|
||||
@@ -1059,7 +1096,7 @@ int GuiApplication::Run() {
|
||||
}
|
||||
|
||||
InitializeSettings();
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().BindSettings(
|
||||
config_center_.get(), [this] {
|
||||
std::lock_guard<std::mutex> lock(password_change_mutex_);
|
||||
@@ -1083,6 +1120,9 @@ int GuiApplication::Run() {
|
||||
InitializeModules();
|
||||
update_checker_ = std::make_unique<UpdateChecker>(CROSSDESK_VERSION);
|
||||
InitializeUi();
|
||||
#ifndef __OHOS__
|
||||
ui_->activation_receiver = std::move(activation_receiver);
|
||||
#endif
|
||||
// Configure the initial geometry before the native window becomes visible.
|
||||
// Subsequent ticks only acknowledge that placement and its resolved DPI.
|
||||
RestoreWindowGeometry(true);
|
||||
@@ -1121,7 +1161,7 @@ void GuiApplication::InitializeSettings() {
|
||||
}
|
||||
|
||||
bool GuiApplication::InitializeSDL() {
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
ConfigureLinuxCudaGraphics(config_center_->IsHardwareVideoCodec());
|
||||
// Standard Wayland top-level windows cannot choose an absolute screen
|
||||
// position. Use the session's XWayland compatibility server for the GUI so
|
||||
@@ -1146,7 +1186,7 @@ bool GuiApplication::InitializeSDL() {
|
||||
LOG_ERROR("SDL initialization failed: {}", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
const char* active_video_driver = SDL_GetCurrentVideoDriver();
|
||||
// X11 permits the manual move/position operations used by the frameless
|
||||
// main window. Keep native Wayland decorations as a compatibility fallback.
|
||||
@@ -1192,7 +1232,7 @@ void GuiApplication::InitializeUi() {
|
||||
LOG_INFO("Using requested Slint backend: {}", requested_slint_backend);
|
||||
}
|
||||
#endif
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
// Detect the desktop session before temporarily hiding Wayland variables
|
||||
// for the XWayland GUI backend below.
|
||||
const bool is_wayland_session = IsWaylandSession();
|
||||
@@ -1214,7 +1254,7 @@ void GuiApplication::InitializeUi() {
|
||||
std::make_unique<SlintVideoPresenter>(*video_renderer_);
|
||||
#if _WIN32
|
||||
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_wayland_titlebar(use_xwayland_gui_);
|
||||
// Set this only at startup so dismissing the notice lasts until exit.
|
||||
@@ -1295,28 +1335,41 @@ void GuiApplication::InitializeUi() {
|
||||
SyncMainWindow();
|
||||
}
|
||||
|
||||
void GuiApplication::ShowMainWindow(const std::string& activation_token) {
|
||||
#if !defined(__linux__) || defined(__OHOS__)
|
||||
(void)activation_token;
|
||||
#endif
|
||||
if (!ui_) {
|
||||
return;
|
||||
}
|
||||
ui_->main->show();
|
||||
ui_->main->window().set_minimized(false);
|
||||
#if _WIN32
|
||||
const HWND window = ui_->main->window().win32_hwnd();
|
||||
ConfigureWindowsWindowIcons(window);
|
||||
ShowWindow(window, IsIconic(window) ? SW_RESTORE : SW_SHOW);
|
||||
SetForegroundWindow(window);
|
||||
#endif
|
||||
#if defined(__APPLE__)
|
||||
ConfigureMainWindowChrome(ui_->main->window().appkit_view());
|
||||
MacActivateWindow(ui_->main->window().appkit_view());
|
||||
// A window restored from the tray may only obtain its NSWindow after the
|
||||
// callback returns, so let the regular UI tick configure it.
|
||||
ui_->main_native_titlebar_attempts = 30;
|
||||
#elif defined(__linux__) && !defined(__OHOS__)
|
||||
// Mapping/unminimizing through Slint is asynchronous. Retry native lookup
|
||||
// after the window manager has updated its client list and geometry.
|
||||
ui_->main_activation_attempts = 30;
|
||||
ui_->main_activation_token = activation_token;
|
||||
#endif
|
||||
}
|
||||
|
||||
void GuiApplication::InitializeSystemTray() {
|
||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
||||
if (!ui_) {
|
||||
return;
|
||||
}
|
||||
auto show_window = [this] {
|
||||
if (!ui_) {
|
||||
return;
|
||||
}
|
||||
ui_->main->show();
|
||||
#if _WIN32
|
||||
ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd());
|
||||
#endif
|
||||
ui_->main->window().set_minimized(false);
|
||||
#if defined(__APPLE__)
|
||||
ConfigureMainWindowChrome(ui_->main->window().appkit_view());
|
||||
MacActivateWindow(ui_->main->window().appkit_view());
|
||||
// A window restored from the tray may only obtain its NSWindow after the
|
||||
// callback returns, so let the regular UI tick configure it.
|
||||
ui_->main_native_titlebar_attempts = 30;
|
||||
#endif
|
||||
};
|
||||
auto show_window = [this] { ShowMainWindow(); };
|
||||
auto hide_window = [this] {
|
||||
if (ui_) {
|
||||
ui_->main->hide();
|
||||
@@ -1342,7 +1395,11 @@ void GuiApplication::InitializeSystemTray() {
|
||||
ui_->tray = std::make_unique<MacTray>(
|
||||
std::move(show_window), std::move(hide_window), std::move(open_settings),
|
||||
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>(
|
||||
std::move(show_window), std::move(hide_window),
|
||||
std::move(open_settings), std::move(exit_app), "CrossDesk",
|
||||
@@ -1384,6 +1441,9 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
||||
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
||||
main->set_self_hosted_enabled(enable_self_hosted_);
|
||||
main->set_autostart_enabled(enable_autostart_);
|
||||
#if defined(__OHOS__)
|
||||
main->set_autostart_system_managed(true);
|
||||
#endif
|
||||
main->set_daemon_enabled(enable_daemon_);
|
||||
main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
|
||||
main->set_privacy_on_connect_enabled(
|
||||
@@ -1393,12 +1453,20 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
|
||||
main->set_capture_method_index(
|
||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||
#endif
|
||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
||||
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
|
||||
main->set_privacy_setting_available(true);
|
||||
#else
|
||||
main->set_privacy_setting_available(false);
|
||||
#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_);
|
||||
#endif
|
||||
main->set_server_host(signal_server_ip_self_);
|
||||
main->set_server_port(signal_server_port_self_);
|
||||
main->set_self_host_settings_error("");
|
||||
@@ -1493,6 +1561,10 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
||||
SyncAnnouncements();
|
||||
});
|
||||
main->on_refresh_announcements([this] { announcements_.Refresh(); });
|
||||
main->on_read_all_announcements([this] {
|
||||
announcements_.ReadAll();
|
||||
SyncAnnouncements();
|
||||
});
|
||||
main->on_open_announcement_link([this](slint::SharedString link) {
|
||||
const std::string url(link);
|
||||
if (announcement_text::IsWebUrl(url)) OpenUrl(url);
|
||||
@@ -1629,6 +1701,9 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
||||
main->on_cancel_settings([this, main] {
|
||||
ResetSettingsUi(main->get_gallery_mode());
|
||||
});
|
||||
#if defined(__OHOS__)
|
||||
main->on_refresh_autostart_status([] { RefreshHarmonyAutostartStatus(); });
|
||||
#endif
|
||||
main->on_save_self_hosted_settings([this, main] {
|
||||
// Stage the endpoint until the surrounding settings dialog is confirmed.
|
||||
return ValidateSelfHostedSettingsFromUi(main->get_gallery_mode());
|
||||
@@ -1828,12 +1903,19 @@ void GuiApplication::BindStreamCallbacks() {
|
||||
});
|
||||
stream->on_send_shortcut([this](slint::SharedString shortcut) {
|
||||
auto props = SelectedSession();
|
||||
if (!props || !props->peer_) {
|
||||
if (!props || !props->peer_ || !props->control_mouse_ ||
|
||||
props->connection_status_.load() != ConnectionStatus::Connected) {
|
||||
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{};
|
||||
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::lock_workstation;
|
||||
const std::string message = action.to_json();
|
||||
@@ -2095,7 +2177,7 @@ void GuiApplication::Tick() {
|
||||
signal_connection_indicator_.Reset();
|
||||
CreateConnectionPeer();
|
||||
}
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().Poll(
|
||||
signal_connected_.load(),
|
||||
[this](const std::string& password) { RequestPasswordChange(password); },
|
||||
@@ -2126,7 +2208,30 @@ void GuiApplication::Tick() {
|
||||
}
|
||||
}
|
||||
clipboard_.ApplyPendingRemoteText();
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#ifndef __OHOS__
|
||||
std::string activation_token;
|
||||
bool activation_requested =
|
||||
ui_->activation_receiver && ui_->activation_receiver->TakeRequest(&activation_token);
|
||||
#ifdef __APPLE__
|
||||
if (ui_->tray && ui_->tray->TakeReopenRequest()) activation_requested = true;
|
||||
#endif
|
||||
if (activation_requested) ShowMainWindow(activation_token);
|
||||
#endif
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
if (ui_->main_activation_attempts > 0) {
|
||||
--ui_->main_activation_attempts;
|
||||
const auto& window = ui_->main->window();
|
||||
if (window.wayland_surface()) {
|
||||
if (platform::ActivateWaylandWindow(window.wayland_display(),
|
||||
window.wayland_surface(), ui_->main_activation_token))
|
||||
ui_->main_activation_attempts = 0;
|
||||
} else {
|
||||
if (!ui_->x11_focus_display) ui_->x11_focus_display = XOpenDisplay(nullptr);
|
||||
if (X11ActivateWindow(ui_->x11_focus_display, window, false))
|
||||
ui_->main_activation_attempts = 0;
|
||||
}
|
||||
if (ui_->main_activation_attempts == 0) ui_->main_activation_token.clear();
|
||||
}
|
||||
if (ui_->tray) {
|
||||
ui_->tray->ProcessEvents();
|
||||
}
|
||||
@@ -2152,7 +2257,7 @@ void GuiApplication::Tick() {
|
||||
HandlePresenceProbeTimeout();
|
||||
HandlePrivacy();
|
||||
HandleWindowsServiceIntegration();
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
SyncXWaylandWindowActivation();
|
||||
#else
|
||||
SyncStreamKeyboardFocus();
|
||||
@@ -2363,7 +2468,7 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
|
||||
bool GuiApplication::RequestPasswordChange(const std::string &password) {
|
||||
const auto notify = [](const std::string& message) {
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().NotifyKey(message.c_str());
|
||||
#else
|
||||
(void)message;
|
||||
@@ -2482,7 +2587,7 @@ void GuiApplication::HandlePasswordChangeResult() {
|
||||
}
|
||||
|
||||
if (!succeeded) {
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().NotifyKey(
|
||||
uncertain ? "console_password_uncertain" : "console_password_rejected");
|
||||
#endif
|
||||
@@ -2513,7 +2618,7 @@ void GuiApplication::HandlePasswordChangeResult() {
|
||||
}
|
||||
|
||||
LOG_INFO("Password changed successfully for [{}]", client_id_);
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().NotifyKey("console_password_confirmed");
|
||||
#endif
|
||||
if (peer_) {
|
||||
@@ -2547,7 +2652,7 @@ void GuiApplication::HandleCredentialRecovery() {
|
||||
}
|
||||
|
||||
if (promote_pending) {
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().NotifyKey("console_password_recovered");
|
||||
#endif
|
||||
if (!settings_.PromotePendingPasswordChange()) {
|
||||
@@ -2556,7 +2661,7 @@ void GuiApplication::HandleCredentialRecovery() {
|
||||
LOG_INFO("Recovered password change with pending credential");
|
||||
}
|
||||
} else if (clear_pending) {
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().NotifyKey("console_password_retained");
|
||||
#endif
|
||||
if (!settings_.ClearPendingPasswordChange()) {
|
||||
@@ -2637,6 +2742,7 @@ void GuiApplication::SyncAnnouncements() {
|
||||
revision == ui_->announcement_selected_revision) {
|
||||
selected_found = true;
|
||||
main->set_announcement_selected_id(row.id);
|
||||
main->set_announcement_selected_revision(row.revision);
|
||||
main->set_announcement_selected_title(row.title);
|
||||
main->set_announcement_selected_body(
|
||||
announcement_text::Format(item["body"].get<std::string>()));
|
||||
@@ -2648,6 +2754,7 @@ void GuiApplication::SyncAnnouncements() {
|
||||
if (!selected_found) {
|
||||
ui_->announcement_selected_id.clear();
|
||||
main->set_announcement_selected_id("");
|
||||
main->set_announcement_selected_revision("");
|
||||
main->set_announcement_selected_body(slint::StyledText{});
|
||||
}
|
||||
main->set_announcements(
|
||||
@@ -2693,6 +2800,17 @@ void GuiApplication::SyncMainWindow() {
|
||||
const bool signal_online = signal_connected_.load();
|
||||
const bool signal_reconnecting = signal_connection_indicator_.Update(signal_online);
|
||||
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_reconnecting(signal_reconnecting);
|
||||
main->set_signal_tls_error(signal_status_ ==
|
||||
@@ -2977,7 +3095,7 @@ void GuiApplication::SyncPlatformDialogs() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
void GuiApplication::SyncXWaylandWindowActivation() {
|
||||
if (!ui_ || !use_xwayland_gui_) {
|
||||
return;
|
||||
@@ -3051,12 +3169,20 @@ void GuiApplication::SetStreamKeyboardFocus(bool focused) {
|
||||
}
|
||||
|
||||
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 defined(__APPLE__)
|
||||
if (!IsGalleryWindowActive()) ReleaseControl();
|
||||
#elif defined(_WIN32)
|
||||
if (GetForegroundWindow() != ui_->wall->window().win32_hwnd())
|
||||
ReleaseControl();
|
||||
#elif defined(__OHOS__)
|
||||
if (!IsHarmonyWindowActive(ui_->wall->window())) ReleaseControl();
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
@@ -3071,6 +3197,10 @@ void GuiApplication::SyncStreamKeyboardFocus() {
|
||||
GetForegroundWindow() == stream_hwnd);
|
||||
#elif defined(__APPLE__)
|
||||
SetStreamKeyboardFocus(IsStreamWindowActive());
|
||||
#elif defined(__OHOS__)
|
||||
const bool active = IsHarmonyWindowActive((*ui_->stream)->window());
|
||||
(*ui_->stream)->set_window_active(active);
|
||||
SetStreamKeyboardFocus(active);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -3087,7 +3217,7 @@ void GuiApplication::SyncStreamWindow() {
|
||||
if (!has_sessions) need_to_create_stream_window_ = false;
|
||||
if (need_to_create_stream_window_ && !ui_->stream) {
|
||||
ui_->stream.emplace(ui::StreamWindow::create());
|
||||
#if defined(__linux__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__))
|
||||
(*ui_->stream)->set_custom_titlebar(use_xwayland_gui_);
|
||||
#endif
|
||||
RegisterFontAwesome((*ui_->stream)->window());
|
||||
@@ -3251,6 +3381,9 @@ void GuiApplication::SyncStreamWindow() {
|
||||
}
|
||||
}
|
||||
(*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) {
|
||||
SetStreamKeyboardFocus(false);
|
||||
}
|
||||
@@ -3352,7 +3485,7 @@ void GuiApplication::SyncStreamWindow() {
|
||||
index, props->display_info_list_[index].name,
|
||||
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_);
|
||||
|
||||
const auto net = props->net_traffic_stats_.Load();
|
||||
@@ -3377,7 +3510,7 @@ void GuiApplication::SyncStreamWindow() {
|
||||
net.data_inbound_stats, net.data_outbound_stats);
|
||||
append_stats_row(localization::total[localization_language_index_],
|
||||
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)
|
||||
->set_stats_resolution(UiText(std::to_string(props->video_width_) + "x" +
|
||||
std::to_string(props->video_height_)));
|
||||
@@ -3438,7 +3571,7 @@ void GuiApplication::SyncStreamWindow() {
|
||||
item.size = UiText(FormatFileSize(info.file_size));
|
||||
transfers.push_back(std::move(item));
|
||||
}
|
||||
ui_->transfer_model->set_vector(std::move(transfers));
|
||||
ui::UpdateModelRows(*ui_->transfer_model, std::move(transfers));
|
||||
(*ui_->stream)
|
||||
->set_file_transfer_visible(
|
||||
props->file_transfer_.file_transfer_window_visible_);
|
||||
@@ -3692,7 +3825,7 @@ void GuiApplication::SyncServerWindow() {
|
||||
// Keep the panel alive throughout privacy transitions so remote
|
||||
// controllers can still see and operate it without resetting its UI state.
|
||||
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.
|
||||
const auto privacy_status = privacy_.Snapshot();
|
||||
const bool privacy_covering = privacy_status.overlay_active ||
|
||||
@@ -3896,7 +4029,9 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
||||
main->get_hardware_codec_enabled();
|
||||
enable_turn_ = main->get_turn_enabled();
|
||||
enable_self_hosted_ = main->get_self_hosted_enabled();
|
||||
#if !defined(__OHOS__)
|
||||
enable_autostart_ = main->get_autostart_enabled();
|
||||
#endif
|
||||
enable_daemon_ = main->get_daemon_enabled();
|
||||
|
||||
localization_language_ =
|
||||
@@ -3911,7 +4046,9 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
||||
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_);
|
||||
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
|
||||
main->set_force_relay_enabled(config_center_->IsForceRelay());
|
||||
#if !defined(__OHOS__)
|
||||
config_center_->SetAutostart(enable_autostart_);
|
||||
#endif
|
||||
config_center_->SetDaemon(enable_daemon_);
|
||||
if (config_center_->SetSaveRemotePreviews(main->get_save_remote_previews()) != 0) {
|
||||
LOG_ERROR("Failed to save remote preview preference");
|
||||
@@ -3939,7 +4076,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
|
||||
main->set_capture_method_index(
|
||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||
#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()) !=
|
||||
0) {
|
||||
main->set_privacy_on_connect_enabled(
|
||||
@@ -3994,7 +4131,7 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
|
||||
enable_autostart_last_ = enable_autostart_;
|
||||
enable_daemon_last_ = enable_daemon_;
|
||||
file_transfer_save_path_last_ = path;
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().SetLanguage(localization_language_index_);
|
||||
#endif
|
||||
if (ui_) {
|
||||
@@ -4443,9 +4580,9 @@ void GuiApplication::ShowGalleryWindow() {
|
||||
#elif defined(_WIN32)
|
||||
ConfigureWindowsWindowIcons(window.win32_hwnd());
|
||||
SetForegroundWindow(window.win32_hwnd());
|
||||
#else
|
||||
#elif defined(__linux__) && !defined(__OHOS__)
|
||||
if (!ui_->x11_focus_display) ui_->x11_focus_display = XOpenDisplay(nullptr);
|
||||
X11ActivateGalleryWindow(ui_->x11_focus_display);
|
||||
X11ActivateWindow(ui_->x11_focus_display, window, true);
|
||||
#endif
|
||||
ui_->gallery_synced = {};
|
||||
SyncGallery();
|
||||
@@ -4973,6 +5110,7 @@ void GuiApplication::RestoreWindowGeometry(bool before_show,
|
||||
auto& window = component->window();
|
||||
const auto current_size = window.size();
|
||||
const bool wall_mode = gallery_window;
|
||||
const float compact_width = component->get_minimum_window_width();
|
||||
const float compact_height = component->get_minimum_window_height();
|
||||
if (!before_show && ui_->loaded_window_scale[gallery_window] > 0) {
|
||||
const float ratio = window.scale_factor() / ui_->loaded_window_scale[gallery_window];
|
||||
@@ -4986,7 +5124,8 @@ void GuiApplication::RestoreWindowGeometry(bool before_show,
|
||||
}
|
||||
ui_->loaded_window_scale[gallery_window] = 0;
|
||||
}
|
||||
auto target_size = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height);
|
||||
auto target_size = placement.RestoreSize(wall_mode, window.scale_factor(),
|
||||
compact_height, compact_width);
|
||||
const auto& position = gallery_window
|
||||
? placement.wall_position
|
||||
: placement.connection_position;
|
||||
@@ -5024,7 +5163,8 @@ void GuiApplication::RestoreWindowGeometry(bool before_show,
|
||||
// placement. Only recover coordinates when its display is unavailable.
|
||||
if (!found && available.w > 0 && available.h > 0) {
|
||||
const auto margin = uint32_t(32 * window.scale_factor());
|
||||
const auto minimum = placement.RestoreSize(false, window.scale_factor(), compact_height);
|
||||
const auto minimum = placement.RestoreSize(false, window.scale_factor(),
|
||||
compact_height, compact_width);
|
||||
safe_size.width = std::max(minimum.width,
|
||||
std::min(safe_size.width, uint32_t(available.w)));
|
||||
safe_size.height = std::max(minimum.height, std::min(
|
||||
@@ -5080,6 +5220,7 @@ void GuiApplication::SyncWindowGeometry(bool gallery_window) {
|
||||
auto& placement = gallery_window ? ui_->gallery_window : ui_->connection_window;
|
||||
const bool wall_mode = gallery_window;
|
||||
const bool transitioning = gallery_window && ui_->gallery_transition.Active();
|
||||
const float compact_width = component->get_minimum_window_width();
|
||||
const float compact_height = component->get_minimum_window_height();
|
||||
#if defined(__APPLE__)
|
||||
const bool fullscreen = gallery_window && IsGalleryWindowFullscreen();
|
||||
@@ -5091,16 +5232,17 @@ void GuiApplication::SyncWindowGeometry(bool gallery_window) {
|
||||
if (!transitioning && placement.RecoverUndersized(wall_mode, window.position(), current_size,
|
||||
window.scale_factor(), window.is_visible(),
|
||||
window.is_minimized(), fullscreen, maximized,
|
||||
compact_height)) {
|
||||
compact_height, compact_width)) {
|
||||
ui_->restore_ticks[gallery_window] = 0;
|
||||
const auto target = placement.RestoreSize(wall_mode, window.scale_factor(),
|
||||
compact_height);
|
||||
compact_height, compact_width);
|
||||
LOG_WARN("DeskHub undersized window: wall={} client={}x{} scale={}; restoring={}x{}",
|
||||
wall_mode, current_size.width, current_size.height,
|
||||
window.scale_factor(), target.width, target.height);
|
||||
}
|
||||
if (placement.Restoring() && ++ui_->restore_ticks[gallery_window] == 120) {
|
||||
const auto target = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height);
|
||||
const auto target = placement.RestoreSize(wall_mode, window.scale_factor(),
|
||||
compact_height, compact_width);
|
||||
LOG_WARN("DeskHub window restore delayed: wall={} client={}x{} target={}x{} scale={} fullscreen={} maximized={}",
|
||||
wall_mode, current_size.width, current_size.height, target.width,
|
||||
target.height, window.scale_factor(), fullscreen, maximized);
|
||||
@@ -5114,7 +5256,7 @@ void GuiApplication::SyncWindowGeometry(bool gallery_window) {
|
||||
current_size.height, window.scale_factor(), fullscreen, maximized);
|
||||
} else if (window.is_visible() && !window.is_minimized() && !transitioning) {
|
||||
placement.Observe(wall_mode, window.position(), current_size, window.scale_factor(),
|
||||
fullscreen, maximized, compact_height);
|
||||
fullscreen, maximized, compact_height, compact_width);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5747,7 +5889,12 @@ void GuiApplication::SyncGallery() {
|
||||
}
|
||||
|
||||
void GuiApplication::Cleanup() {
|
||||
#ifdef __linux__
|
||||
#if _WIN32
|
||||
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
|
||||
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
|
||||
if (windows_service_worker_) windows_service_worker_->Reset();
|
||||
#endif
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().BindSettings(nullptr, {}, {});
|
||||
#endif
|
||||
if (!ui_) {
|
||||
|
||||
@@ -49,6 +49,7 @@ private:
|
||||
void InitializeModules();
|
||||
void InitializeUi();
|
||||
void InitializeSystemTray();
|
||||
void ShowMainWindow(const std::string& activation_token = {});
|
||||
bool MinimizeMainWindowToTray();
|
||||
void BindMainCallbacks(bool gallery_window = false);
|
||||
void BindStreamCallbacks();
|
||||
@@ -91,7 +92,7 @@ private:
|
||||
void SyncServerWindow();
|
||||
void SetServerPanelExpanded(bool expanded);
|
||||
void PositionServerWindows();
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
void SyncXWaylandWindowActivation();
|
||||
#endif
|
||||
void UpdateLocalization();
|
||||
@@ -127,7 +128,7 @@ private:
|
||||
uint32_t cursor_state_sequence_ = 0;
|
||||
std::chrono::steady_clock::time_point next_video_frame_time_{};
|
||||
SignalConnectionIndicator signal_connection_indicator_;
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
bool use_xwayland_gui_ = false;
|
||||
bool use_x11_custom_titlebar_ = false;
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate message_spacing.ttf with fontTools (only needed for regeneration).
|
||||
|
||||
Slint 1.17 pins line height to the requested font's ascent, descent and leading,
|
||||
but StyledText does not expose line-height. This metrics-only font supplies 1.5em
|
||||
line boxes without replacing any visible glyphs: Slint's normal system fallback
|
||||
fonts still render the text and links. Use this family for message and release-note
|
||||
bodies without changing the typography of other controls.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
|
||||
|
||||
def main():
|
||||
font = FontBuilder(1000, isTTF=True)
|
||||
glyphs = [".notdef", "zeroWidthSpace"]
|
||||
font.setupGlyphOrder(glyphs)
|
||||
font.setupCharacterMap({0x200B: "zeroWidthSpace"})
|
||||
font.setupGlyf({name: TTGlyphPen(None).glyph() for name in glyphs})
|
||||
font.setupHorizontalMetrics({name: (0, 0) for name in glyphs})
|
||||
font.setupHorizontalHeader(ascent=1000, descent=-250, lineGap=250)
|
||||
font.setupOS2(
|
||||
version=4,
|
||||
sTypoAscender=1000,
|
||||
sTypoDescender=-250,
|
||||
sTypoLineGap=250,
|
||||
usWinAscent=1000,
|
||||
usWinDescent=250,
|
||||
fsSelection=0xC0, # Regular, use typographic metrics.
|
||||
)
|
||||
font.setupNameTable({
|
||||
"familyName": "CrossDesk Message Spacing",
|
||||
"styleName": "Regular",
|
||||
"uniqueFontIdentifier": "CrossDesk-Message-Spacing-1.0",
|
||||
"fullName": "CrossDesk Message Spacing",
|
||||
"psName": "CrossDeskMessageSpacing-Regular",
|
||||
"version": "Version 1.0",
|
||||
"copyright": "Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.",
|
||||
})
|
||||
font.setupPost()
|
||||
# Fixed timestamps keep the generated asset reproducible.
|
||||
font.font["head"].created = font.font["head"].modified = 3874867200
|
||||
font.font.recalcTimestamp = False
|
||||
font.save(Path(__file__).with_name("message_spacing.ttf"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -23,10 +23,11 @@ struct TranslationRow {
|
||||
X(messages, u8"消息", "Messages", u8"Сообщения") \
|
||||
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
|
||||
X(message_read, u8"已读", "Read", u8"Прочитано") \
|
||||
X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \
|
||||
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
|
||||
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \
|
||||
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
|
||||
X(message_read_save_error, u8"无法保存本地阅读状态,请重新打开公告重试。", "Unable to save reading status locally. Open the announcement again to retry.", u8"Не удалось сохранить статус чтения. Откройте объявление снова.") \
|
||||
X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения. Повторите попытку.") \
|
||||
X(message_close, u8"关闭", "Close", u8"Закрыть") \
|
||||
X(message_delete, u8"删除", "Delete", u8"Удалить") \
|
||||
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace {
|
||||
|
||||
constexpr uint64_t kCaptureResumeKeyFrameGapMs = 500;
|
||||
constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1);
|
||||
#ifdef _WIN32
|
||||
#if defined(_WIN32) || defined(__OHOS__)
|
||||
constexpr auto kKeyboardCaptureRetryDelay = std::chrono::seconds(1);
|
||||
#endif
|
||||
|
||||
@@ -419,7 +419,7 @@ int SessionDeviceManager::StartMouseController() {
|
||||
return -1;
|
||||
}
|
||||
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
if (IsWaylandSession()) {
|
||||
if (!screen_capturer_) {
|
||||
return 1;
|
||||
@@ -467,7 +467,7 @@ int SessionDeviceManager::StopMouseController() {
|
||||
int SessionDeviceManager::StartKeyboardCapturer() {
|
||||
captured_keyboard_inputs_.Clear();
|
||||
owner_.keyboard_capturer_uses_window_events_ = false;
|
||||
#ifdef _WIN32
|
||||
#if defined(_WIN32) || defined(__OHOS__)
|
||||
next_keyboard_capture_retry_ =
|
||||
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
|
||||
#endif
|
||||
@@ -479,7 +479,7 @@ int SessionDeviceManager::StartKeyboardCapturer() {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
if (IsWaylandSession()) {
|
||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||
LOG_INFO("Start keyboard capturer with Slint Wayland backend");
|
||||
@@ -546,7 +546,7 @@ int SessionDeviceManager::InitializeAudioOutput() {
|
||||
};
|
||||
|
||||
if (!open_device()) {
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
LOG_WARN("Failed to open output stream with driver [{}]: {}",
|
||||
getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER")
|
||||
: "(default)",
|
||||
@@ -629,7 +629,7 @@ void SessionDeviceManager::UpdateInteractions() {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
const bool is_wayland_session = IsWaylandSession();
|
||||
const bool stop_wayland_mouse_before_screen =
|
||||
is_wayland_session && !owner_.start_screen_capturer_ &&
|
||||
@@ -664,24 +664,27 @@ void SessionDeviceManager::UpdateInteractions() {
|
||||
}
|
||||
|
||||
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
|
||||
#ifdef _WIN32
|
||||
#if defined(_WIN32) || defined(__OHOS__)
|
||||
const int active_display = screen_capturer_->GetCurrentMonitorIndex();
|
||||
const auto displays = display_info_list();
|
||||
if (active_display >= 0 &&
|
||||
active_display < static_cast<int>(displays.size())) {
|
||||
owner_.selected_display_ = active_display;
|
||||
#ifdef _WIN32
|
||||
if (displays[active_display].width <= 0 ||
|
||||
displays[active_display].height <= 0)
|
||||
ReleaseRemoteMouseButtons();
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if (owner_.start_keyboard_capturer_ && owner_.focus_on_stream_window_ &&
|
||||
!owner_.privacy_.Snapshot().input_blocked) {
|
||||
#ifdef _WIN32
|
||||
#if defined(_WIN32) || defined(__OHOS__)
|
||||
if (owner_.keyboard_capturer_is_started_ &&
|
||||
owner_.keyboard_capturer_uses_window_events_ && keyboard_capturer_ &&
|
||||
keyboard_capturer_->ShouldRetryHook() &&
|
||||
std::chrono::steady_clock::now() >= next_keyboard_capture_retry_) {
|
||||
// A transient installation failure or stalled hook must not leave
|
||||
// system shortcuts in the window-event fallback until focus changes.
|
||||
@@ -699,7 +702,7 @@ void SessionDeviceManager::UpdateInteractions() {
|
||||
owner_.keyboard_.ForceReleasePressedKeys();
|
||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||
owner_.keyboard_capturer_is_started_ = true;
|
||||
#ifdef _WIN32
|
||||
#if defined(_WIN32) || defined(__OHOS__)
|
||||
next_keyboard_capture_retry_ =
|
||||
std::chrono::steady_clock::now() + kKeyboardCaptureRetryDelay;
|
||||
#endif
|
||||
|
||||
@@ -111,8 +111,10 @@ private:
|
||||
std::vector<DisplayInfo> display_info_list_;
|
||||
size_t registered_display_stream_count_ = 0;
|
||||
CapturedKeyboardQueue captured_keyboard_inputs_;
|
||||
#ifdef _WIN32
|
||||
#if defined(_WIN32) || defined(__OHOS__)
|
||||
std::chrono::steady_clock::time_point next_keyboard_capture_retry_{};
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
std::mutex service_mouse_mutex_;
|
||||
unsigned service_mouse_buttons_ = 0;
|
||||
int service_mouse_x_ = 0, service_mouse_y_ = 0;
|
||||
|
||||
@@ -1,370 +1,290 @@
|
||||
#include "features/file_transfer/file_transfer_manager.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include "file_transfer.h"
|
||||
#include "filesystem_utf8.h"
|
||||
#include "rd_log.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
FileTransferManager::FileTransferManager(GuiRuntime &owner) : owner_(owner) {}
|
||||
FileTransferManager::FileTransferManager(GuiRuntime& owner) : owner_(owner) {}
|
||||
FileTransferManager::~FileTransferManager() { Shutdown(); }
|
||||
|
||||
FileTransferManager::~FileTransferManager() {
|
||||
void FileTransferManager::Shutdown() {
|
||||
std::vector<std::future<void>> workers;
|
||||
{
|
||||
std::lock_guard lock(workers_mutex_);
|
||||
stopping_ = true;
|
||||
workers.swap(workers_);
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(sessions_mutex_);
|
||||
for (auto& [id, sender] : senders_) sender.session->Cancel();
|
||||
for (auto& [handler, peers] : receivers_)
|
||||
for (auto& [id, receiver] : peers) receiver.session->Cancel();
|
||||
receivers_.clear();
|
||||
}
|
||||
wake_.notify_all();
|
||||
for (auto& worker : workers) worker.wait();
|
||||
}
|
||||
|
||||
FileTransferManager::FileTransferState &FileTransferManager::global_state() {
|
||||
return global_state_;
|
||||
}
|
||||
|
||||
FileTransferManager::FileTransferState &FileTransferManager::state_for(
|
||||
const std::shared_ptr<RemoteSession> &props) {
|
||||
FileTransferManager::FileTransferState& FileTransferManager::global_state() { return global_state_; }
|
||||
FileTransferManager::FileTransferState& FileTransferManager::state_for(const std::shared_ptr<RemoteSession>& props) {
|
||||
return props ? props->file_transfer_ : global_state_;
|
||||
}
|
||||
|
||||
void FileTransferManager::ProcessSelectedFile(
|
||||
const std::string &path,
|
||||
const std::shared_ptr<RemoteSession> &props,
|
||||
const std::string &file_label, const std::string &remote_id) {
|
||||
if (path.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
FileTransferState &state = state_for(props);
|
||||
LOG_INFO("Selected file: {}", path.c_str());
|
||||
|
||||
const std::filesystem::path file_path = PathFromUtf8(path);
|
||||
void FileTransferManager::ProcessSelectedFile(const std::string& path,
|
||||
const std::shared_ptr<RemoteSession>& props, const std::string& file_label,
|
||||
const std::string& remote_id) {
|
||||
if (path.empty() || stopping_ || (props && props->closing_)) return;
|
||||
const auto file_path = PathFromUtf8(path);
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::is_regular_file(file_path, ec)) {
|
||||
LOG_ERROR("Selected path is not a regular file: {}", path);
|
||||
return;
|
||||
}
|
||||
const uint64_t file_size = std::filesystem::file_size(file_path, ec);
|
||||
if (ec) {
|
||||
LOG_ERROR("Failed to get file size: {}", ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!std::filesystem::is_regular_file(file_path, ec)) return;
|
||||
const auto size = std::filesystem::file_size(file_path, ec);
|
||||
if (ec) return;
|
||||
auto& state = state_for(props);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.file_transfer_list_mutex_);
|
||||
std::lock_guard lock(state.file_transfer_list_mutex_);
|
||||
FileTransferState::FileTransferInfo info;
|
||||
const auto utf8_name = file_path.filename().u8string();
|
||||
info.file_name.assign(reinterpret_cast<const char *>(utf8_name.data()),
|
||||
utf8_name.size());
|
||||
info.file_path = file_path;
|
||||
info.file_size = file_size;
|
||||
const auto name = file_path.filename().u8string();
|
||||
info.file_name.assign(name.begin(), name.end());
|
||||
info.file_path = file_path; info.file_size = size;
|
||||
info.status = FileTransferState::FileTransferStatus::Queued;
|
||||
state.file_transfer_list_.push_back(std::move(info));
|
||||
}
|
||||
state.file_transfer_window_visible_ = true;
|
||||
|
||||
auto enqueue = [&]() {
|
||||
size_t queue_size = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.file_queue_mutex_);
|
||||
state.file_send_queue_.push(
|
||||
FileTransferState::QueuedFile{file_path, file_label, remote_id});
|
||||
queue_size = state.file_send_queue_.size();
|
||||
}
|
||||
LOG_INFO("File added to queue: {} ({} files in queue)",
|
||||
file_path.filename().string().c_str(), queue_size);
|
||||
};
|
||||
|
||||
if (state.file_sending_.load()) {
|
||||
enqueue();
|
||||
return;
|
||||
}
|
||||
|
||||
Start(props, file_path, file_label, remote_id);
|
||||
if (!state.file_sending_.load()) {
|
||||
enqueue();
|
||||
}
|
||||
}
|
||||
|
||||
void FileTransferManager::Start(
|
||||
std::shared_ptr<RemoteSession> props,
|
||||
const std::filesystem::path &file_path, const std::string &file_label,
|
||||
const std::string &remote_id) {
|
||||
if (props && props->closing_) return;
|
||||
const bool is_global = !props;
|
||||
PeerPtr* peer = nullptr;
|
||||
std::shared_ptr<PeerEventHandler> peer_events;
|
||||
{
|
||||
std::shared_lock lock(owner_.remote_sessions_mutex_);
|
||||
if (props && props->closing_) return;
|
||||
peer = is_global ? owner_.peer_ : props->peer_;
|
||||
peer_events = is_global ? owner_.peer_events_ : props->peer_events_;
|
||||
std::lock_guard lock(state.file_queue_mutex_);
|
||||
state.file_send_queue_.push({file_path, file_label, props ? props->remote_id_ : remote_id});
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
} // 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_
|
||||
#define CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <future>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <shared_mutex>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "file_transfer.h"
|
||||
#include "runtime/remote_session.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class GuiRuntime;
|
||||
class PeerEventHandler;
|
||||
|
||||
class FileTransferManager {
|
||||
public:
|
||||
public:
|
||||
using FileTransferState = gui_detail::FileTransferState;
|
||||
using RemoteSession = gui_detail::RemoteSession;
|
||||
|
||||
explicit FileTransferManager(GuiRuntime &owner);
|
||||
explicit FileTransferManager(GuiRuntime& owner);
|
||||
~FileTransferManager();
|
||||
FileTransferState& global_state();
|
||||
FileTransferState& 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();
|
||||
FileTransferState &
|
||||
state_for(const std::shared_ptr<RemoteSession> &props);
|
||||
|
||||
void
|
||||
ProcessSelectedFile(const std::string &path,
|
||||
const std::shared_ptr<RemoteSession> &props,
|
||||
const std::string &file_label,
|
||||
const std::string &remote_id = "");
|
||||
void HandleAck(const char *data, size_t size);
|
||||
|
||||
private:
|
||||
void Start(std::shared_ptr<RemoteSession> props,
|
||||
const std::filesystem::path &file_path,
|
||||
const std::string &file_label, const std::string &remote_id = "");
|
||||
private:
|
||||
struct Sender {
|
||||
std::shared_ptr<file_transfer::Session> session;
|
||||
std::shared_ptr<PeerEventHandler> handler;
|
||||
std::string remote;
|
||||
};
|
||||
struct Receiver {
|
||||
std::shared_ptr<FileReceiver> session;
|
||||
std::shared_ptr<PeerEventHandler> handler;
|
||||
std::filesystem::path default_directory;
|
||||
};
|
||||
void ProcessQueue(std::shared_ptr<RemoteSession> props);
|
||||
void Unregister(uint32_t file_id, bool per_peer);
|
||||
|
||||
std::mutex workers_mutex_;
|
||||
void UpdateProgress(FileTransferState& state, const std::shared_ptr<RemoteSession>& props,
|
||||
const file_transfer::SendState& progress);
|
||||
std::mutex workers_mutex_, sessions_mutex_, wake_mutex_;
|
||||
std::condition_variable wake_;
|
||||
std::vector<std::future<void>> workers_;
|
||||
bool stopping_ = false;
|
||||
GuiRuntime &owner_;
|
||||
std::atomic<bool> stopping_{false};
|
||||
std::unordered_map<uint32_t, Sender> senders_;
|
||||
std::unordered_map<PeerEventHandler*, std::unordered_map<std::string, Receiver>> receivers_;
|
||||
std::unordered_map<PeerEventHandler*, std::set<std::string>> disconnected_;
|
||||
GuiRuntime& owner_;
|
||||
FileTransferState global_state_;
|
||||
std::unordered_map<uint32_t, std::weak_ptr<RemoteSession>>
|
||||
file_id_to_props_;
|
||||
std::shared_mutex file_id_to_props_mutex_;
|
||||
std::unordered_map<uint32_t, FileTransferState *> file_id_to_state_;
|
||||
std::shared_mutex file_id_to_state_mutex_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
|
||||
#endif
|
||||
|
||||
@@ -24,14 +24,16 @@ struct WindowState {
|
||||
|
||||
void Observe(bool wall, slint::PhysicalPosition position,
|
||||
slint::PhysicalSize size, float scale, bool fullscreen,
|
||||
bool maximized, float compact_height = 486) {
|
||||
bool maximized, float compact_height = 486,
|
||||
float compact_width = 640) {
|
||||
if (Restoring())
|
||||
return;
|
||||
if (!wall) {
|
||||
connection_position = position;
|
||||
return;
|
||||
}
|
||||
if (!fullscreen && !maximized && IsUndersized(size, scale, compact_height))
|
||||
if (!fullscreen && !maximized &&
|
||||
IsUndersized(size, scale, compact_height, compact_width))
|
||||
return;
|
||||
scale = std::max(scale, 1.0f);
|
||||
wall_fullscreen = fullscreen;
|
||||
@@ -46,12 +48,13 @@ struct WindowState {
|
||||
bool RecoverUndersized(bool wall, slint::PhysicalPosition position,
|
||||
slint::PhysicalSize size, float scale, bool visible,
|
||||
bool minimized, bool fullscreen, bool maximized,
|
||||
float compact_height = 486) {
|
||||
float compact_height = 486,
|
||||
float compact_width = 640) {
|
||||
// Hidden/minimized/uninitialized windows can report temporary tiny bounds.
|
||||
// Leave those alone, and let an in-flight restoration retain its target.
|
||||
if (Restoring() || !visible || minimized || fullscreen || maximized ||
|
||||
size.width == 0 || size.height == 0 ||
|
||||
!IsUndersized(size, scale, compact_height))
|
||||
!IsUndersized(size, scale, compact_height, compact_width))
|
||||
return false;
|
||||
if (wall) {
|
||||
wall_position = position;
|
||||
@@ -66,9 +69,11 @@ struct WindowState {
|
||||
}
|
||||
|
||||
slint::PhysicalSize RestoreSize(bool wall, float scale,
|
||||
float compact_height = 486) const {
|
||||
float compact_height = 486,
|
||||
float compact_width = 640) const {
|
||||
const float width =
|
||||
wall && wall_size ? std::max(640.0f, wall_size->width) : 640;
|
||||
wall && wall_size ? std::max(compact_width, wall_size->width)
|
||||
: compact_width;
|
||||
const float height = wall && wall_size
|
||||
? std::max(compact_height, wall_size->height)
|
||||
: compact_height;
|
||||
@@ -157,9 +162,9 @@ private:
|
||||
std::optional<slint::PhysicalSize> target_, last_size_;
|
||||
int matches_ = 0;
|
||||
static bool IsUndersized(slint::PhysicalSize size, float scale,
|
||||
float compact_height) {
|
||||
float compact_height, float compact_width) {
|
||||
scale = std::max(scale, 1.0f);
|
||||
return double(size.width) + 1 < 640 * scale ||
|
||||
return double(size.width) + 1 < compact_width * scale ||
|
||||
double(size.height) + 1 < compact_height * scale;
|
||||
}
|
||||
static bool SameSize(slint::PhysicalSize a, slint::PhysicalSize b) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include "platform/macos/gui/metal_video_renderer.h"
|
||||
#else
|
||||
#elif !defined(__OHOS__)
|
||||
#if defined(_WIN32)
|
||||
#include <windows.h>
|
||||
#endif
|
||||
@@ -28,10 +28,10 @@
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
#if !defined(__APPLE__)
|
||||
#if !defined(__APPLE__) && !defined(__OHOS__)
|
||||
constexpr GLint kGlClampToEdge = 0x812F;
|
||||
constexpr float kStreamWindowCornerRadius = 12.0f;
|
||||
#else
|
||||
#elif defined(__APPLE__)
|
||||
constexpr int kMetalAttachmentAttemptLimit = 30;
|
||||
#endif
|
||||
|
||||
@@ -78,7 +78,7 @@ struct SlintVideoPresenter::Impl {
|
||||
#if defined(__APPLE__)
|
||||
int attachment_attempts = 0;
|
||||
bool skip_attachment_once = false;
|
||||
#else
|
||||
#elif !defined(__OHOS__)
|
||||
std::mutex gl_mutex;
|
||||
std::vector<uint8_t> gl_conversion_frame;
|
||||
std::vector<uint8_t> gl_pending_frame;
|
||||
@@ -109,7 +109,7 @@ struct SlintVideoPresenter::Impl {
|
||||
return true;
|
||||
}
|
||||
|
||||
#if !defined(__APPLE__)
|
||||
#if !defined(__APPLE__) && !defined(__OHOS__)
|
||||
void ConfigureOpenGlNotifier() {
|
||||
if (!stream) {
|
||||
return;
|
||||
@@ -238,7 +238,7 @@ void SlintVideoPresenter::PrepareWindow(
|
||||
stream->set_native_video_enabled(false);
|
||||
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
|
||||
impl_->skip_attachment_once = true;
|
||||
#else
|
||||
#elif !defined(__OHOS__)
|
||||
impl_->ConfigureOpenGlNotifier();
|
||||
#endif
|
||||
}
|
||||
@@ -351,7 +351,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
#else
|
||||
#elif !defined(__OHOS__)
|
||||
auto* opengl_renderer =
|
||||
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
|
||||
if (opengl_renderer && opengl_renderer->IsReady()) {
|
||||
@@ -419,7 +419,7 @@ SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||
}
|
||||
}
|
||||
|
||||
#if !defined(__APPLE__)
|
||||
#if !defined(__APPLE__) && !defined(__OHOS__)
|
||||
uint32_t texture_id = 0;
|
||||
{
|
||||
std::lock_guard lock(impl_->gl_mutex);
|
||||
|
||||
@@ -154,6 +154,29 @@ bool AnnouncementInbox::Read(int64_t id, int64_t revision) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool AnnouncementInbox::ReadAll() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (catalog_revision_ < 0) return false;
|
||||
auto updated = reads_;
|
||||
// Use the complete catalog, including announcements whose bodies have not
|
||||
// been paged in. Persist once before changing any visible reading state.
|
||||
for (const auto& [id, revision] : catalog_) {
|
||||
if (!IsDismissed(id, revision)) updated[id] = revision;
|
||||
}
|
||||
if (updated == reads_) return true;
|
||||
if (!SaveLocalState(updated, dismissed_)) {
|
||||
state_.read_save_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
reads_ = std::move(updated);
|
||||
state_.read_save_failed = false;
|
||||
state_.delete_failed = false;
|
||||
UpdateReadingState();
|
||||
++state_.version;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const {
|
||||
const auto found = dismissed_.find(id);
|
||||
return found != dismissed_.end() && found->second == revision;
|
||||
|
||||
@@ -38,6 +38,7 @@ class AnnouncementInbox {
|
||||
void Refresh();
|
||||
void LoadMore();
|
||||
bool Read(int64_t id, int64_t revision);
|
||||
bool ReadAll();
|
||||
bool Dismiss(int64_t id, int64_t revision);
|
||||
nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now =
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
@@ -188,6 +188,7 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
|
||||
|
||||
void GuiRuntime::HandleServerControllerDisconnected(
|
||||
const std::string& remote_id, const char* reason) {
|
||||
transfers_.Cancel(peer_events_.get(), remote_id);
|
||||
preview_subscriptions_.Remove(remote_id);
|
||||
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
|
||||
#ifdef _WIN32
|
||||
@@ -215,6 +216,10 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
||||
remaining_controller_id = id;
|
||||
}
|
||||
}
|
||||
#if _WIN32
|
||||
windows_rdp_handoff_connected_->store(has_connected_controller,
|
||||
std::memory_order_relaxed);
|
||||
#endif
|
||||
}
|
||||
if (has_connected_controller) {
|
||||
remote_client_id_ = remaining_controller_id;
|
||||
@@ -228,7 +233,7 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
||||
need_to_create_server_window_.store(false, std::memory_order_release);
|
||||
need_to_destroy_server_window_.store(true, std::memory_order_release);
|
||||
is_server_mode_ = false;
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
if (IsWaylandSession()) {
|
||||
// Keep Wayland capture session warm to avoid black screen on subsequent
|
||||
// reconnects.
|
||||
@@ -298,7 +303,10 @@ int GuiRuntime::ForceSignalReconnect() {
|
||||
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
if (!props || props->closing_.exchange(true)) return;
|
||||
MuteRemoteAudio(props);
|
||||
if (props->peer_events_) props->peer_events_->Deactivate();
|
||||
if (props->peer_events_) {
|
||||
props->peer_events_->Deactivate();
|
||||
transfers_.Cancel(props->peer_events_.get());
|
||||
}
|
||||
props->connection_established_ = false;
|
||||
props->streaming_ = false;
|
||||
props->connection_status_.store(ConnectionStatus::Closed);
|
||||
@@ -392,6 +400,7 @@ void GuiRuntime::CloseConnectionPeer() {
|
||||
return;
|
||||
}
|
||||
peer_events_->Deactivate();
|
||||
transfers_.Cancel(peer_events_.get());
|
||||
announcements_.Reset();
|
||||
std::vector<std::string> controllers;
|
||||
{
|
||||
@@ -439,6 +448,7 @@ void GuiRuntime::CloseAllRemoteSessions() {
|
||||
}
|
||||
|
||||
void GuiRuntime::HandleSessionCleanup() {
|
||||
transfers_.Poll();
|
||||
auto completed = [](std::future<void>& task) {
|
||||
if (!task.valid() || task.wait_for(std::chrono::seconds(0)) !=
|
||||
std::future_status::ready) return false;
|
||||
|
||||
@@ -63,7 +63,7 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#elif defined(__linux__) && !defined(__APPLE__)
|
||||
#elif (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
|
||||
|
||||
#include <X11/Xlib.h>
|
||||
#include <X11/extensions/Xfixes.h>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
#include "localization.h"
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rd_log.h"
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
#include "platform/linux/headless/headless_console.h"
|
||||
#endif
|
||||
|
||||
@@ -26,7 +26,15 @@ GuiRuntime::GuiRuntime()
|
||||
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this),
|
||||
keyboard_(*this) {}
|
||||
|
||||
GuiRuntime::~GuiRuntime() { WaitForSessionCleanup(); }
|
||||
GuiRuntime::~GuiRuntime() {
|
||||
#if _WIN32
|
||||
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
|
||||
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
|
||||
if (windows_service_worker_) windows_service_worker_->Reset();
|
||||
#endif
|
||||
transfers_.Shutdown();
|
||||
WaitForSessionCleanup();
|
||||
}
|
||||
|
||||
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
|
||||
const bool mouse = action.type == ControlType::mouse;
|
||||
@@ -140,7 +148,7 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
|
||||
const char *active_identity =
|
||||
self_hosted ? self_hosted_user_id_ : client_id_with_password_;
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
// Publish the committed identity; an unconfirmed recovery credential must
|
||||
// not be advertised as the current connection password.
|
||||
HeadlessConsole::Instance().SetIdentity(active_identity);
|
||||
@@ -174,7 +182,7 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
sizeof(params_.log_path) - 1);
|
||||
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
|
||||
params_.hardware_acceleration = config_center_->IsHardwareVideoCodec();
|
||||
#if defined(_WIN32) || defined(__linux__) || defined(__APPLE__)
|
||||
#if defined(_WIN32) || (defined(__linux__) && !defined(__OHOS__)) || defined(__APPLE__)
|
||||
// Windows and Linux retain pooled CPU NV12 frames or CUDA device frames;
|
||||
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
|
||||
// platforms fall back to a packed CPU copy when native upload is unavailable.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <app_version.h>
|
||||
#include <remote_action.h>
|
||||
#include <stream_names.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -24,6 +25,9 @@
|
||||
#include "platform.h"
|
||||
#include "rd_log.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
#ifdef __OHOS__
|
||||
#include "platform/harmony/input/system_shortcuts.h"
|
||||
#endif
|
||||
|
||||
#if _WIN32
|
||||
#include "desktop_transition_policy.h"
|
||||
@@ -101,38 +105,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
source_id == runtime->clipboard_label_))
|
||||
return;
|
||||
if (source_id == runtime->file_label_) {
|
||||
std::string remote_user_id = std::string(user_id, user_id_size);
|
||||
|
||||
static FileReceiver receiver;
|
||||
// Update output directory from config
|
||||
std::string configured_path =
|
||||
runtime->config_center_->GetFileTransferSavePath();
|
||||
if (!configured_path.empty()) {
|
||||
receiver.SetOutputDir(PathFromUtf8(configured_path));
|
||||
} else if (receiver.OutputDir().empty()) {
|
||||
receiver = FileReceiver(); // re-init with default desktop path
|
||||
}
|
||||
receiver.SetOnSendAck([runtime,
|
||||
remote_user_id](const FileTransferAck &ack) -> int {
|
||||
const auto encoded_ack = EncodeFileTransferAck(ack);
|
||||
bool is_server_sending = remote_user_id.rfind("C-", 0) != 0;
|
||||
if (is_server_sending) {
|
||||
auto props =
|
||||
runtime->FindRemoteSession(remote_user_id);
|
||||
if (props) {
|
||||
PeerPtr *peer = props->peer_;
|
||||
return SendReliableDataFrame(
|
||||
peer, encoded_ack.data(), encoded_ack.size(),
|
||||
runtime->file_feedback_label_.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
return SendReliableDataFrame(
|
||||
runtime->peer_, encoded_ack.data(), encoded_ack.size(),
|
||||
runtime->file_feedback_label_.c_str());
|
||||
});
|
||||
|
||||
receiver.OnData(data, size);
|
||||
runtime->transfers_.Receive(data, size, handler, std::string(user_id, user_id_size));
|
||||
return;
|
||||
} else if (source_id == runtime->clipboard_label_) {
|
||||
if (size > 0) {
|
||||
@@ -148,7 +121,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
return;
|
||||
} else if (source_id == runtime->file_feedback_label_) {
|
||||
runtime->transfers_.HandleAck(data, size);
|
||||
runtime->transfers_.HandleAck(data, size, handler, std::string(user_id, user_id_size));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -261,7 +234,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
return;
|
||||
}
|
||||
if (remote_action.type == ControlType::video_settings) {
|
||||
if (source_id != runtime->control_data_label_ ||
|
||||
// Web multiplexes its reliable control actions on a single DataChannel.
|
||||
// Accept that transport's source alias while retaining controller admission.
|
||||
if ((source_id != runtime->control_data_label_ &&
|
||||
source_id != kWebControlStream) ||
|
||||
!runtime->IsAuthorizedController(remote_id))
|
||||
return;
|
||||
const auto& settings = remote_action.vs;
|
||||
@@ -270,6 +246,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
runtime->peer_, remote_id.data(), remote_id.size(),
|
||||
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
|
||||
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
|
||||
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
|
||||
"preference={} accepted={}",
|
||||
remote_id, source_id, settings.request_id, settings.quality,
|
||||
settings.frame_rate, settings.preference, remote_action.vs.accepted);
|
||||
remote_action.type = ControlType::video_settings_status;
|
||||
const auto message = remote_action.to_json();
|
||||
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
|
||||
@@ -339,6 +319,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
GetLastError());
|
||||
}
|
||||
}
|
||||
#elif defined(__OHOS__)
|
||||
if (!SendHarmonySystemShortcut(remote_action.c.flag))
|
||||
LOG_WARN("Harmony system shortcut unsupported or unauthorized, command={}",
|
||||
static_cast<int>(remote_action.c.flag));
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rd_log.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
#include "platform/linux/headless/headless_console.h"
|
||||
#endif
|
||||
|
||||
@@ -38,7 +38,9 @@ namespace {
|
||||
constexpr HostPlatform kClientPlatform = HostPlatform::Windows;
|
||||
#elif defined(__APPLE__)
|
||||
constexpr HostPlatform kClientPlatform = HostPlatform::MacOS;
|
||||
#elif defined(__linux__)
|
||||
#elif defined(__OHOS__)
|
||||
constexpr HostPlatform kClientPlatform = HostPlatform::HarmonyOS;
|
||||
#elif (defined(__linux__) && !defined(__OHOS__))
|
||||
constexpr HostPlatform kClientPlatform = HostPlatform::Linux;
|
||||
#else
|
||||
constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
|
||||
@@ -61,7 +63,9 @@ void PeerEventHandler::SendClientInfo(PeerPtr* peer,
|
||||
}
|
||||
}
|
||||
|
||||
PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {}
|
||||
PeerEventHandler::PeerEventHandler(GuiRuntime& owner) : owner_(owner) {
|
||||
owner_.transfers_.Connected(this, "");
|
||||
}
|
||||
|
||||
void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
|
||||
void* user_data) {
|
||||
@@ -257,6 +261,9 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
if (!runtime) return;
|
||||
|
||||
std::string remote_id(user_id, user_id_size);
|
||||
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
|
||||
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
|
||||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
|
||||
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
||||
{
|
||||
std::shared_lock lock(runtime->remote_sessions_mutex_);
|
||||
@@ -429,6 +436,12 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
runtime->ApplyPrivacyAdmission(automatic_enable);
|
||||
}
|
||||
runtime->connection_status_[remote_id] = status;
|
||||
#if _WIN32
|
||||
runtime->windows_rdp_handoff_connected_->store(std::any_of(
|
||||
runtime->connection_status_.begin(), runtime->connection_status_.end(),
|
||||
[](const auto& entry) { return entry.second == ConnectionStatus::Connected; }),
|
||||
std::memory_order_relaxed);
|
||||
#endif
|
||||
runtime->devices_.OnControllerConnectionsChanged();
|
||||
}
|
||||
|
||||
@@ -553,7 +566,7 @@ void PeerEventHandler::OnNetStatusReport(
|
||||
LOG_INFO("Use client id [{}] and save id into cache file", id);
|
||||
runtime->settings_.Save();
|
||||
}
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().SetIdentity(id + "@" + password);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ struct FileTransferState {
|
||||
std::chrono::steady_clock::time_point file_send_start_time_;
|
||||
std::chrono::steady_clock::time_point file_send_last_update_time_;
|
||||
uint64_t file_send_last_bytes_ = 0;
|
||||
bool file_transfer_window_visible_ = false;
|
||||
std::atomic<bool> file_transfer_window_visible_ = false;
|
||||
std::atomic<uint32_t> current_file_id_{0};
|
||||
|
||||
struct QueuedFile {
|
||||
|
||||
@@ -92,6 +92,13 @@ struct PeerState {
|
||||
struct PlatformIntegrationState {
|
||||
#if _WIN32
|
||||
std::unique_ptr<ServiceQueryWorker> windows_service_worker_;
|
||||
// Lifecycle gate for interactive hosts; revoked before shutdown.
|
||||
std::shared_ptr<std::atomic<bool>> windows_rdp_handoff_allowed_ =
|
||||
std::make_shared<std::atomic<bool>>(false);
|
||||
// Transport callbacks revoke this immediately; a stale UI snapshot cannot
|
||||
// authorize a request after the last authenticated controller disconnects.
|
||||
std::shared_ptr<std::atomic<bool>> windows_rdp_handoff_connected_ =
|
||||
std::make_shared<std::atomic<bool>>(false);
|
||||
// Published by the UI, read by transport callbacks without sharing a mutable string.
|
||||
mutable std::mutex windows_input_stage_mutex_;
|
||||
std::string windows_input_stage_;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line
|
||||
// colors in one place so the Slint renderer produces the same visual weight.
|
||||
export global ImGuiLineStyle {
|
||||
@@ -8,7 +9,7 @@ export global ImGuiLineStyle {
|
||||
}
|
||||
|
||||
export global FloatingBarStyle {
|
||||
out property <length> corner-radius: 6px;
|
||||
out property <length> corner-radius: UiRadius.menu;
|
||||
}
|
||||
|
||||
// ImGui loads a 32px font atlas and applies per-window font scales. These
|
||||
@@ -76,7 +77,7 @@ export component IconButton inherits Rectangle {
|
||||
background: touch.pressed ? (danger ? #d92d20 : root.pressed-background)
|
||||
: touch.has-hover ? (danger ? #f04438 : root.hover-background)
|
||||
: root.normal-background;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
opacity: root.enabled ? 1 : 0.4;
|
||||
|
||||
Text {
|
||||
@@ -109,7 +110,7 @@ export component IconButton inherits Rectangle {
|
||||
width: tip.preferred-width + 12px;
|
||||
height: 24px;
|
||||
background: #22252be8;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
z: 20;
|
||||
tip := Text {
|
||||
text: root.tooltip;
|
||||
@@ -136,7 +137,7 @@ export component ImageButton inherits Rectangle {
|
||||
background: touch.pressed ? (danger ? #d92d20 : #d9dde5)
|
||||
: touch.has-hover ? (danger ? #f04438 : #edf0f5)
|
||||
: transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
|
||||
Image {
|
||||
x: (parent.width - self.width) / 2;
|
||||
@@ -157,7 +158,7 @@ export component ImageButton inherits Rectangle {
|
||||
width: tip.preferred-width + 12px;
|
||||
height: 24px;
|
||||
background: #22252be8;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
z: 20;
|
||||
tip := Text {
|
||||
text: root.tooltip;
|
||||
@@ -175,10 +176,23 @@ export component CompactComboBox inherits Rectangle {
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> subtle: false;
|
||||
in property <bool> show-arrow: true;
|
||||
in property <color> normal-border-color: root.subtle ? #dfe6ef : #bfc2c7;
|
||||
in property <color> hover-border-color: root.normal-border-color;
|
||||
property <bool> popup-animation-enabled: false;
|
||||
property <float> popup-progress: 0.0;
|
||||
callback selected(int);
|
||||
|
||||
// Session state can disable a setting while its popup is already open.
|
||||
// Remove the stale options and stop a pending reveal on every backend.
|
||||
changed enabled => {
|
||||
if !root.enabled {
|
||||
popup-open-timer.running = false;
|
||||
root.popup-animation-enabled = false;
|
||||
root.popup-progress = 0.0;
|
||||
combo-popup.close();
|
||||
}
|
||||
}
|
||||
|
||||
animate popup-progress {
|
||||
duration: 160ms;
|
||||
easing: ease-out;
|
||||
@@ -189,8 +203,8 @@ export component CompactComboBox inherits Rectangle {
|
||||
height: 24px;
|
||||
background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1;
|
||||
border-width: 1px;
|
||||
border-color: root.subtle ? #dfe6ef : #bfc2c7;
|
||||
border-radius: root.subtle ? 6px : 3px;
|
||||
border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color;
|
||||
border-radius: UiRadius.control;
|
||||
|
||||
Text {
|
||||
x: root.subtle ? 10px : 5px;
|
||||
@@ -207,6 +221,8 @@ export component CompactComboBox inherits Rectangle {
|
||||
height: parent.height - 2px;
|
||||
y: 1px;
|
||||
background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc;
|
||||
border-top-right-radius: UiRadius.control - 1px;
|
||||
border-bottom-right-radius: UiRadius.control - 1px;
|
||||
Text {
|
||||
text: FontAwesomeIcons.angle-down;
|
||||
color: root.subtle ? #79879a : #202124;
|
||||
@@ -223,7 +239,7 @@ export component CompactComboBox inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
TouchArea {
|
||||
combo-touch := TouchArea {
|
||||
enabled: root.enabled; mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.popup-animation-enabled = false;
|
||||
@@ -263,8 +279,8 @@ export component CompactComboBox inherits Rectangle {
|
||||
opacity: root.popup-progress;
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: root.subtle ? #dfe6ef : #bfc2c7;
|
||||
border-radius: root.subtle ? 6px : 3px;
|
||||
border-color: root.normal-border-color;
|
||||
border-radius: UiRadius.menu;
|
||||
clip: true;
|
||||
for choice[index] in root.model: Rectangle {
|
||||
y: index * 23px;
|
||||
@@ -273,12 +289,15 @@ export component CompactComboBox inherits Rectangle {
|
||||
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
|
||||
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
option-touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
clicked => {
|
||||
popup-open-timer.running = false;
|
||||
root.popup-animation-enabled = false;
|
||||
root.popup-progress = 0.0;
|
||||
root.current-index = index;
|
||||
root.selected(index);
|
||||
if root.enabled && index >= 0 && index < root.model.length {
|
||||
root.current-index = index;
|
||||
root.selected(index);
|
||||
}
|
||||
combo-popup.close();
|
||||
}
|
||||
}
|
||||
@@ -288,8 +307,8 @@ export component CompactComboBox inherits Rectangle {
|
||||
height: parent.height;
|
||||
background: transparent;
|
||||
border-width: 1px;
|
||||
border-color: root.subtle ? #dfe6ef : #bfc2c7;
|
||||
border-radius: root.subtle ? 6px : 3px;
|
||||
border-color: root.normal-border-color;
|
||||
border-radius: UiRadius.menu;
|
||||
z: 2;
|
||||
}
|
||||
}
|
||||
@@ -361,7 +380,7 @@ export component CompactButton inherits Rectangle {
|
||||
: root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7;
|
||||
border-width: 1px;
|
||||
border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0;
|
||||
border-radius: 3px;
|
||||
border-radius: UiRadius.control;
|
||||
Text {
|
||||
text: root.text;
|
||||
color: root.enabled ? #24272d : #8e9298;
|
||||
@@ -385,7 +404,7 @@ export component PrimaryButton inherits Rectangle {
|
||||
: touch.pressed ? #1849a9
|
||||
: touch.has-hover ? #2970d6
|
||||
: #2463c7;
|
||||
border-radius: 5px;
|
||||
border-radius: UiRadius.control;
|
||||
|
||||
Text {
|
||||
text: root.text;
|
||||
@@ -414,7 +433,7 @@ export component SecondaryButton inherits Rectangle {
|
||||
: touch.has-hover ? #f3f4f6 : white;
|
||||
border-width: 1px;
|
||||
border-color: #c9ced7;
|
||||
border-radius: 5px;
|
||||
border-radius: UiRadius.control;
|
||||
clip: true;
|
||||
|
||||
Text {
|
||||
@@ -434,6 +453,8 @@ export component SecondaryButton inherits Rectangle {
|
||||
export component ToggleSwitch inherits Rectangle {
|
||||
in-out property <bool> checked;
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> toggle-on-click: true;
|
||||
callback clicked();
|
||||
callback toggled(bool);
|
||||
|
||||
width: 36px;
|
||||
@@ -454,8 +475,11 @@ export component ToggleSwitch inherits Rectangle {
|
||||
TouchArea {
|
||||
enabled: root.enabled;
|
||||
clicked => {
|
||||
root.checked = !root.checked;
|
||||
root.toggled(root.checked);
|
||||
if root.toggle-on-click {
|
||||
root.checked = !root.checked;
|
||||
root.toggled(root.checked);
|
||||
}
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,5 +495,5 @@ export component DialogSurface inherits Rectangle {
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
border-radius: 8px;
|
||||
border-radius: UiRadius.surface;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Single AOT entry point. Generating one header for the entire UI avoids
|
||||
// duplicate imported-component definitions when C++ uses all three windows.
|
||||
export { AnnouncementItem, MessageStrings } from "message_panel.slint";
|
||||
export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint";
|
||||
export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint";
|
||||
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
|
||||
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { IconButton, FontAwesomeIcons } from "common.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
|
||||
@@ -12,6 +13,7 @@ export global DeviceCardStyle {
|
||||
|
||||
component CardAction inherits IconButton {
|
||||
width: 26px; height: 23px;
|
||||
border-radius: UiRadius.control;
|
||||
icon-size: 12px; icon-color: white;
|
||||
hover-background: #ffffff2e;
|
||||
pressed-background: #598cf273;
|
||||
@@ -28,7 +30,7 @@ export component DeviceCardActions inherits Rectangle {
|
||||
callback connect-device();
|
||||
callback disconnect-device();
|
||||
width: DeviceCardStyle.toolbar-width; height: 23px;
|
||||
background: #4a4e55dc; border-radius: 5px;
|
||||
background: #4a4e55dc; border-radius: UiRadius.control;
|
||||
HorizontalLayout {
|
||||
spacing: 0px;
|
||||
CardAction {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ScrollView, LineEdit } from "std-widgets.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
||||
import { CompactButton, ImGuiCheckBox } from "common.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
|
||||
@@ -23,7 +25,7 @@ export component DeviceGroupDialog inherits Rectangle {
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(360px, parent.width - 40px);
|
||||
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
|
||||
background: white; border-radius: 8px;
|
||||
background: white; border-radius: UiRadius.surface;
|
||||
border-width: 1px; border-color: #b2b2b2;
|
||||
TouchArea {}
|
||||
Text {
|
||||
@@ -42,7 +44,8 @@ export component DeviceGroupDialog inherits Rectangle {
|
||||
group-list := Rectangle {
|
||||
x: 18px; y: 95px; width: parent.width - 36px;
|
||||
height: min(5, max(1, root.options.length)) * 32px + 2px;
|
||||
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: 5px;
|
||||
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: UiRadius.surface;
|
||||
clip: true;
|
||||
ScrollView {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
viewport-width: self.width;
|
||||
@@ -64,8 +67,21 @@ export component DeviceGroupDialog inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep row hover backgrounds inside the rounded list on every renderer.
|
||||
Rectangle {
|
||||
x: -UiRadius.surface; y: -UiRadius.surface;
|
||||
width: parent.width + 2 * UiRadius.surface;
|
||||
height: parent.height + 2 * UiRadius.surface;
|
||||
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
|
||||
border-color: white;
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width; height: parent.height;
|
||||
border-width: 1px; border-radius: UiRadius.surface;
|
||||
border-color: #e1e5eb;
|
||||
}
|
||||
}
|
||||
LineEdit {
|
||||
RoundedLineEdit {
|
||||
x: 18px; y: group-list.y + group-list.height + 12px;
|
||||
width: parent.width - 36px; height: 30px;
|
||||
text <=> root.new-group;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { CompactButton, FontAwesomeIcons } from "common.slint";
|
||||
|
||||
export struct GalleryMenuItem { label: string, enabled: bool }
|
||||
@@ -61,6 +62,7 @@ export component GalleryMenuButton inherits Rectangle {
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
CompactButton {
|
||||
width: parent.width; height: parent.height;
|
||||
border-radius: UiRadius.control;
|
||||
enabled: root.enabled; primary: root.primary;
|
||||
clicked => { trigger-focus.focus(); root.open-menu(); }
|
||||
}
|
||||
@@ -86,10 +88,10 @@ export component GalleryMenuButton inherits Rectangle {
|
||||
popup-focus := FocusScope {
|
||||
key-pressed(event) => { return root.handle-key(event); }
|
||||
Rectangle {
|
||||
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: 4px;
|
||||
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu;
|
||||
for item[index] in root.items: Rectangle {
|
||||
x: 4px; y: 4px + index * 30px; width: parent.width - 8px; height: 30px;
|
||||
border-radius: 3px;
|
||||
border-radius: UiRadius.control;
|
||||
background: item.enabled && index == root.current-index ? #edf4fc : transparent;
|
||||
accessible-role: button; accessible-label: item.label; accessible-enabled: item.enabled;
|
||||
accessible-action-default => { root.choose(index); }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
import { CompactButton } from "common.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
@@ -137,7 +138,7 @@ component NetworkRow inherits Rectangle {
|
||||
}
|
||||
if !root.heading && name-hover.has-hover && device-name.preferred-width > identity.width: Rectangle {
|
||||
x: 12px; y: 32px; width: min(name-tip.preferred-width + 20px, root.width - 24px);
|
||||
height: 26px; background: #293746; border-radius: 4px; z: 10;
|
||||
height: 26px; background: #293746; border-radius: UiRadius.control; z: 10;
|
||||
name-tip := Text {
|
||||
x: 10px; width: parent.width - 20px; height: parent.height;
|
||||
text: root.device.name; font-size: 11px; color: white;
|
||||
@@ -172,7 +173,7 @@ export component GalleryNetworkDialog inherits Rectangle {
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(760px, parent.width - 32px);
|
||||
height: min(120px + root.list-height, parent.height - 32px);
|
||||
background: white; border-radius: 8px;
|
||||
background: white; border-radius: UiRadius.surface;
|
||||
border-width: 1px; border-color: #c6cdd5;
|
||||
TouchArea {}
|
||||
FocusScope {
|
||||
@@ -188,6 +189,7 @@ export component GalleryNetworkDialog inherits Rectangle {
|
||||
font-size: 17px; font-weight: 600; color: #293746;
|
||||
}
|
||||
CompactButton {
|
||||
border-radius: UiRadius.control;
|
||||
x: parent.width - 80px; y: 17px; width: 60px; height: 26px;
|
||||
text: GalleryStrings.tr("关闭", "Close", "Закрыть"); clicked => { root.close(); }
|
||||
}
|
||||
@@ -199,7 +201,7 @@ export component GalleryNetworkDialog inherits Rectangle {
|
||||
}
|
||||
NetworkRow {
|
||||
x: 12px; y: 78px; width: parent.width - 38px; height: 30px;
|
||||
heading: true; background: #f3f5f8; border-radius: 4px;
|
||||
heading: true; background: #f3f5f8; border-radius: UiRadius.control;
|
||||
device: {
|
||||
name: GalleryStrings.tr("设备 / ID", "Device / ID", "Устройство / ID"),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
import { SettingsGroup, SettingsRow, SettingsSelect } from "settings_controls.slint";
|
||||
|
||||
@@ -13,6 +14,7 @@ export component GallerySettings inherits Rectangle {
|
||||
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение");
|
||||
label-reserve: self.width - 112px;
|
||||
SettingsSelect {
|
||||
border-radius: UiRadius.control;
|
||||
x: 136px; y: 4px; width: parent.width - 150px;
|
||||
current-index <=> root.resolution-index;
|
||||
model: [GalleryStrings.tr("自动", "Auto", "Авто"),
|
||||
@@ -24,6 +26,7 @@ export component GallerySettings inherits Rectangle {
|
||||
label: GalleryStrings.tr("帧率", "Frame rate", "Частота кадров");
|
||||
label-reserve: self.width - 112px;
|
||||
SettingsSelect {
|
||||
border-radius: UiRadius.control;
|
||||
x: 136px; y: 4px; width: parent.width - 150px;
|
||||
current-index <=> root.frame-rate-index;
|
||||
model: [GalleryStrings.tr("跟随设备设置", "Follow device settings", "Настройки устройства"), "15 FPS", "30 FPS", "60 FPS"];
|
||||
@@ -33,22 +36,15 @@ export component GallerySettings inherits Rectangle {
|
||||
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео");
|
||||
label-reserve: self.width - 112px; divider: false;
|
||||
SettingsSelect {
|
||||
border-radius: UiRadius.control;
|
||||
x: 136px; y: 4px; width: parent.width - 150px;
|
||||
current-index <=> root.bandwidth-index;
|
||||
model: ["2 Mbps", "5 Mbps", "10 Mbps", "20 Mbps", "50 Mbps", "100 Mbps"];
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
x: 2px; y: 144px; width: parent.width - 4px; height: 70px;
|
||||
text: GalleryStrings.tr(
|
||||
"可见设备共享视频预算,优先保障展开画面;不可见设备暂停视频。音频与文件传输不计入,旧版设备不支持限额。瞬时速率可能超过预算。",
|
||||
"Visible devices share the video budget, with priority for expanded views. Hidden video pauses. Audio, files and older devices are excluded; brief bursts may exceed the budget.",
|
||||
"Бюджет видео делится между видимыми устройствами с приоритетом крупного вида. Скрытое видео приостановлено. Звук, файлы и старые устройства не учитываются; возможны краткие превышения.");
|
||||
color: #718096; font-size: 11px; wrap: word-wrap;
|
||||
}
|
||||
if root.error != "": Text {
|
||||
x: 2px; y: 224px; width: parent.width - 4px; height: 32px;
|
||||
x: 2px; y: 144px; width: parent.width - 4px; height: 32px;
|
||||
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { GalleryStrings } from "device_strings.slint";
|
||||
import { RemoteCursor } from "remote_cursor.slint";
|
||||
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
|
||||
import { GalleryMenuButton } from "gallery_menu_button.slint";
|
||||
import { GroupFilter } from "group_filter.slint";
|
||||
import { ScrollView, LineEdit } from "std-widgets.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
||||
import { CompactButton, SecondaryButton, IconButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint";
|
||||
|
||||
component GalleryToolbarButton inherits CompactButton {
|
||||
component GalleryButton inherits CompactButton {
|
||||
border-radius: UiRadius.control;
|
||||
}
|
||||
|
||||
component GalleryToolbarButton inherits GalleryButton {
|
||||
in property <string> alternate-text;
|
||||
in property <length> horizontal-padding: 12px;
|
||||
// Reserve both labels so toggling a mode never shifts adjacent controls.
|
||||
@@ -487,7 +493,7 @@ export component GalleryView inherits Rectangle {
|
||||
}
|
||||
if root.batch && !root.simplified: Rectangle {
|
||||
x: parent.width - 27px; y: parent.height - 27px;
|
||||
width: 18px; height: 18px; border-radius: 5px; border-width: 1px;
|
||||
width: 18px; height: 18px; border-radius: 4px; border-width: 1px;
|
||||
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white;
|
||||
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8;
|
||||
animate background, border-color { duration: 120ms; }
|
||||
@@ -560,14 +566,14 @@ export component GalleryView inherits Rectangle {
|
||||
height: footer.y - 12px - self.y;
|
||||
HorizontalLayout {
|
||||
x: 0px; y: 0px; width: parent.width - root.view-controls-space; height: 24px; spacing: 8px;
|
||||
CompactButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } }
|
||||
GalleryButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } }
|
||||
Rectangle {
|
||||
horizontal-stretch: 1; min-width: 0px;
|
||||
Text { x: 0px; width: parent.width; height: parent.height; text: root.focused.name; color: root.focused.online ? #303943 : DeviceCardStyle.offline-text; font-size: 12px; font-weight: 600; overflow: elide; horizontal-alignment: left; vertical-alignment: center; }
|
||||
}
|
||||
Rectangle {
|
||||
width: min(focus-state.preferred-width + 16px, 110px); height: 22px;
|
||||
border-radius: 5px;
|
||||
border-radius: UiRadius.control;
|
||||
background: root.controlling ? #e7f1fd : #f0f2f5;
|
||||
focus-state := Text {
|
||||
x: 8px; width: parent.width - 16px; height: parent.height;
|
||||
@@ -589,12 +595,12 @@ export component GalleryView inherits Rectangle {
|
||||
accessible-action-default => { self.clicked(); }
|
||||
clicked => { root.action(root.group-controlling ? "group-release" : "group-control", root.focused.id); }
|
||||
}
|
||||
CompactButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
|
||||
GalleryButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
|
||||
}
|
||||
Rectangle {
|
||||
x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px;
|
||||
width: parent.width; height: parent.height - self.y;
|
||||
background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: 6px;
|
||||
background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: UiRadius.surface;
|
||||
border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : root.focused.online ? #d5dbe3 : DeviceCardStyle.offline-border;
|
||||
video := Rectangle {
|
||||
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
||||
@@ -666,7 +672,7 @@ export component GalleryView inherits Rectangle {
|
||||
private property <string> presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети")
|
||||
: tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
|
||||
private property <string> details: tile.name + "\n" + tile.host-name + "\nID: " + tile.id + "\n" + tile.group-names + "\n" + self.presence;
|
||||
width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: 5px;
|
||||
width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: UiRadius.surface;
|
||||
background: tile.online ? #f5f6f8 : DeviceCardStyle.offline-background; border-width: 1px; border-color: tile.online ? #d5dbe3 : DeviceCardStyle.offline-border;
|
||||
accessible-role: button;
|
||||
accessible-label: self.details;
|
||||
@@ -696,7 +702,8 @@ export component GalleryView inherits Rectangle {
|
||||
if self.has-hover { self.show-details(); }
|
||||
else if root.device-tooltip-id == tile.id { root.device-tooltip-id = ""; }
|
||||
}
|
||||
moved => { if self.has-hover { self.show-details(); } }
|
||||
changed mouse-x => { if self.has-hover { self.show-details(); } }
|
||||
changed mouse-y => { if self.has-hover { self.show-details(); } }
|
||||
clicked => { root.device-tooltip-id = ""; root.action("focus", tile.id); }
|
||||
}
|
||||
}
|
||||
@@ -786,7 +793,7 @@ export component GalleryView inherits Rectangle {
|
||||
network-button := SecondaryButton {
|
||||
x: connection-count.x + connection-count.width + 12px;
|
||||
min-width: 64px; width: 64px; height: parent.height;
|
||||
border-color: #c8cbd0; border-radius: 3px;
|
||||
border-color: #c8cbd0; border-radius: UiRadius.control;
|
||||
text: GalleryStrings.tr("网络状态", "Network", "Сеть");
|
||||
accessible-role: button;
|
||||
accessible-label: self.text;
|
||||
@@ -796,9 +803,9 @@ export component GalleryView inherits Rectangle {
|
||||
}
|
||||
pager := HorizontalLayout {
|
||||
x: parent.width - self.width; y: 0px; width: 112px; height: 24px; spacing: 6px;
|
||||
previous-page := CompactButton { primary: root.drag-page-direction < 0; width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } }
|
||||
previous-page := GalleryButton { primary: root.drag-page-direction < 0; width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } }
|
||||
Text { text: (root.page + 1) + " / " + root.pages; min-width: 0px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; }
|
||||
next-page := CompactButton { primary: root.drag-page-direction > 0; width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } }
|
||||
next-page := GalleryButton { primary: root.drag-page-direction > 0; width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } }
|
||||
}
|
||||
}
|
||||
// Paint the fullscreen tooltip above the grid/rail, below modal overlays.
|
||||
@@ -815,6 +822,7 @@ export component GalleryView inherits Rectangle {
|
||||
}
|
||||
IconButton {
|
||||
width: 24px; height: 24px;
|
||||
border-radius: UiRadius.control;
|
||||
icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
|
||||
tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
|
||||
clicked => { root.toggle-fullscreen(); }
|
||||
@@ -836,7 +844,7 @@ export component GalleryView inherits Rectangle {
|
||||
x: clamp(grid.x + root.drag-x + 14px, 8px, parent.width - self.width - 8px);
|
||||
y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px);
|
||||
width: 224px; height: 54px; z: 10;
|
||||
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: 4px;
|
||||
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: UiRadius.surface;
|
||||
Rectangle {
|
||||
x: 8px; y: 9px; width: 64px; height: 36px;
|
||||
background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview;
|
||||
@@ -857,7 +865,7 @@ export component GalleryView inherits Rectangle {
|
||||
if root.swap-save-failed: Rectangle {
|
||||
x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px;
|
||||
width: min(420px, parent.width - 32px); height: 36px; z: 10;
|
||||
background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: 4px;
|
||||
background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: UiRadius.surface;
|
||||
Text { x: 12px; y: 0px; width: parent.width - 24px; height: parent.height; font-size: 12px; color: #9d4339; vertical-alignment: center;
|
||||
text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide;
|
||||
}
|
||||
@@ -867,7 +875,7 @@ export component GalleryView inherits Rectangle {
|
||||
x: clamp(root.device-tooltip-x, 8px, parent.width - self.width - 8px);
|
||||
y: clamp(root.device-tooltip-y + self.height > footer.y ? root.device-tooltip-y - self.height - 8px : root.device-tooltip-y, 4px, parent.height - self.height - 4px);
|
||||
width: min(320px, parent.width - 16px); height: device-tooltip-label.preferred-height + 16px;
|
||||
background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: 5px;
|
||||
background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: UiRadius.control;
|
||||
device-tooltip-label := Text {
|
||||
x: 8px; y: 8px; width: parent.width - 16px;
|
||||
text: root.device-tooltip-text; color: white; font-size: 11px; wrap: word-wrap;
|
||||
@@ -878,7 +886,7 @@ export component GalleryView inherits Rectangle {
|
||||
TouchArea {}
|
||||
add-dialog := Rectangle {
|
||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px;
|
||||
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
|
||||
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
|
||||
function submit() {
|
||||
if root.add-device(device-id.text, password.text, group.text, remember.checked) {
|
||||
password.text = "";
|
||||
@@ -887,21 +895,21 @@ export component GalleryView inherits Rectangle {
|
||||
}
|
||||
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("添加设备", "Add device", "Добавить устройство"); font-size: 16px; }
|
||||
Text { x: 18px; y: 56px; width: 72px; height: 30px; text: GalleryStrings.tr("设备 ID", "Device ID", "ID"); vertical-alignment: center; font-size: 12px; }
|
||||
device-id := LineEdit {
|
||||
device-id := RoundedLineEdit {
|
||||
x: 96px; y: 56px; width: 286px; height: 30px;
|
||||
placeholder-text: GalleryStrings.tr("9 位设备 ID", "9-digit device ID", "ID из 9 цифр");
|
||||
input-type: InputType.number;
|
||||
accepted => { password.focus(); }
|
||||
}
|
||||
Text { x: 18px; y: 100px; width: 72px; height: 30px; text: GalleryStrings.tr("密码", "Password", "Пароль"); vertical-alignment: center; font-size: 12px; }
|
||||
password := LineEdit {
|
||||
password := RoundedLineEdit {
|
||||
x: 96px; y: 100px; width: 286px; height: 30px;
|
||||
placeholder-text: GalleryStrings.tr("设备连接密码", "Device password", "Пароль устройства");
|
||||
input-type: InputType.password;
|
||||
accepted => { group.focus(); }
|
||||
}
|
||||
Text { x: 18px; y: 144px; width: 72px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); vertical-alignment: center; font-size: 12px; }
|
||||
group := LineEdit {
|
||||
group := RoundedLineEdit {
|
||||
x: 96px; y: 144px; width: 286px; height: 30px;
|
||||
placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)");
|
||||
accepted => { add-dialog.submit(); }
|
||||
@@ -911,8 +919,8 @@ export component GalleryView inherits Rectangle {
|
||||
TouchArea { clicked => { remember.checked = !remember.checked; } }
|
||||
}
|
||||
Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; }
|
||||
CompactButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
|
||||
CompactButton {
|
||||
GalleryButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
|
||||
GalleryButton {
|
||||
x: 254px; y: 260px; width: 128px; height: 28px; primary: true;
|
||||
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить");
|
||||
enabled: device-id.text != "" && password.text != "";
|
||||
@@ -925,7 +933,7 @@ export component GalleryView inherits Rectangle {
|
||||
TouchArea {}
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px;
|
||||
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
|
||||
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
|
||||
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
|
||||
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; }
|
||||
ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-available > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-available; }
|
||||
@@ -934,7 +942,7 @@ export component GalleryView inherits Rectangle {
|
||||
Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("待添加 ", "To add ", "К добавлению ") + root.recent-selected + " / " + root.recent-available; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; }
|
||||
Rectangle {
|
||||
x: 18px; y: 102px; width: 404px; height: 160px;
|
||||
border-width: 1px; border-color: #d8dadd; border-radius: 4px; clip: true;
|
||||
border-width: 1px; border-color: #d8dadd; border-radius: UiRadius.surface; clip: true;
|
||||
recent-list := ScrollView {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
viewport-width: self.width - 12px;
|
||||
@@ -955,12 +963,24 @@ export component GalleryView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
Rectangle {
|
||||
x: -UiRadius.surface; y: -UiRadius.surface;
|
||||
width: parent.width + 2 * UiRadius.surface;
|
||||
height: parent.height + 2 * UiRadius.surface;
|
||||
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
|
||||
border-color: white;
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width; height: parent.height;
|
||||
border-width: 1px; border-radius: UiRadius.surface;
|
||||
border-color: #d8dadd;
|
||||
}
|
||||
}
|
||||
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; }
|
||||
import-group := LineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
|
||||
import-group := RoundedLineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
|
||||
Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; }
|
||||
CompactButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
|
||||
CompactButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
|
||||
GalleryButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
|
||||
GalleryButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
|
||||
}
|
||||
}
|
||||
if root.confirming: Rectangle {
|
||||
@@ -968,7 +988,7 @@ export component GalleryView inherits Rectangle {
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
|
||||
width: 360px; height: confirmation-list.height + 118px;
|
||||
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
|
||||
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
|
||||
Text {
|
||||
x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
|
||||
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
|
||||
@@ -976,13 +996,13 @@ export component GalleryView inherits Rectangle {
|
||||
}
|
||||
Rectangle {
|
||||
x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
|
||||
background: #eff3f8; border-radius: 11px;
|
||||
background: #eff3f8; border-radius: UiRadius.surface;
|
||||
Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
}
|
||||
confirmation-list := Rectangle {
|
||||
x: 20px; y: 58px; width: parent.width - 40px;
|
||||
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px);
|
||||
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: 6px; clip: true;
|
||||
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: UiRadius.surface; clip: true;
|
||||
confirmation-scroll := ScrollView {
|
||||
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
|
||||
viewport-width: self.width - 12px;
|
||||
@@ -1002,8 +1022,8 @@ export component GalleryView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
CompactButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } }
|
||||
CompactButton {
|
||||
GalleryButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } }
|
||||
GalleryButton {
|
||||
x: parent.width - 126px; y: parent.height - 46px; width: 108px; height: 28px; primary: true;
|
||||
text: root.action-code == "disconnect" ? GalleryStrings.tr("确认断开", "Disconnect", "Отключить") : GalleryStrings.tr("确认执行", "Continue", "Продолжить");
|
||||
clicked => { root.confirming = false; root.action(root.action-code, ""); }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { FontAwesomeIcons } from "common.slint";
|
||||
|
||||
@@ -6,6 +7,9 @@ export component GroupFilter inherits Rectangle {
|
||||
in property <[string]> model;
|
||||
in property <int> system-count: 2;
|
||||
in property <bool> enabled: true;
|
||||
in property <color> normal-border-color: #c9cdd2;
|
||||
in property <color> hover-border-color: root.normal-border-color;
|
||||
in property <color> focus-border-color: #8db5df;
|
||||
in-out property <int> current-index: 0;
|
||||
callback selected(int);
|
||||
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
|
||||
@@ -23,8 +27,9 @@ export component GroupFilter inherits Rectangle {
|
||||
width: 155px; height: 28px;
|
||||
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
|
||||
border-width: 1px;
|
||||
border-color: popup.is-open || trigger-focus.has-focus ? #8db5df : #c9cdd2;
|
||||
border-radius: 4px;
|
||||
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color
|
||||
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color;
|
||||
border-radius: UiRadius.control;
|
||||
animate background, border-color { duration: 100ms; easing: ease-out; }
|
||||
forward-focus: trigger-focus;
|
||||
accessible-role: combobox;
|
||||
@@ -117,7 +122,7 @@ export component GroupFilter inherits Rectangle {
|
||||
width: parent.width; height: parent.height;
|
||||
opacity: root.popup-progress;
|
||||
background: white; border-width: 1px;
|
||||
border-color: #c9cdd2; border-radius: 5px;
|
||||
border-color: root.normal-border-color; border-radius: UiRadius.menu;
|
||||
ScrollView {
|
||||
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
|
||||
viewport-width: self.width;
|
||||
@@ -131,7 +136,7 @@ export component GroupFilter inherits Rectangle {
|
||||
}
|
||||
for choice[index] in root.model: Rectangle {
|
||||
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
|
||||
width: parent.width; height: 26px; border-radius: 3px;
|
||||
width: parent.width; height: 26px; border-radius: UiRadius.control;
|
||||
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
|
||||
animate background { duration: 100ms; easing: ease-out; }
|
||||
accessible-role: list-item;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint";
|
||||
import { IconButton, FontAwesomeIcons } from "common.slint";
|
||||
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
|
||||
import { WorkbenchStyle } from "workbench_controls.slint";
|
||||
import "../assets/fonts/message_spacing.ttf";
|
||||
|
||||
export struct AnnouncementItem {
|
||||
id: string,
|
||||
@@ -13,10 +17,11 @@ export global MessageStrings {
|
||||
in-out property <string> messages: "Messages";
|
||||
in-out property <string> unread: "Unread";
|
||||
in-out property <string> read: "Read";
|
||||
in-out property <string> read-all: "Mark all as read";
|
||||
in-out property <string> empty: "No announcements";
|
||||
in-out property <string> select-message: "Select an announcement to read";
|
||||
in-out property <string> loading: "Loading…";
|
||||
in-out property <string> read-save-error: "Unable to save reading status locally. Open the announcement again to retry.";
|
||||
in-out property <string> read-save-error: "Unable to save reading status locally. Please try again.";
|
||||
in-out property <string> close: "Close";
|
||||
in-out property <string> delete-message: "Delete";
|
||||
in-out property <string> delete-title: "Delete announcement";
|
||||
@@ -25,7 +30,7 @@ export global MessageStrings {
|
||||
in-out property <string> cancel: "Cancel";
|
||||
}
|
||||
|
||||
export component MessagePanel inherits DialogSurface {
|
||||
export component MessagePanel inherits SettingsDialog {
|
||||
in property <[AnnouncementItem]> items;
|
||||
in property <int> unread-count;
|
||||
in property <int> total;
|
||||
@@ -36,6 +41,7 @@ export component MessagePanel inherits DialogSurface {
|
||||
in property <bool> loaded;
|
||||
in property <bool> connected;
|
||||
in property <string> selected-id;
|
||||
in property <string> selected-revision;
|
||||
in property <string> selected-title;
|
||||
in property <styled-text> selected-body;
|
||||
in property <string> selected-date;
|
||||
@@ -44,11 +50,9 @@ export component MessagePanel inherits DialogSurface {
|
||||
callback open-link(string);
|
||||
callback delete-message(string, string);
|
||||
callback load-more;
|
||||
callback close;
|
||||
callback read-all;
|
||||
private property <string> notice-text: root.delete-failed ? MessageStrings.delete-error
|
||||
: root.read-save-failed ? MessageStrings.read-save-error : "";
|
||||
private property <length> content-top: title-bar.height + (root.notice-text == "" ? 14px : 50px);
|
||||
private property <color> separator-color: #e6e8eb;
|
||||
private property <string> pending-delete-id;
|
||||
private property <string> pending-delete-revision;
|
||||
function request-delete(id: string, revision: string) {
|
||||
@@ -67,137 +71,151 @@ export component MessagePanel inherits DialogSurface {
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
background: white;
|
||||
border-radius: 7px;
|
||||
TouchArea { }
|
||||
title-bar := Rectangle {
|
||||
x: 0px; y: 0px; width: parent.width; height: 21px;
|
||||
background: #d3d3d3; border-radius: 7px;
|
||||
Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; }
|
||||
message-heading := Text {
|
||||
x: 6px; width: parent.width - 100px; height: parent.height;
|
||||
text: MessageStrings.messages;
|
||||
font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
if root.unread-count > 0: Rectangle {
|
||||
x: message-heading.x + message-heading.preferred-width + 8px;
|
||||
y: (parent.height - self.height) / 2; height: 16px;
|
||||
width: max(16px, unread-count-label.preferred-width + 8px);
|
||||
border-radius: 8px; background: #eaf2ff;
|
||||
accessible-role: text;
|
||||
accessible-label: MessageStrings.unread + " " + root.unread-count;
|
||||
unread-count-label := Text {
|
||||
text: "" + root.unread-count; color: #2563eb;
|
||||
font-size: ImGuiFontStyle.small; font-weight: 600;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
if root.loading: Text {
|
||||
x: parent.width - 150px; width: 110px; height: parent.height;
|
||||
text: MessageStrings.loading; color: #61656c; font-size: ImGuiFontStyle.small;
|
||||
horizontal-alignment: right; vertical-alignment: center;
|
||||
}
|
||||
IconButton {
|
||||
x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px;
|
||||
border-radius: 3px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.base;
|
||||
icon-color: #202124;
|
||||
hover-background: #bfc2c7; pressed-background: #b2b2b2;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.close;
|
||||
accessible-action-default => { root.close(); }
|
||||
clicked => { root.close(); }
|
||||
title: MessageStrings.messages;
|
||||
close-label: MessageStrings.close;
|
||||
badge-count: root.unread-count;
|
||||
badge-label: MessageStrings.unread + " " + root.unread-count;
|
||||
header-status: root.loading ? MessageStrings.loading : "";
|
||||
action-text: MessageStrings.read-all;
|
||||
action-enabled: root.loaded && !root.loading && root.unread-count > 0;
|
||||
action => { root.read-all(); }
|
||||
notice := Rectangle {
|
||||
x: 24px; y: root.content-top; width: parent.width - 48px;
|
||||
height: root.notice-text == "" ? 0px : notice-label.preferred-height + 20px;
|
||||
visible: root.notice-text != "";
|
||||
border-radius: UiRadius.control; background: #fef2f2;
|
||||
notice-label := Text {
|
||||
x: 10px; y: 10px; width: parent.width - 20px;
|
||||
height: self.preferred-height;
|
||||
text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e;
|
||||
}
|
||||
}
|
||||
if root.notice-text != "": Rectangle {
|
||||
x: 16px; y: title-bar.height + 10px; width: parent.width - 32px; height: 28px;
|
||||
border-radius: 4px;
|
||||
background: #fef2f2;
|
||||
Text {
|
||||
x: 8px; width: parent.width - 16px; height: parent.height;
|
||||
text: root.notice-text; font-size: ImGuiFontStyle.body; overflow: elide;
|
||||
color: #b3261e;
|
||||
vertical-alignment: center;
|
||||
message-content := Rectangle {
|
||||
x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
|
||||
width: parent.width - 48px; height: parent.height - self.y - 24px;
|
||||
background: WorkbenchStyle.surface;
|
||||
border-radius: UiRadius.surface; border-width: 1px; border-color: WorkbenchStyle.border;
|
||||
if root.items.length > 0 || root.selected-id != "": Rectangle {
|
||||
x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
|
||||
background: WorkbenchStyle.field-surface;
|
||||
border-top-left-radius: UiRadius.surface - 1px;
|
||||
border-bottom-left-radius: UiRadius.surface - 1px;
|
||||
}
|
||||
}
|
||||
announcement-list := Rectangle {
|
||||
x: 16px; y: root.content-top; width: 168px; height: parent.height - self.y - 16px;
|
||||
announcements-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
VerticalLayout {
|
||||
width: announcements-scroll.width; spacing: 0px; padding: 0px;
|
||||
padding-right: 8px;
|
||||
alignment: start;
|
||||
for item[index] in root.items: Rectangle {
|
||||
height: 54px;
|
||||
background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f0f1f2 : transparent;
|
||||
border-radius: 4px;
|
||||
Text { x: 8px; y: 6px; width: parent.width - 32px; height: 18px; text: item.title; overflow: elide; font-size: ImGuiFontStyle.base; font-weight: item.read ? 400 : 600; color: #202124; vertical-alignment: center; }
|
||||
if !item.read: Rectangle {
|
||||
x: parent.width - 17px; y: 13px; width: 5px; height: 5px;
|
||||
border-radius: 2.5px; background: #2563eb;
|
||||
accessible-role: none;
|
||||
}
|
||||
Text { x: 8px; y: 28px; width: parent.width - 36px; height: 16px; text: item.date; color: #61656c; font-size: ImGuiFontStyle.small; overflow: elide; vertical-alignment: center; }
|
||||
accessible-role: button;
|
||||
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
|
||||
accessible-action-default => { root.open-message(item.id, item.revision); }
|
||||
entry-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-message(item.id, item.revision); } }
|
||||
IconButton {
|
||||
x: parent.width - 24px; y: 26px; width: 20px; height: 20px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.small;
|
||||
icon-color: #61656c; danger: true;
|
||||
announcement-list := Rectangle {
|
||||
x: 8px; y: 8px; width: 176px; height: parent.height - 16px;
|
||||
announcements-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
viewport-width: self.width - 10px;
|
||||
viewport-height: announcement-entries.preferred-height;
|
||||
horizontal-scrollbar-policy: always-off;
|
||||
vertical-scrollbar-policy: as-needed;
|
||||
announcement-entries := VerticalLayout {
|
||||
width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
|
||||
alignment: start;
|
||||
for item in root.items: Rectangle {
|
||||
height: 64px;
|
||||
background: root.selected-id == item.id ? #e8efff
|
||||
: entry-touch.pressed ? WorkbenchStyle.pressed-surface
|
||||
: entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: 1px;
|
||||
border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border;
|
||||
Text {
|
||||
x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
|
||||
text: item.title; overflow: elide; font-size: 13px;
|
||||
font-weight: item.read ? 400 : 600;
|
||||
color: root.selected-id == item.id ? WorkbenchStyle.accent : WorkbenchStyle.text;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
if !item.read: Rectangle {
|
||||
x: parent.width - 15px; y: 16px; width: 6px; height: 6px;
|
||||
border-radius: 3px; background: WorkbenchStyle.accent;
|
||||
accessible-role: none;
|
||||
}
|
||||
Text {
|
||||
x: 10px; y: 35px; width: parent.width - 20px; height: 18px;
|
||||
text: item.date; color: WorkbenchStyle.muted; font-size: 11px;
|
||||
overflow: elide; vertical-alignment: center;
|
||||
}
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.delete-message + " " + item.title;
|
||||
accessible-action-default => { root.request-delete(item.id, item.revision); }
|
||||
clicked => { root.request-delete(item.id, item.revision); }
|
||||
}
|
||||
if index < root.items.length - 1: Rectangle {
|
||||
x: 8px; y: parent.height - 1px;
|
||||
width: parent.width - 16px; height: 1px;
|
||||
background: root.separator-color;
|
||||
accessible-role: none;
|
||||
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
|
||||
accessible-action-default => { root.open-message(item.id, item.revision); }
|
||||
entry-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.open-message(item.id, item.revision); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
|
||||
x: 16px; y: root.content-top; width: parent.width - 32px; height: announcement-list.height;
|
||||
text: MessageStrings.empty; wrap: word-wrap;
|
||||
color: #61656c; font-size: ImGuiFontStyle.body;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
content-divider := Rectangle {
|
||||
x: announcement-list.x + announcement-list.width + 12px;
|
||||
y: root.content-top; width: 1px; height: announcement-list.height;
|
||||
background: root.separator-color;
|
||||
visible: root.items.length > 0 || root.selected-id != "";
|
||||
accessible-role: none;
|
||||
}
|
||||
announcement-details := Rectangle {
|
||||
x: content-divider.x + content-divider.width + 12px;
|
||||
y: root.content-top; width: parent.width - self.x - 16px; height: announcement-list.height;
|
||||
if root.selected-id == "" && root.items.length > 0: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.select-message; color: #61656c; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; }
|
||||
if root.selected-id != "": details-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
VerticalLayout {
|
||||
width: details-scroll.width; padding: 8px; padding-top: 6px; padding-right: 16px;
|
||||
spacing: 12px; alignment: start;
|
||||
VerticalLayout {
|
||||
vertical-stretch: 0; spacing: 4px;
|
||||
Text { vertical-stretch: 0; min-height: 18px; text: root.selected-title; wrap: word-wrap; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; }
|
||||
Text { vertical-stretch: 0; min-height: 16px; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #61656c; font-size: ImGuiFontStyle.small; wrap: word-wrap; vertical-alignment: center; }
|
||||
}
|
||||
Rectangle { height: 1px; background: root.separator-color; }
|
||||
StyledText {
|
||||
vertical-stretch: 0;
|
||||
text: root.selected-body;
|
||||
default-color: #202124; default-font-size: ImGuiFontStyle.body;
|
||||
link-color: #2563eb;
|
||||
link-clicked(url) => { root.open-link(url); }
|
||||
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
|
||||
x: 24px; width: parent.width - 48px; height: parent.height;
|
||||
text: MessageStrings.empty; wrap: word-wrap;
|
||||
color: WorkbenchStyle.muted; font-size: 13px;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
content-divider := Rectangle {
|
||||
x: announcement-list.x + announcement-list.width + 8px;
|
||||
y: 1px; width: 1px; height: parent.height - 2px;
|
||||
background: WorkbenchStyle.separator;
|
||||
visible: root.items.length > 0 || root.selected-id != "";
|
||||
accessible-role: none;
|
||||
}
|
||||
announcement-details := Rectangle {
|
||||
x: content-divider.x + 17px; y: 16px;
|
||||
width: parent.width - self.x - 16px; height: parent.height - 32px;
|
||||
clip: true;
|
||||
if root.selected-id == "" && root.items.length > 0: Text {
|
||||
width: parent.width; height: parent.height;
|
||||
text: MessageStrings.select-message; color: WorkbenchStyle.muted; font-size: 13px;
|
||||
wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
if root.selected-id != "": details-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
viewport-width: self.width - 12px;
|
||||
viewport-height: details-content.preferred-height;
|
||||
horizontal-scrollbar-policy: always-off;
|
||||
vertical-scrollbar-policy: as-needed;
|
||||
details-content := VerticalLayout {
|
||||
width: details-scroll.viewport-width; padding: 0px; padding-bottom: 4px;
|
||||
spacing: 14px; alignment: start;
|
||||
Rectangle {
|
||||
height: max(28px, selected-heading.preferred-height);
|
||||
selected-heading := VerticalLayout {
|
||||
x: 0px; y: 0px;
|
||||
width: parent.width - 40px; height: self.preferred-height;
|
||||
padding: 0px; spacing: 6px;
|
||||
Text {
|
||||
vertical-stretch: 0; text: root.selected-title; wrap: word-wrap;
|
||||
font-size: 16px; font-weight: 600; color: WorkbenchStyle.text;
|
||||
}
|
||||
Text {
|
||||
vertical-stretch: 0; text: root.selected-date;
|
||||
accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread);
|
||||
color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
IconButton {
|
||||
x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
|
||||
icon: FontAwesomeIcons.trash-can; icon-size: 12px;
|
||||
icon-color: WorkbenchStyle.muted; danger: true;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.delete-message + " " + root.selected-title;
|
||||
accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); }
|
||||
clicked => { root.request-delete(root.selected-id, root.selected-revision); }
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
|
||||
StyledText {
|
||||
vertical-stretch: 0;
|
||||
text: root.selected-body;
|
||||
default-color: WorkbenchStyle.text; default-font-size: 13px;
|
||||
// Metrics-only family provides 1.5em line height; visible
|
||||
// glyphs continue to use Slint's normal font fallback.
|
||||
default-font-family: "CrossDesk Message Spacing";
|
||||
link-color: WorkbenchStyle.accent;
|
||||
link-clicked(url) => { root.open-link(url); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,30 +229,31 @@ export component MessagePanel inherits DialogSurface {
|
||||
triggered => { root.load-more(); }
|
||||
}
|
||||
if root.pending-delete-id != "": Rectangle {
|
||||
background: #00000042; border-radius: root.border-radius; z: 30;
|
||||
background: #15223850; border-radius: root.border-radius; z: 30;
|
||||
TouchArea { clicked => { confirmation-keys.focus(); } }
|
||||
DialogSurface {
|
||||
SettingsDialog {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 230px; height: confirmation-content.preferred-height;
|
||||
border-radius: root.border-radius;
|
||||
TouchArea { clicked => { confirmation-keys.focus(); } }
|
||||
width: min(360px, root.width - 48px);
|
||||
height: self.content-top + confirmation-content.preferred-height + 24px;
|
||||
title: MessageStrings.delete-title;
|
||||
close-label: MessageStrings.cancel;
|
||||
close => { root.pending-delete-id = ""; }
|
||||
confirmation-content := VerticalLayout {
|
||||
padding: 16px;
|
||||
padding-top: 26px;
|
||||
padding-bottom: 26px;
|
||||
spacing: 20px;
|
||||
Text { text: MessageStrings.delete-confirmation; font-size: ImGuiFontStyle.base; color: #202124; wrap: word-wrap; horizontal-alignment: center; }
|
||||
x: 24px; y: parent.content-top; width: parent.width - 48px;
|
||||
height: self.preferred-height;
|
||||
padding: 0px; spacing: 24px;
|
||||
Text { text: MessageStrings.delete-confirmation; font-size: 13px; color: WorkbenchStyle.text; wrap: word-wrap; }
|
||||
HorizontalLayout {
|
||||
alignment: center; spacing: 8px;
|
||||
cancel-delete-button := CompactButton {
|
||||
width: 72px; text: MessageStrings.cancel;
|
||||
alignment: end; spacing: 8px;
|
||||
cancel-delete-button := SettingsButton {
|
||||
width: 88px; text: MessageStrings.cancel;
|
||||
accessible-role: button; accessible-label: MessageStrings.cancel;
|
||||
accessible-action-default => { cancel-delete-button.clicked(); }
|
||||
clicked => { root.pending-delete-id = ""; }
|
||||
}
|
||||
confirm-delete-button := CompactButton {
|
||||
width: 72px; text: MessageStrings.delete-message; primary: true;
|
||||
confirm-delete-button := SettingsButton {
|
||||
width: 88px; text: MessageStrings.delete-message; primary: true;
|
||||
accessible-role: button; accessible-label: MessageStrings.delete-title;
|
||||
accessible-action-default => { confirm-delete-button.clicked(); }
|
||||
clicked => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _MODEL_UPDATE_H_
|
||||
#define _MODEL_UPDATE_H_
|
||||
|
||||
#include <slint.h>
|
||||
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::ui {
|
||||
|
||||
// A reset invalidates row-count bindings even if the count is unchanged.
|
||||
// Repeating it from a timer can continually restart dependent size animations.
|
||||
template <typename Row>
|
||||
void UpdateModelRows(slint::VectorModel<Row>& model, std::vector<Row> rows) {
|
||||
if (model.row_count() != rows.size()) {
|
||||
model.set_vector(std::move(rows));
|
||||
return;
|
||||
}
|
||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||
if (model.row_data(i) != rows[i]) {
|
||||
model.set_row_data(i, rows[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk::ui
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,50 @@
|
||||
import { LineEdit } from "std-widgets.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
|
||||
// Retain the standard editor's selection, clipboard, clear button and input
|
||||
// method support. Only its outer frame is replaced by the shared shape.
|
||||
export component RoundedLineEdit inherits Rectangle {
|
||||
in-out property <string> text;
|
||||
in property <string> placeholder-text;
|
||||
in property <InputType> input-type: InputType.text;
|
||||
in property <bool> enabled: true;
|
||||
in property <length> font-size: 12px;
|
||||
in property <color> normal-border-color: #dfe6ef;
|
||||
in property <brush> surrounding-background: white;
|
||||
callback accepted();
|
||||
callback edited();
|
||||
height: 30px;
|
||||
border-radius: UiRadius.control;
|
||||
background: white;
|
||||
clip: true;
|
||||
forward-focus: editor;
|
||||
|
||||
editor := LineEdit {
|
||||
x: -1px; y: -1px;
|
||||
width: parent.width + 2px; height: parent.height + 2px;
|
||||
text <=> root.text;
|
||||
placeholder-text: root.placeholder-text;
|
||||
input-type: root.input-type;
|
||||
enabled: root.enabled;
|
||||
font-size: root.font-size;
|
||||
accepted => { root.accepted(); }
|
||||
edited => { root.edited(); }
|
||||
}
|
||||
// The software renderer clips children to rectangular bounds. Mask the
|
||||
// standard editor's corners before drawing our rounded border on top.
|
||||
Rectangle {
|
||||
x: -root.border-radius; y: -root.border-radius;
|
||||
width: parent.width + 2 * root.border-radius;
|
||||
height: parent.height + 2 * root.border-radius;
|
||||
border-width: root.border-radius;
|
||||
border-radius: 2 * root.border-radius;
|
||||
border-color: root.surrounding-background;
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width; height: parent.height;
|
||||
border-radius: root.border-radius;
|
||||
border-width: editor.has-focus ? 2px : 1px;
|
||||
border-color: editor.has-focus ? #3564e3 : root.normal-border-color;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint";
|
||||
|
||||
@@ -27,7 +28,7 @@ component SessionButton inherits Rectangle {
|
||||
callback clicked;
|
||||
width: label.preferred-width + 20px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: 1px;
|
||||
border-color: root.danger ? #d92d20 : #c9ced7;
|
||||
background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20)
|
||||
@@ -73,7 +74,7 @@ component SessionButton inherits Rectangle {
|
||||
}
|
||||
if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle {
|
||||
x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px;
|
||||
border-radius: 3px; border-width: 1px; border-color: root.danger ? white : #2463c7;
|
||||
border-radius: UiRadius.control - 2px; border-width: 1px; border-color: root.danger ? white : #2463c7;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +173,7 @@ export component ServerWindow inherits Window {
|
||||
Rectangle {
|
||||
width: root.width; height: root.height;
|
||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
|
||||
border-radius: 8px;
|
||||
border-radius: UiRadius.surface;
|
||||
Text {
|
||||
x: 10px; y: 10px; width: parent.width - 20px; height: 24px;
|
||||
text: ServerStrings.heading + (root.controllers.length > 1 ? " · " + root.controllers.length : "");
|
||||
@@ -189,7 +190,7 @@ export component ServerWindow inherits Window {
|
||||
select := Rectangle {
|
||||
x: 0px; y: 0px;
|
||||
width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px);
|
||||
height: parent.height; border-radius: 4px;
|
||||
height: parent.height; border-radius: UiRadius.control;
|
||||
background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent;
|
||||
if root.controllers.length > 1: Rectangle {
|
||||
x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px;
|
||||
@@ -274,7 +275,7 @@ export component ServerWindow inherits Window {
|
||||
// Draw outside the scrolling list so its last row can reveal its name.
|
||||
if root.hovered-name != "": Rectangle {
|
||||
x: 10px; y: root.tooltip-y; width: 205px; height: tip.preferred-height + 12px; z: 20;
|
||||
background: #24272d; border-radius: 5px;
|
||||
background: #24272d; border-radius: UiRadius.control;
|
||||
tip := Text { x: 6px; y: 6px; width: parent.width - 12px; text: root.hovered-name; color: white; font-size: 12px; wrap: word-wrap; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CompactComboBox, ToggleSwitch } from "common.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint";
|
||||
import { WorkbenchStyle } from "workbench_controls.slint";
|
||||
|
||||
// Shared visual treatment for the global settings pages.
|
||||
export component SettingsButton inherits Rectangle {
|
||||
@@ -8,12 +10,13 @@ export component SettingsButton inherits Rectangle {
|
||||
in property <bool> align-left: false;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked();
|
||||
width: 88px; height: 32px; border-radius: 7px;
|
||||
width: 88px; height: 32px; border-radius: UiRadius.control;
|
||||
background: !root.enabled ? #f0f3f7
|
||||
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
|
||||
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white;
|
||||
border-width: root.quiet || root.primary ? 0px : 1px;
|
||||
border-color: #dce3ec;
|
||||
border-color: !root.enabled ? WorkbenchStyle.border
|
||||
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border;
|
||||
accessible-role: button; accessible-label: root.text;
|
||||
accessible-action-default => { if root.enabled { root.clicked(); } }
|
||||
Text {
|
||||
@@ -25,9 +28,71 @@ export component SettingsButton inherits Rectangle {
|
||||
touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } }
|
||||
}
|
||||
|
||||
export component SettingsDialog inherits DialogSurface {
|
||||
in property <string> title;
|
||||
in property <string> close-label;
|
||||
in property <int> badge-count: 0;
|
||||
in property <string> badge-label;
|
||||
in property <string> header-status;
|
||||
in property <string> action-text;
|
||||
in property <bool> action-enabled: true;
|
||||
out property <length> content-top: 64px;
|
||||
callback action();
|
||||
callback close();
|
||||
background: #f8fafc;
|
||||
border-color: WorkbenchStyle.separator;
|
||||
drop-shadow-color: #14294424;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-offset-y: 8px;
|
||||
TouchArea { }
|
||||
HorizontalLayout {
|
||||
x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
|
||||
spacing: 10px;
|
||||
Text {
|
||||
text: root.title; font-size: 20px; font-weight: 600;
|
||||
color: WorkbenchStyle.text; vertical-alignment: center;
|
||||
}
|
||||
if root.badge-count > 0: Rectangle {
|
||||
width: max(24px, count-label.preferred-width + 14px); height: 22px;
|
||||
border-radius: 11px; background: #e8efff;
|
||||
accessible-role: text; accessible-label: root.badge-label;
|
||||
count-label := Text {
|
||||
text: "" + root.badge-count; color: WorkbenchStyle.accent;
|
||||
font-size: 11px; font-weight: 600;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
if root.header-status != "": Text {
|
||||
text: root.header-status; color: WorkbenchStyle.muted;
|
||||
font-size: 11px; vertical-alignment: center;
|
||||
}
|
||||
if root.action-text != "": SettingsButton {
|
||||
width: action-label-metrics.preferred-width + 24px; height: 28px;
|
||||
text: root.action-text; enabled: root.action-enabled;
|
||||
clicked => { root.action(); }
|
||||
action-label-metrics := Text { visible: false; text: root.action-text; font-size: 12px; }
|
||||
}
|
||||
IconButton {
|
||||
width: 28px; height: 28px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: 14px;
|
||||
icon-color: WorkbenchStyle.muted;
|
||||
hover-background: WorkbenchStyle.hover-surface;
|
||||
pressed-background: WorkbenchStyle.pressed-surface;
|
||||
accessible-role: button; accessible-label: root.close-label;
|
||||
accessible-action-default => { root.close(); }
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
}
|
||||
@children
|
||||
}
|
||||
|
||||
export component SettingsSelect inherits CompactComboBox {
|
||||
subtle: true;
|
||||
show-arrow: false;
|
||||
normal-border-color: WorkbenchStyle.control-border;
|
||||
hover-border-color: WorkbenchStyle.hover-border;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@@ -41,7 +106,7 @@ export component SettingsGroup inherits Rectangle {
|
||||
}
|
||||
Rectangle {
|
||||
y: 24px; width: parent.width; height: parent.height - self.y;
|
||||
background: white; border-radius: 9px; border-color: #e5eaf1; border-width: 1px;
|
||||
background: white; border-radius: UiRadius.surface; border-color: WorkbenchStyle.border; border-width: 1px;
|
||||
VerticalLayout { padding: 0px; spacing: 0px; @children }
|
||||
}
|
||||
}
|
||||
@@ -58,16 +123,20 @@ export component SettingsRow inherits Rectangle {
|
||||
font-size: 12px; overflow: elide; vertical-alignment: center;
|
||||
}
|
||||
if root.divider: Rectangle {
|
||||
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #eff2f6;
|
||||
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec;
|
||||
}
|
||||
@children
|
||||
}
|
||||
|
||||
export component SettingsToggleRow inherits SettingsRow {
|
||||
in-out property <bool> checked;
|
||||
in property <bool> toggle-on-click: true;
|
||||
callback clicked();
|
||||
label-reserve: 64px;
|
||||
ToggleSwitch {
|
||||
x: parent.width - self.width - 12px; y: (parent.height - self.height) / 2;
|
||||
checked <=> root.checked; enabled: root.enabled;
|
||||
toggle-on-click: root.toggle-on-click;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { ComboBox, ScrollView } from "std-widgets.slint";
|
||||
import { RemoteCursor } from "remote_cursor.slint";
|
||||
import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle, FloatingBarStyle } from "common.slint";
|
||||
@@ -98,7 +99,7 @@ component ControlBarButton inherits Rectangle {
|
||||
background: root.muted ? #eef0f3 : touch.pressed && root.action-enabled ? #0f87fa
|
||||
: touch.has-hover ? #4296fa
|
||||
: root.selected ? #4296fa : #4296fa66;
|
||||
border-radius: 3px;
|
||||
border-radius: UiRadius.control;
|
||||
|
||||
Text {
|
||||
text: root.icon;
|
||||
@@ -159,7 +160,7 @@ component VideoSettingRow inherits VerticalLayout {
|
||||
height: 27px;
|
||||
background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff
|
||||
: option-touch.has-hover ? #eaf3ff : #f5f6f8;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: 1px;
|
||||
border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6;
|
||||
Text { text: option; color: root.enabled ? #30343b : #8b8f95;
|
||||
@@ -237,6 +238,11 @@ export component StreamWindow inherits Window {
|
||||
callback reorder-tab(int, length, length);
|
||||
callback close-tab(string);
|
||||
callback switch-display(int);
|
||||
in property <bool> secure-attention-supported: true;
|
||||
private property <[string]> secure-shortcut-items: ["Ctrl+Alt+Del", "Win+L"];
|
||||
private property <[string]> ordinary-shortcut-items: ["Win+L"];
|
||||
private property <[string]> shortcut-items: root.secure-attention-supported ? root.secure-shortcut-items : root.ordinary-shortcut-items;
|
||||
private property <length> shortcut-menu-height: root.shortcut-items.length * 30px + 8px;
|
||||
callback send-shortcut(string);
|
||||
callback toggle-mouse-control;
|
||||
callback toggle-privacy;
|
||||
@@ -264,6 +270,10 @@ export component StreamWindow inherits Window {
|
||||
private property <length> stats-header-height: 18px;
|
||||
private property <length> stats-content-height:
|
||||
root.stats-header-height + root.stats-rows.length * root.stats-row-height + 65px;
|
||||
private property <length> file-panel-x: 18px;
|
||||
private property <length> file-panel-y: video.height - min(210px, video.height * 0.35);
|
||||
private property <length> file-panel-width: min(430px, video.width * 0.6);
|
||||
private property <length> file-panel-height: min(190px, video.height * 0.32);
|
||||
private property <bool> display-menu-open: false;
|
||||
private property <bool> display-menu-animation-enabled: false;
|
||||
private property <float> display-menu-progress: 0.0;
|
||||
@@ -391,11 +401,14 @@ export component StreamWindow inherits Window {
|
||||
&& pointer-x >= shortcut-menu-x
|
||||
&& pointer-x <= shortcut-menu-x + 180px
|
||||
&& pointer-y >= shortcut-menu-y
|
||||
&& pointer-y <= shortcut-menu-y + 68px;
|
||||
&& pointer-y <= shortcut-menu-y + root.shortcut-menu-height;
|
||||
let over-video-menu = root.video-menu-open
|
||||
&& pointer-x >= root.video-menu-x && pointer-x <= root.video-menu-x + root.video-menu-width
|
||||
&& pointer-y >= root.video-menu-y && pointer-y <= root.video-menu-y + root.video-menu-height;
|
||||
return over-control || over-display-menu || over-shortcut-menu || over-video-menu;
|
||||
let over-file-panel = root.file-transfer-visible
|
||||
&& pointer-x >= root.file-panel-x && pointer-x <= root.file-panel-x + root.file-panel-width
|
||||
&& pointer-y >= root.file-panel-y && pointer-y <= root.file-panel-y + root.file-panel-height;
|
||||
return over-control || over-display-menu || over-shortcut-menu || over-video-menu || over-file-panel;
|
||||
}
|
||||
|
||||
begin-control-drag(press-x, press-y) => {
|
||||
@@ -967,7 +980,7 @@ export component StreamWindow inherits Window {
|
||||
width: parent.width;
|
||||
height: root.stats-header-height;
|
||||
background: #f3f5f8;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
Text {
|
||||
x: stats-table.label-column-width + 4px;
|
||||
width: stats-table.value-column-width - 8px;
|
||||
@@ -1004,7 +1017,7 @@ export component StreamWindow inherits Window {
|
||||
width: parent.width;
|
||||
height: stats-table.row-height;
|
||||
background: index == root.stats-rows.length - 1 ? #edf3fc : transparent;
|
||||
border-radius: 4px;
|
||||
border-radius: UiRadius.control;
|
||||
Text {
|
||||
x: 4px;
|
||||
width: stats-table.label-column-width - 8px;
|
||||
@@ -1133,7 +1146,7 @@ export component StreamWindow inherits Window {
|
||||
x: root.video-menu-x; y: root.video-menu-y;
|
||||
width: root.video-menu-width; height: root.video-menu-height;
|
||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
|
||||
border-radius: 5px; z: 11;
|
||||
border-radius: UiRadius.menu; z: 11;
|
||||
TouchArea {
|
||||
pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } }
|
||||
scroll-event(event) => { accept }
|
||||
@@ -1183,7 +1196,7 @@ export component StreamWindow inherits Window {
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
border-radius: 5px;
|
||||
border-radius: UiRadius.menu;
|
||||
clip: true;
|
||||
z: 11;
|
||||
|
||||
@@ -1195,7 +1208,7 @@ export component StreamWindow inherits Window {
|
||||
: index == display-menu.hovered-index ? #d8e6f8
|
||||
: index == root.selected-display ? #e8eef9
|
||||
: transparent;
|
||||
border-radius: 3px;
|
||||
border-radius: UiRadius.control;
|
||||
Text {
|
||||
x: 8px;
|
||||
width: parent.width - 34px;
|
||||
@@ -1245,11 +1258,11 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
|
||||
if root.shortcut-menu-open: Rectangle {
|
||||
x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: 68px;
|
||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11;
|
||||
x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: root.shortcut-menu-height;
|
||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.menu; z: 11;
|
||||
VerticalLayout {
|
||||
padding: 4px; spacing: 1px;
|
||||
for shortcut in ["Ctrl+Alt+Del", "Win+L"]: Rectangle {
|
||||
for shortcut in root.shortcut-items: Rectangle {
|
||||
height: 29px; background: shortcut-touch.has-hover ? #e8eef9 : transparent;
|
||||
Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
|
||||
shortcut-touch := TouchArea {
|
||||
@@ -1280,7 +1293,7 @@ export component StreamWindow inherits Window {
|
||||
// Anchor to the button row so expanded stats stay beneath the tooltip.
|
||||
y: control.y + root.control-toolbar-height + self.height + 8px <= video.height
|
||||
? control.y + root.control-toolbar-height + 4px : max(4px, control.y - self.height - 4px);
|
||||
background: #30343bf5; border-radius: 5px; z: 12;
|
||||
background: #30343bf5; border-radius: UiRadius.control; z: 12;
|
||||
control-tip := Text {
|
||||
x: 8px; y: 6px; width: parent.width - 16px;
|
||||
text: root.hovered-control.text;
|
||||
@@ -1304,7 +1317,7 @@ export component StreamWindow inherits Window {
|
||||
width: min(520px, parent.width - 48px);
|
||||
height: max(208px, remote-update-message.y + remote-update-message.height + 92px);
|
||||
border-color: #e5e9f0;
|
||||
border-radius: 14px;
|
||||
border-radius: UiRadius.surface;
|
||||
drop-shadow-color: #07142b30;
|
||||
drop-shadow-blur: 32px;
|
||||
drop-shadow-offset-y: 12px;
|
||||
@@ -1320,7 +1333,7 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
Rectangle {
|
||||
x: 32px; y: 32px; width: 48px; height: 48px;
|
||||
background: #edf4ff; border-radius: 12px;
|
||||
background: #edf4ff; border-radius: UiRadius.surface;
|
||||
Path {
|
||||
x: 12px; y: 12px; width: 24px; height: 24px;
|
||||
viewbox-width: 24; viewbox-height: 24;
|
||||
@@ -1355,7 +1368,7 @@ export component StreamWindow inherits Window {
|
||||
PrimaryButton {
|
||||
x: parent.width - 136px; y: parent.height - 68px;
|
||||
width: 104px; height: 36px;
|
||||
border-radius: 7px;
|
||||
border-radius: UiRadius.control;
|
||||
text: StreamStrings.remote-update-dismiss;
|
||||
text-size: 14px;
|
||||
clicked => { root.dismiss-remote-update(); }
|
||||
@@ -1364,10 +1377,21 @@ export component StreamWindow inherits Window {
|
||||
|
||||
// Transfer progress overlay at bottom-left.
|
||||
if root.file-transfer-visible: Rectangle {
|
||||
x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32);
|
||||
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 6px; z: 7;
|
||||
x: root.file-panel-x; y: root.file-panel-y;
|
||||
width: root.file-panel-width; height: root.file-panel-height;
|
||||
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.surface; z: 7;
|
||||
// Header/padding and drags leaving the panel belong to local UI.
|
||||
TouchArea {
|
||||
pointer-event(event) => {
|
||||
if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; }
|
||||
}
|
||||
scroll-event(event) => { accept }
|
||||
}
|
||||
Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; }
|
||||
IconButton { x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px; clicked => { root.close-file-transfer(); } }
|
||||
IconButton {
|
||||
x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px;
|
||||
clicked => { root.suppress-next-remote-pointer-release = true; root.close-file-transfer(); }
|
||||
}
|
||||
ScrollView {
|
||||
x: 10px; y: 32px; width: parent.width - 20px; height: parent.height - 42px;
|
||||
VerticalLayout {
|
||||
|
||||
@@ -46,6 +46,7 @@ inline int ApplyMainWindowStrings(
|
||||
messages.set_messages(Text(localization::messages[language]));
|
||||
messages.set_unread(Text(localization::message_unread[language]));
|
||||
messages.set_read(Text(localization::message_read[language]));
|
||||
messages.set_read_all(Text(localization::message_read_all[language]));
|
||||
messages.set_empty(Text(localization::message_empty[language]));
|
||||
messages.set_select_message(Text(localization::message_select[language]));
|
||||
messages.set_loading(Text(localization::message_loading[language]));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Logical pixels: compact controls, popup menus, and larger surfaces.
|
||||
// Circular indicators, switches, progress bars, and native window chrome
|
||||
// retain radii derived from their own geometry.
|
||||
export global UiRadius {
|
||||
out property <length> control: 6px;
|
||||
out property <length> menu: 8px;
|
||||
out property <length> surface: 12px;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
// Colors and spacing for the connection workbench; shapes are shared app-wide.
|
||||
export global WorkbenchStyle {
|
||||
out property <color> canvas: #f2f5f9;
|
||||
out property <color> surface: #ffffff;
|
||||
out property <color> border: #c6cfdd;
|
||||
out property <color> control-border: #b2bfd1;
|
||||
out property <color> hover-border: #7f97b8;
|
||||
out property <color> field-surface: #f5f7fb;
|
||||
out property <color> hover-surface: #eef3fb;
|
||||
out property <color> pressed-surface: #e4ebf6;
|
||||
out property <color> separator: #e1e7ef;
|
||||
out property <color> text: #1c2940;
|
||||
out property <color> muted: #69788e;
|
||||
out property <color> accent: #3564e3;
|
||||
out property <color> success: #18835a;
|
||||
}
|
||||
|
||||
export component WorkbenchButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <string> icon;
|
||||
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> quiet: false;
|
||||
in property <bool> enabled: true;
|
||||
out property <bool> has-focus: button-focus.has-focus;
|
||||
private property <bool> keyboard-focus: false;
|
||||
private property <bool> show-focus: button-focus.has-focus && root.keyboard-focus;
|
||||
private property <bool> tooltip-active: root.enabled && root.tooltip != ""
|
||||
&& !root.tooltip-suppressed && !touch.pressed
|
||||
&& (touch.has-hover || root.show-focus);
|
||||
private property <bool> tooltip-visible: false;
|
||||
changed tooltip-active => { root.tooltip-visible = false; }
|
||||
callback clicked;
|
||||
width: 112px;
|
||||
height: 36px;
|
||||
border-radius: UiRadius.control;
|
||||
border-width: root.show-focus ? 2px : 1px;
|
||||
border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent)
|
||||
: root.primary ? WorkbenchStyle.accent
|
||||
: touch.has-hover ? WorkbenchStyle.hover-border
|
||||
: root.quiet ? transparent : WorkbenchStyle.control-border;
|
||||
background: root.primary
|
||||
? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent)
|
||||
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : root.quiet ? transparent : white);
|
||||
opacity: root.enabled ? 1 : 0.45;
|
||||
animate background, border-color { duration: 120ms; }
|
||||
forward-focus: button-focus;
|
||||
accessible-role: button;
|
||||
accessible-label: root.tooltip != "" ? root.tooltip : root.text;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-action-default => { if root.enabled { root.clicked(); } }
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
spacing: 8px;
|
||||
alignment: center;
|
||||
if root.icon != "": Text {
|
||||
text: root.icon;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
color: root.icon-color;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
if root.text != "": Text {
|
||||
text: root.text;
|
||||
font-size: 13px;
|
||||
font-weight: root.primary ? 600 : 500;
|
||||
color: root.primary ? white : WorkbenchStyle.text;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
button-focus := FocusScope {
|
||||
enabled: root.enabled;
|
||||
focus-gained(reason) => {
|
||||
if reason == FocusReason.tab-navigation {
|
||||
root.keyboard-focus = true;
|
||||
} else if reason == FocusReason.pointer-click {
|
||||
root.keyboard-focus = false;
|
||||
}
|
||||
}
|
||||
key-pressed(event) => {
|
||||
root.keyboard-focus = true;
|
||||
if event.text == Key.Return || event.text == " " {
|
||||
root.clicked();
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
mouse-cursor: pointer;
|
||||
pointer-event(event) => {
|
||||
if event.kind == PointerEventKind.down {
|
||||
root.keyboard-focus = false;
|
||||
}
|
||||
}
|
||||
// Keep focus for subsequent keyboard activation without leaving a
|
||||
// keyboard focus ring behind after a pointer click.
|
||||
clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); }
|
||||
}
|
||||
}
|
||||
tooltip-timer := Timer {
|
||||
interval: 450ms;
|
||||
running: root.tooltip-active && !root.tooltip-visible;
|
||||
triggered => { root.tooltip-visible = true; }
|
||||
}
|
||||
if root.tooltip-active && root.tooltip-visible: Rectangle {
|
||||
x: min(0px, root.width - self.width);
|
||||
y: root.height + 6px;
|
||||
width: min(260px, tip.preferred-width + 20px);
|
||||
height: max(28px, tip.preferred-height + 12px);
|
||||
background: #24324a;
|
||||
border-radius: UiRadius.control;
|
||||
z: 20;
|
||||
tip := Text {
|
||||
x: 10px; y: 6px;
|
||||
width: parent.width - 20px;
|
||||
height: parent.height - 12px;
|
||||
text: root.tooltip;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
wrap: word-wrap;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A raised surface keeps transient action feedback distinct from the workbench.
|
||||
export component WorkbenchNotice inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <string> icon;
|
||||
in property <bool> error: false;
|
||||
in property <length> width-limit: 360px;
|
||||
private property <color> foreground: root.error ? #b42318 : #146c49;
|
||||
width: min(root.width-limit, max(280px, message.preferred-width + 64px));
|
||||
height: 64px;
|
||||
background: root.error ? #fff1f0 : #e9f6ef;
|
||||
border-width: 1px;
|
||||
border-color: root.error ? #f0c4bf : #b9dfc9;
|
||||
border-radius: UiRadius.surface;
|
||||
drop-shadow-color: #14294424;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-offset-y: 6px;
|
||||
accessible-role: AccessibleRole.text;
|
||||
accessible-label: root.text;
|
||||
Text {
|
||||
x: message.x - 28px; width: 18px; height: parent.height;
|
||||
text: root.icon;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 16px;
|
||||
color: root.foreground;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
message := Text {
|
||||
x: (parent.width - self.width + 28px) / 2;
|
||||
width: min(self.preferred-width, parent.width - 64px);
|
||||
height: parent.height;
|
||||
text: root.text;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: root.foreground;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
|
||||
export component WorkbenchCard inherits Rectangle {
|
||||
background: WorkbenchStyle.surface;
|
||||
border-width: 1px;
|
||||
border-color: WorkbenchStyle.border;
|
||||
border-radius: UiRadius.surface;
|
||||
}
|
||||
@@ -6,11 +6,29 @@
|
||||
|
||||
#include "spdlog/sinks/rotating_file_sink.h"
|
||||
#include "spdlog/sinks/stdout_color_sinks.h"
|
||||
#if defined(__OHOS__)
|
||||
#include <hilog/log.h>
|
||||
#include "spdlog/sinks/base_sink.h"
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
namespace {
|
||||
|
||||
#if defined(__OHOS__)
|
||||
class HarmonyLogSink final : public spdlog::sinks::base_sink<std::mutex> {
|
||||
void sink_it_(const spdlog::details::log_msg& message) override {
|
||||
spdlog::memory_buf_t formatted;
|
||||
formatter_->format(message, formatted);
|
||||
const std::string text(formatted.data(), formatted.size());
|
||||
const auto level = message.level >= spdlog::level::err ? LOG_ERROR :
|
||||
message.level >= spdlog::level::warn ? LOG_WARN : LOG_INFO;
|
||||
OH_LOG_Print(LOG_APP, level, 0, "CrossDeskDesktop", "%{public}s", text.c_str());
|
||||
}
|
||||
void flush_() override {}
|
||||
};
|
||||
#endif
|
||||
|
||||
std::string g_log_dir = "logs";
|
||||
std::string g_logger_name = LOGGER_NAME;
|
||||
std::once_flag g_logger_once_flag;
|
||||
@@ -56,7 +74,11 @@ std::shared_ptr<spdlog::logger> get_logger() {
|
||||
g_log_dir + "/" + g_logger_name + ".log", 5 * 1024 * 1024, 3);
|
||||
|
||||
std::vector<spdlog::sink_ptr> sinks;
|
||||
#if defined(__OHOS__)
|
||||
sinks.push_back(std::make_shared<HarmonyLogSink>());
|
||||
#else
|
||||
sinks.push_back(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
|
||||
#endif
|
||||
sinks.push_back(file_sink);
|
||||
|
||||
auto logger = std::make_shared<spdlog::logger>(g_logger_name, sinks.begin(),
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#include "platform/instance_activation.h"
|
||||
#include "platform/single_instance.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view kRequest = "CrossDesk.Activate.v1";
|
||||
constexpr std::size_t kMaxActivationToken = 4096;
|
||||
|
||||
#if defined(__linux__)
|
||||
std::string Environment(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value ? value : "";
|
||||
}
|
||||
#endif
|
||||
|
||||
std::string ActivationDirectory() {
|
||||
// Match the single-instance guard's user scope, independent of TMPDIR/HOME.
|
||||
return "/tmp/crossdesk-activation-" + std::to_string(geteuid());
|
||||
}
|
||||
|
||||
bool PrivateDirectory(bool create, std::string& error) {
|
||||
const auto directory = ActivationDirectory();
|
||||
if (create && mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST) {
|
||||
error = "Cannot create activation directory: " + std::string(strerror(errno));
|
||||
return false;
|
||||
}
|
||||
struct stat info{};
|
||||
if (lstat(directory.c_str(), &info) != 0) {
|
||||
if (errno != ENOENT)
|
||||
error = "Cannot inspect activation directory: " + std::string(strerror(errno));
|
||||
return false;
|
||||
}
|
||||
if (!S_ISDIR(info.st_mode) || info.st_uid != geteuid() ||
|
||||
(info.st_mode & 0777) != 0700) {
|
||||
error = "Activation directory must be private and owned by this user";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string ActivationPath(const std::string& application, std::string& error) {
|
||||
if (!ValidInstanceApplication(application)) {
|
||||
error = "Invalid activation application name";
|
||||
return {};
|
||||
}
|
||||
std::string session = "macos";
|
||||
#if defined(__linux__)
|
||||
if (!Environment("CROSSDESK_HEADLESS_ACTIVE").empty()) {
|
||||
error = "Window activation is unavailable in a headless session";
|
||||
return {};
|
||||
}
|
||||
const auto wayland = Environment("WAYLAND_DISPLAY");
|
||||
auto display = Environment("DISPLAY");
|
||||
if (!wayland.empty()) {
|
||||
session = "wayland:" + Environment("XDG_RUNTIME_DIR") + "/" + wayland;
|
||||
} else if (!display.empty()) {
|
||||
// :0 and :0.0 address the same server; a screen suffix is not a session.
|
||||
const auto colon = display.rfind(':');
|
||||
const auto screen = display.find('.', colon == std::string::npos ? 0 : colon);
|
||||
if (screen != std::string::npos) display.resize(screen);
|
||||
if (display.rfind("unix:", 0) == 0) display.erase(0, 4);
|
||||
session = "x11:" + display;
|
||||
} else {
|
||||
error = "No graphical session is available to activate the existing window";
|
||||
return {};
|
||||
}
|
||||
#endif
|
||||
// A stable bounded name fits both Darwin and Linux sockaddr_un limits.
|
||||
std::uint64_t hash = 14695981039346656037ull;
|
||||
for (unsigned char byte : application + '\0' + session) {
|
||||
hash ^= byte;
|
||||
hash *= 1099511628211ull;
|
||||
}
|
||||
return ActivationDirectory() + "/" + std::to_string(hash) + ".sock";
|
||||
}
|
||||
|
||||
int CreateSocket(std::string& error) {
|
||||
const int fd = socket(AF_UNIX, SOCK_DGRAM, 0);
|
||||
if (fd < 0) {
|
||||
error = "Cannot create activation socket: " + std::string(strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
if (fcntl(fd, F_SETFD, FD_CLOEXEC) < 0 ||
|
||||
fcntl(fd, F_SETFL, O_NONBLOCK) < 0) {
|
||||
error = "Cannot configure activation socket: " + std::string(strerror(errno));
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
sockaddr_un Address(const std::string& path) {
|
||||
sockaddr_un address{};
|
||||
address.sun_family = AF_UNIX;
|
||||
// ActivationPath always produces a name shorter than sun_path on both OSes.
|
||||
std::memcpy(address.sun_path, path.c_str(), path.size() + 1);
|
||||
return address;
|
||||
}
|
||||
|
||||
socklen_t AddressSize(const std::string& path) {
|
||||
return static_cast<socklen_t>(offsetof(sockaddr_un, sun_path) + path.size() + 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
InstanceActivationReceiver::InstanceActivationReceiver(std::string application)
|
||||
: application_(std::move(application)) {}
|
||||
|
||||
InstanceActivationReceiver::~InstanceActivationReceiver() {
|
||||
if (socket_ < 0) return;
|
||||
struct stat info{};
|
||||
if (lstat(socket_path_.c_str(), &info) == 0 &&
|
||||
static_cast<std::uint64_t>(info.st_dev) == socket_device_ &&
|
||||
static_cast<std::uint64_t>(info.st_ino) == socket_inode_) {
|
||||
unlink(socket_path_.c_str());
|
||||
}
|
||||
close(socket_);
|
||||
}
|
||||
|
||||
bool InstanceActivationReceiver::Start(std::string& error) {
|
||||
error.clear();
|
||||
if (socket_ >= 0) return true;
|
||||
const auto path = ActivationPath(application_, error);
|
||||
if (path.empty() || !PrivateDirectory(true, error)) return false;
|
||||
// The caller holds the client lease. Any socket left at this path is from a
|
||||
// crashed GUI, and may be removed without disrupting another live instance.
|
||||
struct stat info{};
|
||||
if (lstat(path.c_str(), &info) == 0) {
|
||||
if (!S_ISSOCK(info.st_mode) || info.st_uid != geteuid()) {
|
||||
error = "Activation endpoint is not a socket owned by this user";
|
||||
return false;
|
||||
}
|
||||
if (unlink(path.c_str()) != 0) {
|
||||
error = "Cannot remove stale activation socket: " + std::string(strerror(errno));
|
||||
return false;
|
||||
}
|
||||
} else if (errno != ENOENT) {
|
||||
error = "Cannot inspect activation socket: " + std::string(strerror(errno));
|
||||
return false;
|
||||
}
|
||||
const int fd = CreateSocket(error);
|
||||
if (fd < 0) return false;
|
||||
const auto address = Address(path);
|
||||
if (bind(fd, reinterpret_cast<const sockaddr*>(&address), AddressSize(path)) != 0) {
|
||||
error = "Cannot bind activation socket: " + std::string(strerror(errno));
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
if (lstat(path.c_str(), &info) != 0) {
|
||||
error = "Cannot inspect bound activation socket: " + std::string(strerror(errno));
|
||||
unlink(path.c_str());
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
socket_ = fd;
|
||||
socket_path_ = path;
|
||||
socket_device_ = info.st_dev;
|
||||
socket_inode_ = info.st_ino;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool InstanceActivationReceiver::TakeRequest(std::string* activation_token) {
|
||||
if (activation_token) activation_token->clear();
|
||||
if (socket_ < 0) return false;
|
||||
bool requested = false;
|
||||
// Bound each UI tick's work even if many duplicate launches arrive together.
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
char buffer[kRequest.size() + 1 + kMaxActivationToken + 1];
|
||||
const auto size = recv(socket_, buffer, sizeof(buffer), 0);
|
||||
if (size < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
const std::string_view message(buffer, size);
|
||||
if (message == kRequest) {
|
||||
requested = true;
|
||||
} else if (message.size() > kRequest.size() &&
|
||||
message.size() <= kRequest.size() + 1 + kMaxActivationToken &&
|
||||
message.substr(0, kRequest.size()) == kRequest &&
|
||||
message[kRequest.size()] == '\0') {
|
||||
const auto token = message.substr(kRequest.size() + 1);
|
||||
if (token.find('\0') == std::string_view::npos) {
|
||||
requested = true;
|
||||
if (activation_token) *activation_token = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
bool RequestInstanceActivation(std::string& error, const std::string& application) {
|
||||
error.clear();
|
||||
const auto path = ActivationPath(application, error);
|
||||
if (path.empty()) return false;
|
||||
const int fd = CreateSocket(error);
|
||||
if (fd < 0) return false;
|
||||
const auto address = Address(path);
|
||||
std::string request(kRequest);
|
||||
#if defined(__linux__)
|
||||
const auto token = Environment("XDG_ACTIVATION_TOKEN");
|
||||
if (!token.empty() && token.size() <= kMaxActivationToken) {
|
||||
request += '\0';
|
||||
request += token;
|
||||
}
|
||||
#endif
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||
int last_error = ENOENT;
|
||||
do {
|
||||
if (PrivateDirectory(false, error)) {
|
||||
if (sendto(fd, request.data(), request.size(), 0,
|
||||
reinterpret_cast<const sockaddr*>(&address), AddressSize(path)) ==
|
||||
static_cast<ssize_t>(request.size())) {
|
||||
close(fd);
|
||||
return true;
|
||||
}
|
||||
last_error = errno;
|
||||
if (last_error != ENOENT && last_error != ECONNREFUSED &&
|
||||
last_error != EAGAIN && last_error != EINTR) break;
|
||||
} else if (!error.empty()) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
if (std::chrono::steady_clock::now() >= deadline) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
} while (true);
|
||||
close(fd);
|
||||
error = "Cannot notify the existing CrossDesk window in this graphical session: " +
|
||||
std::string(strerror(last_error));
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _HARMONY_TRAY_H_
|
||||
#define _HARMONY_TRAY_H_
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk {
|
||||
// Constructed and invoked on the shared Slint event thread.
|
||||
class HarmonyTray {
|
||||
public:
|
||||
HarmonyTray(std::function<void()> show, std::function<void()> hide,
|
||||
std::function<void()> settings, std::function<void()> exit,
|
||||
const std::string& tooltip, int language);
|
||||
~HarmonyTray();
|
||||
bool MinimizeToTray();
|
||||
void RemoveTrayIcon();
|
||||
static void Dispatch(const std::string& action);
|
||||
private:
|
||||
std::function<void()> show_, hide_, settings_, exit_;
|
||||
bool active_ = false;
|
||||
};
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _WINDOW_STATE_H_
|
||||
#define _WINDOW_STATE_H_
|
||||
|
||||
namespace slint { class Window; }
|
||||
namespace crossdesk {
|
||||
// GUI-thread query of the native window focus delivered by Harmony ArkUI.
|
||||
bool IsHarmonyWindowActive(const slint::Window& window);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _HARMONY_AUTOSTART_H_
|
||||
#define _HARMONY_AUTOSTART_H_
|
||||
|
||||
namespace crossdesk {
|
||||
enum class HarmonyAutostartStatus { checking, enabled, disabled, unsupported, failed };
|
||||
|
||||
// ArkTS publishes system state; the GUI reads it without waiting on a service.
|
||||
HarmonyAutostartStatus GetHarmonyAutostartStatus();
|
||||
void SetHarmonyAutostartStatus(HarmonyAutostartStatus status);
|
||||
void RefreshHarmonyAutostartStatus();
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SYSTEM_SHORTCUTS_H_
|
||||
#define _SYSTEM_SHORTCUTS_H_
|
||||
|
||||
#include <remote_action.h>
|
||||
namespace crossdesk {
|
||||
bool SendHarmonySystemShortcut(ServiceCommandFlag command);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-17
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _INSTANCE_ACTIVATION_H_
|
||||
#define _INSTANCE_ACTIVATION_H_
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <cstdint>
|
||||
#endif
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
// Created only by the interactive GUI, on its event-loop thread. Requests can
|
||||
// arrive during startup and remain pending until the UI is ready to show
|
||||
// itself.
|
||||
class InstanceActivationReceiver {
|
||||
public:
|
||||
explicit InstanceActivationReceiver(std::string application = "CrossDesk");
|
||||
~InstanceActivationReceiver();
|
||||
InstanceActivationReceiver(const InstanceActivationReceiver&) = delete;
|
||||
InstanceActivationReceiver& operator=(const InstanceActivationReceiver&) =
|
||||
delete;
|
||||
|
||||
// The caller must hold this application's client single-instance lease.
|
||||
bool Start(std::string& error);
|
||||
bool TakeRequest(std::string* activation_token = nullptr);
|
||||
|
||||
private:
|
||||
std::string application_;
|
||||
#ifdef _WIN32
|
||||
static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam,
|
||||
LPARAM lparam);
|
||||
|
||||
HWND window_ = nullptr;
|
||||
UINT activation_message_ = 0;
|
||||
bool requested_ = false;
|
||||
#else
|
||||
int socket_ = -1;
|
||||
std::string socket_path_;
|
||||
std::uint64_t socket_device_ = 0;
|
||||
std::uint64_t socket_inode_ = 0;
|
||||
#endif
|
||||
};
|
||||
|
||||
// Targets the current user's GUI on the current desktop, including a daemon
|
||||
// child. Waits briefly for startup/restart, without waiting on its UI thread.
|
||||
bool RequestInstanceActivation(std::string& error,
|
||||
const std::string& application = "CrossDesk");
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "wayland_activation.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
// The public libwayland-client wire ABI. Load it at runtime so X11-only builds
|
||||
// do not acquire a new Wayland build or runtime dependency.
|
||||
struct Interface;
|
||||
struct Message {
|
||||
const char* name;
|
||||
const char* signature;
|
||||
const Interface** types;
|
||||
};
|
||||
struct Interface {
|
||||
const char* name;
|
||||
int version;
|
||||
int method_count;
|
||||
const Message* methods;
|
||||
int event_count;
|
||||
const Message* events;
|
||||
};
|
||||
|
||||
struct WaylandApi {
|
||||
void* library = dlopen("libwayland-client.so.0", RTLD_NOW | RTLD_LOCAL);
|
||||
void* (*create_queue)(void*) = nullptr;
|
||||
void (*destroy_queue)(void*) = nullptr;
|
||||
void* (*create_wrapper)(void*) = nullptr;
|
||||
void (*destroy_wrapper)(void*) = nullptr;
|
||||
void (*set_queue)(void*, void*) = nullptr;
|
||||
void (*destroy_proxy)(void*) = nullptr;
|
||||
void* (*marshal)(void*, uint32_t, const Interface*, uint32_t, uint32_t, ...) = nullptr;
|
||||
int (*add_listener)(void*, void (**)(void), void*) = nullptr;
|
||||
int (*roundtrip)(void*, void*) = nullptr;
|
||||
int (*flush)(void*) = nullptr;
|
||||
const Interface* registry_interface = nullptr;
|
||||
const Interface* surface_interface = nullptr;
|
||||
bool available = false;
|
||||
|
||||
template <class T>
|
||||
bool Load(T& target, const char* name) {
|
||||
target = reinterpret_cast<T>(dlsym(library, name));
|
||||
return target != nullptr;
|
||||
}
|
||||
|
||||
WaylandApi() {
|
||||
available = library &&
|
||||
Load(create_queue, "wl_display_create_queue") &&
|
||||
Load(destroy_queue, "wl_event_queue_destroy") &&
|
||||
Load(create_wrapper, "wl_proxy_create_wrapper") &&
|
||||
Load(destroy_wrapper, "wl_proxy_wrapper_destroy") &&
|
||||
Load(set_queue, "wl_proxy_set_queue") &&
|
||||
Load(destroy_proxy, "wl_proxy_destroy") &&
|
||||
Load(marshal, "wl_proxy_marshal_flags") &&
|
||||
Load(add_listener, "wl_proxy_add_listener") &&
|
||||
Load(roundtrip, "wl_display_roundtrip_queue") &&
|
||||
Load(flush, "wl_display_flush") &&
|
||||
Load(registry_interface, "wl_registry_interface") &&
|
||||
Load(surface_interface, "wl_surface_interface");
|
||||
}
|
||||
~WaylandApi() { if (library) dlclose(library); }
|
||||
};
|
||||
|
||||
struct Activation {
|
||||
WaylandApi& api;
|
||||
void* queue = nullptr;
|
||||
void* wrapper = nullptr;
|
||||
void* registry = nullptr;
|
||||
void* activation = nullptr;
|
||||
void* token = nullptr;
|
||||
std::string token_value;
|
||||
|
||||
const Interface* surface_types[1];
|
||||
const Interface* activate_types[2];
|
||||
const Message token_methods[5];
|
||||
const Message token_events[1] = {{"done", "s", nullptr}};
|
||||
const Interface token_interface;
|
||||
const Interface* new_token_types[1];
|
||||
const Message activation_methods[3];
|
||||
const Interface activation_interface;
|
||||
|
||||
explicit Activation(WaylandApi& value)
|
||||
: api(value), surface_types{api.surface_interface},
|
||||
activate_types{nullptr, api.surface_interface},
|
||||
token_methods{{"set_serial", "uo", nullptr}, {"set_app_id", "s", nullptr},
|
||||
{"set_surface", "o", surface_types}, {"commit", "", nullptr},
|
||||
{"destroy", "", nullptr}},
|
||||
token_interface{"xdg_activation_token_v1", 1, 5, token_methods, 1, token_events},
|
||||
new_token_types{&token_interface},
|
||||
activation_methods{{"destroy", "", nullptr},
|
||||
{"get_activation_token", "n", new_token_types},
|
||||
{"activate", "so", activate_types}},
|
||||
activation_interface{"xdg_activation_v1", 1, 3, activation_methods, 0, nullptr} {}
|
||||
|
||||
~Activation() {
|
||||
// Destroy protocol objects as well as client-side proxies on every path.
|
||||
constexpr uint32_t destroy = 1; // WL_MARSHAL_FLAG_DESTROY
|
||||
if (token) api.marshal(token, 4, nullptr, 1, destroy);
|
||||
if (activation) api.marshal(activation, 0, nullptr, 1, destroy);
|
||||
if (registry) api.destroy_proxy(registry);
|
||||
if (wrapper) api.destroy_wrapper(wrapper);
|
||||
if (queue) api.destroy_queue(queue);
|
||||
}
|
||||
|
||||
static void Global(void* data, void* registry, uint32_t name,
|
||||
const char* interface, uint32_t version) {
|
||||
auto& self = *static_cast<Activation*>(data);
|
||||
if (self.activation || version < 1 || std::strcmp(interface, "xdg_activation_v1")) return;
|
||||
self.activation = self.api.marshal(registry, 0, &self.activation_interface, 1, 0,
|
||||
name, self.activation_interface.name, uint32_t{1}, nullptr);
|
||||
}
|
||||
static void GlobalRemove(void*, void*, uint32_t) {}
|
||||
static void Done(void* data, void*, const char* value) {
|
||||
static_cast<Activation*>(data)->token_value = value;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ActivateWaylandWindow(void* display, void* surface,
|
||||
const std::string& activation_token) {
|
||||
if (!display || !surface) return false;
|
||||
static WaylandApi api;
|
||||
if (!api.available) return false;
|
||||
Activation request(api);
|
||||
// Use a separate queue and a display wrapper: roundtrips must never dispatch
|
||||
// Slint/winit's own events from inside its timer callback.
|
||||
request.queue = api.create_queue(display);
|
||||
request.wrapper = api.create_wrapper(display);
|
||||
if (!request.queue || !request.wrapper) return false;
|
||||
api.set_queue(request.wrapper, request.queue);
|
||||
request.registry = api.marshal(request.wrapper, 1, api.registry_interface, 1, 0, nullptr);
|
||||
if (!request.registry) return false;
|
||||
void (*registry_listener[])(void) = {
|
||||
reinterpret_cast<void (*)(void)>(&Activation::Global),
|
||||
reinterpret_cast<void (*)(void)>(&Activation::GlobalRemove)};
|
||||
if (api.add_listener(request.registry, registry_listener, &request) != 0 ||
|
||||
api.roundtrip(display, request.queue) < 0 || !request.activation) return false;
|
||||
|
||||
request.token_value = activation_token;
|
||||
if (request.token_value.empty()) {
|
||||
// Terminal/tray launches may carry no startup token. Ask the compositor
|
||||
// for one; it remains free to refuse focus without a recent input serial.
|
||||
request.token = api.marshal(request.activation, 1, &request.token_interface, 1, 0, nullptr);
|
||||
if (!request.token) return false;
|
||||
void (*token_listener[])(void) = {reinterpret_cast<void (*)(void)>(&Activation::Done)};
|
||||
if (api.add_listener(request.token, token_listener, &request) != 0) return false;
|
||||
api.marshal(request.token, 1, nullptr, 1, 0, "crossdesk");
|
||||
api.marshal(request.token, 2, nullptr, 1, 0, surface);
|
||||
api.marshal(request.token, 3, nullptr, 1, 0);
|
||||
if (api.roundtrip(display, request.queue) < 0 || request.token_value.empty()) return false;
|
||||
}
|
||||
api.marshal(request.activation, 2, nullptr, 1, 0, request.token_value.c_str(), surface);
|
||||
api.flush(display);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-17
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _WAYLAND_ACTIVATION_H_
|
||||
#define _WAYLAND_ACTIVATION_H_
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
// Uses Slint's native handles and xdg-activation-v1 when the compositor offers
|
||||
// it. A successful request still leaves the focus decision to the compositor.
|
||||
bool ActivateWaylandWindow(void* display, void* surface,
|
||||
const std::string& activation_token);
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
|
||||
#endif
|
||||
@@ -33,6 +33,7 @@ class MacTray {
|
||||
|
||||
void MinimizeToTray();
|
||||
void RemoveTrayIcon();
|
||||
bool TakeReopenRequest();
|
||||
|
||||
private:
|
||||
std::unique_ptr<MacTrayImpl> impl_;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <CoreServices/CoreServices.h>
|
||||
|
||||
#include "localization.h"
|
||||
|
||||
@@ -16,6 +17,8 @@
|
||||
- (void)showMainWindow:(id)sender;
|
||||
- (void)openSettings:(id)sender;
|
||||
- (void)exitApplication:(id)sender;
|
||||
- (void)reopenApplication:(NSAppleEventDescriptor *)event
|
||||
withReply:(NSAppleEventDescriptor *)reply;
|
||||
@end
|
||||
|
||||
namespace crossdesk {
|
||||
@@ -28,6 +31,7 @@ struct MacTrayImpl {
|
||||
language_index(language_index_value),
|
||||
target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) {
|
||||
EnsureStatusItem();
|
||||
InstallReopenHandler();
|
||||
}
|
||||
|
||||
explicit MacTrayImpl(std::function<void()> show_window_callback,
|
||||
@@ -43,9 +47,13 @@ struct MacTrayImpl {
|
||||
language_index(language_index_value),
|
||||
target([[CrossDeskMacTrayTarget alloc] initWithOwner:this]) {
|
||||
EnsureStatusItem();
|
||||
InstallReopenHandler();
|
||||
}
|
||||
|
||||
~MacTrayImpl() {
|
||||
[[NSAppleEventManager sharedAppleEventManager]
|
||||
removeEventHandlerForEventClass:kCoreEventClass
|
||||
andEventID:kAEReopenApplication];
|
||||
RemoveTrayIcon();
|
||||
target = nil;
|
||||
}
|
||||
@@ -156,7 +164,28 @@ struct MacTrayImpl {
|
||||
[NSApp activateIgnoringOtherApps:YES];
|
||||
}
|
||||
|
||||
void RequestReopen() { reopen_requested = true; }
|
||||
bool TakeReopenRequest() {
|
||||
if (!reopen_handler_ready) {
|
||||
// AppKit may replace handlers even after its did-finish-launching
|
||||
// notification. The first GUI tick runs after finishLaunching returns.
|
||||
InstallReopenHandler();
|
||||
reopen_handler_ready = true;
|
||||
}
|
||||
return std::exchange(reopen_requested, false);
|
||||
}
|
||||
|
||||
private:
|
||||
void InstallReopenHandler() {
|
||||
// Finder/Dock normally sends a reopen Apple event instead of launching a
|
||||
// second process. Queue it for the UI tick, just like the IPC notification.
|
||||
[[NSAppleEventManager sharedAppleEventManager]
|
||||
setEventHandler:target
|
||||
andSelector:@selector(reopenApplication:withReply:)
|
||||
forEventClass:kCoreEventClass
|
||||
andEventID:kAEReopenApplication];
|
||||
}
|
||||
|
||||
void EnsureStatusItem() {
|
||||
if (status_item) {
|
||||
return;
|
||||
@@ -265,6 +294,8 @@ struct MacTrayImpl {
|
||||
int language_index = 0;
|
||||
NSStatusItem *status_item = nil;
|
||||
CrossDeskMacTrayTarget *target = nil;
|
||||
bool reopen_requested = false;
|
||||
bool reopen_handler_ready = false;
|
||||
};
|
||||
|
||||
MacTray::MacTray(::SDL_Window *app_window, const std::string &tooltip,
|
||||
@@ -285,8 +316,12 @@ MacTray::MacTray(std::function<void()> show_window,
|
||||
MacTray::~MacTray() = default;
|
||||
|
||||
void MacActivateWindow(void *appkit_view) {
|
||||
// IPC/Finder activation bypasses the tray's ShowWindow(), including when
|
||||
// MinimizeToTray() previously removed the app from the Dock.
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
|
||||
NSView *view = (__bridge NSView *)appkit_view;
|
||||
NSWindow *window = [view window];
|
||||
[window deminiaturize:nil];
|
||||
[NSApp activateIgnoringOtherApps:YES];
|
||||
[window makeKeyAndOrderFront:nil];
|
||||
}
|
||||
@@ -295,6 +330,8 @@ void MacTray::MinimizeToTray() { impl_->MinimizeToTray(); }
|
||||
|
||||
void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); }
|
||||
|
||||
bool MacTray::TakeReopenRequest() { return impl_->TakeReopenRequest(); }
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
@implementation CrossDeskMacTrayTarget {
|
||||
@@ -317,6 +354,13 @@ void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); }
|
||||
owner_->ShowMenu();
|
||||
}
|
||||
|
||||
- (void)reopenApplication:(NSAppleEventDescriptor *)event
|
||||
withReply:(NSAppleEventDescriptor *)reply {
|
||||
(void)event;
|
||||
(void)reply;
|
||||
if (owner_) owner_->RequestReopen();
|
||||
}
|
||||
|
||||
- (void)exitApplication:(id)sender {
|
||||
(void)sender;
|
||||
if (owner_) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#include "platform/instance_activation.h"
|
||||
#include "platform/single_instance.h"
|
||||
|
||||
#include <sddl.h>
|
||||
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
constexpr wchar_t kWindowClass[] = L"CrossDesk.InstanceActivationWindow";
|
||||
constexpr wchar_t kActivationMessage[] = L"CrossDesk.ActivateMainWindow.v1";
|
||||
constexpr ULONGLONG kStartupWaitMs = 5000;
|
||||
|
||||
std::wstring ActivationWindowName(const std::string& application,
|
||||
std::string& error) {
|
||||
if (!ValidInstanceApplication(application)) {
|
||||
error = "Invalid activation application name";
|
||||
return {};
|
||||
}
|
||||
HANDLE token = nullptr;
|
||||
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) {
|
||||
error =
|
||||
"Cannot read activation user token: " + std::to_string(GetLastError());
|
||||
return {};
|
||||
}
|
||||
DWORD size = 0;
|
||||
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
|
||||
std::vector<unsigned char> buffer(size);
|
||||
if (!GetTokenInformation(token, TokenUser, buffer.data(), size, &size)) {
|
||||
const DWORD code = GetLastError();
|
||||
CloseHandle(token);
|
||||
error = "Cannot read activation user SID: " + std::to_string(code);
|
||||
return {};
|
||||
}
|
||||
CloseHandle(token);
|
||||
wchar_t* sid = nullptr;
|
||||
if (!ConvertSidToStringSidW(
|
||||
reinterpret_cast<TOKEN_USER*>(buffer.data())->User.Sid, &sid)) {
|
||||
error =
|
||||
"Cannot format activation user SID: " + std::to_string(GetLastError());
|
||||
return {};
|
||||
}
|
||||
const std::wstring name =
|
||||
std::wstring(application.begin(), application.end()) + L".Activation." + sid;
|
||||
LocalFree(sid);
|
||||
return name;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
InstanceActivationReceiver::InstanceActivationReceiver(std::string application)
|
||||
: application_(std::move(application)) {}
|
||||
|
||||
InstanceActivationReceiver::~InstanceActivationReceiver() {
|
||||
if (window_) DestroyWindow(window_);
|
||||
}
|
||||
|
||||
bool InstanceActivationReceiver::Start(std::string& error) {
|
||||
error.clear();
|
||||
if (window_) return true;
|
||||
const auto name = ActivationWindowName(application_, error);
|
||||
if (name.empty()) return false;
|
||||
activation_message_ = RegisterWindowMessageW(kActivationMessage);
|
||||
if (!activation_message_) {
|
||||
error =
|
||||
"Cannot register activation message: " + std::to_string(GetLastError());
|
||||
return false;
|
||||
}
|
||||
WNDCLASSW window_class{};
|
||||
window_class.lpfnWndProc = WindowProc;
|
||||
window_class.hInstance = GetModuleHandleW(nullptr);
|
||||
window_class.lpszClassName = kWindowClass;
|
||||
if (!RegisterClassW(&window_class) &&
|
||||
GetLastError() != ERROR_CLASS_ALREADY_EXISTS) {
|
||||
error =
|
||||
"Cannot register activation window: " + std::to_string(GetLastError());
|
||||
return false;
|
||||
}
|
||||
window_ =
|
||||
CreateWindowExW(0, kWindowClass, name.c_str(), 0, 0, 0, 0, 0,
|
||||
HWND_MESSAGE, nullptr, window_class.hInstance, this);
|
||||
if (!window_) {
|
||||
error =
|
||||
"Cannot create activation window: " + std::to_string(GetLastError());
|
||||
return false;
|
||||
}
|
||||
// An unelevated portable launch may need to activate an elevated installed
|
||||
// client. Allow only this payload-free notification through UIPI.
|
||||
if (!ChangeWindowMessageFilterEx(window_, activation_message_, MSGFLT_ALLOW,
|
||||
nullptr)) {
|
||||
error =
|
||||
"Cannot allow activation message: " + std::to_string(GetLastError());
|
||||
DestroyWindow(window_);
|
||||
window_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool InstanceActivationReceiver::TakeRequest(std::string* activation_token) {
|
||||
if (activation_token) activation_token->clear();
|
||||
return std::exchange(requested_, false);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK InstanceActivationReceiver::WindowProc(HWND window,
|
||||
UINT message,
|
||||
WPARAM wparam,
|
||||
LPARAM lparam) {
|
||||
if (message == WM_NCCREATE) {
|
||||
const auto* create = reinterpret_cast<CREATESTRUCTW*>(lparam);
|
||||
SetWindowLongPtrW(window, GWLP_USERDATA,
|
||||
reinterpret_cast<LONG_PTR>(create->lpCreateParams));
|
||||
}
|
||||
auto* receiver = reinterpret_cast<InstanceActivationReceiver*>(
|
||||
GetWindowLongPtrW(window, GWLP_USERDATA));
|
||||
if (receiver && message == receiver->activation_message_) {
|
||||
// Let the regular UI tick restore the window, rather than entering Slint
|
||||
// from a native message callback (which may run inside a nested event
|
||||
// loop).
|
||||
receiver->requested_ = true;
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
bool RequestInstanceActivation(std::string& error, const std::string& application) {
|
||||
error.clear();
|
||||
const auto name = ActivationWindowName(application, error);
|
||||
if (name.empty()) return false;
|
||||
const UINT message = RegisterWindowMessageW(kActivationMessage);
|
||||
if (!message) {
|
||||
error =
|
||||
"Cannot register activation message: " + std::to_string(GetLastError());
|
||||
return false;
|
||||
}
|
||||
const ULONGLONG deadline = GetTickCount64() + kStartupWaitMs;
|
||||
DWORD last_error = ERROR_NOT_FOUND;
|
||||
do {
|
||||
if (const HWND window =
|
||||
FindWindowExW(HWND_MESSAGE, nullptr, kWindowClass, name.c_str())) {
|
||||
DWORD process_id = 0;
|
||||
if (GetWindowThreadProcessId(window, &process_id) && process_id != 0) {
|
||||
// Transfer the new launch's foreground permission to the GUI process,
|
||||
// not its supervisor, before posting the request. Windows may still
|
||||
// deny foreground focus; the receiver will restore visibility anyway.
|
||||
AllowSetForegroundWindow(process_id);
|
||||
if (PostMessageW(window, message, 0, 0)) return true;
|
||||
last_error = GetLastError();
|
||||
}
|
||||
}
|
||||
if (GetTickCount64() >= deadline) break;
|
||||
Sleep(50);
|
||||
} while (true);
|
||||
error = "Cannot notify the existing CrossDesk window on this desktop: " +
|
||||
std::to_string(last_error);
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "interactive_state.h"
|
||||
#include "service_host.h"
|
||||
#include "unattended_config.h"
|
||||
#include "rdp_session_handoff.h"
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
@@ -126,11 +127,36 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
|
||||
ResetLocalWindowsServiceState(false);
|
||||
return;
|
||||
}
|
||||
windows_rdp_handoff_allowed_->store(
|
||||
!unattended_host_, std::memory_order_relaxed);
|
||||
|
||||
if (!windows_service_worker_) {
|
||||
windows_service_worker_ = std::make_unique<ServiceQueryWorker>(
|
||||
[](const std::string& command) {
|
||||
[allowed = windows_rdp_handoff_allowed_,
|
||||
connected = windows_rdp_handoff_connected_, next_handoff_tick = ULONGLONG{0},
|
||||
last_handoff_response = std::string{}]
|
||||
(const std::string& command) mutable {
|
||||
ServiceQueryResult result;
|
||||
if (command == "status") {
|
||||
const bool eligible = allowed->load(std::memory_order_relaxed) &&
|
||||
connected->load(std::memory_order_relaxed) &&
|
||||
IsCurrentProcessSessionDisconnected();
|
||||
const ULONGLONG now = GetTickCount64();
|
||||
if (!eligible) {
|
||||
next_handoff_tick = 0;
|
||||
last_handoff_response.clear();
|
||||
} else if (now >= next_handoff_tick &&
|
||||
allowed->load(std::memory_order_relaxed) &&
|
||||
connected->load(std::memory_order_relaxed)) {
|
||||
next_handoff_tick = now + 5000;
|
||||
const auto response = QueryCrossDeskService(kRdpHandoffCommand, 1000);
|
||||
if (response != last_handoff_response) {
|
||||
// A reply timeout does not imply that Windows rejected the switch.
|
||||
LOG_INFO("RDP auto handoff service response: {}", response);
|
||||
last_handoff_response = response;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.response = QueryCrossDeskService(
|
||||
command, command == "sas" ? kWindowsServiceSasTimeoutMs
|
||||
: kWindowsServiceQueryTimeoutMs);
|
||||
@@ -277,6 +303,7 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
|
||||
|
||||
#if _WIN32
|
||||
void GuiRuntime::ResetLocalWindowsServiceState(bool clear_pending_sas) {
|
||||
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
|
||||
if (windows_service_worker_) windows_service_worker_->Reset();
|
||||
last_windows_service_status_tick_ = 0;
|
||||
if (clear_pending_sas) {
|
||||
|
||||
@@ -860,9 +860,17 @@ int ScreenCapturerWin::Start(bool show_cursor) {
|
||||
const bool needs_wgc = usbmmidd &&
|
||||
capture_method_ == ScreenCaptureMethod::Auto &&
|
||||
dynamic_cast<WgcPluginCapturer*>(impl_.get()) == nullptr;
|
||||
if (headless != was_headless || (headless && !backend_is_gdi) || needs_wgc) {
|
||||
// A prior session may have fallen back while RDP was changing its output.
|
||||
// Retry the preferred GPU backend on a new connection instead of retaining
|
||||
// WGC/GDI indefinitely. Keep the headless and usbmmidd cursor policies.
|
||||
const bool needs_dxgi = capture_method_ == ScreenCaptureMethod::Auto &&
|
||||
!headless && !usbmmidd &&
|
||||
dynamic_cast<ScreenCapturerDxgi*>(impl_.get()) == nullptr;
|
||||
if (headless != was_headless || (headless && !backend_is_gdi) || needs_wgc ||
|
||||
needs_dxgi) {
|
||||
LOG_INFO("Windows capturer: reselecting capture backend "
|
||||
"(headless={}, usbmmidd={})", headless, usbmmidd);
|
||||
"(headless={}, usbmmidd={}, retry_dxgi={})", headless, usbmmidd,
|
||||
needs_dxgi);
|
||||
impl_->Destroy();
|
||||
// Keep an instance around on failure so the next Start can retry.
|
||||
std::unique_ptr<ScreenCapturer> previous;
|
||||
@@ -1229,9 +1237,22 @@ bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() {
|
||||
const bool show_cursor = show_cursor_.load(std::memory_order_relaxed);
|
||||
const int current_monitor = monitor_index_.load(std::memory_order_relaxed);
|
||||
const bool usbmmidd = virtual_display_.active() || CapturesUsbmmiddOutput(impl_.get());
|
||||
const bool headless = DetectHeadlessDesktop(capture_method_, false, true);
|
||||
headless_compat_.store(headless, std::memory_order_relaxed);
|
||||
|
||||
LOG_INFO("Windows capturer: restarting capture backend after secure desktop");
|
||||
impl_->Stop();
|
||||
if (capture_method_ == ScreenCaptureMethod::Auto && !headless && !usbmmidd &&
|
||||
dynamic_cast<ScreenCapturerDxgi*>(impl_.get()) == nullptr) {
|
||||
LOG_INFO("Windows capturer: desktop recovered; retrying DXGI before {}",
|
||||
CaptureBackendName(impl_.get()));
|
||||
if (StartPreferredBackend(headless, current_monitor, usbmmidd)) {
|
||||
NotifyPrivacyCapture(true);
|
||||
return true;
|
||||
}
|
||||
// Failed candidates retain the previous instance, which may still recover
|
||||
// even if constructing a replacement failed during the display transition.
|
||||
}
|
||||
last_capture_progress_tick_.store(GetTickCount64(), std::memory_order_relaxed);
|
||||
int ret = impl_->Start(show_cursor);
|
||||
if (ret == 0) {
|
||||
@@ -1282,7 +1303,7 @@ void ScreenCapturerWin::CheckDisplayPresence(ULONGLONG now) {
|
||||
last_display_probe_tick_ = now;
|
||||
const bool topology_changed =
|
||||
display_probe_requested_.exchange(false, std::memory_order_relaxed);
|
||||
if (!IsWindowsPrivacyDesktopAvailable()) {
|
||||
if (!IsCurrentSessionUserDesktopActive()) {
|
||||
headless_recovery_.ShouldProvision(now, false, false, false);
|
||||
return;
|
||||
}
|
||||
@@ -1621,6 +1642,8 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
ULONGLONG capture_stage_started_tick = 0;
|
||||
bool post_secure_restart_pending = false;
|
||||
bool desktop_was_unavailable = false;
|
||||
std::string last_desktop_probe_reason;
|
||||
bool privacy_recovery_pending = false;
|
||||
bool normal_backend_suspended = false;
|
||||
ULONGLONG post_secure_restart_deadline_tick = 0;
|
||||
ULONGLONG last_post_secure_restart_tick = 0;
|
||||
@@ -1664,11 +1687,22 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
};
|
||||
|
||||
while (running_.load(std::memory_order_relaxed)) {
|
||||
// Capture follows this process's session, including an active RDP session.
|
||||
// Privacy additionally requires the console desktop and must not gate video.
|
||||
std::string desktop_probe_reason;
|
||||
const bool desktop_available = IsCurrentSessionUserDesktopActive(&desktop_probe_reason);
|
||||
if (desktop_probe_reason != last_desktop_probe_reason) {
|
||||
LOG_INFO("Windows capturer: user desktop probe: {}",
|
||||
desktop_available ? "available" : desktop_probe_reason);
|
||||
last_desktop_probe_reason = desktop_probe_reason;
|
||||
}
|
||||
const bool privacy_available =
|
||||
desktop_available && IsWindowsPrivacyDesktopAvailable();
|
||||
if (!desktop_available) desktop_was_unavailable = true;
|
||||
// Remember automatic privacy even if the connection started while locked
|
||||
// and its first enable attempt has already failed.
|
||||
const bool desktop_available = IsWindowsPrivacyDesktopAvailable();
|
||||
if (!desktop_available) {
|
||||
desktop_was_unavailable = true;
|
||||
if (!privacy_available) {
|
||||
privacy_recovery_pending = true;
|
||||
if (privacy_) privacy_->SuspendForDesktop();
|
||||
}
|
||||
if (paused_.load(std::memory_order_relaxed)) {
|
||||
@@ -1772,7 +1806,13 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
if (impl_) {
|
||||
impl_->Stop();
|
||||
normal_backend_suspended = true;
|
||||
LOG_INFO("Windows capturer: normal backend suspended for secure desktop");
|
||||
DWORD process_session_id = 0xFFFFFFFF;
|
||||
ProcessIdToSessionId(GetCurrentProcessId(), &process_session_id);
|
||||
LOG_INFO("Windows capturer: normal backend suspended, "
|
||||
"user_desktop_available={}, secure_capture_active={}, "
|
||||
"process_session_id={}, console_session_id={}",
|
||||
desktop_available, status.capture_active, process_session_id,
|
||||
WTSGetActiveConsoleSessionId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1801,11 +1841,18 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
last_post_secure_restart_tick = now;
|
||||
post_secure_restart_pending =
|
||||
!RestartCaptureBackendAfterSecureDesktop();
|
||||
if (!post_secure_restart_pending) normal_backend_suspended = false;
|
||||
if (!post_secure_restart_pending && privacy_)
|
||||
privacy_->ResumeAfterDesktopRecovery();
|
||||
if (!post_secure_restart_pending) {
|
||||
normal_backend_suspended = false;
|
||||
// Input callbacks may have suspended privacy between local probes.
|
||||
privacy_recovery_pending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (privacy_recovery_pending && privacy_available &&
|
||||
!post_secure_restart_pending && !normal_backend_suspended) {
|
||||
privacy_recovery_pending = false;
|
||||
if (privacy_) privacy_->ResumeAfterDesktopRecovery();
|
||||
}
|
||||
if (!post_secure_restart_pending && !normal_backend_suspended &&
|
||||
now >= next_cursor_update_tick) {
|
||||
next_cursor_update_tick = now + 500;
|
||||
|
||||
@@ -54,35 +54,53 @@ inline bool IsCurrentProcessSession(DWORD session_id) {
|
||||
session_id == process_session_id;
|
||||
}
|
||||
|
||||
inline bool IsCurrentSessionUnlocked() {
|
||||
inline bool DesktopProbeFailed(std::string* reason, const char* stage, DWORD value) {
|
||||
if (reason) *reason = std::string(stage) + ":" + std::to_string(value);
|
||||
return false;
|
||||
}
|
||||
|
||||
inline bool IsCurrentSessionUnlocked(std::string* reason = nullptr) {
|
||||
if (reason) reason->clear();
|
||||
DWORD session_id = 0xFFFFFFFF;
|
||||
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session_id)) return false;
|
||||
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session_id))
|
||||
return DesktopProbeFailed(reason, "session_id_query_failed", GetLastError());
|
||||
PWTSINFOEXW info = nullptr;
|
||||
DWORD bytes = 0;
|
||||
if (!WTSQuerySessionInformationW(
|
||||
WTS_CURRENT_SERVER_HANDLE, session_id, WTSSessionInfoEx,
|
||||
reinterpret_cast<LPWSTR*>(&info), &bytes)) {
|
||||
return false;
|
||||
return DesktopProbeFailed(reason, "session_info_query_failed", GetLastError());
|
||||
}
|
||||
const bool unlocked =
|
||||
info != nullptr && bytes >= sizeof(WTSINFOEXW) && info->Level == 1 &&
|
||||
info->Data.WTSInfoExLevel1.SessionState == WTSActive &&
|
||||
info->Data.WTSInfoExLevel1.SessionFlags == WTS_SESSIONSTATE_UNLOCK;
|
||||
const bool valid = info && bytes >= sizeof(WTSINFOEXW) && info->Level == 1;
|
||||
const DWORD state = valid ? info->Data.WTSInfoExLevel1.SessionState : 0xFFFFFFFF;
|
||||
const DWORD flags = valid ? info->Data.WTSInfoExLevel1.SessionFlags : 0xFFFFFFFF;
|
||||
if (info != nullptr) WTSFreeMemory(info);
|
||||
return unlocked;
|
||||
if (!valid) return DesktopProbeFailed(reason, "session_info_invalid", bytes);
|
||||
if (state != WTSActive) return DesktopProbeFailed(reason, "session_not_active", state);
|
||||
if (flags != WTS_SESSIONSTATE_UNLOCK)
|
||||
return DesktopProbeFailed(reason, "session_not_unlocked", flags);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool IsCurrentSessionUserDesktopActive() {
|
||||
inline bool IsCurrentSessionUserDesktopActive(std::string* reason = nullptr) {
|
||||
// LockApp can be visible on Default while the session is still locked.
|
||||
if (!IsCurrentSessionUnlocked()) return false;
|
||||
if (!IsCurrentSessionUnlocked(reason)) return false;
|
||||
HDESK desktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
|
||||
if (desktop == nullptr) return false;
|
||||
// OpenInputDesktop also succeeds in disconnected RDP sessions. The name
|
||||
// alone does not tell us whether this desktop can currently receive input.
|
||||
const bool active = IsDesktopReceivingInput(desktop) &&
|
||||
_wcsicmp(GetDesktopNameW(desktop).c_str(), L"Default") == 0;
|
||||
if (desktop == nullptr)
|
||||
return DesktopProbeFailed(reason, "open_input_desktop_failed", GetLastError());
|
||||
// A minimized RDP connection can suppress desktop output while WTS still
|
||||
// reports Active/Unlocked. Do not treat its black WGC callbacks as recovery.
|
||||
BOOL receiving_input = FALSE;
|
||||
const bool queried = GetUserObjectInformationW(
|
||||
desktop, UOI_IO, &receiving_input, sizeof(receiving_input), nullptr) != FALSE;
|
||||
const DWORD error = queried ? ERROR_SUCCESS : GetLastError();
|
||||
const bool is_default = queried && receiving_input &&
|
||||
_wcsicmp(GetDesktopNameW(desktop).c_str(), L"Default") == 0;
|
||||
CloseDesktop(desktop);
|
||||
return active;
|
||||
if (!queried) return DesktopProbeFailed(reason, "desktop_input_query_failed", error);
|
||||
if (!receiving_input) return DesktopProbeFailed(reason, "desktop_not_receiving_input", 0);
|
||||
if (!is_default) return DesktopProbeFailed(reason, "desktop_not_default", 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use only on worker threads without windows or hooks. Restore the original
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#include "rdp_session_handoff.h"
|
||||
|
||||
#include <WtsApi32.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
struct Handle {
|
||||
HANDLE value = nullptr;
|
||||
~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); }
|
||||
};
|
||||
|
||||
bool ReadTokenUser(HANDLE token, std::vector<unsigned char>& data) {
|
||||
DWORD bytes = 0;
|
||||
GetTokenInformation(token, TokenUser, nullptr, 0, &bytes);
|
||||
if (bytes == 0) return false;
|
||||
data.resize(bytes);
|
||||
return GetTokenInformation(token, TokenUser, data.data(), bytes, &bytes) != FALSE;
|
||||
}
|
||||
|
||||
bool RunningAsSystem() {
|
||||
Handle token;
|
||||
std::vector<unsigned char> user;
|
||||
return OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token.value) &&
|
||||
ReadTokenUser(token.value, user) &&
|
||||
IsWellKnownSid(reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid,
|
||||
WinLocalSystemSid);
|
||||
}
|
||||
|
||||
template <typename Value>
|
||||
bool QuerySessionValue(DWORD session, WTS_INFO_CLASS field, Value& value) {
|
||||
LPWSTR data = nullptr;
|
||||
DWORD bytes = 0;
|
||||
if (!WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session, field,
|
||||
&data, &bytes)) return false;
|
||||
const bool valid = data && bytes >= sizeof(Value);
|
||||
if (valid) value = *reinterpret_cast<const Value*>(data);
|
||||
WTSFreeMemory(data);
|
||||
return valid;
|
||||
}
|
||||
|
||||
bool IsInstalledClient(HANDLE process) {
|
||||
wchar_t service_path[32768]{}, client_path[32768]{};
|
||||
const DWORD length = GetModuleFileNameW(nullptr, service_path, ARRAYSIZE(service_path));
|
||||
DWORD capacity = ARRAYSIZE(client_path);
|
||||
if (!length || length >= ARRAYSIZE(service_path) ||
|
||||
!QueryFullProcessImageNameW(process, 0, client_path, &capacity)) return false;
|
||||
const auto expected = std::filesystem::path(service_path).parent_path() / L"crossdesk.exe";
|
||||
return _wcsicmp(expected.c_str(), client_path) == 0;
|
||||
}
|
||||
|
||||
bool VerifyOwner(HANDLE process, DWORD session) {
|
||||
Handle caller, owner;
|
||||
std::vector<unsigned char> caller_user, owner_user;
|
||||
DWORD token_session = 0xFFFFFFFF, bytes = 0;
|
||||
if (!OpenProcessToken(process, TOKEN_QUERY, &caller.value) ||
|
||||
!GetTokenInformation(caller.value, TokenSessionId, &token_session,
|
||||
sizeof(token_session), &bytes) || token_session != session ||
|
||||
!ReadTokenUser(caller.value, caller_user) ||
|
||||
!WTSQueryUserToken(session, &owner.value) || !ReadTokenUser(owner.value, owner_user))
|
||||
return false;
|
||||
return EqualSid(reinterpret_cast<TOKEN_USER*>(caller_user.data())->User.Sid,
|
||||
reinterpret_cast<TOKEN_USER*>(owner_user.data())->User.Sid) != FALSE;
|
||||
}
|
||||
|
||||
const char* ValidateSessions(DWORD session, bool observed_remote,
|
||||
DWORD& console_session) {
|
||||
console_session = WTSGetActiveConsoleSessionId();
|
||||
if (session == console_session) return nullptr;
|
||||
if (console_session == 0 || console_session == 0xFFFFFFFF)
|
||||
return "rdp_handoff_state_unavailable";
|
||||
|
||||
WTS_CONNECTSTATE_CLASS source_state{}, console_state{};
|
||||
USHORT protocol = 0;
|
||||
wchar_t source_user = 0, console_user = 0;
|
||||
// Only the first character is needed to distinguish an empty user name.
|
||||
if (!QuerySessionValue(session, WTSConnectState, source_state) ||
|
||||
(!observed_remote &&
|
||||
!QuerySessionValue(session, WTSClientProtocolType, protocol)) ||
|
||||
!QuerySessionValue(session, WTSUserName, source_user) || !source_user ||
|
||||
!QuerySessionValue(console_session, WTSUserName, console_user) ||
|
||||
!QuerySessionValue(console_session, WTSConnectState, console_state))
|
||||
return "rdp_handoff_state_unavailable";
|
||||
if (source_state != WTSDisconnected || (!observed_remote && protocol != 2))
|
||||
return "rdp_handoff_session_not_disconnected";
|
||||
// Even a locked console with the same account must not be displaced.
|
||||
if (console_user || (console_state != WTSConnected && console_state != WTSActive))
|
||||
return "rdp_handoff_console_busy";
|
||||
return nullptr;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool IsCurrentProcessSessionDisconnected() {
|
||||
DWORD session = 0xFFFFFFFF;
|
||||
WTS_CONNECTSTATE_CLASS state{};
|
||||
return ProcessIdToSessionId(GetCurrentProcessId(), &session) &&
|
||||
QuerySessionValue(session, WTSConnectState, state) && state == WTSDisconnected;
|
||||
}
|
||||
|
||||
RdpHandoffResult HandoffDisconnectedRdpSession(HANDLE pipe, DWORD client_session,
|
||||
bool observed_rdp_session) {
|
||||
if (!RunningAsSystem()) return {"rdp_handoff_requires_service"};
|
||||
if (client_session == 0 || client_session == 0xFFFFFFFF)
|
||||
return {"rdp_handoff_client_denied", ERROR_ACCESS_DENIED};
|
||||
ULONG pid = 0, pipe_session = 0xFFFFFFFF;
|
||||
if (!GetNamedPipeClientProcessId(pipe, &pid) ||
|
||||
!GetNamedPipeClientSessionId(pipe, &pipe_session))
|
||||
return {"rdp_handoff_client_denied", GetLastError()};
|
||||
if (pipe_session != client_session)
|
||||
return {"rdp_handoff_client_denied", ERROR_ACCESS_DENIED};
|
||||
Handle process{OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, pid)};
|
||||
if (!process.value) return {"rdp_handoff_process_query_failed", GetLastError()};
|
||||
if (!IsInstalledClient(process.value))
|
||||
return {"rdp_handoff_client_path_mismatch", ERROR_ACCESS_DENIED};
|
||||
if (!VerifyOwner(process.value, client_session))
|
||||
return {"rdp_handoff_owner_unverified", ERROR_ACCESS_DENIED};
|
||||
|
||||
DWORD console_session = 0xFFFFFFFF;
|
||||
if (const char* error = ValidateSessions(client_session, observed_rdp_session,
|
||||
console_session))
|
||||
return {error};
|
||||
if (client_session == console_session) return {nullptr, 0, true};
|
||||
|
||||
// Recheck immediately before acting: WTS enumeration and token queries can
|
||||
// span a logon, reconnect or console switch. Retain the process object by
|
||||
// handle to prevent PID reuse and refuse a change of console destination.
|
||||
if (WaitForSingleObject(process.value, 0) != WAIT_TIMEOUT ||
|
||||
!VerifyOwner(process.value, client_session))
|
||||
return {"rdp_handoff_client_denied", ERROR_ACCESS_DENIED};
|
||||
DWORD current_console = 0xFFFFFFFF;
|
||||
if (const char* error = ValidateSessions(client_session, observed_rdp_session,
|
||||
current_console))
|
||||
return {error};
|
||||
if (client_session == current_console) return {nullptr, 0, true};
|
||||
if (console_session != current_console)
|
||||
return {"rdp_handoff_console_changed"};
|
||||
|
||||
// Ask Windows to perform the same session routing as tscon /dest:console.
|
||||
// Asynchronous: the capture/status loops observe the resulting WTS change;
|
||||
// the single service IPC thread must remain available for status and input.
|
||||
wchar_t password[] = L"";
|
||||
if (!WTSConnectSessionW(client_session, current_console, password, FALSE))
|
||||
return {"rdp_handoff_connect_failed", GetLastError()};
|
||||
return {};
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-16
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _RDP_SESSION_HANDOFF_H_
|
||||
#define _RDP_SESSION_HANDOFF_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
namespace crossdesk {
|
||||
inline constexpr char kRdpHandoffCommand[] = "resume-rdp-session";
|
||||
|
||||
struct RdpHandoffResult {
|
||||
const char* error = nullptr;
|
||||
DWORD error_code = 0;
|
||||
bool already_console = false;
|
||||
};
|
||||
|
||||
// The session comes from GetNamedPipeClientSessionId, never request payloads.
|
||||
RdpHandoffResult HandoffDisconnectedRdpSession(HANDLE pipe, DWORD client_session,
|
||||
bool observed_rdp_session);
|
||||
bool IsCurrentProcessSessionDisconnected();
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "session_helper_shared.h"
|
||||
#include "unattended_config.h"
|
||||
#include "unattended_policy.h"
|
||||
#include "rdp_session_handoff.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -1021,6 +1022,24 @@ int CrossDeskServiceHost::InitializeRuntime() {
|
||||
if (!session_state_event_) return static_cast<int>(GetLastError());
|
||||
last_sas_tick_ = 0;
|
||||
active_session_id_ = WTSGetActiveConsoleSessionId();
|
||||
// A service started inside an existing RDP login may not receive its earlier
|
||||
// remote-connect notification. Seed currently identifiable RDP sessions.
|
||||
PWTS_SESSION_INFOW sessions = nullptr;
|
||||
DWORD session_count = 0;
|
||||
if (WTSEnumerateSessionsW(WTS_CURRENT_SERVER_HANDLE, 0, 1, &sessions, &session_count)) {
|
||||
for (DWORD index = 0; index < session_count; ++index) {
|
||||
LPWSTR protocol = nullptr;
|
||||
DWORD bytes = 0;
|
||||
if (WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, sessions[index].SessionId,
|
||||
WTSClientProtocolType, &protocol, &bytes)) {
|
||||
if (protocol && bytes >= sizeof(USHORT) &&
|
||||
*reinterpret_cast<USHORT*>(protocol) == 2)
|
||||
rdp_sessions_.insert(sessions[index].SessionId);
|
||||
WTSFreeMemory(protocol);
|
||||
}
|
||||
}
|
||||
WTSFreeMemory(sessions);
|
||||
}
|
||||
process_session_id_ = 0xFFFFFFFF;
|
||||
input_desktop_error_code_ = 0;
|
||||
session_helper_process_id_ = 0;
|
||||
@@ -1415,7 +1434,7 @@ void CrossDeskServiceHost::IpcServerLoop() {
|
||||
const std::string response =
|
||||
GetNamedPipeClientSessionId(pipe, &client_session_id)
|
||||
? HandleIpcCommand(std::string(buffer, buffer + bytes_read),
|
||||
client_session_id)
|
||||
client_session_id, pipe)
|
||||
: BuildErrorJson("client_session_query_failed", GetLastError());
|
||||
DWORD bytes_written = 0;
|
||||
if (pipe_deadline_detail::Transfer(
|
||||
@@ -2174,6 +2193,10 @@ void CrossDeskServiceHost::RecordSessionEvent(DWORD event_type,
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
last_session_event_type_ = event_type;
|
||||
if (event_type == WTS_REMOTE_CONNECT || event_type == WTS_REMOTE_DISCONNECT)
|
||||
rdp_sessions_.insert(session_id);
|
||||
else if (event_type == WTS_CONSOLE_CONNECT || event_type == WTS_SESSION_LOGOFF)
|
||||
rdp_sessions_.erase(session_id);
|
||||
++session_state_generation_;
|
||||
last_session_event_session_id_ = session_id;
|
||||
const DWORD previous_session_id = active_session_id_;
|
||||
@@ -2210,8 +2233,28 @@ void CrossDeskServiceHost::RecordSessionEvent(DWORD event_type,
|
||||
}
|
||||
|
||||
std::string CrossDeskServiceHost::HandleIpcCommand(const std::string& command,
|
||||
DWORD client_session_id) {
|
||||
DWORD client_session_id,
|
||||
HANDLE client_pipe) {
|
||||
std::string normalized = ToLower(Trim(command));
|
||||
if (normalized == kRdpHandoffCommand) {
|
||||
bool observed_rdp = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex_);
|
||||
observed_rdp = rdp_sessions_.count(client_session_id) != 0;
|
||||
}
|
||||
const auto result = HandoffDisconnectedRdpSession(client_pipe, client_session_id,
|
||||
observed_rdp);
|
||||
if (result.error) {
|
||||
LOG_WARN("RDP handoff refused: session_id={}, error={}, code={}",
|
||||
client_session_id, result.error, result.error_code);
|
||||
return BuildErrorJson(result.error, result.error_code);
|
||||
}
|
||||
LOG_INFO("RDP handoff accepted: session_id={}, already_console={}",
|
||||
client_session_id, result.already_console);
|
||||
WakeSessionState();
|
||||
return Json{{"ok", true}, {"rdp_handoff", result.already_console ?
|
||||
"already_console" : "requested"}, {"session_id", client_session_id}}.dump();
|
||||
}
|
||||
if (normalized == "ping") {
|
||||
return "{\"ok\":true,\"reply\":\"pong\"}";
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -72,7 +73,7 @@ class CrossDeskServiceHost {
|
||||
void RefreshSessionHelperReportedState();
|
||||
void RecordSessionEvent(DWORD event_type, DWORD session_id);
|
||||
std::string HandleIpcCommand(const std::string& command,
|
||||
DWORD client_session_id);
|
||||
DWORD client_session_id, HANDLE client_pipe);
|
||||
std::string BuildStatusResponse(DWORD client_session_id);
|
||||
std::string SendSecureAttentionSequence(DWORD client_session_id);
|
||||
struct SecureInputTarget {
|
||||
@@ -109,6 +110,8 @@ class CrossDeskServiceHost {
|
||||
DWORD unattended_session_ = 0xFFFFFFFF;
|
||||
std::atomic<uint64_t> unattended_logoff_generation_{0};
|
||||
std::mutex state_mutex_;
|
||||
// Preserve RDP ownership when Windows clears the protocol after disconnect.
|
||||
std::unordered_set<DWORD> rdp_sessions_;
|
||||
uint64_t session_state_generation_ = 0;
|
||||
ULONGLONG last_session_transition_tick_ = 0;
|
||||
bool session_lock_state_known_ = false;
|
||||
|
||||
@@ -48,7 +48,9 @@ InstanceResult SingleInstanceGuard::TryAcquire(InstanceRole role,
|
||||
std::wstring(role_name.begin(), role_name.end());
|
||||
LocalFree(sid);
|
||||
// Object lifetime is the lease. No thread-owned mutex to transfer to a daemon.
|
||||
HANDLE mutex = CreateMutexW(nullptr, FALSE, name.c_str());
|
||||
// CreateMutexW requests full access to an existing mutex, which can prevent
|
||||
// an unelevated launch from recognizing the same user's elevated instance.
|
||||
HANDLE mutex = CreateMutexExW(nullptr, name.c_str(), 0, SYNCHRONIZE);
|
||||
const DWORD code = GetLastError();
|
||||
if (!mutex) {
|
||||
error = "Cannot create single-instance mutex: " + std::to_string(code);
|
||||
|
||||
@@ -57,7 +57,7 @@ std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend();
|
||||
// Flush queued AppKit cleanup after joining the controller at app shutdown.
|
||||
void FlushMacPrivacyTasks();
|
||||
#endif
|
||||
#ifdef __linux__
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend();
|
||||
#endif
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ PrivacyController::PrivacyController(Factory factory) {
|
||||
if (!factory) factory = CreateWindowsPrivacyBackend;
|
||||
#elif defined(__APPLE__)
|
||||
if (!factory) factory = CreateMacPrivacyBackend;
|
||||
#elif defined(__linux__)
|
||||
#elif (defined(__linux__) && !defined(__OHOS__))
|
||||
if (!factory) factory = CreateLinuxPrivacyBackend;
|
||||
#endif
|
||||
thread_ = std::thread([this, factory] { Run(factory); });
|
||||
|
||||
@@ -1,119 +1,31 @@
|
||||
#include "file_transfer.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
|
||||
#include <cstdlib>
|
||||
#include "rd_log.h"
|
||||
#if defined(__OHOS__)
|
||||
#include "platform/path_backend.h"
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
namespace {
|
||||
std::atomic<uint32_t> g_next_file_id{1};
|
||||
} // namespace
|
||||
|
||||
uint32_t FileSender::NextFileId() { return g_next_file_id.fetch_add(1); }
|
||||
|
||||
int FileSender::SendFile(const std::filesystem::path& path,
|
||||
const std::string& label, const SendFunc& send,
|
||||
std::size_t chunk_size, uint32_t file_id) {
|
||||
if (!send) {
|
||||
LOG_ERROR("FileSender::SendFile: send function is empty");
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(path, ec) ||
|
||||
!std::filesystem::is_regular_file(path, ec)) {
|
||||
LOG_ERROR("FileSender::SendFile: file [{}] not found or not regular",
|
||||
path.string().c_str());
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint64_t total_size = std::filesystem::file_size(path, ec);
|
||||
if (ec) {
|
||||
LOG_ERROR("FileSender::SendFile: failed to get size of [{}]: {}",
|
||||
path.string().c_str(), ec.message().c_str());
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::ifstream ifs(path, std::ios::binary);
|
||||
if (!ifs.is_open()) {
|
||||
LOG_ERROR("FileSender::SendFile: failed to open [{}]",
|
||||
path.string().c_str());
|
||||
return -1;
|
||||
}
|
||||
LOG_INFO("FileSender send file {}, total size {}, file_id={}",
|
||||
path.string().c_str(), total_size, file_id);
|
||||
|
||||
if (file_id == 0) {
|
||||
file_id = NextFileId();
|
||||
}
|
||||
const uint32_t final_file_id = file_id;
|
||||
uint64_t offset = 0;
|
||||
bool is_first = true;
|
||||
std::string file_name = label.empty() ? path.filename().string() : label;
|
||||
|
||||
std::vector<char> buffer;
|
||||
buffer.resize(chunk_size);
|
||||
|
||||
while (ifs && offset < total_size) {
|
||||
uint64_t remaining = total_size - offset;
|
||||
uint32_t to_read =
|
||||
static_cast<uint32_t>(std::min<uint64_t>(remaining, chunk_size));
|
||||
|
||||
ifs.read(buffer.data(), static_cast<std::streamsize>(to_read));
|
||||
std::streamsize bytes_read = ifs.gcount();
|
||||
if (bytes_read <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
bool is_last = (offset + static_cast<uint64_t>(bytes_read) >= total_size);
|
||||
const std::string* name_ptr = is_first ? &file_name : nullptr;
|
||||
|
||||
std::vector<char> chunk = BuildChunk(
|
||||
final_file_id, offset, total_size, buffer.data(),
|
||||
static_cast<uint32_t>(bytes_read), name_ptr, is_first, is_last);
|
||||
|
||||
int ret = send(chunk.data(), chunk.size());
|
||||
if (ret != 0) {
|
||||
LOG_ERROR("FileSender::SendFile: send failed for [{}], ret={}",
|
||||
path.string().c_str(), ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
offset += static_cast<uint64_t>(bytes_read);
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::vector<char> FileSender::BuildChunk(uint32_t file_id, uint64_t offset,
|
||||
uint64_t total_size, const char* data,
|
||||
uint32_t data_size,
|
||||
const std::string* file_name,
|
||||
bool is_first, bool is_last) {
|
||||
return EncodeFileChunk(file_id, offset, total_size, data,
|
||||
data_size, file_name, is_first, is_last);
|
||||
}
|
||||
|
||||
// ---------- FileReceiver ----------
|
||||
|
||||
FileReceiver::FileReceiver() : output_dir_(GetDefaultDesktopPath()) {}
|
||||
FileReceiver::FileReceiver() : FileReceiver(GetDefaultDesktopPath()) {}
|
||||
|
||||
FileReceiver::FileReceiver(const std::filesystem::path& output_dir)
|
||||
: output_dir_(output_dir) {
|
||||
std::error_code ec;
|
||||
if (!output_dir_.empty()) {
|
||||
std::filesystem::create_directories(output_dir_, ec);
|
||||
if (ec) {
|
||||
LOG_ERROR("FileReceiver: failed to create output dir [{}]: {}",
|
||||
output_dir_.string().c_str(), ec.message().c_str());
|
||||
}
|
||||
}
|
||||
: output_dir_(output_dir), session_(
|
||||
[this](const char*, const char* data, size_t size) {
|
||||
FileTransferAck ack{};
|
||||
return on_send_ack_ && DecodeFileTransferAck(data, size, &ack)
|
||||
? on_send_ack_(ack) : 0;
|
||||
}, {}) {
|
||||
session_.SetDirectory(output_dir);
|
||||
}
|
||||
|
||||
std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
|
||||
#if defined(__OHOS__)
|
||||
// Harmony has no process HOME/Desktop. Use writable application storage
|
||||
// until the user selects and authorizes a public directory in settings.
|
||||
const auto desktop_path = platform::GetCachePath("CrossDesk") / "Received Files";
|
||||
#else
|
||||
#ifdef _WIN32
|
||||
const char* home_env = std::getenv("USERPROFILE");
|
||||
#else
|
||||
@@ -125,6 +37,7 @@ std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
|
||||
|
||||
std::filesystem::path desktop_path =
|
||||
std::filesystem::path(home_env) / "Desktop";
|
||||
#endif
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(desktop_path, ec);
|
||||
@@ -136,117 +49,4 @@ std::filesystem::path FileReceiver::GetDefaultDesktopPath() {
|
||||
return desktop_path;
|
||||
}
|
||||
|
||||
bool FileReceiver::OnData(const char* data, size_t size) {
|
||||
FileChunkView chunk;
|
||||
if (!DecodeFileChunk(data, size, &chunk)) {
|
||||
LOG_ERROR("FileReceiver::OnData: invalid buffer");
|
||||
return false;
|
||||
}
|
||||
const std::string* file_name =
|
||||
chunk.file_name.empty() ? nullptr : &chunk.file_name;
|
||||
return HandleChunk(chunk.header, chunk.payload, chunk.payload_size,
|
||||
file_name);
|
||||
}
|
||||
|
||||
bool FileReceiver::HandleChunk(const FileChunkHeader& header,
|
||||
const char* payload, size_t payload_size,
|
||||
const std::string* file_name) {
|
||||
auto it = contexts_.find(header.file_id);
|
||||
if (it == contexts_.end()) {
|
||||
// new file context must start with first chunk.
|
||||
if ((header.flags & 0x01) == 0) {
|
||||
LOG_ERROR("FileReceiver: received non-first chunk for unknown file_id={}",
|
||||
header.file_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
FileContext ctx;
|
||||
ctx.total_size = header.total_size;
|
||||
|
||||
std::string filename;
|
||||
if (file_name && !file_name->empty()) {
|
||||
filename = *file_name;
|
||||
} else {
|
||||
filename = "received_" + std::to_string(header.file_id);
|
||||
}
|
||||
|
||||
ctx.file_name = filename;
|
||||
|
||||
std::filesystem::path save_path = output_dir_.empty()
|
||||
? std::filesystem::path(filename)
|
||||
: output_dir_ / filename;
|
||||
|
||||
// if file exists, append timestamp.
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(save_path, ec)) {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto ts = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now.time_since_epoch())
|
||||
.count();
|
||||
save_path = save_path.parent_path() /
|
||||
(save_path.stem().string() + "_" + std::to_string(ts) +
|
||||
save_path.extension().string());
|
||||
}
|
||||
|
||||
ctx.ofs.open(save_path, std::ios::binary | std::ios::trunc);
|
||||
if (!ctx.ofs.is_open()) {
|
||||
LOG_ERROR("FileReceiver: failed to open [{}] for writing",
|
||||
save_path.string().c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
contexts_.emplace(header.file_id, std::move(ctx));
|
||||
it = contexts_.find(header.file_id);
|
||||
}
|
||||
|
||||
FileContext& ctx = it->second;
|
||||
|
||||
if (payload_size > 0 && payload) {
|
||||
ctx.ofs.seekp(static_cast<std::streamoff>(header.offset), std::ios::beg);
|
||||
ctx.ofs.write(payload, static_cast<std::streamsize>(payload_size));
|
||||
if (!ctx.ofs.good()) {
|
||||
LOG_ERROR("FileReceiver: write failed for file_id={}", header.file_id);
|
||||
return false;
|
||||
}
|
||||
ctx.received += static_cast<uint64_t>(payload_size);
|
||||
}
|
||||
|
||||
// Send ACK after processing chunk
|
||||
if (on_send_ack_) {
|
||||
FileTransferAck ack{};
|
||||
ack.magic = kFileAckMagic;
|
||||
ack.file_id = header.file_id;
|
||||
ack.acked_offset = header.offset + static_cast<uint64_t>(payload_size);
|
||||
ack.total_size = header.total_size;
|
||||
ack.flags = 0;
|
||||
|
||||
bool is_last = (header.flags & 0x02) != 0;
|
||||
if (is_last || ctx.received >= ctx.total_size) {
|
||||
ack.flags |= 0x01; // completed
|
||||
}
|
||||
|
||||
int ret = on_send_ack_(ack);
|
||||
if (ret != 0) {
|
||||
LOG_ERROR("FileReceiver: failed to send ACK for file_id={}, ret={}",
|
||||
header.file_id, ret);
|
||||
}
|
||||
}
|
||||
|
||||
bool is_last = (header.flags & 0x02) != 0;
|
||||
if (is_last || ctx.received >= ctx.total_size) {
|
||||
ctx.ofs.close();
|
||||
|
||||
std::filesystem::path saved_path =
|
||||
output_dir_.empty() ? std::filesystem::path(ctx.file_name)
|
||||
: output_dir_ / ctx.file_name;
|
||||
|
||||
LOG_INFO("FileReceiver: file received complete, file_id={}, size={}",
|
||||
header.file_id, ctx.received);
|
||||
|
||||
contexts_.erase(header.file_id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -1,99 +1,46 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2025-12-18
|
||||
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _FILE_TRANSFER_H_
|
||||
#define _FILE_TRANSFER_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <file_transfer_format.h>
|
||||
#include <stream_names.h>
|
||||
#include <file_transfer_core.h>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class FileSender {
|
||||
public:
|
||||
using SendFunc = std::function<int(const char* data, size_t size)>;
|
||||
|
||||
public:
|
||||
FileSender() = default;
|
||||
|
||||
// generate a new file id
|
||||
static uint32_t NextFileId();
|
||||
|
||||
// synchronously send a file using the provided send function.
|
||||
// `path` : full path to the local file.
|
||||
// `label` : logical filename to send (usually path.filename()).
|
||||
// `send` : callback that pushes one encoded chunk into the data channel.
|
||||
// `file_id` : file id to use (0 means auto-generate).
|
||||
// Return 0 on success, <0 on error.
|
||||
int SendFile(const std::filesystem::path& path, const std::string& label,
|
||||
const SendFunc& send,
|
||||
std::size_t chunk_size = kFileChunkSize,
|
||||
uint32_t file_id = 0);
|
||||
|
||||
// Build a single encoded chunk buffer using the FileChunkHeader wire format.
|
||||
static std::vector<char> BuildChunk(uint32_t file_id, uint64_t offset,
|
||||
uint64_t total_size, const char* data,
|
||||
uint32_t data_size,
|
||||
const std::string* file_name,
|
||||
bool is_first, bool is_last);
|
||||
};
|
||||
|
||||
// Desktop destination policy; protocol, assembly, ACKs and cleanup live in the
|
||||
// same engine used by the mobile adapter.
|
||||
class FileReceiver {
|
||||
public:
|
||||
struct FileContext {
|
||||
std::string file_name;
|
||||
uint64_t total_size = 0;
|
||||
uint64_t received = 0;
|
||||
std::ofstream ofs;
|
||||
};
|
||||
|
||||
using OnFileComplete =
|
||||
std::function<void(const std::filesystem::path& saved_path)>;
|
||||
using OnSendAck = std::function<int(const FileTransferAck& ack)>;
|
||||
|
||||
public:
|
||||
// save to default desktop directory.
|
||||
using OnSendAck = std::function<int(const FileTransferAck&)>;
|
||||
FileReceiver();
|
||||
|
||||
// save to a specified directory.
|
||||
explicit FileReceiver(const std::filesystem::path& output_dir);
|
||||
|
||||
// process one received data buffer (one chunk).
|
||||
// return true if parsed and processed successfully, false otherwise.
|
||||
bool OnData(const char* data, size_t size);
|
||||
|
||||
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = cb; }
|
||||
|
||||
const std::filesystem::path& OutputDir() const { return output_dir_; }
|
||||
|
||||
bool OnData(const char* data, size_t size) { return session_.Receive(data, size); }
|
||||
void Poll() { session_.Pump(); }
|
||||
void Cancel() { session_.Cancel(); }
|
||||
// Set once, before the receiver is published to transport callbacks.
|
||||
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = std::move(cb); }
|
||||
std::filesystem::path OutputDir() const {
|
||||
std::lock_guard lock(directory_mutex_);
|
||||
return output_dir_;
|
||||
}
|
||||
void SetOutputDir(const std::filesystem::path& dir) {
|
||||
std::lock_guard lock(directory_mutex_);
|
||||
output_dir_ = dir;
|
||||
if (!output_dir_.empty()) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(output_dir_, ec);
|
||||
}
|
||||
session_.SetDirectory(dir);
|
||||
}
|
||||
|
||||
private:
|
||||
static std::filesystem::path GetDefaultDesktopPath();
|
||||
|
||||
bool HandleChunk(const FileChunkHeader& header, const char* payload,
|
||||
size_t payload_size, const std::string* file_name);
|
||||
|
||||
private:
|
||||
mutable std::mutex directory_mutex_;
|
||||
std::filesystem::path output_dir_;
|
||||
std::unordered_map<uint32_t, FileContext> contexts_;
|
||||
OnSendAck on_send_ack_ = nullptr;
|
||||
OnSendAck on_send_ack_;
|
||||
file_transfer::Session session_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -122,7 +122,11 @@ std::optional<VersionInfo> CheckUpdate() {
|
||||
const auto manifest = nlohmann::json::parse(res->body);
|
||||
auto info = ParseVersionInfo(manifest);
|
||||
if (info) {
|
||||
#if defined(__linux__)
|
||||
#if defined(__OHOS__)
|
||||
// A Linux package cannot be installed by Harmony's package manager.
|
||||
// Until the manifest has a Harmony artifact, use the release website.
|
||||
info->download_url = "https://crossdesk.cn";
|
||||
#elif defined(__linux__)
|
||||
std::string architecture;
|
||||
#if defined(__x86_64__)
|
||||
architecture = "amd64";
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
#include "crossdesk_ui.h"
|
||||
#include "slint_render_test_platform.h"
|
||||
#include "ui/model_update.h"
|
||||
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
namespace {
|
||||
bool Check(bool value, const char* message) {
|
||||
if (!value) std::cerr << message << '\n';
|
||||
return value;
|
||||
}
|
||||
|
||||
bool VerifyStreamPopups(RenderTestPlatform& platform) {
|
||||
auto stream = crossdesk::ui::StreamWindow::create();
|
||||
auto& screen = *platform.adapter;
|
||||
stream->window().set_size(slint::LogicalSize({1280, 720}));
|
||||
stream->set_video_settings_enabled(true);
|
||||
stream->show();
|
||||
const int top = int(stream->get_video_area_top());
|
||||
auto displays = std::make_shared<slint::VectorModel<slint::SharedString>>();
|
||||
auto transfers = std::make_shared<slint::VectorModel<crossdesk::ui::FileTransferEntry>>();
|
||||
stream->set_displays(displays);
|
||||
stream->set_file_transfers(transfers);
|
||||
bool file_visible = false;
|
||||
unsigned ticks = 0;
|
||||
slint::Timer sync;
|
||||
sync.start(slint::TimerMode::Repeated, 16ms, [&] {
|
||||
// Match the live session's model synchronization, including long clicks.
|
||||
crossdesk::ui::UpdateModelRows(*displays,
|
||||
std::vector<slint::SharedString>{"Display 1", "Display 2", "Display 3"});
|
||||
crossdesk::ui::FileTransferEntry entry{};
|
||||
entry.name = "test.txt"; entry.progress = (++ticks % 100) / 100.f;
|
||||
crossdesk::ui::UpdateModelRows(*transfers, std::vector(8, entry));
|
||||
stream->set_file_transfer_visible(file_visible);
|
||||
});
|
||||
int remote_presses = 0, remote_releases = 0, remote_scrolls = 0;
|
||||
stream->on_pointer_input([&](auto, auto kind, float, float) {
|
||||
if (kind == slint::PointerEventKind::Down) ++remote_presses;
|
||||
if (kind == slint::PointerEventKind::Up) ++remote_releases;
|
||||
});
|
||||
stream->on_scroll_input([&](float, float, float, float) { ++remote_scrolls; });
|
||||
int selected_display = -1;
|
||||
stream->on_switch_display([&](int index) { selected_display = index; });
|
||||
std::string shortcut;
|
||||
stream->on_send_shortcut([&](slint::SharedString text) { shortcut = text; });
|
||||
int video_changes = 0;
|
||||
stream->on_change_video_settings([&](int quality, int rate, int policy) {
|
||||
stream->set_video_quality_index(quality);
|
||||
stream->set_frame_rate_index(rate);
|
||||
stream->set_adaptation_policy_index(policy);
|
||||
++video_changes;
|
||||
});
|
||||
stream->on_select_file([&] { file_visible = true; });
|
||||
bool close_synchronously = false;
|
||||
stream->on_close_file_transfer([&] {
|
||||
// The real application hides this on its next synchronization tick.
|
||||
file_visible = false;
|
||||
if (close_synchronously) stream->set_file_transfer_visible(false);
|
||||
});
|
||||
screen.Settle();
|
||||
for (int repeat = 0; repeat < 2; ++repeat) {
|
||||
screen.Click(21, top + 19);
|
||||
if (!Check(screen.Light(12, top + 128), "Display menu must fully open without pointer movement")) return false;
|
||||
screen.Click(60, top + 86, 4);
|
||||
if (!Check(selected_display == 1 && screen.Black(12, top + 128),
|
||||
"Display selection must survive live refresh and close the menu")) return false;
|
||||
screen.Click(53, top + 19);
|
||||
if (!Check(screen.Light(44, top + 102), "Shortcut menu must open without pointer movement")) return false;
|
||||
screen.Click(70, top + 88, 4);
|
||||
if (!Check(shortcut == "Win+L" && screen.Black(44, top + 102),
|
||||
"Shortcut selection must close the menu immediately")) return false;
|
||||
screen.Click(213, top + 19);
|
||||
if (!Check(stream->get_video_menu_open() && screen.Light(203, top + 210),
|
||||
"Video settings menu must fully open without pointer movement")) return false;
|
||||
screen.Click(240, top + 80, 4);
|
||||
screen.Click(900, top + 300);
|
||||
if (!Check(!stream->get_video_menu_open() && video_changes == repeat + 1,
|
||||
"Video selection and outside dismissal must respond immediately")) return false;
|
||||
}
|
||||
if (!Check(remote_presses == 0 && remote_releases == 0 && remote_scrolls == 0,
|
||||
"Menu gestures must not be forwarded to the peer")) return false;
|
||||
screen.Click(181, top + 19);
|
||||
const int panel_y = top + int(stream->get_video_area_height()) - 210;
|
||||
if (!Check(file_visible && screen.Light(20, panel_y + 25),
|
||||
"File progress must appear without pointer movement")) return false;
|
||||
// Header/padding is outside the list's own input handler.
|
||||
screen.Click(25, panel_y + 20, 4);
|
||||
stream->window().dispatch_pointer_scroll_event(
|
||||
slint::LogicalPosition({25, float(panel_y + 20)}), 0, -60);
|
||||
screen.Settle();
|
||||
if (!Check(remote_presses == 0 && remote_releases == 0 && remote_scrolls == 0,
|
||||
"File progress header and padding must consume input locally")) return false;
|
||||
const slint::LogicalPosition panel_point({25, float(panel_y + 20)});
|
||||
const slint::LogicalPosition outside({900, float(top + 300)});
|
||||
stream->window().dispatch_pointer_press_event(panel_point, slint::PointerEventButton::Left);
|
||||
screen.Settle(4);
|
||||
stream->window().dispatch_pointer_move_event(outside);
|
||||
stream->window().dispatch_pointer_release_event(outside, slint::PointerEventButton::Left);
|
||||
screen.Settle();
|
||||
if (!Check(remote_presses == 0 && remote_releases == 0,
|
||||
"Dragging out of the file panel must not release a button on the peer")) return false;
|
||||
screen.Click(430, panel_y + 16, 4);
|
||||
if (!Check(!file_visible && screen.Black(20, panel_y + 25),
|
||||
"File progress close must clear its pixels without pointer movement")) return false;
|
||||
if (!Check(remote_presses == 0 && remote_releases == 0,
|
||||
"Closing file progress must not send an orphan pointer release")) return false;
|
||||
close_synchronously = true;
|
||||
screen.Click(181, top + 19);
|
||||
screen.Click(430, panel_y + 16, 4);
|
||||
if (!Check(!file_visible && screen.Black(20, panel_y + 25) && remote_releases == 0,
|
||||
"Synchronous file panel dismissal must consume its closing release")) return false;
|
||||
screen.Click(900, top + 300);
|
||||
return Check(remote_presses == 1 && remote_releases == 1,
|
||||
"Closing local menus must restore remote input");
|
||||
}
|
||||
|
||||
bool VerifyWorkbenchPopups(RenderTestPlatform& platform) {
|
||||
auto main = crossdesk::ui::MainWindow::create();
|
||||
auto& screen = *platform.adapter;
|
||||
const float width = main->get_minimum_window_width();
|
||||
const float height = main->get_minimum_window_height();
|
||||
main->window().set_size(slint::LogicalSize({width, height}));
|
||||
main->show();
|
||||
auto groups = std::make_shared<slint::VectorModel<slint::SharedString>>(
|
||||
std::vector<slint::SharedString>{"All", "Ungrouped", "Lab"});
|
||||
main->set_recent_groups(groups);
|
||||
int selected_group = -1;
|
||||
main->on_recent_group_selected([&](int index) { selected_group = index; });
|
||||
screen.Settle();
|
||||
const auto filter_closed = screen.Region(width - 170, 302, 140, 70);
|
||||
screen.Click(width - 90, 276);
|
||||
if (!Check(screen.Region(width - 170, 302, 140, 70) != filter_closed,
|
||||
"Group filter must reveal its contents while the pointer stays on the trigger")) return false;
|
||||
screen.Key(slint::platform::key_codes::Escape);
|
||||
if (!Check(screen.Region(width - 170, 302, 140, 70) == filter_closed,
|
||||
"Escape must dismiss the group filter")) return false;
|
||||
screen.Click(width - 90, 276);
|
||||
screen.Click(width - 90, 371, 4);
|
||||
if (!Check(selected_group == 2 && main->get_recent_group_index() == 2,
|
||||
"Group selection must survive a held click")) return false;
|
||||
screen.Click(width - 38, 20);
|
||||
screen.Click(width - 78, 56, 4);
|
||||
if (!Check(main->get_settings_open(), "Application menu must open settings on click")) return false;
|
||||
const float settings_y = (height - 446) / 2;
|
||||
screen.Click(width / 2 + 87, settings_y + 76, 4);
|
||||
if (!Check(main->get_settings_tab() == 1, "Settings tabs must switch without pointer movement")) return false;
|
||||
screen.Click(width / 2 - 87, settings_y + 76, 4);
|
||||
if (!Check(main->get_settings_tab() == 0, "Settings tabs must switch back immediately")) return false;
|
||||
// The first row uses the same CompactComboBox as codec/capture settings.
|
||||
const int combo_x = int(width / 2 + 100);
|
||||
const int combo_y = int(settings_y + 150);
|
||||
const int popup_y = int(settings_y + 164);
|
||||
const auto combo_closed = screen.Region(combo_x - 35, popup_y + 2, 60, 60);
|
||||
screen.Click(combo_x, combo_y);
|
||||
if (!Check(screen.Region(combo_x - 35, popup_y + 2, 60, 60) != combo_closed,
|
||||
"Settings dropdown must animate open without pointer movement")) return false;
|
||||
screen.Key(slint::platform::key_codes::Escape);
|
||||
if (!Check(screen.Region(combo_x - 35, popup_y + 2, 60, 60) == combo_closed,
|
||||
"Escape must dismiss the settings dropdown")) return false;
|
||||
screen.Click(combo_x, combo_y);
|
||||
screen.Click(combo_x, popup_y + 34, 4);
|
||||
if (!Check(main->get_language_index() == 1, "Settings dropdown must apply a selected row")) return false;
|
||||
// A session starting while a codec menu is open disables the setting.
|
||||
// Existing popup rows must not remain actionable after that transition.
|
||||
const int codec_y = int(settings_y + 224);
|
||||
screen.Click(combo_x, codec_y);
|
||||
main->set_settings_session_active(true);
|
||||
screen.Settle();
|
||||
screen.Click(combo_x, codec_y + 48, 4);
|
||||
if (!Check(main->get_codec_index() == 0,
|
||||
"A disabled codec dropdown must close and reject its stale options")) return false;
|
||||
main->set_settings_session_active(false);
|
||||
screen.Click(width / 2 + 32, settings_y + 412);
|
||||
if (!Check(!main->get_settings_open(), "Settings Cancel must close the dialog immediately")) return false;
|
||||
screen.Click(width - 38, 20);
|
||||
screen.Click(width - 78, 86, 4);
|
||||
if (!Check(main->get_messages_open(), "Messages must open directly from the application menu")) return false;
|
||||
screen.Key(slint::platform::key_codes::Escape);
|
||||
if (!Check(!main->get_messages_open(), "Escape must dismiss Messages")) return false;
|
||||
screen.Click(width - 38, 20);
|
||||
screen.Click(width - 78, 132, 4);
|
||||
if (!Check(main->get_about_open(), "About must open directly from the application menu")) return false;
|
||||
screen.Key(slint::platform::key_codes::Escape);
|
||||
return Check(!main->get_about_open(), "Escape must dismiss About");
|
||||
}
|
||||
|
||||
bool VerifyAutostartSettings(RenderTestPlatform& platform) {
|
||||
auto main = crossdesk::ui::MainWindow::create();
|
||||
auto& screen = *platform.adapter;
|
||||
using Status = crossdesk::ui::AutostartStatus;
|
||||
const float width = main->get_minimum_window_width();
|
||||
const float height = main->get_minimum_window_height();
|
||||
main->window().set_size(slint::LogicalSize({width, height}));
|
||||
main->set_autostart_system_managed(true);
|
||||
main->set_autostart_enabled(true); // Stale app preference must not label system state.
|
||||
main->set_autostart_status(Status::Disabled);
|
||||
int refreshes = 0;
|
||||
main->on_refresh_autostart_status([&] { ++refreshes; });
|
||||
main->show(); main->set_settings_open(true); screen.Settle();
|
||||
if (!Check(refreshes == 1, "Opening managed autostart settings must query the system")) return false;
|
||||
const float top = (height - 446) / 2;
|
||||
main->window().dispatch_pointer_scroll_event(slint::LogicalPosition({width / 2, top + 260}), 0, -1200);
|
||||
screen.Settle();
|
||||
const float toggle_x = width / 2 + 144, toggle_y = top + 322;
|
||||
const auto toggle_pixels = [&] {
|
||||
return screen.Region(int(toggle_x - 18), int(toggle_y - 10), 36, 20);
|
||||
};
|
||||
const auto save_snapshot = [&](const std::string& suffix) {
|
||||
if (const char* snapshot = std::getenv("CROSSDESK_AUTOSTART_UI_SNAPSHOT")) {
|
||||
std::ofstream out(std::string(snapshot) + suffix, std::ios::binary);
|
||||
out << "P6\n" << screen.extent.width << ' ' << screen.extent.height << "\n255\n";
|
||||
for (const auto pixel : screen.pixels) { out.put(pixel.r); out.put(pixel.g); out.put(pixel.b); }
|
||||
}
|
||||
};
|
||||
const auto disabled = toggle_pixels();
|
||||
save_snapshot(".settings.ppm");
|
||||
main->set_autostart_status(Status::Enabled); screen.Settle();
|
||||
const auto enabled = toggle_pixels();
|
||||
if (!Check(enabled != disabled,
|
||||
"The autostart switch must reflect system changes while settings stay open")) return false;
|
||||
main->set_autostart_status(Status::Disabled); screen.Settle();
|
||||
screen.Click(toggle_x, toggle_y);
|
||||
if (!Check(main->get_autostart_guide_open() && main->get_autostart_status() == Status::Disabled &&
|
||||
main->get_autostart_enabled(),
|
||||
"Clicking the disabled system switch opens guidance without changing state or preference")) return false;
|
||||
screen.Key(slint::platform::key_codes::Escape);
|
||||
if (!Check(toggle_pixels() == disabled, "The system switch must not flip when guidance closes")) return false;
|
||||
screen.Click(toggle_x, toggle_y);
|
||||
// Both actions stay below the scrollable instructions at minimum size.
|
||||
const auto button_y = [&]() {
|
||||
const int x = int(width / 2 + 120), y = int(height / 2 + 158);
|
||||
const auto p = screen.pixels.at(y * screen.extent.width + x);
|
||||
return p.b > 180 && p.r < 90 && p.g > 80 && p.g < 160 ? y : -1;
|
||||
};
|
||||
int refresh_y = button_y();
|
||||
if (!Check(refresh_y > 0, "Autostart guide buttons must fit inside the minimum window")) return false;
|
||||
save_snapshot("");
|
||||
screen.Click(width / 2 - 145, refresh_y);
|
||||
if (!Check(refreshes == 2, "Autostart guide Refresh must query the actual system")) return false;
|
||||
main->set_autostart_status(Status::Checking); screen.Settle();
|
||||
screen.Click(width / 2 - 145, button_y());
|
||||
if (!Check(refreshes == 2, "Refresh is disabled while the system query is pending")) return false;
|
||||
for (const auto status : {Status::Failed, Status::Unsupported, Status::Enabled}) {
|
||||
main->set_autostart_status(status); screen.Settle();
|
||||
if (!Check(button_y() > 0, "Error and unsupported states must remain readable and dismissible")) return false;
|
||||
}
|
||||
screen.Key(slint::platform::key_codes::Escape);
|
||||
if (!Check(!main->get_autostart_guide_open() && main->get_settings_open(),
|
||||
"Escape closes the guide without cancelling other settings")) return false;
|
||||
if (!Check(toggle_pixels() == enabled, "The switch must still follow system state after opening guidance")) return false;
|
||||
screen.Click(toggle_x, toggle_y);
|
||||
if (!Check(main->get_autostart_guide_open() && main->get_autostart_status() == Status::Enabled,
|
||||
"Clicking the enabled system switch must also open guidance without changing state")) return false;
|
||||
screen.Click(width / 2 + 160, button_y());
|
||||
if (!Check(!main->get_autostart_guide_open(), "The guide confirmation must close immediately")) return false;
|
||||
if (!Check(toggle_pixels() == enabled, "Dismissing guidance must keep the system switch enabled")) return false;
|
||||
main->set_autostart_guide_open(true); main->set_settings_open(false); screen.Settle();
|
||||
if (!Check(!main->get_autostart_guide_open(), "Closing settings must also dismiss its guide")) return false;
|
||||
main->set_autostart_system_managed(false);
|
||||
main->set_settings_open(true); screen.Settle();
|
||||
main->window().dispatch_pointer_scroll_event(slint::LogicalPosition({width / 2, top + 260}), 0, -1200);
|
||||
screen.Settle();
|
||||
screen.Click(toggle_x, toggle_y);
|
||||
return Check(refreshes == 2 && !main->get_autostart_enabled() && !main->get_autostart_guide_open(),
|
||||
"Other platforms toggle their autostart preference directly without querying Harmony or opening guidance");
|
||||
}
|
||||
|
||||
bool VerifyGalleryPopups(RenderTestPlatform& platform) {
|
||||
auto wall = crossdesk::ui::MainWindow::create();
|
||||
auto& screen = *platform.adapter;
|
||||
wall->set_gallery_mode(true);
|
||||
wall->window().set_size(slint::LogicalSize({900, 600}));
|
||||
wall->set_gallery_count(8);
|
||||
wall->set_gallery_connected(8);
|
||||
wall->set_gallery_groups(std::make_shared<slint::VectorModel<slint::SharedString>>(
|
||||
std::vector<slint::SharedString>{"All", "Lab"}));
|
||||
auto network = std::make_shared<slint::VectorModel<crossdesk::ui::GalleryNetworkDevice>>();
|
||||
wall->set_gallery_network_devices(network);
|
||||
wall->show();
|
||||
unsigned ticks = 0;
|
||||
slint::Timer sync;
|
||||
sync.start(slint::TimerMode::Repeated, 16ms, [&] {
|
||||
crossdesk::ui::GalleryNetworkDevice row{};
|
||||
row.id = "123456789"; row.name = "Test device";
|
||||
row.inbound = slint::SharedString(std::to_string(++ticks) + " kbps");
|
||||
crossdesk::ui::UpdateModelRows(*network, std::vector(8, row));
|
||||
});
|
||||
screen.Settle();
|
||||
const auto baseline = screen.Region(75, 100, 740, 400);
|
||||
for (int repeat = 0; repeat < 2; ++repeat) {
|
||||
screen.Click(160, 578);
|
||||
if (!Check(wall->get_gallery_network_open() && screen.Region(75, 100, 740, 400) != baseline,
|
||||
"DeskHub network dialog must paint immediately during live refresh")) return false;
|
||||
if (repeat == 0) screen.Click(780, 102, 4);
|
||||
else screen.Key(slint::platform::key_codes::Escape);
|
||||
if (!Check(!wall->get_gallery_network_open() && screen.Region(75, 100, 740, 400) == baseline,
|
||||
"DeskHub network dialog must close completely without pointer movement")) return false;
|
||||
}
|
||||
// Open the add menu, leave the pointer on it, then select its first row.
|
||||
const auto menu_closed = screen.Region(490, 42, 220, 60);
|
||||
screen.Click(680, 24);
|
||||
if (!Check(screen.Region(490, 42, 220, 60) != menu_closed,
|
||||
"DeskHub add menu must appear without pointer movement")) return false;
|
||||
screen.Click(600, 55, 4);
|
||||
if (!Check(wall->get_gallery_adding(), "Add menu must open the device dialog")) return false;
|
||||
screen.Click(458, 421, 4);
|
||||
return Check(!wall->get_gallery_adding(), "Add dialog Cancel must respond immediately");
|
||||
}
|
||||
|
||||
bool VerifyServerPanel(RenderTestPlatform& platform) {
|
||||
auto panel = crossdesk::ui::ServerWindow::create();
|
||||
auto& screen = *platform.adapter;
|
||||
panel->set_controllers(std::make_shared<slint::VectorModel<crossdesk::ui::ControllerEntry>>(
|
||||
std::vector<crossdesk::ui::ControllerEntry>{{"1", "First"}, {"2", "Second"}}));
|
||||
panel->window().set_size(slint::LogicalSize({225, panel->get_content_height()}));
|
||||
panel->show();
|
||||
int selected = -1, disconnected = 0, picked = 0;
|
||||
panel->on_controller_selected([&](int index) { selected = index; });
|
||||
panel->on_disconnect_all([&] { ++disconnected; });
|
||||
panel->on_select_file([&] { ++picked; panel->set_file_transfer_visible(true); });
|
||||
slint::Timer sync;
|
||||
sync.start(slint::TimerMode::Repeated, 16ms, [&] {
|
||||
const auto height = unsigned(panel->get_content_height());
|
||||
if (screen.extent.height != height)
|
||||
panel->window().set_size(slint::LogicalSize({225, float(height)}));
|
||||
panel->set_file_progress(0.5);
|
||||
});
|
||||
screen.Settle();
|
||||
screen.Click(50, 84, 4);
|
||||
if (!Check(selected == 1, "Controlled-side controller selection must respond to a held click")) return false;
|
||||
const auto normal_height = screen.extent.height;
|
||||
screen.Click(180, 123, 4);
|
||||
if (!Check(panel->get_confirming_disconnect() && screen.extent.height == normal_height + 36,
|
||||
"Disconnect confirmation must expand immediately")) return false;
|
||||
screen.Click(110, 123, 4);
|
||||
if (!Check(!panel->get_confirming_disconnect() && disconnected == 0 && screen.extent.height == normal_height,
|
||||
"Cancel must collapse the controlled-side confirmation")) return false;
|
||||
screen.Click(180, 123, 4);
|
||||
screen.Click(180, 123, 4);
|
||||
if (!Check(disconnected == 1 && !panel->get_confirming_disconnect(),
|
||||
"Disconnect all must require and accept confirmation")) return false;
|
||||
screen.Click(110, 123, 4);
|
||||
return Check(picked == 1 && screen.extent.height == normal_height + 58,
|
||||
"Controlled-side file progress must resize the panel immediately");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
auto platform = std::make_unique<RenderTestPlatform>();
|
||||
auto* backend = platform.get();
|
||||
slint::platform::set_platform(std::move(platform));
|
||||
if (!VerifyStreamPopups(*backend)) return 1;
|
||||
if (!VerifyWorkbenchPopups(*backend)) return 2;
|
||||
if (!VerifyGalleryPopups(*backend)) return 3;
|
||||
if (!VerifyServerPanel(*backend)) return 4;
|
||||
if (!VerifyAutostartSettings(*backend)) return 5;
|
||||
std::cout << "Slint popup rendering and local input passed.\n";
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SLINT_RENDER_TEST_PLATFORM_H_
|
||||
#define _SLINT_RENDER_TEST_PLATFORM_H_
|
||||
|
||||
#include <slint-platform.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
// Retain pixels and honor redraw notifications, as the Harmony adapter does.
|
||||
// take_snapshot() forces a repaint and can conceal a stalled popup/animation.
|
||||
class RenderTestWindow : public slint::platform::WindowAdapter {
|
||||
public:
|
||||
slint::platform::SoftwareRenderer software{
|
||||
slint::platform::SoftwareRenderer::RepaintBufferType::ReusedBuffer};
|
||||
slint::PhysicalSize extent{{1280, 720}};
|
||||
std::vector<slint::Rgb8Pixel> pixels = std::vector<slint::Rgb8Pixel>(1280 * 720);
|
||||
bool dirty = true;
|
||||
slint::PhysicalSize size() override { return extent; }
|
||||
slint::platform::AbstractRenderer& renderer() override { return software; }
|
||||
void request_redraw() override { dirty = true; }
|
||||
void set_size(slint::PhysicalSize value) override {
|
||||
extent = value;
|
||||
pixels.resize(extent.width * extent.height);
|
||||
window().dispatch_resize_event(slint::LogicalSize(
|
||||
{float(extent.width), float(extent.height)}));
|
||||
dirty = true;
|
||||
}
|
||||
void Render() {
|
||||
if (!dirty && !window().has_active_animations()) return;
|
||||
dirty = false;
|
||||
software.render(pixels, extent.width);
|
||||
}
|
||||
void Settle(int frames = 15) {
|
||||
for (int i = 0; i < frames; ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(16));
|
||||
slint::platform::update_timers_and_animations();
|
||||
Render();
|
||||
}
|
||||
}
|
||||
void Click(float x, float y, int hold_frames = 0) {
|
||||
const slint::LogicalPosition point({x, y});
|
||||
window().dispatch_pointer_move_event(point);
|
||||
window().dispatch_pointer_press_event(point, slint::PointerEventButton::Left);
|
||||
Settle(hold_frames);
|
||||
window().dispatch_pointer_release_event(point, slint::PointerEventButton::Left);
|
||||
Settle(); // Do not move the pointer to make a delayed repaint pass.
|
||||
}
|
||||
bool Light(int x, int y) const {
|
||||
const auto p = pixels.at(y * extent.width + x);
|
||||
return p.r >= 230 && p.g >= 230 && p.b >= 230;
|
||||
}
|
||||
bool Black(int x, int y) const {
|
||||
const auto p = pixels.at(y * extent.width + x);
|
||||
return p.r == 0 && p.g == 0 && p.b == 0;
|
||||
}
|
||||
std::uint64_t Region(int x, int y, int width, int height) const {
|
||||
std::uint64_t hash = 14695981039346656037ull;
|
||||
for (int row = y; row < y + height; ++row)
|
||||
for (int col = x; col < x + width; ++col) {
|
||||
const auto p = pixels.at(row * extent.width + col);
|
||||
for (auto channel : {p.r, p.g, p.b}) hash = (hash ^ channel) * 1099511628211ull;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
void Key(const slint::SharedString& key) {
|
||||
window().dispatch_key_press_event(key);
|
||||
window().dispatch_key_release_event(key);
|
||||
Settle();
|
||||
}
|
||||
};
|
||||
|
||||
class RenderTestPlatform : public slint::platform::Platform {
|
||||
public:
|
||||
RenderTestWindow* adapter = nullptr;
|
||||
std::unique_ptr<slint::platform::WindowAdapter> create_window_adapter() override {
|
||||
auto result = std::make_unique<RenderTestWindow>();
|
||||
adapter = result.get();
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "crossdesk_ui.h"
|
||||
#include "ui/model_update.h"
|
||||
|
||||
#include <slint-platform.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
// Match the retained software buffer used by the Harmony desktop adapter.
|
||||
// A full window snapshot would conceal missing damage/redraw notifications.
|
||||
class TestWindow : public slint::platform::WindowAdapter {
|
||||
public:
|
||||
slint::platform::SoftwareRenderer software{
|
||||
slint::platform::SoftwareRenderer::RepaintBufferType::ReusedBuffer};
|
||||
std::vector<slint::Rgb8Pixel> pixels = std::vector<slint::Rgb8Pixel>(1280 * 720);
|
||||
bool dirty = true;
|
||||
slint::PhysicalSize size() override { return slint::PhysicalSize({1280, 720}); }
|
||||
slint::platform::AbstractRenderer& renderer() override { return software; }
|
||||
void request_redraw() override { dirty = true; }
|
||||
void Render() {
|
||||
if (!dirty && !window().has_active_animations()) return;
|
||||
dirty = false;
|
||||
software.render(pixels, 1280);
|
||||
}
|
||||
};
|
||||
|
||||
class TestPlatform : public slint::platform::Platform {
|
||||
public:
|
||||
TestWindow* adapter = nullptr;
|
||||
std::unique_ptr<slint::platform::WindowAdapter> create_window_adapter() override {
|
||||
auto result = std::make_unique<TestWindow>();
|
||||
adapter = result.get();
|
||||
return result;
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
auto platform = std::make_unique<TestPlatform>();
|
||||
auto* backend = platform.get();
|
||||
slint::platform::set_platform(std::move(platform));
|
||||
auto stream = crossdesk::ui::StreamWindow::create();
|
||||
auto& adapter = *backend->adapter;
|
||||
stream->window().dispatch_resize_event(slint::LogicalSize({1280, 720}));
|
||||
stream->show();
|
||||
auto rows = std::make_shared<slint::VectorModel<crossdesk::ui::NetworkStatsRow>>();
|
||||
stream->set_stats_rows(rows);
|
||||
std::vector<crossdesk::ui::NetworkStatsRow> stats(4);
|
||||
unsigned samples = 0;
|
||||
bool stats_visible = false;
|
||||
stream->on_toggle_stats([&] { stats_visible = !stats_visible; });
|
||||
// Synchronize inside a Slint timer, exactly as GuiApplication does. Updating
|
||||
// before update_timers_and_animations would use the previous animation tick.
|
||||
slint::Timer sync;
|
||||
sync.start(slint::TimerMode::Repeated, std::chrono::milliseconds(16), [&] {
|
||||
stream->set_stats_visible(stats_visible);
|
||||
// Keep values changing while the panel animates, as during a live session.
|
||||
stats[0].inbound = slint::SharedString(std::to_string(++samples) + " kbps");
|
||||
crossdesk::ui::UpdateModelRows(*rows, stats);
|
||||
});
|
||||
const auto tick = [&] {
|
||||
slint::platform::update_timers_and_animations();
|
||||
adapter.Render();
|
||||
};
|
||||
const auto settle = [&](int frames = 12) {
|
||||
for (int i = 0; i < frames; ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(16));
|
||||
tick();
|
||||
}
|
||||
};
|
||||
settle();
|
||||
const float top = stream->get_video_area_top();
|
||||
for (bool dock_right : {false, true}) {
|
||||
if (dock_right) {
|
||||
stream->invoke_begin_control_drag(5, 20);
|
||||
stream->invoke_move_control_drag(1275, 20);
|
||||
stream->invoke_end_control_drag();
|
||||
settle();
|
||||
}
|
||||
const int control_x = dock_right ? 916 : 0;
|
||||
const slint::LogicalPosition button({float(control_x + (dock_right ? 278 : 245)), top + 19});
|
||||
stream->window().dispatch_pointer_move_event(button);
|
||||
settle(35); // Include the tooltip that appears before the click.
|
||||
for (bool expanded : {true, false, true, false}) {
|
||||
stream->window().dispatch_pointer_press_event(button, slint::PointerEventButton::Left);
|
||||
stream->window().dispatch_pointer_release_event(button, slint::PointerEventButton::Left);
|
||||
settle(); // No pointer movement: animation must finish on its own.
|
||||
for (int y : {65, 195}) {
|
||||
const auto pixel = adapter.pixels[(int(top) + y) * 1280 + control_x + 4];
|
||||
const bool painted = pixel.r >= 240 && pixel.g >= 240 && pixel.b >= 240;
|
||||
const bool cleared = pixel.r == 0 && pixel.g == 0 && pixel.b == 0;
|
||||
if (stats_visible != expanded || (expanded ? !painted : !cleared)) {
|
||||
std::cerr << "Stats must fully " << (expanded ? "expand" : "collapse")
|
||||
<< " without pointer movement; dock_right=" << dock_right
|
||||
<< ", y=" << y << ", pixel=" << int(pixel.r) << ','
|
||||
<< int(pixel.g) << ',' << int(pixel.b) << '\n';
|
||||
return 1;
|
||||
}
|
||||
if (stream->invoke_is_local_control_area(control_x + 4, y) != expanded) {
|
||||
std::cerr << "Stats input area must match the rendered panel\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::cout << "Stream stats retained-buffer rendering passed.\n";
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
#include "crossdesk_ui.h"
|
||||
#include "fa_solid_900.h"
|
||||
#include "features/gallery/gallery_window_state.h"
|
||||
#include "ui/release_notes_text.h"
|
||||
#include "ui/ui_localization.h"
|
||||
|
||||
#include <slint-platform.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <charconv>
|
||||
@@ -12,6 +16,7 @@
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
@@ -125,9 +130,16 @@ bool ConfigureDocumentationDemo(
|
||||
{"987654321", "Dev Workstation", "fixtures/source.png"},
|
||||
{"246813579", "Office PC", "fixtures/website.png"},
|
||||
{"135792468", "Home Laptop", "web-client.png"},
|
||||
{"864209753", "Studio Mac", "fixtures/source.png"},
|
||||
{"753198642", "Build Server", "fixtures/website.png"},
|
||||
{"642087531", "Test Device", "web-client.png"},
|
||||
};
|
||||
std::vector<crossdesk::ui::RecentConnection> connections;
|
||||
for (const auto &device : devices) {
|
||||
if (connections.size() == 3 && options.page != "main-recent-overflow" &&
|
||||
options.page != "main-recent-scrolled") {
|
||||
break;
|
||||
}
|
||||
const std::string path = options.demo_assets_path + "/" + device.thumbnail;
|
||||
const auto thumbnail =
|
||||
slint::Image::load_from_path(slint::SharedString(path.c_str()));
|
||||
@@ -219,8 +231,23 @@ void ConfigureMainCapturePage(
|
||||
window->set_wayland_titlebar(false);
|
||||
#endif
|
||||
|
||||
if (options.page == "settings") {
|
||||
if (options.page == "settings" || options.page == "settings-scroll-hover" ||
|
||||
options.page == "settings-scrolled" || options.page == "settings-scroll-drag") {
|
||||
window->set_settings_open(true);
|
||||
} else if (options.page == "main-menu-badges") {
|
||||
window->set_announcement_unread(4);
|
||||
window->set_update_available(true);
|
||||
window->set_update_open(false);
|
||||
} else if (options.page == "settings-deskhub" ||
|
||||
options.page == "settings-deskhub-error") {
|
||||
window->set_settings_open(true);
|
||||
window->set_settings_tab(1);
|
||||
if (options.page == "settings-deskhub-error") {
|
||||
window->set_gallery_settings_error(
|
||||
window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
|
||||
"无法保存设置,请重试。", "Unable to save settings. Please try again.",
|
||||
"Не удалось сохранить настройки. Повторите попытку."));
|
||||
}
|
||||
} else if (options.page == "self-hosted") {
|
||||
window->set_settings_open(true);
|
||||
window->set_self_host_settings_open(true);
|
||||
@@ -238,11 +265,52 @@ void ConfigureMainCapturePage(
|
||||
? crossdesk::ui::UpdateCheckStatus::Failed
|
||||
: crossdesk::ui::UpdateCheckStatus::Available);
|
||||
}
|
||||
} else if (options.page == "update") {
|
||||
} else if (options.page == "update" || options.page == "update-scrolled" ||
|
||||
options.page == "update-short") {
|
||||
window->set_update_available(true);
|
||||
window->set_latest_version("v0.0.1");
|
||||
window->set_latest_version("v1.6.1-20261010");
|
||||
window->set_release_name("CrossDesk Preview");
|
||||
window->set_release_date("2026-08-25");
|
||||
window->set_release_date("2026-10-10");
|
||||
// Illustrative notes exercise the production Markdown parser and wrapping,
|
||||
// including enough content to inspect both ends of the scrolling viewport.
|
||||
const char *notes[] = {
|
||||
"**新增功能与改进**\n\n"
|
||||
"1. 增强信令快速重连与连接恢复能力;桌面端配置修改无需重新连接信令服务。\n"
|
||||
"2. Windows 安装版新增登录前远程控制支持,被控端可随系统启动,在尚未登录时接受远程连接。\n"
|
||||
"3. Linux 提供普通版与 Xfce 虚拟桌面版独立安装包,普通版不再强制安装 Xfce 和 Xvfb。\n"
|
||||
"4. 被控端会话窗口改为紧凑的屏幕边缘状态栏。\n"
|
||||
"5. 完善 iOS / Android 原生控制端,优化远程画面、触控与外接键鼠体验。\n\n"
|
||||
"**问题修复**\n\n"
|
||||
"6. 修复连接中断后的状态显示,改善高分辨率屏幕上的文字与按钮边界。\n"
|
||||
"7. 统一窗口、输入框与按钮的圆角,优化更新提示与长文本滚动体验。",
|
||||
"**Features and improvements**\n\n"
|
||||
"1. Improve signaling reconnection and session recovery. Desktop configuration changes no longer require reconnecting to the signaling service.\n"
|
||||
"2. Add remote control before Windows sign-in, allowing the installed client to accept connections when the system starts.\n"
|
||||
"3. Provide separate Linux packages for standard installations and Xfce virtual desktops.\n"
|
||||
"4. Replace the controlled session window with a compact status bar at the edge of the screen.\n"
|
||||
"5. Improve the native iOS and Android clients, including video, touch, and external keyboard and mouse support.\n\n"
|
||||
"**Fixes**\n\n"
|
||||
"6. Correct connection status after interruptions and improve text and control borders on high resolution displays.\n"
|
||||
"7. Unify corner radii and improve update notifications and scrolling for long release notes.",
|
||||
"**Новые возможности и улучшения**\n\n"
|
||||
"1. Улучшено восстановление соединения. Изменение настроек больше не требует повторного подключения к серверу сигнализации.\n"
|
||||
"2. Добавлено удалённое управление Windows до входа пользователя в систему.\n"
|
||||
"3. Подготовлены отдельные пакеты Linux для стандартной установки и виртуальных рабочих столов Xfce.\n"
|
||||
"4. Окно управляемого сеанса заменено компактной строкой состояния у края экрана.\n"
|
||||
"5. Улучшены клиенты iOS и Android, работа с сенсорным экраном, внешней клавиатурой и мышью.\n\n"
|
||||
"**Исправления**\n\n"
|
||||
"6. Исправлено отображение состояния после обрыва соединения, улучшена читаемость на экранах высокого разрешения.\n"
|
||||
"7. Унифицированы скругления элементов и улучшена прокрутка длинных описаний обновлений."};
|
||||
std::vector<crossdesk::ui::ReleaseNoteBlock> blocks;
|
||||
for (const auto &parsed : crossdesk::release_notes_text::Parse(notes[language])) {
|
||||
crossdesk::ui::ReleaseNoteBlock block{};
|
||||
block.content = parsed.content;
|
||||
block.section_gap = parsed.section_gap;
|
||||
blocks.push_back(std::move(block));
|
||||
if (options.page == "update-short" && blocks.size() == 2) break;
|
||||
}
|
||||
window->set_release_note_blocks(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::ReleaseNoteBlock>>(blocks));
|
||||
window->set_update_open(true);
|
||||
} else if (options.page == "reset-password") {
|
||||
window->set_reset_password_open(true);
|
||||
@@ -363,10 +431,72 @@ int RunCaptureMode(
|
||||
slint::ComponentHandle<crossdesk::ui::MainWindow> &window,
|
||||
slint::ComponentHandle<crossdesk::ui::StreamWindow> &stream,
|
||||
slint::ComponentHandle<crossdesk::ui::ServerWindow> &server) {
|
||||
window->on_format_remote_id([](slint::SharedString id) { return id; });
|
||||
ConfigureMainCapturePage(window, options);
|
||||
if (!ConfigureDocumentationDemo(window, options)) {
|
||||
return 8;
|
||||
}
|
||||
// Preview DeskHub with the same demo images, without opening any sessions.
|
||||
if (options.page.starts_with("gallery")) {
|
||||
window->set_gallery_mode(true);
|
||||
std::vector<crossdesk::ui::GalleryTile> tiles;
|
||||
const auto recent = window->get_recent_connections();
|
||||
for (size_t index = 0; index < recent->row_count(); ++index) {
|
||||
const auto device = recent->row_data(index).value();
|
||||
crossdesk::ui::GalleryTile tile{};
|
||||
tile.id = device.remote_id;
|
||||
tile.name = device.display_name;
|
||||
tile.online = tile.presence_known = tile.connected = true;
|
||||
tile.has_frame = device.has_thumbnail;
|
||||
tile.frame = device.thumbnail;
|
||||
tile.selected = options.page == "gallery-batch";
|
||||
tiles.push_back(tile);
|
||||
}
|
||||
window->set_gallery_tiles(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::GalleryTile>>(tiles));
|
||||
window->set_gallery_count(static_cast<int>(tiles.size()));
|
||||
window->set_gallery_connected(static_cast<int>(tiles.size()));
|
||||
window->set_gallery_groups(
|
||||
std::make_shared<slint::VectorModel<slint::SharedString>>(
|
||||
std::vector{window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
|
||||
"全部设备", "All devices", "Все устройства")}));
|
||||
window->set_gallery_batch(options.page == "gallery-batch");
|
||||
window->set_gallery_selected(options.page == "gallery-batch"
|
||||
? static_cast<int>(tiles.size()) : 0);
|
||||
window->set_gallery_adding(options.page == "gallery-add");
|
||||
if (options.page == "gallery-focus" && !tiles.empty())
|
||||
window->set_gallery_focused(tiles.front());
|
||||
// A 960 x 720 preview includes the native 22px macOS titlebar.
|
||||
const float client_height =
|
||||
#if defined(__APPLE__)
|
||||
698.0f;
|
||||
#else
|
||||
720.0f;
|
||||
#endif
|
||||
window->window().set_size(
|
||||
slint::LogicalSize(slint::Size<float>{960.0f, client_height}));
|
||||
}
|
||||
// Workbench states use the same rendered components and demo-only data.
|
||||
if (options.page == "main-empty" || options.page == "main-empty-group") {
|
||||
window->set_recent_connections(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>());
|
||||
window->set_recent_group_index(options.page == "main-empty-group" ? 1 : 0);
|
||||
} else if (options.page == "main-error") {
|
||||
window->set_remote_id_input("123");
|
||||
window->set_connection_input_error(
|
||||
window->global<crossdesk::ui::GalleryStrings>().invoke_tr(
|
||||
"请输入完整的 9 位设备 ID", "Enter a complete 9-digit device ID",
|
||||
"Введите полный ID устройства из 9 цифр"));
|
||||
} else if (options.page == "main-offline") {
|
||||
window->set_signal_connected(false);
|
||||
window->set_password_visible(false);
|
||||
const auto connections = window->get_recent_connections();
|
||||
for (size_t index = 0; index < connections->row_count(); ++index) {
|
||||
auto device = connections->row_data(index).value();
|
||||
device.online = false;
|
||||
connections->set_row_data(index, device);
|
||||
}
|
||||
}
|
||||
stream->hide();
|
||||
server->hide();
|
||||
|
||||
@@ -422,11 +552,113 @@ int RunCaptureMode(
|
||||
// taking the snapshot; capturing immediately after show() can yield 1x.
|
||||
bool snapshot_written = false;
|
||||
slint::Timer snapshot_timer;
|
||||
slint::Timer capture_settle_timer;
|
||||
slint::Timer pointer_move_timer;
|
||||
snapshot_timer.start(slint::TimerMode::SingleShot,
|
||||
std::chrono::milliseconds(250), [&] {
|
||||
snapshot_written =
|
||||
WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str());
|
||||
slint::quit_event_loop();
|
||||
if (options.page == "main-device-tooltip" ||
|
||||
options.page == "main-device-tooltip-moved" ||
|
||||
options.page == "main-device-tooltip-edge" ||
|
||||
options.page == "main-device-tooltip-exit") {
|
||||
const float titlebar = window->get_minimum_window_height() - 472.0f;
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{90.0f, titlebar + 330.0f}));
|
||||
if (options.page != "main-device-tooltip") {
|
||||
// A second event in the same card verifies hover movement without
|
||||
// pressing the mouse or relying on another enter/leave event.
|
||||
pointer_move_timer.start(slint::TimerMode::SingleShot,
|
||||
std::chrono::milliseconds(50), [&, titlebar] {
|
||||
const bool edge = options.page == "main-device-tooltip-edge";
|
||||
const bool leave = options.page == "main-device-tooltip-exit";
|
||||
window->window().dispatch_pointer_move_event(slint::LogicalPosition(
|
||||
slint::Point<float>{leave ? 2.0f : edge ? window->get_minimum_window_width() - 40.0f : 170.0f,
|
||||
leave ? 2.0f : titlebar + 340.0f}));
|
||||
});
|
||||
}
|
||||
} else if (options.page == "settings-scroll-hover" ||
|
||||
options.page == "settings-scrolled" ||
|
||||
options.page == "settings-scroll-drag") {
|
||||
const float dialog_x = (window->get_minimum_window_width() - 400.0f) / 2;
|
||||
const float dialog_y = (window->get_minimum_window_height() - 446.0f) / 2;
|
||||
const auto scrollbar_position = slint::LogicalPosition(
|
||||
slint::Point<float>{dialog_x + 389.0f, dialog_y + 150.0f});
|
||||
if (options.page == "settings-scrolled") {
|
||||
window->window().dispatch_pointer_scroll_event(
|
||||
slint::LogicalPosition(slint::Point<float>{320.0f, 260.0f}),
|
||||
0.0f, -2000.0f);
|
||||
}
|
||||
window->window().dispatch_pointer_move_event(scrollbar_position);
|
||||
if (options.page == "settings-scroll-drag") {
|
||||
window->window().dispatch_pointer_press_event(
|
||||
scrollbar_position, slint::PointerEventButton::Left);
|
||||
const auto drag_end = slint::LogicalPosition(
|
||||
slint::Point<float>{dialog_x + 389.0f, dialog_y + 340.0f});
|
||||
window->window().dispatch_pointer_move_event(drag_end);
|
||||
window->window().dispatch_pointer_release_event(
|
||||
drag_end, slint::PointerEventButton::Left);
|
||||
}
|
||||
} else if (options.page == "update-scrolled") {
|
||||
window->window().dispatch_pointer_scroll_event(
|
||||
slint::LogicalPosition(slint::Point<float>{320.0f, 260.0f}),
|
||||
0.0f, -2000.0f);
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
|
||||
} else if (options.page == "main-recent-scrolled") {
|
||||
window->window().dispatch_pointer_scroll_event(
|
||||
slint::LogicalPosition(slint::Point<float>{
|
||||
400.0f, window->get_minimum_window_height() - 472.0f + 360.0f}),
|
||||
-400.0f, 0.0f);
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
|
||||
} else if (options.page == "main-menu" ||
|
||||
options.page == "main-menu-badges") {
|
||||
const auto menu_position = slint::LogicalPosition(
|
||||
slint::Point<float>{window->get_minimum_window_width() - 38.0f,
|
||||
window->get_minimum_window_height() - 472.0f + 20.0f});
|
||||
window->window().dispatch_pointer_press_event(
|
||||
menu_position, slint::PointerEventButton::Left);
|
||||
window->window().dispatch_pointer_release_event(
|
||||
menu_position, slint::PointerEventButton::Left);
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
|
||||
} else if (options.page == "main-copy-tooltip" ||
|
||||
options.page == "main-password-tooltip") {
|
||||
const bool copy = options.page == "main-copy-tooltip";
|
||||
window->window().dispatch_pointer_move_event(slint::LogicalPosition(
|
||||
slint::Point<float>{window->get_minimum_window_width() / 2 - (copy ? 48.0f : 42.0f),
|
||||
window->get_minimum_window_height() - 472.0f +
|
||||
(copy ? 150.0f : 216.0f)}));
|
||||
} else if (options.page == "main-copy-released" ||
|
||||
options.page == "main-copy-released-outside" ||
|
||||
options.page == "main-keyboard-tab") {
|
||||
const auto copy_position = slint::LogicalPosition(slint::Point<float>{
|
||||
window->get_minimum_window_width() / 2 - 48.0f,
|
||||
window->get_minimum_window_height() - 472.0f + 150.0f});
|
||||
window->window().dispatch_pointer_move_event(copy_position);
|
||||
window->window().dispatch_pointer_press_event(
|
||||
copy_position, slint::PointerEventButton::Left);
|
||||
window->window().dispatch_pointer_release_event(
|
||||
copy_position, slint::PointerEventButton::Left);
|
||||
if (options.page != "main-copy-released") {
|
||||
window->window().dispatch_pointer_move_event(
|
||||
slint::LogicalPosition(slint::Point<float>{2.0f, 2.0f}));
|
||||
}
|
||||
if (options.page == "main-keyboard-tab") {
|
||||
// Traverse the remote ID input and focus the Connect button.
|
||||
for (int index = 0; index < 2; ++index) {
|
||||
window->window().dispatch_key_press_event("\t");
|
||||
window->window().dispatch_key_release_event("\t");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Let settings scroll animations settle as well as hover/focus feedback.
|
||||
capture_settle_timer.start(slint::TimerMode::SingleShot,
|
||||
std::chrono::milliseconds(
|
||||
options.page.rfind("settings", 0) == 0 ? 350 : 150), [&] {
|
||||
snapshot_written =
|
||||
WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str());
|
||||
slint::quit_event_loop();
|
||||
});
|
||||
});
|
||||
slint::run_event_loop();
|
||||
return snapshot_written ? 0 : 7;
|
||||
@@ -440,10 +672,256 @@ int RunCaptureMode(
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sample the native window while the same restoration used by the application
|
||||
// runs. A component-only screenshot used to miss the conflicting restore width.
|
||||
bool VerifyMainWindowGeometry() {
|
||||
for (bool restore : {false, true}) {
|
||||
auto component = crossdesk::ui::MainWindow::create();
|
||||
auto &window = component->window();
|
||||
crossdesk::gallery::WindowState placement;
|
||||
const float width = component->get_minimum_window_width();
|
||||
const float height = component->get_minimum_window_height();
|
||||
if (width != 640.0f || height != 472.0f) return false;
|
||||
if (restore) {
|
||||
placement.BeginRestore();
|
||||
placement.PrepareForShow(
|
||||
window, false,
|
||||
placement.RestoreSize(false, window.scale_factor(), height, width),
|
||||
std::nullopt);
|
||||
}
|
||||
window.show();
|
||||
bool stable = true;
|
||||
int samples = 0;
|
||||
slint::Timer timer;
|
||||
timer.start(slint::TimerMode::Repeated, std::chrono::milliseconds(16), [&] {
|
||||
const auto actual = window.size();
|
||||
const auto expected = placement.RestoreSize(
|
||||
false, window.scale_factor(), height, width);
|
||||
if (actual.width != expected.width || actual.height != expected.height) {
|
||||
std::cerr << "Startup geometry changed: restore=" << restore
|
||||
<< " sample=" << samples << " actual=" << actual.width
|
||||
<< 'x' << actual.height << " expected=" << expected.width
|
||||
<< 'x' << expected.height << '\n';
|
||||
stable = false;
|
||||
}
|
||||
if (placement.RestoringNormal()) {
|
||||
if (!placement.NormalSizeRestored(actual, expected))
|
||||
window.set_size(expected);
|
||||
} else if (placement.Restoring()) {
|
||||
// Match the application's fixed connection-window role. AppKit can
|
||||
// report a fixed-size window as zoomed even at its normal dimensions.
|
||||
placement.FinishRestore(false, actual, false, false);
|
||||
}
|
||||
if (++samples == 20) slint::quit_event_loop();
|
||||
});
|
||||
slint::run_event_loop();
|
||||
timer.stop();
|
||||
window.hide();
|
||||
if (!stable || placement.Restoring()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exercise hit targets after the workbench layout change, rather than calling
|
||||
// callbacks directly: overlapping surfaces must not swallow these actions.
|
||||
bool VerifyWorkbenchActions() {
|
||||
auto window = crossdesk::ui::MainWindow::create();
|
||||
const float width = window->get_minimum_window_width();
|
||||
window->window().set_size(
|
||||
slint::LogicalSize(slint::Size<float>{width, window->get_minimum_window_height()}));
|
||||
auto click = [&](float x, float y) {
|
||||
const auto position = slint::LogicalPosition(slint::Point<float>{x, y});
|
||||
window->window().dispatch_pointer_press_event(
|
||||
position, slint::PointerEventButton::Left);
|
||||
window->window().dispatch_pointer_release_event(
|
||||
position, slint::PointerEventButton::Left);
|
||||
};
|
||||
int copies = 0, visibility_changes = 0, refreshes = 0, connections = 0;
|
||||
bool gallery_opened = false;
|
||||
window->on_copy_local_id([&] { ++copies; return true; });
|
||||
window->on_toggle_password_visibility([&] { ++visibility_changes; });
|
||||
window->on_refresh_password([&] { ++refreshes; });
|
||||
window->on_open_gallery([&] { gallery_opened = true; });
|
||||
window->set_remote_id_input("987 654 321");
|
||||
window->on_connect_requested([&](slint::SharedString id) {
|
||||
if (id == "987 654 321") ++connections;
|
||||
});
|
||||
click(width / 2 - 48, 150);
|
||||
click(width / 2 - 118, 216);
|
||||
click(width / 2 - 80, 216);
|
||||
click(width / 2 - 42, 216);
|
||||
if (copies != 1 || visibility_changes != 1 || refreshes != 1 ||
|
||||
!window->get_reset_password_open()) return false;
|
||||
window->set_reset_password_open(false);
|
||||
window->set_password_change_pending(true);
|
||||
click(width / 2 - 80, 216);
|
||||
click(width / 2 - 42, 216);
|
||||
if (refreshes != 1 || window->get_reset_password_open()) return false;
|
||||
click(width * 0.75f - 6, 216);
|
||||
// Space activates the focused primary button; pending state blocks repeats.
|
||||
window->window().dispatch_key_press_event(" ");
|
||||
window->window().dispatch_key_release_event(" ");
|
||||
if (connections != 2) return false;
|
||||
window->set_connection_pending(true);
|
||||
click(width * 0.75f - 6, 216);
|
||||
if (connections != 2) return false;
|
||||
window->set_connection_pending(false);
|
||||
click(width - 38, 20);
|
||||
click(width - 78, 56);
|
||||
if (!window->get_settings_open()) return false;
|
||||
// Both tabs must remain at the same position, including with a save error.
|
||||
click(width / 2 + 87, 89);
|
||||
if (window->get_settings_tab() != 1) return false;
|
||||
click(width / 2 - 87, 89);
|
||||
if (window->get_settings_tab() != 0) return false;
|
||||
click(width / 2 + 87, 89);
|
||||
window->set_gallery_settings_error("Unable to save settings. Please try again.");
|
||||
click(width / 2 - 87, 89);
|
||||
if (window->get_settings_tab() != 0) return false;
|
||||
window->set_gallery_settings_error("");
|
||||
window->set_settings_open(false);
|
||||
click(60, 20);
|
||||
if (!gallery_opened) return false;
|
||||
crossdesk::ui::RecentConnection device{};
|
||||
device.remote_id = "987654321";
|
||||
device.display_name = "Workstation";
|
||||
device.online = true;
|
||||
window->set_recent_connections(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>(
|
||||
std::vector{device}));
|
||||
bool recent_connected = false;
|
||||
window->on_recent_connect([&](slint::SharedString id) {
|
||||
recent_connected = id == "987654321";
|
||||
});
|
||||
click(100, 344);
|
||||
if (!recent_connected) return false;
|
||||
bool password_submitted = false, cancelled = false;
|
||||
window->on_connection_submit_password([&](slint::SharedString value, bool) {
|
||||
password_submitted = value == "654321";
|
||||
});
|
||||
window->on_connection_cancel([&] { cancelled = true; });
|
||||
window->set_connection_password("654321");
|
||||
window->set_connection_password_required(true);
|
||||
window->set_connection_dialog_open(true);
|
||||
click(width / 2 + 62, 312);
|
||||
click(width / 2 - 62, 312);
|
||||
if (!password_submitted || !cancelled) return false;
|
||||
window->set_connection_dialog_open(false);
|
||||
|
||||
// Rounded frames must preserve editor focus, text bindings and Enter actions.
|
||||
auto type = [&](std::string_view text) {
|
||||
for (char character : text) {
|
||||
const slint::SharedString key(std::string(1, character).c_str());
|
||||
window->window().dispatch_key_press_event(key);
|
||||
window->window().dispatch_key_release_event(key);
|
||||
}
|
||||
};
|
||||
bool renamed = false;
|
||||
window->on_recent_edit_alias([&](slint::SharedString, slint::SharedString name) {
|
||||
renamed = name == "Office";
|
||||
});
|
||||
window->set_alias_input("");
|
||||
window->set_alias_open(true);
|
||||
click(width / 2, 238);
|
||||
type("Office\n");
|
||||
if (!renamed || window->get_alias_open()) {
|
||||
std::cerr << "Alias editor did not accept the typed name; text="
|
||||
<< std::string(window->get_alias_input()) << '\n';
|
||||
return false;
|
||||
}
|
||||
|
||||
bool added = false;
|
||||
window->on_gallery_add_device([&](slint::SharedString id,
|
||||
slint::SharedString password, slint::SharedString group, bool) {
|
||||
added = id == "123456789" && password == "123456" && group == "Lab";
|
||||
return added;
|
||||
});
|
||||
window->set_gallery_mode(true);
|
||||
window->window().set_size(slint::LogicalSize(slint::Size<float>{width, 448.0f}));
|
||||
window->set_gallery_adding(true);
|
||||
click(width / 2, 142);
|
||||
type("123456789\n123456\nLab\n");
|
||||
if (!added || window->get_gallery_adding()) {
|
||||
std::cerr << "Add-device editors did not retain Enter focus traversal\n";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VerifySystemShortcutMenu() {
|
||||
auto stream = crossdesk::ui::StreamWindow::create();
|
||||
stream->window().set_size(slint::LogicalSize(slint::Size<float>{1280, 720}));
|
||||
std::vector<std::string> commands;
|
||||
stream->on_send_shortcut([&](slint::SharedString command) { commands.emplace_back(command); });
|
||||
const auto click = [&](float y) {
|
||||
const slint::LogicalPosition point({55, stream->get_video_area_top() + y});
|
||||
stream->window().dispatch_pointer_move_event(point);
|
||||
stream->window().dispatch_pointer_press_event(point, slint::PointerEventButton::Left);
|
||||
stream->window().dispatch_pointer_release_event(point, slint::PointerEventButton::Left);
|
||||
};
|
||||
click(20);
|
||||
if (!stream->invoke_is_local_control_area(55, 90)) return false;
|
||||
click(58);
|
||||
stream->set_secure_attention_supported(false);
|
||||
click(20);
|
||||
if (stream->invoke_is_local_control_area(55, 90)) return false;
|
||||
click(58);
|
||||
stream->set_secure_attention_supported(true);
|
||||
click(20); click(88);
|
||||
return commands == std::vector<std::string>{"Ctrl+Alt+Del", "Win+L", "Win+L"};
|
||||
}
|
||||
|
||||
bool VerifyRemoteIdEditing() {
|
||||
auto window = crossdesk::ui::MainWindow::create();
|
||||
window->window().set_size(slint::LogicalSize(slint::Size<float>{
|
||||
window->get_minimum_window_width(), window->get_minimum_window_height()}));
|
||||
window->on_format_remote_id([](slint::SharedString text) { return text; });
|
||||
window->invoke_reset_remote_id();
|
||||
const auto key = [&](const slint::SharedString& text) {
|
||||
window->window().dispatch_key_press_event(text);
|
||||
window->window().dispatch_key_release_event(text);
|
||||
};
|
||||
const auto settle = [] {
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
};
|
||||
settle(); // Let the initial timer state settle before the first user edit.
|
||||
for (char digit : std::string("1234")) {
|
||||
key(slint::SharedString(std::string(1, digit)));
|
||||
settle(); // Separate edits must not leave the formatter repeating.
|
||||
}
|
||||
if (std::string(window->get_remote_id_input()) != "1234") return false;
|
||||
window->window().dispatch_key_press_event(slint::platform::key_codes::Control);
|
||||
key("a");
|
||||
window->window().dispatch_key_release_event(slint::platform::key_codes::Control);
|
||||
settle(); // Formatting must not repeatedly collapse the selection.
|
||||
key("5");
|
||||
settle();
|
||||
return std::string(window->get_remote_id_input()) == "5";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
if (!VerifySystemShortcutMenu()) {
|
||||
std::cerr << "Shortcut menu must omit unsupported SAS and resize its input area\n";
|
||||
return 13;
|
||||
}
|
||||
if (!VerifyRemoteIdEditing()) {
|
||||
std::cerr << "Remote ID editor must preserve Ctrl+A selection after formatting\n";
|
||||
return 12;
|
||||
}
|
||||
const CaptureOptions capture_options = LoadCaptureOptions();
|
||||
if (!VerifyMainWindowGeometry()) {
|
||||
std::cerr << "Main window dimensions must stay stable through startup\n";
|
||||
return 11;
|
||||
}
|
||||
if (!VerifyWorkbenchActions()) {
|
||||
std::cerr << "Workbench controls must retain their pointer and keyboard actions\n";
|
||||
return 10;
|
||||
}
|
||||
auto window = crossdesk::ui::MainWindow::create();
|
||||
auto stream = crossdesk::ui::StreamWindow::create();
|
||||
auto server = crossdesk::ui::ServerWindow::create();
|
||||
|
||||
@@ -154,9 +154,11 @@ function setup_targets()
|
||||
add_includedirs("apps/desktop/src", {public = true})
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/single_instance.cpp")
|
||||
add_syslinks("Advapi32")
|
||||
add_files("apps/desktop/src/platform/windows/gui/instance_activation.cpp")
|
||||
add_syslinks("Advapi32", "User32")
|
||||
else
|
||||
add_files("apps/desktop/src/platform/common/posix_single_instance.cpp")
|
||||
add_files("apps/desktop/src/platform/common/posix_instance_activation.cpp")
|
||||
end
|
||||
|
||||
target("repository_structure_test")
|
||||
@@ -230,6 +232,32 @@ function setup_targets()
|
||||
add_files("apps/desktop/tests/slint_ui_smoke_test.cpp")
|
||||
add_rules("crossdesk.slint_runtime")
|
||||
|
||||
target("slint_stream_render_test")
|
||||
set_kind("binary")
|
||||
set_languages("c++20")
|
||||
set_default(false)
|
||||
if is_os("windows") then
|
||||
add_cxxflags("/bigobj")
|
||||
end
|
||||
add_packages("slint")
|
||||
add_deps("crossdesk_ui")
|
||||
add_includedirs("apps/desktop/src/gui")
|
||||
add_files("apps/desktop/tests/slint_stream_render_test.cpp")
|
||||
add_rules("crossdesk.slint_runtime")
|
||||
|
||||
target("slint_popup_render_test")
|
||||
set_kind("binary")
|
||||
set_languages("c++20")
|
||||
set_default(false)
|
||||
if is_os("windows") then
|
||||
add_cxxflags("/bigobj")
|
||||
end
|
||||
add_packages("slint")
|
||||
add_deps("crossdesk_ui")
|
||||
add_includedirs("apps/desktop/src/gui")
|
||||
add_files("apps/desktop/tests/slint_popup_render_test.cpp")
|
||||
add_rules("crossdesk.slint_runtime")
|
||||
|
||||
target("version_checker_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
@@ -485,6 +513,7 @@ function setup_targets()
|
||||
add_files("apps/desktop/src/platform/linux/clipboard.cpp")
|
||||
end
|
||||
add_includedirs("apps/desktop/src", "apps/desktop/src/tools", {public = true})
|
||||
add_includedirs("libs/controller/include", {public = true})
|
||||
|
||||
target("gui")
|
||||
set_kind("object")
|
||||
@@ -501,6 +530,7 @@ function setup_targets()
|
||||
add_defines("CROSSDESK_PORTABLE=1", {public = true})
|
||||
end
|
||||
add_deps("rd_log", "desktop_common", "assets", "config_center", "minirtc",
|
||||
"single_instance",
|
||||
"path_manager", "screen_capturer", "speaker_capturer",
|
||||
"device_controller", "thumbnail", "version_checker", "tools",
|
||||
"crossdesk_wire", "privacy", "crossdesk_ui")
|
||||
@@ -549,6 +579,7 @@ function setup_targets()
|
||||
add_links("GL")
|
||||
add_deps("headless_console")
|
||||
add_files("apps/desktop/src/platform/linux/gui/tray/linux_tray.cpp",
|
||||
"apps/desktop/src/platform/linux/gui/wayland_activation.cpp",
|
||||
"apps/desktop/src/platform/linux/gui/cuda_graphics.cpp",
|
||||
"apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp",
|
||||
"apps/desktop/src/platform/common/gui/cuda_driver.cpp",
|
||||
@@ -584,7 +615,8 @@ function setup_targets()
|
||||
add_deps("rd_log", "path_manager", "windows_unattended_config")
|
||||
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
|
||||
add_files("apps/desktop/src/platform/windows/service/main.cpp",
|
||||
"apps/desktop/src/platform/windows/service/service_host.cpp")
|
||||
"apps/desktop/src/platform/windows/service/service_host.cpp",
|
||||
"apps/desktop/src/platform/windows/service/rdp_session_handoff.cpp")
|
||||
add_files("apps/desktop/resources/windows/crossdesk_service.rc",
|
||||
{defines = windows_resource_defines})
|
||||
add_includedirs("apps/desktop/src/platform/windows/service",
|
||||
@@ -616,7 +648,8 @@ function setup_targets()
|
||||
add_includedirs("apps/desktop/src", "apps/desktop/src/app", {public = true})
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/daemon_backend.cpp")
|
||||
add_files("apps/desktop/src/platform/windows/service/service_host.cpp")
|
||||
add_files("apps/desktop/src/platform/windows/service/service_host.cpp",
|
||||
"apps/desktop/src/platform/windows/service/rdp_session_handoff.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/windows/service",
|
||||
"apps/desktop/src/platform/windows/screen_capturer")
|
||||
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
|
||||
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user