Compare commits

...
44 Commits
Author SHA1 Message Date
dijunkun 1e2520d472 [fix] update mobile license catalog recipe checksums 2026-10-16 01:14:11 +08:00
kunkundi 697843cccd [docs] document HarmonyOS support and project copyright 2026-10-16 00:53:54 +08:00
kunkundi 45bf6ec4ee [feat] add HarmonyOS desktop and mobile clients,issue #117 2026-10-16 00:42:55 +08:00
kunkundi 43a0789c1e [refactor] share mobile controller protocol and video timing 2026-10-16 00:40:07 +08:00
kunkundi c4756b298b [refactor] share file transfer core across desktop and mobile 2026-10-16 00:38:13 +08:00
dijunkun 47e6385f9a [ui] refine floating notices and dismiss them on interaction 2026-10-14 23:40:15 +08:00
dijunkun 9f610bfc9b [ui] update macOS permission dialog to match desktop styling 2026-10-14 22:56:29 +08:00
dijunkun 006526389a [docs] update README for DeskHub and refresh desktop screenshots 2026-10-14 21:06:06 +08:00
dijunkun e76d2bf903 [ui] unify settings dialogs and improve announcement controls 2026-10-14 20:18:51 +08:00
kunkundi 2cf0d79543 [ui] refine desktop layouts and unify control styling 2026-10-14 19:29:08 +08:00
dijunkun 3043b5f110 [fix] follow the selected device cursor in DeskHub 2026-10-14 10:20:31 +08:00
dijunkun 5721222724 [fix] disable already added devices in DeskHub recent picker 2026-10-14 10:20:20 +08:00
dijunkun 3ee0d42494 [feat] add DeskHub group control 2026-10-14 10:20:05 +08:00
dijunkun 417ef7d58c [fix] disconnect remote sessions when closing DeskHub 2026-10-14 04:17:44 +08:00
dijunkun 0a6356085d [feat] show video latency in DeskHub network status 2026-10-14 04:17:38 +08:00
dijunkun 61ff9b2787 [feat] add resizable DeskHub focus previews 2026-10-14 04:17:31 +08:00
dijunkun 4855b2d3b6 [fix] preserve DeskHub previews and control during fullscreen changes 2026-10-14 04:17:23 +08:00
dijunkun 2ac197c2de [ui] streamline DeskHub toolbar layout and buttons 2026-10-14 04:17:17 +08:00
dijunkun b90359b08d [fix] sync DeskHub previews with tile size and unify tooltips 2026-10-14 02:58:08 +08:00
dijunkun 5199b2df11 [feat] add standalone DeskHub window and resizable device table 2026-10-14 02:37:06 +08:00
dijunkun fd86dba212 [fix] preserve remote cursors when macOS hides the pointer, refs #116 2026-10-13 21:49:06 +08:00
dijunkun 2a9887ab8a [fix] fix UPnP leaks and disable UPnP on iOS, refs #115 2026-10-13 20:42:57 +08:00
dijunkun 435feb419c [ui] simplify DeskHub controls and unify device status styling 2026-10-13 18:12:57 +08:00
dijunkun 4e394ca650 [ui] place DeskHub thumbnails above the focused desktop and compact labels 2026-10-13 16:11:27 +08:00
dijunkun 4dfd8c3005 [feat] allocate DeskHub video bandwidth automatically 2026-10-13 15:39:50 +08:00
dijunkun de16eb3b92 [refactor] use MiniRTC APIs for DeskHub media pause controls 2026-10-13 15:39:34 +08:00
dijunkun 3bd0676672 [feat] add DeskHub stream controls and network status 2026-10-13 10:45:48 +08:00
dijunkun 73b4d0f645 [feat] add DeskHub device wall and shared group actions 2026-10-12 03:08:04 +08:00
dijunkun 54dbd04a8d [fix] isolate background connections and password retries 2026-10-12 03:08:00 +08:00
dijunkun c8907a00f9 [feat] negotiate per-device preview and audio subscriptions 2026-10-12 03:07:10 +08:00
dijunkun c9e0078bfb [fix] persist successful connections without saved passwords 2026-10-12 03:06:33 +08:00
dijunkun e156ff5277 [fix] handle Markdown separators in release notes 2026-10-12 03:06:18 +08:00
dijunkun b44a203b19 [fix] update MiniRTC with DTLS retransmission recovery 2026-10-12 03:06:03 +08:00
Junkun Di 852710f428 Merge pull request #111 from lamugu/fix/recent-tooltip-layer
最近连接悬浮框不再压住连接弹窗的确认按钮
2026-10-11 17:22:58 +08:00
Junkun Di 2d51936ca8 Merge pull request #110 from lamugu/fix/dtls-handshake-timeout
[fix] time out stalled DTLS handshakes so reconnects can recover
2026-10-11 17:03:37 +08:00
dijunkun 3baedac6df [chore] update MiniRTC after removing deadline tests 2026-10-11 16:59:41 +08:00
dijunkun a667fe3b31 [fix] preserve current MiniRTC fixes with bounded DTLS recovery 2026-10-11 16:57:26 +08:00
dijunkun 05f6d3d90a [fix] show truthful signaling recovery state and pin safe retries 2026-10-11 15:35:11 +08:00
lamagu 0e7d175631 [feat] add manual signaling reconnect action 2026-10-11 15:35:11 +08:00
lamagu 52ca253980 [fix] delay signaling offline indication during reconnects 2026-10-11 15:35:11 +08:00
lamagu 1925e5ffcc [fix] time out stalled DTLS handshakes so reconnects can recover
Without a deadline a half-negotiated session stayed CONNECTED forever;
every further reconnect reused that dead state until the app restarted.
2026-10-10 21:03:14 +08:00
dijunkun 3aa0470510 [fix] restore password prompts and handle join authentication failures 2026-10-10 17:00:58 +08:00
dijunkun fbbb8c9e2c [fix] align Android versions with release tags, refs #107 2026-10-10 15:10:03 +08:00
lamagu 3750687e66 最近连接悬浮框不再压住连接弹窗的确认按钮
悬浮框当初为了盖住 ScrollView 给了 z:50,正好和模态层(z 39~51)同层,
鼠标停在列表上时黑框会盖住"对等连接已建立"的确认按钮。降到 z:20(模态层
之下、标题菜单之上),并在连接弹窗打开时直接不显示。
2026-10-10 14:01:44 +08:00
239 changed files with 21338 additions and 3208 deletions
+56 -26
View File
@@ -2,6 +2,12 @@ name: Build Android
on:
workflow_dispatch:
inputs:
patch:
description: "Optional patch number, matching the main build workflow."
required: false
type: string
default: "0"
workflow_call:
inputs:
source_tag:
@@ -9,9 +15,14 @@ on:
required: false
type: string
default: ""
patch:
description: "Optional patch number from the main build workflow."
required: false
type: string
default: "0"
outputs:
version:
description: "Independent Android version and build date."
description: "Android artifact version; combined releases match the desktop tag."
value: ${{ jobs.build.outputs.version }}
pull_request:
paths:
@@ -147,51 +158,70 @@ jobs:
- name: Verify shared license catalog
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Resolve Android version
shell: python
env:
PATCH_NUMBER: ${{ inputs.patch || '0' }}
run: |
import datetime
import os
from pathlib import Path
import re
import runpy
resolver = Path("apps/android/scripts/release_version.py")
if resolver.exists():
runpy.run_path(str(resolver), run_name="__main__")
else:
# Older standalone releases predate the resolver and Gradle properties.
# Keep their original package version when retrying an existing tag.
tag = os.environ["CROSSDESK_SOURCE_TAG"]
match = re.fullmatch(r"android-v([0-9]+\.[0-9]+\.[0-9]+)-([0-9]{8})", tag)
config = Path("apps/android/app/build.gradle").read_text()
name = re.search(r"versionName\s+'([^']+)'", config)
code = re.search(r"versionCode\s+([0-9]+)", config)
if not match or not name or not code or match[1] != name[1]:
raise ValueError("Legacy Android sources require a matching standalone release tag")
datetime.datetime.strptime(match[2], "%Y%m%d")
with open(os.environ["GITHUB_ENV"], "a") as stream:
stream.write(f"ANDROID_VERSION_NAME={name[1]}\nANDROID_VERSION_CODE={code[1]}\n"
f"ANDROID_ARTIFACT_VERSION={tag.removeprefix('android-')}\n")
- name: Build APKs
working-directory: apps/android
run: |
./gradlew --no-daemon --stacktrace \
-PcrossdeskVersionName="${ANDROID_VERSION_NAME}" \
-PcrossdeskVersionCode="${ANDROID_VERSION_CODE}" \
:app:assembleDebug :app:assembleRelease
- name: Verify APKs
working-directory: apps/android
run: |
python3 -B test/verify_apk.py app/build/outputs/apk/debug/app-debug.apk
python3 -B test/verify_apk.py app/build/outputs/apk/release/app-release-unsigned.apk
for APK in app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/release/app-release-unsigned.apk; do
python3 -B test/verify_apk.py "${APK}" \
--aapt2 "${ANDROID_HOME}/build-tools/36.0.0/aapt2" \
--version-name "${ANDROID_VERSION_NAME}" --version-code "${ANDROID_VERSION_CODE}"
done
"${ANDROID_HOME}/build-tools/36.0.0/apksigner" verify app/build/outputs/apk/debug/app-debug.apk
- name: Package APKs with the independent Android version
- name: Package APKs with the resolved Android version
id: package
shell: python
run: |
import datetime
import json
import os
from pathlib import Path
import re
import shutil
from zoneinfo import ZoneInfo
apk_dir = Path("apps/android/app/build/outputs/apk")
metadata = json.loads((apk_dir / "release/output-metadata.json").read_text())
version = metadata["elements"][0]["versionName"]
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
raise ValueError(f"Invalid Android version: {version!r}")
build_date = datetime.datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
# Match the dated release tags used by the desktop and iOS jobs.
tag = os.environ.get("CROSSDESK_SOURCE_TAG")
if not tag and os.environ["GITHUB_REF_TYPE"] == "tag":
tag = os.environ["GITHUB_REF_NAME"]
if tag:
tag = tag.removeprefix("android-")
match = re.fullmatch(r"v?[0-9]+(?:\.[0-9]+){1,3}-(?:[0-9]+-)?([0-9]{8})(?:-[0-9]+)?", tag)
if match:
build_date = match[1]
datetime.datetime.strptime(build_date, "%Y%m%d")
version = f"v{version}-{build_date}"
source_tag = os.environ.get("CROSSDESK_SOURCE_TAG", "")
if source_tag.startswith("android-v") and source_tag.removeprefix("android-") != version:
raise ValueError(f"Android source tag does not match the app version: {source_tag} vs {version}")
version = os.environ["ANDROID_ARTIFACT_VERSION"]
for variant in ("debug", "release"):
metadata = json.loads((apk_dir / variant / "output-metadata.json").read_text())
for element in metadata["elements"]:
if (element["versionName"] != os.environ["ANDROID_VERSION_NAME"] or
element["versionCode"] != int(os.environ["ANDROID_VERSION_CODE"])):
raise ValueError(f"Unexpected Android {variant} version: {element}")
output = Path(os.environ["RUNNER_TEMP"]) / "crossdesk-android"
for variant, source in (
("unsigned", "release/app-release-unsigned.apk"),
+2
View File
@@ -346,6 +346,8 @@ jobs:
build-android:
name: Build Android
uses: ./.github/workflows/build-android.yml
with:
patch: ${{ inputs.patch || '0' }}
permissions:
contents: read
+17 -9
View File
@@ -1,18 +1,18 @@
# CrossDesk Privacy Policy
Last updated: October 10, 2026
Last updated: October 15, 2026
[中文](#中文) | [English](#english)
## 中文
更新日期:2026 年 10 月 10 日
更新日期:2026 年 10 月 15 日
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
### 一、适用范围
本政策适用于 CrossDesk 桌面客户端、iOS 与 Android 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
本政策适用于 CrossDesk 桌面客户端、iOS、Android 与鸿蒙移动客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
@@ -50,11 +50,13 @@ Android 中的设备身份及您选择保存的访问密码使用 Android Keysto
iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。
Android 默认信令连接同样进行 TLS 证书校验。
Android 与鸿蒙移动端默认信令连接同样进行 TLS 证书校验。
鸿蒙移动端使用系统 Asset Store 加密保存设备身份及您选择保存的访问密码,明确禁止凭据跨设备同步。设置和连接记录按服务器隔离。远程预览默认关闭,单独确认后从下一次连接起保存一张 640×360 预览;预览保存在应用私有缓存中,不上传,可随时清除,也可能被系统回收。关闭预览或撤回联网授权会清除预览。接收文件保存在应用私有目录,您可在会话中通过系统文件选择器导出。
### 五、移动端权限与授权选择
**隐私授权。** iOS 与 Android 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
**隐私授权。** iOS、Android 与鸿蒙移动端 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。
@@ -64,6 +66,8 @@ Android 客户端同样作为控制端接收远程画面与音频,不采集本
Android 远程会话优先使用系统画中画在切换应用后继续显示画面。画中画不可用时,通过前台服务和连接通知保持您已建立的远程连接;后台暂停本地画面渲染和音频播放,不申请悬浮窗权限。通知权限用于提供返回及断开入口,CPU 唤醒锁用于会话后台期间维持连接,返回前台或会话结束时释放。您可以在应用或连接通知中断开,移除应用任务也会结束会话及保活服务。
鸿蒙手机和平板只作为控制端,不申请本机录屏、输入注入、摄像头或麦克风权限;文件选择和剪贴板操作由您主动触发。已建立会话在切入后台时优先使用系统画中画,提供静音控制;画中画不可用时申请数据传输后台任务并显示系统通知。若系统不允许或停止后台任务且画中画不可用,会话会断开。后台切换释放远端按键;空闲后台不维持信令连接。完成联网授权后,应用在前台检查鸿蒙本机版本,并在连接后检查远端版本;版本请求不携带设备身份或远端版本。
应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。
### 六、查询与删除请求
@@ -86,13 +90,13 @@ Android 远程会话优先使用系统画中画在切换应用后继续显示画
## English
Updated: October 2, 2026
Updated: October 15, 2026
CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software.
### 1. Scope
This policy applies to the CrossDesk desktop, iOS and Android clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
This policy applies to the CrossDesk desktop, iOS, Android and HarmonyOS mobile clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply.
@@ -130,11 +134,13 @@ On Android, device identities and access passwords you choose to save are encryp
On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people.
Default signaling connections on Android also validate TLS certificates.
Default signaling connections on Android and HarmonyOS mobile also validate TLS certificates.
HarmonyOS mobile uses the system Asset Store to encrypt device identities and saved access passwords, with cross-device credential synchronization disabled. Settings and connection records are scoped to the server. Remote previews are off by default. After separate confirmation, one 640×360 preview is saved per connection starting with the next connection. Previews remain in private app cache, are never uploaded, can be cleared and may be reclaimed by the system. Disabling previews or withdrawing network consent clears them. Received files remain in private app storage and can be exported through the system document picker during the session.
### 5. Mobile permissions and consent choices
**Privacy authorization.** On first use of iOS or Android, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
**Privacy authorization.** On first use of iOS, Android or HarmonyOS mobile, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
**Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data.
@@ -144,6 +150,8 @@ The Android client also receives remote screens and audio as a controller and do
Android remote sessions prefer system picture-in-picture to continue displaying video when you switch apps. If picture-in-picture is unavailable, a foreground service and connection notification maintain the remote connection you have established. Local video rendering and audio playback pause in the background; no overlay permission is requested. Notification permission provides return and disconnect actions. A CPU wake lock helps maintain the connection while the session is backgrounded and is released on foreground return or session end. You can disconnect in the app or its connection notification; removing the app task also ends the session and its foreground service.
HarmonyOS phones and tablets act only as controllers and do not request local screen recording, input injection, camera or microphone permissions. File selection and clipboard actions are user initiated. An established session prefers system picture-in-picture with a mute control when backgrounded. If unavailable, the app requests a data-transfer background task with a system notification. The session ends if the system disallows or stops that task while picture-in-picture is unavailable. Backgrounding releases remote keys; idle background sessions do not retain signaling. After network consent, the foreground app checks the local HarmonyOS version and checks the remote version after connection. These requests contain neither device identity nor the remote version.
The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking.
### 6. Access and deletion requests
+82 -17
View File
@@ -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">
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android-blue)](#download)
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
@@ -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
View File
@@ -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">
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android-blue)](#download)
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
@@ -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 -7
View File
@@ -1,6 +1,6 @@
# CrossDesk Android
原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。当前版本为 1.6.0,支持 Android 8.0(API 26)及以上的 arm64 手机和平板。
原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。当前版本为 1.6.1,支持 Android 8.0(API 26)及以上的 arm64 手机和平板。
## 当前功能
@@ -83,11 +83,11 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP
[`Build Android`](../../.github/workflows/build-android.yml) 可在 Actions 页面单独手动运行;修改 Android、MiniRTC、共享协议或许可证资料的 Pull Request 也会触发。仓库原有的 [`Build and Release`](../../.github/workflows/build.yml) 在分支推送、标签推送及手动运行时调用同一工作流,`v` 标签发布会等待安卓构建及签名成功,并将已签名 Release APK 加入 GitHub Release 和现有下载服务器的产物集合。推送主仓库前,先确保它引用的 MiniRTC 提交已推送到子模块远程仓库,否则 CI 无法完成检出。
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先提交 `app/build.gradle` 中需要发布的 `versionName`,再为该提交创建 `android-v<versionName>-YYYYMMDD` 标签,例如:
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先更新并提交 `gradle.properties` 中的 `crossdeskVersionName` 和递增的 `crossdeskVersionCode`,再为该提交创建 `android-v<versionName>-YYYYMMDD` 标签,例如:
```sh
git tag android-v1.6.0-20261002
git push origin android-v1.6.0-20261002
git tag android-v1.6.1-20261010
git push origin android-v1.6.1-20261010
```
该标签只触发安卓构建,使用固定发布密钥签名并验证 Release APK 后,再发布到独立的 GitHub Release 和现有下载服务器。签名失败或缺少签名 Secrets 时停止发布,不回退到未签名 APK。标签版本必须与应用版本一致,日期必须有效。重试发布时,在 Actions 页面手动运行 `Release Mobile`,将已有标签填入 `source_tag`;即使从其他分支触发,也会检出该标签的源码。APK 的源码资料记录安卓标签及准确的应用、MiniRTC 提交。
@@ -100,15 +100,15 @@ git push origin android-v1.6.0-20261002
Gradle 缓存与 Android 原生依赖缓存分别管理;原生缓存按宿主架构、构建脚本及 MiniRTC 配方区分,不复用桌面或 iOS 的配置和目标文件。GitHub 托管 runner 每次启动仍是新环境;第一次运行或缓存失效时需要安装。不同分支和标签可读取默认分支的缓存,因此建议先在默认分支运行一次 `Build Android` 预热,再创建发布标签。
CI 仅构建和打包 Debug、Release APK,并校验 APK 的 arm64 ABI、16 KB ELF / ZIP 对齐、完整许可证及 Debug 签名。单元测试、本机 C++ 协议与画面队列测试、Lint 和设备测试改为按需在本地执行;设备测试使用下面的设备验证流程在 arm64 手机或模拟器上执行。
CI 仅构建和打包 Debug、Release APK,并校验 APK 的包名、`versionName` / `versionCode`、arm64 ABI、16 KB ELF / ZIP 对齐、完整许可证及 Debug 签名。单元测试、本机 C++ 协议与画面队列测试、Lint 和设备测试改为按需在本地执行;设备测试使用下面的设备验证流程在 arm64 手机或模拟器上执行。
产物使用 Android 工程自己的 `versionName`,例如 `v1.6.0-20261002`,不跟随桌面版本号。构建日期采用上海时区;带日期的版本标签沿用标签中的日期,与 iOS 一致。Actions 提供:
统一 `v*` 发布从标签提取数字版本,通过 Gradle 属性写入 APK 的 `versionName`;App 内版本展示读取同一包信息。产物版本、日期及可选热修复编号与桌面端保持一致,例如 `v1.6.1-20261010` 或 `v1.6.1-2-20261010`。分支构建和独立 `android-v*` 发布使用 `gradle.properties` 中的版本,独立发布会检查标签与配置一致。构建日期采用上海时区;带日期的版本标签沿用标签中的日期,与 iOS 一致。Actions 提供:
- `crossdesk-android-arm64-<版本>.apk`:已签名 Release APK,独立安卓发布和全平台标签发布收集此 APK,可直接安装。
- `crossdesk-android-arm64-unsigned-<版本>.apk`:构建工作流的中间产物,也可用于自行签名和验证修改后的构建。
- `crossdesk-android-arm64-debug-<版本>.apk`:可安装的调试应用,保留 14 天。
Debug APK 使用每次运行的临时调试证书,可能无法覆盖手机上已有的其他签名版本;卸载原应用会清除本机连接记录和密码。需要持续覆盖升级时,应使用同一私钥签名 Release APK,并在发布新版本时递增 `app/build.gradle` 中的 `versionCode`。未签名 APK 已完成对齐,可通过 SDK 的 `apksigner sign --ks <自己的密钥库> --out <已签名.apk> <未签名.apk>` 签名,再用 `apksigner verify` 检查。
Debug APK 使用每次运行的临时调试证书,可能无法覆盖手机上已有的其他签名版本;卸载原应用会清除本机连接记录和密码。需要持续覆盖升级时,应使用同一私钥签名 Release APK,并在每次发布新版本(含统一发布及热修复)时递增 `gradle.properties` 中的 `crossdeskVersionCode`。该安装升级序号独立于显示版本,不从日期或 CI 运行编号推算。未签名 APK 已完成对齐,可通过 SDK 的 `apksigner sign --ks <自己的密钥库> --out <已签名.apk> <未签名.apk>` 签名,再用 `apksigner verify` 检查。
### Release 签名
+2 -2
View File
@@ -8,8 +8,8 @@ android {
applicationId 'cn.crossdesk.mobile'
minSdk 26
targetSdk 36
versionCode 2
versionName '1.6.0'
versionCode providers.gradleProperty('crossdeskVersionCode').get().toInteger()
versionName providers.gradleProperty('crossdeskVersionName').get()
ndk { abiFilters 'arm64-v8a' }
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
+2
View File
@@ -1,2 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
crossdeskVersionName=1.6.1
crossdeskVersionCode=3
+4 -28
View File
@@ -7,35 +7,11 @@
#ifndef _CONTROLLER_PROTOCOL_H_
#define _CONTROLLER_PROTOCOL_H_
#include <minirtc.h>
#include <nlohmann/json.hpp>
#include <remote_action.h>
#include "../../../libs/controller/include/mobile_controller_protocol.h"
namespace crossdesk::android_controller {
inline std::string SecureAttentionMessage() {
RemoteAction action{};
action.type = service_command;
action.c.flag = send_sas;
return action.to_json();
using mobile_controller::SecureAttentionMessage;
using mobile_controller::VideoSettingsMessage;
using mobile_controller::NetworkReport;
}
inline std::string VideoSettingsMessage(int quality, int frame_rate, int preference,
uint32_t request_id) {
RemoteAction action{};
action.type = video_settings;
action.vs = {quality, frame_rate, preference, request_id, false};
return action.to_json();
}
inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, TraversalMode mode) {
const auto traffic = [](const MiniRtcInboundStats& in, const MiniRtcOutboundStats& out) {
return nlohmann::json{{"inbound", in.bitrate}, {"outbound", out.bitrate}, {"loss", in.loss_rate}};
};
return {{"rtt", stats.rtt_ms}, {"mode", mode}, {"srtp", stats.srtp_active},
{"video", traffic(stats.video_inbound_stats, stats.video_outbound_stats)},
{"audio", traffic(stats.audio_inbound_stats, stats.audio_outbound_stats)},
{"data", traffic(stats.data_inbound_stats, stats.data_outbound_stats)},
{"total", traffic(stats.total_inbound_stats, stats.total_outbound_stats)}};
}
} // namespace crossdesk::android_controller
#endif
+4 -260
View File
@@ -7,265 +7,9 @@
#ifndef _FILE_TRANSFER_SESSION_H_
#define _FILE_TRANSFER_SESSION_H_
#include <file_transfer_format.h>
#include <stream_names.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <mutex>
#include <optional>
#include "../../../libs/controller/include/mobile_file_transfer_session.h"
namespace crossdesk::android_controller {
using mobile_controller::FileTransferSession;
}
// File I/O is bounded to one chunk per pump. Feedback is delivered by MiniRTC
// callback threads; no callback waits for the RTC executor or for a remote ACK.
class FileTransferSession {
public:
using Clock = std::chrono::steady_clock;
using Send = std::function<int(const char*, const char*, size_t)>;
using Progress = std::function<void(const std::string&, double, bool,
const std::string&)>;
FileTransferSession(Send send, Progress progress)
: send_(std::move(send)), progress_(std::move(progress)) {}
~FileTransferSession() {
if (outgoing_) {
outgoing_->input.close();
std::error_code ec;
std::filesystem::remove(outgoing_->path, ec);
}
for (auto& entry : incoming_) Remove(entry.second);
}
void SetDirectory(std::filesystem::path directory) {
std::lock_guard<std::mutex> lock(mutex_);
directory_ = std::move(directory);
}
bool Start(const std::filesystem::path& path, const std::string& name,
Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
if (outgoing_ || name.empty() || name.size() > UINT16_MAX) return false;
std::error_code ec;
if (!std::filesystem::is_regular_file(path, ec)) return false;
const auto size = std::filesystem::file_size(path, ec);
if (ec) return false;
Outgoing file;
file.input.open(path, std::ios::binary);
if (!file.input) return false;
file.id = ++next_id_;
file.name = name;
file.path = path;
file.total = size;
file.updated = now;
outgoing_ = std::move(file);
progress_(name, 0, true, "");
return true;
}
void Pump(Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
for (auto it = incoming_.begin(); it != incoming_.end();) {
if (now - it->second.updated < timeout_) { ++it; continue; }
Reject(it->first, it->second.total, it->second.received);
progress_(it->second.name, -1, false, "");
Remove(it->second);
it = incoming_.erase(it);
}
if (!outgoing_) return;
auto& file = *outgoing_;
if (now - file.updated >= timeout_) { FinishSend(false); return; }
if (file.last || file.sent - file.acked >= 1024 * 1024) return;
std::array<char, kFileChunkSize> payload{};
const auto size = static_cast<uint32_t>(std::min<uint64_t>(payload.size(), file.total - file.sent));
if (size) {
file.input.read(payload.data(), size);
if (file.input.gcount() != size) { FinishSend(false); return; }
}
const bool first = file.sent == 0;
const bool last = file.sent + size == file.total;
const auto chunk = EncodeFileChunk(file.id, file.sent, file.total,
payload.data(), size, first ? &file.name : nullptr, first, last);
if (chunk.empty() || send_(kFileStream, chunk.data(), chunk.size()) != 0) {
FinishSend(false); return;
}
file.sent += size;
file.last = last;
}
void Ack(const char* data, size_t size, Clock::time_point now = Clock::now()) {
FileTransferAck ack{};
if (!DecodeFileTransferAck(data, size, &ack)) return;
std::lock_guard<std::mutex> lock(mutex_);
if (!outgoing_ || ack.file_id != outgoing_->id) return;
auto& file = *outgoing_;
if (ack.total_size != file.total || ack.acked_offset > file.sent ||
(ack.flags & ~3u)) return;
if (ack.flags & 2) { FinishSend(false); return; }
if (ack.flags & 1) {
if (file.last && ack.acked_offset == file.total) FinishSend(true);
return;
}
if (ack.acked_offset <= file.acked) return;
file.acked = ack.acked_offset;
file.updated = now;
Report(file.name, file.acked, file.total, true, file.percent);
}
void Receive(const char* data, size_t size, Clock::time_point now = Clock::now()) {
FileChunkView chunk;
if (!DecodeFileChunk(data, size, &chunk)) return;
const auto& h = chunk.header;
std::lock_guard<std::mutex> lock(mutex_);
auto it = incoming_.find(h.file_id);
const bool first = h.flags & 1, last = h.flags & 2;
const bool invalid = (h.flags & ~3u) || h.total_size > INT64_MAX ||
(first && h.offset != 0) || chunk.payload_size > kFileChunkSize ||
size != sizeof(FileChunkHeader) + h.name_len + chunk.payload_size ||
(!first && h.name_len) || (!chunk.payload_size && !last) ||
last != (h.offset + chunk.payload_size == h.total_size);
if (invalid || (it == incoming_.end() && incoming_.size() >= 4)) {
Reject(h.file_id, h.total_size, h.offset);
if (it != incoming_.end()) { progress_(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it); }
return;
}
if (it == incoming_.end()) {
Incoming file;
file.name = SafeName(chunk.file_name);
file.total = h.total_size;
std::error_code ec;
if (!directory_.empty()) std::filesystem::create_directories(directory_, ec);
// A per-transfer directory avoids overwrites, even for duplicate names.
auto folder = directory_ / (std::to_string(h.file_id) + "-" + std::to_string(++receive_id_));
if (directory_.empty() || ec || !std::filesystem::create_directory(folder, ec)) {
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); return;
}
file.path = folder / file.name;
file.partial = folder / ".partial";
file.output.open(file.partial, std::ios::binary | std::ios::trunc);
if (!file.output) {
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); Remove(file); return;
}
it = incoming_.emplace(h.file_id, std::move(file)).first;
}
auto& file = it->second;
if (h.total_size != file.total || (first && file.first) ||
!AddRange(file, h.offset, h.offset + chunk.payload_size)) {
Reject(h.file_id, h.total_size, h.offset);
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
}
if (first) {
file.first = true;
file.name = SafeName(chunk.file_name);
file.path = file.path.parent_path() / file.name;
}
file.last |= last;
// KCP removes messages in order, but delivers batches outside its lock on
// both RTP and timer threads. Track coverage instead of assuming callbacks
// arrive in order; ACK only the contiguous prefix and never expose holes.
file.received = !file.ranges.empty() && file.ranges.begin()->first == 0
? file.ranges.begin()->second : 0;
const bool complete = file.first && file.last && file.received == file.total;
file.output.seekp(static_cast<std::streamoff>(h.offset));
file.output.write(chunk.payload, chunk.payload_size);
if (complete) { file.output.flush(); file.output.close(); }
std::error_code ec;
if (complete && !file.output.fail()) std::filesystem::rename(file.partial, file.path, ec);
if (file.output.fail() || ec) {
Reject(h.file_id, h.total_size, h.offset);
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
}
file.updated = now;
const FileTransferAck ack{kFileAckMagic, h.file_id, file.received, file.total, complete ? 1u : 0u};
const auto packet = EncodeFileTransferAck(ack);
send_(kFileFeedbackStream, packet.data(), packet.size());
if (complete) {
progress_(file.name, 1, false, file.path.string());
incoming_.erase(it);
} else Report(file.name, file.received, file.total, false, file.percent);
}
bool Sending() {
std::lock_guard<std::mutex> lock(mutex_);
return outgoing_.has_value();
}
private:
struct Outgoing {
uint32_t id = 0;
std::string name;
std::filesystem::path path;
std::ifstream input;
uint64_t total = 0, sent = 0, acked = 0;
bool last = false;
int percent = -1;
Clock::time_point updated;
};
struct Incoming {
std::string name;
std::filesystem::path path, partial;
std::ofstream output;
uint64_t total = 0, received = 0;
bool first = false, last = false;
std::map<uint64_t, uint64_t> ranges;
int percent = -1;
Clock::time_point updated;
};
static bool AddRange(Incoming& file, uint64_t start, uint64_t end) {
if (start == end) return true;
auto next = file.ranges.lower_bound(start);
if (next != file.ranges.end() && next->first < end) return false;
if (next != file.ranges.begin()) {
auto previous = std::prev(next);
if (previous->second > start) return false;
if (previous->second == start) { start = previous->first; file.ranges.erase(previous); }
}
if (next != file.ranges.end() && next->first == end) { end = next->second; file.ranges.erase(next); }
file.ranges.emplace(start, end);
return file.ranges.size() <= 1024;
}
static std::string SafeName(std::string name) {
const auto slash = name.find_last_of("/\\");
if (slash != std::string::npos) name.erase(0, slash + 1);
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == ':') ch = '_';
if (name.empty() || name == "." || name == ".." || name == ".partial" || name.size() > 240) return "received_file";
return name;
}
static void Remove(Incoming& file) {
file.output.close();
std::error_code ec;
std::filesystem::remove(file.partial, ec);
std::filesystem::remove(file.path.parent_path(), ec);
}
void Reject(uint32_t id, uint64_t total, uint64_t offset) {
const auto packet = EncodeFileTransferAck({kFileAckMagic, id, offset, total, 2});
send_(kFileFeedbackStream, packet.data(), packet.size());
}
void Report(const std::string& name, uint64_t offset, uint64_t total, bool sending, int& previous) {
const double progress = total ? static_cast<double>(offset) / total : 0;
const int percent = static_cast<int>(progress * 100);
if (percent != previous) { previous = percent; progress_(name, std::min(progress, .99), sending, ""); }
}
void FinishSend(bool success) {
progress_(outgoing_->name, success ? 1 : -1, true, "");
outgoing_->input.close();
std::error_code ec;
std::filesystem::remove(outgoing_->path, ec);
outgoing_.reset();
}
const std::chrono::seconds timeout_{30};
std::mutex mutex_;
Send send_;
Progress progress_;
std::filesystem::path directory_;
std::optional<Outgoing> outgoing_;
std::map<uint32_t, Incoming> incoming_;
uint32_t next_id_ = 0;
uint64_t receive_id_ = 0;
};
} // namespace crossdesk::android_controller
#endif
#endif
+3 -15
View File
@@ -7,21 +7,9 @@
#ifndef _VIDEO_TIMING_H_
#define _VIDEO_TIMING_H_
#include <cstdint>
#include "../../../libs/controller/include/mobile_video_timing.h"
namespace crossdesk::android_controller {
inline double VideoLatencyMilliseconds(uint64_t captured_us,
uint64_t received_us,
int64_t submitted_us) {
// MiniRTC calibrates capture time when the frame is reassembled. Validate
// that mapping at reception, not after decoding/Surface waits: a real local
// backlog over five seconds must remain visible in the latency measurement.
if (!captured_us || received_us < captured_us || submitted_us <= 0 ||
received_us > static_cast<uint64_t>(submitted_us) ||
received_us - captured_us > 5'000'000)
return -1;
return (static_cast<uint64_t>(submitted_us) - captured_us) / 1000.0;
using mobile_controller::VideoLatencyMilliseconds;
}
} // namespace crossdesk::android_controller
#endif
#endif
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Resolve Android package and artifact versions before building the APKs."""
import datetime
import os
from pathlib import Path
import re
from zoneinfo import ZoneInfo
def resolve_version(version_name, version_code, tag="", patch="0", today=None):
build_date = today or datetime.datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
patch = patch if re.fullmatch(r"[0-9]+", patch) else "0"
if tag:
independent = tag.startswith("android-v")
source_version = tag.removeprefix("android-")
base = r"(?P<base>[0-9]+\.[0-9]+\.[0-9]+)"
for pattern in (
rf"v{base}-(?P<patch>[0-9]+)-(?P<date>[0-9]{{8}})",
rf"v{base}-(?P<date>[0-9]{{8}})(?:-(?P<patch>[0-9]+))?",
rf"v{base}(?:-(?P<patch>[0-9]+))?",
):
match = re.fullmatch(pattern, source_version)
if match:
fields = match.groupdict()
if independent and fields["base"] != version_name:
raise ValueError(f"Android source tag does not match the app version: {tag} vs {version_name}")
version_name = fields["base"]
build_date = fields.get("date") or build_date
patch = fields.get("patch") or patch
break
else:
raise ValueError(f"Invalid Android release tag: {tag!r}")
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version_name):
raise ValueError(f"Invalid Android versionName: {version_name!r}")
if not re.fullmatch(r"[0-9]+", version_code) or not 0 < int(version_code) <= 2100000000:
raise ValueError(f"Invalid Android versionCode: {version_code!r}")
datetime.datetime.strptime(build_date, "%Y%m%d")
suffix = f"-{patch}" if patch != "0" else ""
artifact_version = f"v{version_name}{suffix}-{build_date}"
if tag.startswith("android-v") and tag.removeprefix("android-") != artifact_version:
raise ValueError(f"Android source tag does not match the artifact version: {tag} vs {artifact_version}")
return {"ANDROID_VERSION_NAME": version_name, "ANDROID_VERSION_CODE": str(int(version_code)),
"ANDROID_ARTIFACT_VERSION": artifact_version}
def main():
properties = dict(line.split("=", 1) for line in
(Path(__file__).resolve().parents[1] / "gradle.properties").read_text().splitlines()
if "=" in line and not line.lstrip().startswith("#"))
tag = os.environ.get("CROSSDESK_SOURCE_TAG", "")
if not tag and os.environ.get("GITHUB_REF_TYPE") == "tag":
ref_name = os.environ["GITHUB_REF_NAME"]
# Moving aliases such as "latest" are builds, not versioned releases.
if ref_name.startswith(("v", "android-v")):
tag = ref_name
values = resolve_version(properties["crossdeskVersionName"], properties["crossdeskVersionCode"],
tag, os.environ.get("PATCH_NUMBER", "0"))
text = "".join(f"{key}={value}\n" for key, value in values.items())
if os.environ.get("GITHUB_ENV"):
with open(os.environ["GITHUB_ENV"], "a") as stream:
stream.write(text)
print(text, end="")
if __name__ == "__main__":
main()
+25 -2
View File
@@ -1,13 +1,24 @@
#!/usr/bin/env python3
"""Check the shipped native ABI, ELF load alignment and APK zip alignment."""
from pathlib import Path
import argparse
import hashlib
import json
import re
import struct
import sys
import subprocess
import zipfile
def verify_package_version(badging, version_name, version_code):
package = next((line for line in badging.splitlines() if line.startswith('package: ')), '')
attributes = dict(re.findall(r"(\w+)='([^']*)'", package))
expected = {'name': 'cn.crossdesk.mobile', 'versionName': version_name, 'versionCode': str(version_code)}
for key, value in expected.items():
if attributes.get(key) != value:
raise ValueError(f'Unexpected APK {key}: expected {value!r}, got {attributes.get(key)!r}')
def verify(path):
with zipfile.ZipFile(path) as archive, open(path, 'rb') as raw:
libraries = [i for i in archive.infolist() if i.filename.endswith('.so')]
@@ -59,4 +70,16 @@ def verify(path):
if __name__ == '__main__':
verify(sys.argv[1])
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('apk', type=Path)
parser.add_argument('--aapt2', type=Path)
parser.add_argument('--version-name')
parser.add_argument('--version-code', type=int)
args = parser.parse_args()
if any(value is not None for value in (args.aapt2, args.version_name, args.version_code)):
if any(value is None for value in (args.aapt2, args.version_name, args.version_code)):
parser.error('--aapt2, --version-name and --version-code must be supplied together')
badging = subprocess.check_output([str(args.aapt2), 'dump', 'badging', str(args.apk)], text=True)
verify_package_version(badging, args.version_name, args.version_code)
print(f'PASS: {args.apk.name}: versionName={args.version_name}, versionCode={args.version_code}')
verify(args.apk)
@@ -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;
@@ -72,8 +72,6 @@ struct ServerWindowState {
};
struct UiState {
bool password_validating_ = false;
uint32_t password_validating_time_ = 0;
bool rejoin_ = false;
bool show_password_ = true;
bool show_connection_status_window_ = false;
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,7 @@
#include <remote_action.h>
#include "application/signal_connection_indicator.h"
#include "runtime/cursor_state_provider.h"
#include "runtime/gui_runtime.h"
@@ -49,7 +50,7 @@ private:
void InitializeUi();
void InitializeSystemTray();
bool MinimizeMainWindowToTray();
void BindMainCallbacks();
void BindMainCallbacks(bool gallery_window = false);
void BindStreamCallbacks();
void BindServerCallbacks();
void Tick();
@@ -59,6 +60,25 @@ private:
void HandlePasswordChangeResult();
void HandleCredentialRecovery();
void SyncMainWindow();
void ShowGalleryWindow();
void CloseGalleryWindow();
void SyncWindowGeometry(bool gallery_window);
void ToggleGalleryFullscreen();
void RestoreWindowGeometry(bool before_show = false,
bool gallery_window = false);
void SyncGallery();
void GalleryAction(const std::string& action, const std::string& value,
bool gallery_window = true);
bool AddGalleryDevice(const std::string& id, const std::string& password,
const std::string& group, bool remember_password);
bool ImportGalleryRecent(const std::string& group);
void DeviceGroupAction(const std::string& action, const std::string& value,
bool gallery_window);
bool SaveDeviceGroups(const std::string& new_group, bool gallery_window);
void ReleasePressedInput();
void ReleaseControl();
void SyncAudioPlayback();
void SaveGalleryWindowState();
void SyncAnnouncements();
void SyncConnectionDialog();
void SyncPlatformDialogs();
@@ -71,13 +91,13 @@ 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();
void ResetSettingsUi();
bool ValidateSelfHostedSettingsFromUi();
bool SaveSettingsFromUi();
void ResetSettingsUi(bool gallery_window = false);
bool ValidateSelfHostedSettingsFromUi(bool gallery_window = false);
bool SaveSettingsFromUi(bool gallery_window = false);
void ApplySettingsFromConfig(bool reconnect);
#if _WIN32 && CROSSDESK_PORTABLE
void CheckPortableWindowsService();
@@ -106,7 +126,8 @@ private:
cursor_delivery_states_;
uint32_t cursor_state_sequence_ = 0;
std::chrono::steady_clock::time_point next_video_frame_time_{};
#if defined(__linux__) && !defined(__APPLE__)
SignalConnectionIndicator signal_connection_indicator_;
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
bool use_xwayland_gui_ = false;
bool use_x11_custom_titlebar_ = false;
#endif
@@ -0,0 +1,38 @@
#ifndef CROSSDESK_SIGNAL_CONNECTION_INDICATOR_H_
#define CROSSDESK_SIGNAL_CONNECTION_INDICATOR_H_
#include <chrono>
#include <optional>
namespace crossdesk {
// Presentation state only. The actual connected flag remains authoritative
// for actions and other UI consumers, including the announcement inbox.
class SignalConnectionIndicator {
public:
using Clock = std::chrono::steady_clock;
bool Update(bool connected, Clock::time_point now = Clock::now()) {
if (connected) {
disconnected_at_.reset();
} else if (was_connected_) {
disconnected_at_ = now;
}
was_connected_ = connected;
return disconnected_at_ &&
now - *disconnected_at_ < std::chrono::seconds(10);
}
void Reset() {
was_connected_ = false;
disconnected_at_.reset();
}
private:
bool was_connected_ = false;
std::optional<Clock::time_point> disconnected_at_;
};
} // namespace crossdesk
#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()
@@ -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"Удалить объявление") \
@@ -258,6 +259,9 @@ struct TranslationRow {
X(signal_connected, u8"已连接服务器", "Connected", u8"Подключено к серверу") \
X(signal_disconnected, u8"未连接服务器", "Disconnected", \
u8"Нет подключения к серверу") \
X(signal_reconnect, u8"重新连接", "Reconnect", u8"Переподключиться") \
X(signal_reconnecting, u8"正在重新连接...", "Reconnecting...", \
u8"Переподключение...") \
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", \
"Certificate verification failed. Reinstall the self-hosted root " \
"certificate.", \
@@ -272,6 +276,10 @@ struct TranslationRow {
X(receiving_screen, u8"画面接收中...", "Receiving screen...", \
u8"Получение изображения...") \
X(p2p_failed, u8"对等连接失败", "P2P Failed", u8"Сбой P2P") \
X(connection_auth_throttled, u8"验证尝试过于频繁,请稍后重试。", "Too many authentication attempts. Try again later.", u8"Слишком много попыток. Повторите позже.") \
X(connection_service_busy, u8"认证服务繁忙,请稍后重试。", "Authentication service busy. Try again later.", u8"Сервис проверки занят. Повторите позже.") \
X(connection_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", u8"Сеанс входа истёк. Подключитесь заново.") \
X(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \
X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \
X(about, u8"关于", "About", u8"О программе") \
@@ -46,7 +46,8 @@ void ClipboardController::Shutdown() {
pending_remote_text_.reset();
}
void ClipboardController::QueueRemoteText(const char *data, size_t size) {
void ClipboardController::QueueRemoteText(const char* data, size_t size,
const std::string& remote_id) {
if (!events_enabled_.load(std::memory_order_acquire)) {
return;
}
@@ -62,6 +63,11 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
return;
}
const auto session =
remote_id.empty() ? nullptr : owner_.FindRemoteSession(remote_id);
if (!remote_id.empty() && (!session || !session->clipboard_allowed_.load()))
return;
const auto generation = session ? session->input_generation_.load() : 0;
{
std::lock_guard<std::mutex> lock(pending_mutex_);
if (!events_enabled_.load(std::memory_order_relaxed)) {
@@ -70,6 +76,9 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) {
// Only the newest clipboard value matters. Replacing it also bounds the
// amount of memory a busy or malicious peer can queue.
pending_remote_text_ = std::string(data, size);
pending_remote_id_ = remote_id;
pending_session_ = session;
pending_generation_ = generation;
}
SDL_Event event{};
@@ -87,9 +96,22 @@ void ClipboardController::ApplyPendingRemoteText() {
}
std::optional<std::string> pending_text;
std::string source_id;
std::weak_ptr<gui_detail::RemoteSession> source_session;
uint64_t generation = 0;
{
std::lock_guard<std::mutex> lock(pending_mutex_);
pending_text.swap(pending_remote_text_);
source_id.swap(pending_remote_id_);
source_session.swap(pending_session_);
generation = pending_generation_;
}
if (!source_id.empty()) {
auto props = owner_.FindRemoteSession(source_id);
if (!props || props != source_session.lock() ||
!props->clipboard_allowed_.load() ||
props->input_generation_.load() != generation)
return;
}
if (!pending_text || *pending_text == last_text_) {
return;
@@ -145,7 +167,7 @@ int ClipboardController::SendToPeers(const std::string &text) {
std::shared_lock lock(owner_.remote_sessions_mutex_);
for (const auto &[remote_id, props] : owner_.remote_sessions_) {
if (!props || !props->peer_ || !props->connection_established_ ||
!props->enable_mouse_control_) {
!props->clipboard_allowed_.load()) {
continue;
}
@@ -10,10 +10,15 @@
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
namespace crossdesk::gui_detail {
struct RemoteSession;
}
namespace crossdesk {
class GuiRuntime;
@@ -29,7 +34,8 @@ public:
void Initialize();
void Shutdown();
void QueueRemoteText(const char *data, size_t size);
void QueueRemoteText(const char* data, size_t size,
const std::string& remote_id = {});
void ApplyPendingRemoteText();
void HandleLocalUpdate();
@@ -41,6 +47,9 @@ private:
std::atomic<bool> events_enabled_{false};
std::mutex pending_mutex_;
std::optional<std::string> pending_remote_text_;
std::string pending_remote_id_;
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
uint64_t pending_generation_ = 0;
std::string last_text_;
};
@@ -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
@@ -49,7 +49,13 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
frame.data = reinterpret_cast<const char *>(data);
frame.size = size;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
SendAudioFrame(owner_.peer_, &frame, owner_.audio_label_.c_str());
std::shared_lock lock(owner_.connection_status_mutex_);
for (const auto& [id, status] : owner_.connection_status_) {
if (status == ConnectionStatus::Connected)
SendAudioFrameToPeer(owner_.peer_, &frame,
owner_.audio_label_.c_str(), id.data(),
id.size());
}
}) {}
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
@@ -245,6 +251,8 @@ int SessionDeviceManager::InitializeScreenCapturer() {
const auto send_started = std::chrono::steady_clock::now();
uint64_t send_errors = 0;
for (const std::string &remote_id : connected_remote_ids) {
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
stream_id)) continue;
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
remote_id.data(), remote_id.size()) != 0)
++send_errors;
@@ -411,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;
@@ -459,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
@@ -471,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");
@@ -531,14 +539,14 @@ int SessionDeviceManager::InitializeAudioOutput() {
desired_out.format = SDL_AUDIO_S16;
desired_out.channels = 1;
auto open_stream = [&]() {
output_stream_ = SDL_OpenAudioDeviceStream(
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired_out, nullptr, nullptr);
return output_stream_ != nullptr;
auto open_device = [&]() {
output_device_ = SDL_OpenAudioDevice(
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired_out);
return output_device_ != 0;
};
if (!open_stream()) {
#if defined(__linux__) && !defined(__APPLE__)
if (!open_device()) {
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
LOG_WARN("Failed to open output stream with driver [{}]: {}",
getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER")
: "(default)",
@@ -551,7 +559,7 @@ int SessionDeviceManager::InitializeAudioOutput() {
SDL_GetError());
return -1;
}
if (!open_stream()) {
if (!open_device()) {
LOG_ERROR("Failed to open output stream with dummy driver: {}",
SDL_GetError());
return -1;
@@ -563,27 +571,50 @@ int SessionDeviceManager::InitializeAudioOutput() {
#endif
}
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(output_stream_));
SDL_ResumeAudioDevice(output_device_);
return 0;
}
int SessionDeviceManager::DestroyAudioOutput() {
if (output_stream_) {
SDL_CloseAudioDevice(SDL_GetAudioStreamDevice(output_stream_));
SDL_DestroyAudioStream(output_stream_);
output_stream_ = nullptr;
for (const auto& [_, stream] : output_streams_)
SDL_DestroyAudioStream(stream);
output_streams_.clear();
if (output_device_) {
SDL_CloseAudioDevice(output_device_);
output_device_ = 0;
}
return 0;
}
void SessionDeviceManager::PushAudio(const char *data, size_t size) {
if (!output_stream_) {
return;
void SessionDeviceManager::ClearAudio(const std::string& remote_id) {
const auto found = output_streams_.find(remote_id);
if (found == output_streams_.end()) return;
SDL_DestroyAudioStream(found->second);
output_streams_.erase(found);
}
void SessionDeviceManager::PushAudio(const std::string& remote_id,
const char *data, size_t size) {
if (!output_device_) return;
auto found = output_streams_.find(remote_id);
if (found == output_streams_.end()) {
SDL_AudioSpec spec{};
spec.freq = 48000;
spec.format = SDL_AUDIO_S16;
spec.channels = 1;
auto* stream = SDL_CreateAudioStream(&spec, &spec);
if (!stream) {
LOG_ERROR("Failed to create output stream: {}", SDL_GetError());
return;
}
if (!SDL_BindAudioStream(output_device_, stream)) {
LOG_ERROR("Failed to bind output stream: {}", SDL_GetError());
SDL_DestroyAudioStream(stream);
return;
}
found = output_streams_.emplace(remote_id, stream).first;
}
const int pushed = SDL_PutAudioStreamData(
output_stream_, reinterpret_cast<const Uint8 *>(data),
static_cast<int>(size));
if (pushed < 0) {
if (!SDL_PutAudioStreamData(found->second, data, static_cast<int>(size))) {
LOG_ERROR("Failed to push audio data: {}", SDL_GetError());
}
}
@@ -598,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_ &&
@@ -633,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.
@@ -668,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
@@ -13,6 +13,7 @@
#include <cstdint>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
#include <remote_action.h>
@@ -67,7 +68,10 @@ public:
int InitializeAudioOutput();
int DestroyAudioOutput();
void PushAudio(const char *data, size_t size);
// Serialized by GuiRuntime::audio_delivery_mutex_. Each peer has its own
// stream so SDL mixes simultaneous playback and muting clears only that peer.
void ClearAudio(const std::string& remote_id);
void PushAudio(const std::string& remote_id, const char *data, size_t size);
bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code,
bool extended);
@@ -93,7 +97,8 @@ private:
bool from_secure_desktop);
GuiRuntime &owner_;
SDL_AudioStream *output_stream_ = nullptr;
uint32_t output_device_ = 0;
std::unordered_map<std::string, SDL_AudioStream*> output_streams_;
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
ScreenCapturer *screen_capturer_ = nullptr;
std::atomic<bool> cursor_capture_dirty_{true};
@@ -106,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
@@ -0,0 +1,92 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_FULLSCREEN_TRANSITION_H_
#define _GALLERY_FULLSCREEN_TRANSITION_H_
#include <chrono>
#include <cstdint>
namespace crossdesk::gallery {
// Guard input while native geometry settles, without hiding the live content.
// Driven by the existing UI tick; never pumps events or creates nested timers.
class FullscreenTransition {
public:
using Clock = std::chrono::steady_clock;
enum class Action { Idle, Apply, Finished };
bool Begin(bool fullscreen, Clock::time_point now) {
if (Active())
return false;
target_ = fullscreen;
phase_ = Phase::Pending;
phase_at_ = now;
width_ = height_ = 0;
scale_ = 0;
geometry_changed_ = false;
return true;
}
Action Advance(Clock::time_point now, uint32_t width, uint32_t height,
float scale, bool fullscreen, bool restoring) {
using namespace std::chrono_literals;
if (phase_ == Phase::Pending) {
if (restoring && now - phase_at_ >= 1500ms) {
Cancel();
return Action::Finished;
}
// Apply on the next UI tick as soon as window restoration is complete.
if (restoring)
return Action::Idle;
phase_ = Phase::Settling;
phase_at_ = stable_at_ = now;
width_ = width;
height_ = height;
scale_ = scale;
return Action::Apply;
}
if (phase_ == Phase::Settling) {
const bool changed =
width != width_ || height != height_ || scale != scale_;
if (changed && width > 0 && height > 0)
geometry_changed_ = true;
if (changed || fullscreen != target_ || restoring) {
width_ = width;
height_ = height;
scale_ = scale;
stable_at_ = now;
}
// Some backends report the requested flag before delivering new bounds.
// Also allow fullscreen modes whose client dimensions genuinely match.
if ((width > 0 && height > 0 && fullscreen == target_ && !restoring &&
(geometry_changed_ || now - phase_at_ >= 300ms) &&
now - stable_at_ >= 32ms) ||
now - phase_at_ >= 1500ms) {
Cancel();
return Action::Finished;
}
}
return Action::Idle;
}
void Cancel() { phase_ = Phase::Idle; }
bool Active() const { return phase_ != Phase::Idle; }
bool Target() const { return target_; }
private:
enum class Phase { Idle, Pending, Settling };
Phase phase_ = Phase::Idle;
bool target_ = false;
bool geometry_changed_ = false;
uint32_t width_ = 0, height_ = 0;
float scale_ = 0;
Clock::time_point phase_at_{}, stable_at_{};
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,37 @@
#include "gallery_storage.h"
#include <fstream>
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
namespace crossdesk::gallery {
bool WriteAtomically(const std::filesystem::path &path,
const std::string &text) {
auto temporary = path;
temporary += ".tmp";
try {
{
std::ofstream output;
output.exceptions(std::ios::failbit | std::ios::badbit);
output.open(temporary, std::ios::binary | std::ios::trunc);
output.write(text.data(), text.size());
output.close();
}
#ifdef _WIN32
if (MoveFileExW(temporary.c_str(), path.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
return true;
#else
std::filesystem::rename(temporary, path);
return true;
#endif
} catch (...) {
}
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
return false;
}
} // namespace crossdesk::gallery
@@ -0,0 +1,18 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_STORAGE_H_
#define _GALLERY_STORAGE_H_
#include <filesystem>
#include <string>
namespace crossdesk::gallery {
bool WriteAtomically(const std::filesystem::path &path,
const std::string &text);
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,93 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_STREAM_SETTINGS_H_
#define _GALLERY_STREAM_SETTINGS_H_
#include <algorithm>
#include <array>
#include <cstdint>
#include <nlohmann/json.hpp>
#include <optional>
#include <preview_subscription.h>
#include "preview_resolution.h"
namespace crossdesk::gallery {
struct StreamSettings {
// Persisted preset order: automatic, fixed bounds, then original size.
static constexpr std::array<PreviewResolution, 8> kResolutions{{
{}, {320, 180}, {640, 360}, {1280, 720}, {1920, 1080},
{2560, 1440}, {3840, 2160}, {}}};
static constexpr std::array<int, 4> kFrameRates{0, 15, 30, 60};
static constexpr std::array<int, 6> kBandwidthMbps{2, 5, 10, 20, 50, 100};
int resolution = 0;
int frame_rate = 0; // 0 follows the session's existing 30/60 FPS preference.
int bandwidth_mbps = 20;
static std::optional<StreamSettings> FromIndices(int resolution, int fps,
int bandwidth = 3) {
if (resolution < 0 || resolution >= int(kResolutions.size()) ||
fps < 0 || fps >= int(kFrameRates.size()) ||
bandwidth < 0 || bandwidth >= int(kBandwidthMbps.size()))
return {};
return StreamSettings{resolution, kFrameRates[fps], kBandwidthMbps[bandwidth]};
}
int FrameRateIndex() const {
return int(std::find(kFrameRates.begin(), kFrameRates.end(), frame_rate) -
kFrameRates.begin());
}
int BandwidthIndex() const {
return int(std::find(kBandwidthMbps.begin(), kBandwidthMbps.end(), bandwidth_mbps) -
kBandwidthMbps.begin());
}
bool Valid() const {
return resolution >= 0 && resolution < int(kResolutions.size()) &&
FrameRateIndex() < int(kFrameRates.size()) &&
BandwidthIndex() < int(kBandwidthMbps.size());
}
static StreamSettings FromJson(const nlohmann::json& json) {
try {
if (!json.at("resolution").is_number_integer() ||
!json.at("frame_rate").is_number_integer()) return {};
const auto resolution = json.at("resolution").get<int64_t>();
const auto fps = json.at("frame_rate").get<int64_t>();
if (resolution < 0 || resolution >= int(kResolutions.size()) ||
fps < 0 || fps > 60) return {};
// Retired sparse presets migrate to the lowest current setting.
StreamSettings result{int(resolution), fps == 1 || fps == 8 ? 15 : int(fps)};
if (json.contains("bandwidth_mbps") && json.at("bandwidth_mbps").is_number_integer()) {
const auto bandwidth = json.at("bandwidth_mbps").get<int64_t>();
if (std::find(kBandwidthMbps.begin(), kBandwidthMbps.end(), bandwidth) !=
kBandwidthMbps.end()) result.bandwidth_mbps = int(bandwidth);
}
return result.Valid() ? result : StreamSettings{};
} catch (...) {
return {};
}
}
nlohmann::json ToJson() const {
return {{"resolution", resolution}, {"frame_rate", frame_rate},
{"bandwidth_mbps", bandwidth_mbps}};
}
PreviewResolution Resolution(bool wall_managed, PreviewResolution wall_view,
PreviewResolution stream_view) const {
if (!wall_managed) return {};
if (resolution != 0 && Valid()) return kResolutions[resolution];
// Keep a bounded fallback until a rendered viewport is available.
if (!wall_view.width && !stream_view.width) return {320, 320};
return {std::max(wall_view.width, stream_view.width),
std::max(wall_view.height, stream_view.height)};
}
void ApplyFrameRate(PreviewSubscription& demand, bool supports_fps15) const {
if (!Valid() || !frame_rate) return;
// Older peers reject fps=15. Keep their existing interactive wire format.
demand.fps = frame_rate == 15 && supports_fps15 ? 15 : 60;
demand.frame_rate = frame_rate <= 30 ? 30 : 60;
}
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,178 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_WINDOW_STATE_H_
#define _GALLERY_WINDOW_STATE_H_
#include <slint.h>
#include <algorithm>
#include <cmath>
#include <optional>
namespace crossdesk::gallery {
struct WindowState {
bool wall_fullscreen = false;
bool wall_maximized = false;
// Store client dimensions in logical pixels, independent of the display DPI.
std::optional<slint::LogicalSize> wall_size;
std::optional<slint::PhysicalPosition> wall_position, connection_position;
void Observe(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool fullscreen,
bool maximized, float compact_height = 486,
float compact_width = 640) {
if (Restoring())
return;
if (!wall) {
connection_position = position;
return;
}
if (!fullscreen && !maximized &&
IsUndersized(size, scale, compact_height, compact_width))
return;
scale = std::max(scale, 1.0f);
wall_fullscreen = fullscreen;
wall_maximized = !fullscreen && maximized;
if (!fullscreen && !maximized) {
wall_size = slint::LogicalSize(
slint::Size<float>{size.width / scale, size.height / scale});
wall_position = position;
}
}
bool RecoverUndersized(bool wall, slint::PhysicalPosition position,
slint::PhysicalSize size, float scale, bool visible,
bool minimized, bool fullscreen, bool maximized,
float compact_height = 486,
float compact_width = 640) {
// Hidden/minimized/uninitialized windows can report temporary tiny bounds.
// Leave those alone, and let an in-flight restoration retain its target.
if (Restoring() || !visible || minimized || fullscreen || maximized ||
size.width == 0 || size.height == 0 ||
!IsUndersized(size, scale, compact_height, compact_width))
return false;
if (wall) {
wall_position = position;
wall_fullscreen = false;
wall_maximized = false;
} else {
connection_position = position;
}
// Keep the last valid logical size; only the current anchor is captured.
BeginRestore();
return true;
}
slint::PhysicalSize RestoreSize(bool wall, float scale,
float compact_height = 486,
float compact_width = 640) const {
const float width =
wall && wall_size ? std::max(compact_width, wall_size->width)
: compact_width;
const float height = wall && wall_size
? std::max(compact_height, wall_size->height)
: compact_height;
scale = std::max(scale, 1.0f);
return slint::PhysicalSize(
slint::Size<uint32_t>{uint32_t(std::lround(width * scale)),
uint32_t(std::lround(height * scale))});
}
void BeginRestore() {
phase_ = Phase::Normal;
target_.reset();
last_size_.reset();
matches_ = 0;
}
void PrepareForShow(slint::Window &window, bool wall,
slint::PhysicalSize size,
std::optional<slint::PhysicalPosition> position,
float initial_y_offset = 0) {
const float scale = std::max(window.scale_factor(), 1.0f);
// The actual display scale is resolved when the native window is created.
// Give the backend logical geometry so its first visible frame is correct.
window.set_size(slint::LogicalSize(
slint::Size<float>{size.width / scale, size.height / scale}));
if (position) {
(wall ? wall_position : connection_position) = position;
window.set_position(slint::LogicalPosition(slint::Point<float>{
position->x / scale, position->y / scale + initial_y_offset}));
}
}
bool Restoring() const { return phase_ != Phase::Idle; }
bool RestoringNormal() const { return phase_ == Phase::Normal; }
// A set_size request can complete asynchronously, after layout constraints
// and the backing scale have changed. Acknowledge observations on later
// ticks.
bool NormalSizeRestored(slint::PhysicalSize actual,
slint::PhysicalSize target) {
if (!RestoringNormal())
return false;
if (target_ && SameSize(*target_, target) && SameSize(actual, target))
++matches_;
else
matches_ = 0;
target_ = target;
if (matches_ < 2)
return false;
phase_ = Phase::Presentation;
matches_ = 0;
last_size_.reset();
return true;
}
bool FinishRestore(bool wall, slint::PhysicalSize actual, bool fullscreen,
bool maximized) {
if (phase_ != Phase::Presentation)
return false;
const bool want_fullscreen = wall && wall_fullscreen;
const bool want_maximized = wall && !want_fullscreen && wall_maximized;
const bool normal = !want_fullscreen && !want_maximized;
if (normal && !fullscreen && !maximized && target_ &&
!SameSize(actual, *target_)) {
// A late constraint/DPI update can invalidate an earlier acknowledgement.
// Reissue the normal size instead of getting stuck or saving that size.
BeginRestore();
return false;
}
const bool ready = fullscreen == want_fullscreen &&
(want_fullscreen || maximized == want_maximized) &&
(!normal || (target_ && SameSize(actual, *target_)));
if (ready && last_size_ && SameSize(actual, *last_size_))
++matches_;
else
matches_ = 0;
last_size_ = actual;
if (matches_ < 2)
return false;
phase_ = Phase::Idle;
return true;
}
private:
enum class Phase { Idle, Normal, Presentation };
Phase phase_ = Phase::Idle;
std::optional<slint::PhysicalSize> target_, last_size_;
int matches_ = 0;
static bool IsUndersized(slint::PhysicalSize size, float scale,
float compact_height, float compact_width) {
scale = std::max(scale, 1.0f);
return double(size.width) + 1 < compact_width * scale ||
double(size.height) + 1 < compact_height * scale;
}
static bool SameSize(slint::PhysicalSize a, slint::PhysicalSize b) {
return std::abs(double(a.width) - b.width) <= 1 &&
std::abs(double(a.height) - b.height) <= 1;
}
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,306 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GALLERY_WORKSPACE_H_
#define _GALLERY_WORKSPACE_H_
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <nlohmann/json.hpp>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include "gallery_storage.h"
#include "gallery_stream_settings.h"
namespace crossdesk::gallery {
struct Member {
std::string id;
std::string group;
};
// Layout contains no credentials and never reconnects devices when loaded.
struct Workspace {
// Group assignments are shared by history and the wall. Wall enrollment is
// independent, so assigning a group alone must never add a device to it.
std::vector<Member> members;
std::vector<std::string> groups;
std::set<std::string> selected;
int page = 0;
int capacity = 6;
StreamSettings stream_settings;
static std::string NormalizeId(const std::string &input) {
std::string result;
for (size_t i = 0; i < input.size();) {
const auto ch = static_cast<unsigned char>(input[i]);
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
++i;
continue;
}
if (i + 2 < input.size() && ch == 0xEF &&
static_cast<unsigned char>(input[i + 1]) == 0xBC &&
static_cast<unsigned char>(input[i + 2]) >= 0x90 &&
static_cast<unsigned char>(input[i + 2]) <= 0x99) {
result += char('0' + static_cast<unsigned char>(input[i + 2]) - 0x90);
i += 3;
} else if (input.compare(i, 3, "\xe3\x80\x80") == 0)
i += 3;
else
result += input[i++];
}
return result;
}
static bool ValidId(const std::string &id) {
return id.size() == 9 &&
std::all_of(id.begin(), id.end(),
[](unsigned char c) { return c >= '0' && c <= '9'; });
}
bool Contains(const std::string &id) const {
return wall_ids_.count(id) != 0;
}
bool Add(const std::string &id, const std::string &group = {}) {
if (!ValidId(id))
return false;
const bool joined = wall_ids_.insert(id).second;
// A device grouped earlier in history joins at the end when explicitly
// added, while retaining its existing group assignments.
if (joined)
std::stable_partition(members.begin(), members.end(),
[&](const auto &m) { return m.id != id; });
if (group.empty() && HasAssignments(id))
return joined;
return AddAssignment(id, group) || joined;
}
std::vector<std::string> Ids(const std::string &group = {}) const {
std::vector<std::string> ids;
std::set<std::string> seen;
for (const auto &m : members)
if (wall_ids_.count(m.id) && (group.empty() || m.group == group) &&
seen.insert(m.id).second)
ids.push_back(m.id);
return OrderIds(ids);
}
// Order is independent of membership, group assignments and live sessions.
std::vector<std::string> OrderIds(const std::vector<std::string> &ids) const {
if (device_order_.empty())
return ids;
std::set<std::string> remaining(ids.begin(), ids.end());
std::vector<std::string> ordered;
ordered.reserve(ids.size());
for (const auto &id : device_order_)
if (remaining.erase(id)) ordered.push_back(id);
for (const auto &id : ids)
if (remaining.erase(id)) ordered.push_back(id);
return ordered;
}
bool SwapPositions(const std::string &source, const std::string &target,
const std::vector<std::string> &visible_ids) {
if (source == target || !ValidId(source) || !ValidId(target) ||
std::find(visible_ids.begin(), visible_ids.end(), source) == visible_ids.end() ||
std::find(visible_ids.begin(), visible_ids.end(), target) == visible_ids.end())
return false;
auto ordered = device_order_;
std::set<std::string> known(ordered.begin(), ordered.end());
// Include hidden groups before applying a swap in a filtered view.
for (const auto &id : Ids())
if (known.insert(id).second) ordered.push_back(id);
// Active-only devices can be arranged without enrolling them in the wall.
for (const auto &id : visible_ids)
if (ValidId(id) && known.insert(id).second) ordered.push_back(id);
std::iter_swap(std::find(ordered.begin(), ordered.end(), source),
std::find(ordered.begin(), ordered.end(), target));
device_order_ = std::move(ordered);
return true;
}
// History filtering uses group assignments, independently of wall enrollment.
// nullopt means all devices; an empty name means no named group.
bool MatchesGroup(const std::string &id,
const std::optional<std::string> &group) const {
if (!group)
return true;
if (group->empty())
return std::none_of(members.begin(), members.end(), [&](const auto &m) {
return m.id == id && !m.group.empty();
});
return std::any_of(members.begin(), members.end(), [&](const auto &m) {
return m.id == id && m.group == *group;
});
}
size_t AddMany(const std::vector<std::string> &ids,
const std::string &group = {}) {
size_t added = 0;
for (const auto &id : ids) {
// Without a target group, an existing membership already puts the device
// on the wall. An explicit group may reuse that same device/session.
if (group.empty() && wall_ids_.count(id))
continue;
if (Add(id, group))
++added;
}
return added;
}
// Both editors replace the same group assignments without changing wall
// enrollment. Keep the position of devices already on the wall untouched.
bool SetGroups(const std::string &id, const std::vector<std::string> &names) {
if (!ValidId(id))
return false;
std::vector<Member> replacements;
std::set<std::string> seen;
for (const auto &name : names)
if (!name.empty() && seen.insert(name).second)
replacements.push_back({id, name});
if (replacements.empty())
replacements.push_back({id, ""});
std::vector<Member> updated;
bool inserted = false;
for (const auto &member : members) {
if (member.id != id) {
updated.push_back(member);
} else if (!inserted) {
updated.insert(updated.end(), replacements.begin(), replacements.end());
inserted = true;
}
}
if (!inserted)
updated.insert(updated.end(), replacements.begin(), replacements.end());
members = std::move(updated);
for (const auto &member : replacements)
if (!member.group.empty() &&
std::find(groups.begin(), groups.end(), member.group) == groups.end())
groups.push_back(member.group);
PruneGroups();
return true;
}
void Remove(const std::string &id, const std::string &group = {}) {
if (group.empty()) {
// Removing from the wall leaves history's group assignments intact.
wall_ids_.erase(id);
std::erase(device_order_, id);
} else if (MatchesGroup(id, group)) {
// Removing the last named group leaves an ungrouped wall member.
// Reuse the editor path so enrollment and card order stay unchanged.
std::vector<std::string> remaining;
for (const auto &member : members)
if (member.id == id && member.group != group)
remaining.push_back(member.group);
SetGroups(id, remaining);
}
selected.erase(id);
}
void ResizePage(int new_capacity, int count) {
const int first = page * capacity;
capacity = std::max(1, new_capacity);
page = std::clamp(first / capacity, 0, std::max(0, (count - 1) / capacity));
}
// The caller supplies every ID in the current filter, across all pages.
void ToggleSelectAll(const std::vector<std::string> &ids) {
std::set<std::string> targets(ids.begin(), ids.end());
if (selected == targets)
selected.clear();
else
selected = std::move(targets);
}
bool Load(const std::filesystem::path &path) {
try {
if (!std::filesystem::exists(path))
return true;
std::ifstream file(path);
const auto doc = nlohmann::json::parse(file);
const int version = doc.at("version").get<int>();
if ((version != 1 && version != 2) || !doc.at("members").is_array())
return false;
if (version == 2 && !doc.at("wall_ids").is_array())
return false;
Workspace loaded;
if (doc.contains("stream_settings"))
loaded.stream_settings = StreamSettings::FromJson(doc["stream_settings"]);
for (const auto &member : doc.at("members")) {
const auto id = member.at("id").get<std::string>();
loaded.AddAssignment(id, member.value("group", ""));
// Every saved member in the old format was already on the wall.
if (version == 1 && ValidId(id))
loaded.wall_ids_.insert(id);
}
if (version == 2)
for (const auto &value : doc.at("wall_ids")) {
const auto id = value.get<std::string>();
if (ValidId(id) && loaded.HasAssignments(id))
loaded.wall_ids_.insert(id);
}
if (doc.contains("device_order") && doc["device_order"].is_array()) {
std::set<std::string> seen;
for (const auto &value : doc["device_order"]) {
if (!value.is_string()) continue;
const auto id = value.get<std::string>();
if (ValidId(id) && seen.insert(id).second)
loaded.device_order_.push_back(id);
}
}
members = std::move(loaded.members);
groups = std::move(loaded.groups);
wall_ids_ = std::move(loaded.wall_ids_);
device_order_ = std::move(loaded.device_order_);
stream_settings = loaded.stream_settings;
return true;
} catch (...) {
return false;
}
}
bool Save(const std::filesystem::path &path) const {
try {
nlohmann::json doc{{"version", 2},
{"members", nlohmann::json::array()},
{"wall_ids", wall_ids_},
{"device_order", device_order_},
{"stream_settings", stream_settings.ToJson()}};
for (const auto &m : members)
doc["members"].push_back({{"id", m.id}, {"group", m.group}});
return WriteAtomically(path, doc.dump(2));
} catch (...) {
return false;
}
}
private:
std::set<std::string> wall_ids_;
std::vector<std::string> device_order_;
bool HasAssignments(const std::string &id) const {
return std::any_of(members.begin(), members.end(),
[&](const auto &m) { return m.id == id; });
}
bool AddAssignment(const std::string &id, const std::string &group) {
if (!ValidId(id))
return false;
if (std::any_of(members.begin(), members.end(), [&](const auto &m) {
return m.id == id && m.group == group;
}))
return false;
if (!group.empty() &&
std::find(groups.begin(), groups.end(), group) == groups.end())
groups.push_back(group);
members.push_back({id, group});
return true;
}
void PruneGroups() {
groups.erase(std::remove_if(groups.begin(), groups.end(),
[&](const auto &name) {
return std::none_of(members.begin(),
members.end(),
[&](const auto &m) {
return m.group == name;
});
}),
groups.end());
}
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,99 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PREVIEW_BANDWIDTH_H_
#define _PREVIEW_BANDWIDTH_H_
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <vector>
namespace crossdesk::gallery {
struct PreviewBandwidthDemand {
int desired_bps = 0;
bool foreground = false;
bool operator==(const PreviewBandwidthDemand& other) const {
return desired_bps == other.desired_bps && foreground == other.foreground;
}
static int Estimate(int width, int height, int fps) {
// A demand ceiling, not a throughput measurement. Include room for video
// repair traffic; the sender's congestion control may allocate less.
const int64_t pixels = width > 0 && height > 0
? int64_t(width) * height : 3840LL * 2160;
const int64_t estimate = pixels * std::clamp(fps, 1, 60) * 15 / 100;
return int(std::clamp<int64_t>((estimate + 49'999) / 50'000 * 50'000,
150'000, 20'000'000));
}
};
inline std::vector<int> AllocatePreviewBandwidth(
const std::vector<PreviewBandwidthDemand>& demands, int budget_bps) {
std::vector<int> result(demands.size(), 0);
int remaining = std::max(0, budget_bps);
int active = 0;
int64_t desired_total = 0;
for (const auto& demand : demands) {
active += demand.desired_bps > 0;
desired_total += std::max(0, demand.desired_bps);
}
if (!active || !remaining) return result;
if (desired_total <= remaining) {
for (std::size_t i = 0; i < demands.size(); ++i)
result[i] = std::max(0, demands[i].desired_bps);
return result;
}
// First preserve a small fair share for every visible device, then perform
// weighted max-min allocation. Satisfied devices return unused capacity.
const int baseline = std::min(100'000, remaining / active);
for (std::size_t i = 0; i < demands.size(); ++i) {
result[i] = std::min(std::max(0, demands[i].desired_bps), baseline);
remaining -= result[i];
}
while (remaining > 0) {
int weights = 0;
for (std::size_t i = 0; i < demands.size(); ++i)
if (result[i] < demands[i].desired_bps)
weights += demands[i].foreground ? 4 : 1;
if (!weights) break;
const int available = remaining;
for (std::size_t i = 0; i < demands.size() && remaining > 0; ++i) {
if (result[i] >= demands[i].desired_bps) continue;
const int share = int(int64_t(available) *
(demands[i].foreground ? 4 : 1) / weights);
const int extra = std::min({remaining, demands[i].desired_bps - result[i],
std::max(1, share)});
result[i] += extra;
remaining -= extra;
}
}
return result;
}
// UI refreshes much more often than video demand changes. Keep the previous
// result while membership, dimensions, cadence, priority and budget are stable.
class PreviewBandwidthAllocator {
public:
const std::vector<int>& Allocate(
const std::vector<PreviewBandwidthDemand>& demands, int budget_bps) {
if (budget_bps_ != budget_bps || demands_ != demands) {
demands_ = demands;
budget_bps_ = budget_bps;
allocations_ = AllocatePreviewBandwidth(demands, budget_bps);
}
return allocations_;
}
private:
int budget_bps_ = -1;
std::vector<PreviewBandwidthDemand> demands_;
std::vector<int> allocations_;
};
} // namespace crossdesk::gallery
#endif
@@ -0,0 +1,55 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PREVIEW_RESOLUTION_H_
#define _PREVIEW_RESOLUTION_H_
#include <algorithm>
#include <chrono>
#include <cmath>
namespace crossdesk::gallery {
struct PreviewResolution {
int width = 0;
int height = 0;
bool operator==(const PreviewResolution& other) const {
return width == other.width && height == other.height;
}
static PreviewResolution Viewport(float width, float height, float scale) {
if (!std::isfinite(width) || !std::isfinite(height) ||
!std::isfinite(scale) || width <= 0 || height <= 0 || scale <= 0)
return {};
return {int(std::clamp(std::ceil(double(width) * scale), 2.0, 16384.0)),
int(std::clamp(std::ceil(double(height) * scale), 2.0, 16384.0))};
}
};
class PreviewResolutionDebouncer {
public:
using Clock = std::chrono::steady_clock;
PreviewResolution Update(PreviewResolution desired, Clock::time_point now,
bool reset = false) {
if (!initialized_ || reset) {
initialized_ = true;
applied_ = candidate_ = desired;
changed_at_ = now;
} else if (!(candidate_ == desired)) {
candidate_ = desired;
changed_at_ = now;
} else if (now - changed_at_ >= std::chrono::milliseconds(200)) {
applied_ = candidate_;
}
return applied_;
}
private:
bool initialized_ = false;
PreviewResolution applied_, candidate_;
Clock::time_point changed_at_{};
};
} // namespace crossdesk::gallery
#endif
@@ -160,27 +160,7 @@ void KeyboardController::SendHeartbeat(bool force) {
action.ks.pressed_count = index;
}
const std::string target_id = owner_.controlled_remote_id_.empty()
? owner_.focused_remote_id_
: owner_.controlled_remote_id_;
std::shared_lock sessions_lock(owner_.remote_sessions_mutex_);
const auto props_it = owner_.remote_sessions_.find(target_id);
if (target_id.empty() || props_it == owner_.remote_sessions_.end() ||
props_it->second->connection_status_.load() !=
ConnectionStatus::Connected ||
!props_it->second->peer_) {
last_heartbeat_tick_ = now;
return;
}
const std::string message = action.to_json();
const int result = SendReliableDataFrame(
props_it->second->peer_, message.c_str(), message.size(),
props_it->second->keyboard_label_.c_str());
if (result != 0) {
LOG_WARN("Send keyboard heartbeat failed, remote_id={}, ret={}", target_id,
result);
}
owner_.SendRemoteInput(action);
last_heartbeat_tick_ = now;
}
@@ -200,24 +180,7 @@ int KeyboardController::SendKeyCommand(int key_code, bool is_down,
action.k.scan_code = scan_code;
action.k.extended = extended;
const std::string target_id = owner_.controlled_remote_id_.empty()
? owner_.focused_remote_id_
: owner_.controlled_remote_id_;
std::shared_lock sessions_lock(owner_.remote_sessions_mutex_);
const auto props_it = owner_.remote_sessions_.find(target_id);
if (!target_id.empty() && props_it != owner_.remote_sessions_.end() &&
props_it->second->connection_status_.load() ==
ConnectionStatus::Connected &&
props_it->second->peer_) {
const std::string message = action.to_json();
const int result = SendReliableDataFrame(
props_it->second->peer_, message.c_str(), message.size(),
props_it->second->keyboard_label_.c_str());
if (result != 0) {
LOG_WARN("Send keyboard command failed, remote_id={}, ret={}", target_id,
result);
}
}
owner_.SendRemoteInput(action);
TrackPressedKey(key_code, is_down, scan_code, extended);
return 0;
@@ -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());
@@ -0,0 +1,72 @@
#ifndef CROSSDESK_CONNECTION_AUTH_STATE_H_
#define CROSSDESK_CONNECTION_AUTH_STATE_H_
#include <chrono>
#include <mutex>
#include <string_view>
#include "join_failure.h"
namespace crossdesk {
// One remote session's password prompt and server-requested retry delay.
// Signaling callbacks and the UI access a single synchronized snapshot.
class ConnectionAuthState {
public:
using Clock = std::chrono::steady_clock;
struct Snapshot {
bool password_required = false;
bool validating = false;
bool incorrect_password = false;
minirtc::JoinFailureKind error = minirtc::JoinFailureKind::Unknown;
int retry_after = 0;
};
bool Begin(std::string_view password, Clock::time_point now = Clock::now()) {
std::lock_guard lock(mutex_);
if (retry_at_ > now) return false;
state_ = {};
retry_at_ = {};
state_.password_required = password.empty();
state_.validating = !password.empty();
return state_.validating;
}
void Rejected(const nlohmann::json& reply,
Clock::time_point now = Clock::now()) {
const auto failure = minirtc::ParseJoinFailure(reply);
std::lock_guard lock(mutex_);
state_ = {};
state_.error = failure.kind;
state_.incorrect_password =
failure.kind == minirtc::JoinFailureKind::AuthenticationFailed;
state_.password_required =
state_.incorrect_password ||
failure.kind == minirtc::JoinFailureKind::PasswordRequired;
retry_at_ = now + std::chrono::seconds(failure.retry_after);
}
void Complete() {
std::lock_guard lock(mutex_);
state_.validating = false;
}
Snapshot Get(Clock::time_point now = Clock::now()) const {
std::lock_guard lock(mutex_);
auto snapshot = state_;
if (retry_at_ > now) {
snapshot.retry_after = static_cast<int>(
std::chrono::ceil<std::chrono::seconds>(retry_at_ - now).count());
}
return snapshot;
}
private:
mutable std::mutex mutex_;
Snapshot state_;
Clock::time_point retry_at_{};
};
} // namespace crossdesk
#endif
@@ -35,6 +35,30 @@ bool GuiRuntime::HasActiveSession() {
});
}
void GuiRuntime::MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
if (!props) return;
std::lock_guard delivery_lock(audio_delivery_mutex_);
props->audio_capture_button_pressed_ = false;
props->audio_allowed_ = false;
devices_.ClearAudio(props->remote_id_);
// Capable peers stop sending to this controller on the next subscription;
// old peers remain muted by the local receive gate above.
props->preview_needs_sync_ = true;
}
void GuiRuntime::UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props) {
if (!props || props->closing_) return;
props->audio_capture_button_pressed_ = true;
props->preview_needs_sync_ = true;
// Also resume capture on old peers, or after a legacy controller stopped it.
RemoteAction action{};
action.type = ControlType::audio_capture;
action.a = true;
const auto message = action.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(),
props->control_data_label_.c_str());
}
void GuiRuntime::HandleConnectionStatusChange() {
if (!signal_connected_ || !peer_) {
return;
@@ -51,7 +75,8 @@ void GuiRuntime::HandleConnectionStatusChange() {
}
last_presence_request_attempt_at_ = now;
const auto message =
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true).dump();
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true, {},
gallery_presence_ids_).dump();
if (SendSignalMessage(peer_, message.data(), message.size()) == 0) {
next_presence_refresh_at_ = now + kPresenceRefreshInterval;
} else {
@@ -59,6 +84,39 @@ void GuiRuntime::HandleConnectionStatusChange() {
}
}
void GuiRuntime::HandlePendingRejoins() {
need_to_rejoin_ = false;
std::vector<std::shared_ptr<RemoteSession>> pending;
{
std::shared_lock lock(remote_sessions_mutex_);
for (const auto& [_, props] : remote_sessions_) {
if (props && props->rejoin_ && !props->closing_)
pending.push_back(props);
}
}
for (const auto& props : pending) {
// Password submission already entered the validating state. Let Join
// report its result even when this device is absent from the presence
// cache (for example, a newly added DeskHub device). A failed presence
// probe has no auth callback and would leave the prompt validating forever.
ConnectTo(props->remote_id_, props->remote_password_,
props->remember_password_, true, props->wall_managed_.load());
}
}
void GuiRuntime::CancelPendingConnection(const std::string& remote_id) {
pending_reconnects_.erase(remote_id);
if (auto props = FindRemoteSession(remote_id)) props->rejoin_ = false;
std::lock_guard lock(pending_presence_probe_mutex_);
if (pending_presence_remote_id_ != remote_id) return;
pending_presence_probe_ = false;
pending_presence_result_ready_ = false;
pending_presence_online_ = false;
pending_presence_remote_id_.clear();
pending_presence_password_.clear();
pending_presence_remember_password_ = false;
}
void GuiRuntime::HandlePendingPresenceProbe() {
bool has_action = false;
bool should_connect = false;
@@ -130,6 +188,8 @@ 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
devices_.ReleaseRemoteMouseButtons();
@@ -169,7 +229,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.
@@ -210,7 +270,8 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
}
const auto s = BuildDevicePresenceRequest(
client_id_, recent_connection_ids_, false, remote_id)
client_id_, recent_connection_ids_, false, remote_id,
gallery_presence_ids_)
.dump();
int ret = SendSignalMessage(peer_, s.data(), s.size());
@@ -227,9 +288,21 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
return ret;
}
int GuiRuntime::ForceSignalReconnect() {
if (!peer_) {
LOG_WARN("Signal reconnect requested with no connection peer");
return -1;
}
return ReconnectSignal(peer_);
}
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (!props || props->closing_.exchange(true)) return;
if (props->peer_events_) props->peer_events_->Deactivate();
MuteRemoteAudio(props);
if (props->peer_events_) {
props->peer_events_->Deactivate();
transfers_.Cancel(props->peer_events_.get());
}
props->connection_established_ = false;
props->streaming_ = false;
props->connection_status_.store(ConnectionStatus::Closed);
@@ -323,6 +396,7 @@ void GuiRuntime::CloseConnectionPeer() {
return;
}
peer_events_->Deactivate();
transfers_.Cancel(peer_events_.get());
announcements_.Reset();
std::vector<std::string> controllers;
{
@@ -370,6 +444,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;
@@ -409,7 +484,8 @@ void GuiRuntime::HandleSessionCleanup() {
recent_connection_image_save_time_ = 0;
}
for (const auto& [id, request] : reconnects) {
ConnectTo(id, request.password.c_str(), request.remember_password);
ConnectTo(id, request.password.c_str(), request.remember_password, false,
request.background);
}
}
@@ -439,6 +515,12 @@ void GuiRuntime::WaitForSessionCleanup() {
void GuiRuntime::ResetRemoteSessionResources(
std::shared_ptr<RemoteSession> props) {
props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
{
std::lock_guard lock(props->gallery_frame_mutex_);
props->gallery_video_latency_->Reset();
props->gallery_frame_timing_ = {};
}
{
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
props->video_latency_->Reset();
@@ -478,15 +560,28 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
}
int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check) {
bool remember_password, bool bypass_presence_check,
bool background) {
// Do not recreate a failed peer or send another probe during a server cooldown.
if (const auto current = FindRemoteSession(remote_id);
current && current->connection_auth_.Get().retry_after > 0) {
if (!background) focused_remote_id_ = remote_id;
if (!background) show_connection_status_window_ = true;
current->rejoin_ = false;
return 0;
}
if (session_cleanup_tasks_.count(remote_id)) {
pending_reconnects_[remote_id] = {password ? password : "", remember_password};
pending_reconnects_[remote_id] = {password ? password : "",
remember_password, background};
LOG_INFO("[{}] Reconnect queued until previous peer is destroyed", remote_id);
return 0;
}
if (!bypass_presence_check && !device_presence_cache_.IsOnline(remote_id)) {
int ret =
RequestSingleDevicePresence(remote_id, password, remember_password);
// Wall requests, including deferred reconnects, must not replace the single
// interactive presence probe. Join reports availability for each session.
if (!background && !bypass_presence_check &&
!device_presence_cache_.IsOnline(remote_id)) {
int ret = RequestSingleDevicePresence(remote_id, password,
remember_password);
if (ret != 0) {
offline_warning_text_ =
localization::device_offline[localization_language_index_];
@@ -499,7 +594,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
}
LOG_INFO("Connect to [{}]", remote_id);
focused_remote_id_ = remote_id;
if (!background) focused_remote_id_ = remote_id;
auto stale = FindRemoteSession(remote_id);
bool exists = stale != nullptr;
@@ -529,7 +624,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
pending_reconnects_[remote_id] = {
keep_stored_password ? std::string(stale->remote_password_)
: std::string(password),
remember_password};
remember_password, background};
CloseRemoteSession(stale);
LOG_INFO("[{}] Reconnecting after terminal peer status {}", remote_id,
static_cast<int>(status));
@@ -549,6 +644,11 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
auto props = remote_sessions_[remote_id];
props->local_id_ = "C-" + std::string(client_id_);
props->remote_id_ = remote_id;
props->wall_managed_ = background;
props->tab_opened_ = !background;
props->control_mouse_ = !background;
props->enable_mouse_control_ = !background;
props->audio_capture_button_pressed_ = !background;
memcpy(&props->params_, &params_, sizeof(Params));
props->params_.user_id = props->local_id_.c_str();
props->peer_events_ = std::make_shared<PeerEventHandler>(*this);
@@ -556,7 +656,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
props->peer_ = CreatePeer(&props->params_);
props->connection_status_.store(ConnectionStatus::Connecting);
show_connection_status_window_ = true;
if (!background) show_connection_status_window_ = true;
if (!props->peer_) {
LOG_INFO("Create peer [{}] instance failed", props->local_id_);
@@ -594,19 +694,29 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
int ret = -1;
auto props = FindRemoteSession(remote_id);
if (!props || props->closing_) return -1;
if (background) {
// This also covers reusing a foreground connection that is awaiting auth.
props->wall_managed_ = true;
MuteRemoteAudio(props);
}
if (!props->connection_established_) {
props->connection_status_.store(ConnectionStatus::Connecting);
show_connection_status_window_ = true;
if (!background) show_connection_status_window_ = true;
props->remember_password_ = remember_password;
if (strcmp(password, "") != 0 &&
if (password && *password &&
strcmp(password, props->remote_password_) != 0) {
strncpy(props->remote_password_, password,
sizeof(props->remote_password_) - 1);
props->remote_password_[sizeof(props->remote_password_) - 1] = '\0';
}
std::string remote_id_with_pwd = remote_id + "@" + password;
// Ask locally before Join; an empty-password round trip is no longer needed.
if (!props->connection_auth_.Begin(props->remote_password_)) {
props->rejoin_ = false;
return 0;
}
props->connection_status_.store(ConnectionStatus::Connecting);
std::string remote_id_with_pwd = remote_id + "@" + props->remote_password_;
if (props->peer_) {
ret = JoinConnection(props->peer_, remote_id_with_pwd.c_str());
if (0 == ret) {
@@ -33,6 +33,15 @@ class CursorPresentation {
const bool separate =
state.render_mode == CursorRenderMode::separate ||
(state.render_mode == CursorRenderMode::legacy && state.visible);
system_hidden_ = state.render_mode == CursorRenderMode::hidden &&
state.hidden_reason == CursorHiddenReason::system_hidden;
if (separate) {
mac_locator_allowed_ = state.shape != RemoteCursorShape::none;
} else if (!system_hidden_) {
// Embedded cursors and other hiding policies end the separate-cursor
// history. A later system_hidden sample must not revive that overlay.
mac_locator_allowed_ = false;
}
// Old senders cannot distinguish embedded from hidden. Only explicit
// metadata from a new sender can establish the no-device compatibility
// case.
@@ -47,9 +56,21 @@ class CursorPresentation {
void NoteInput(uint64_t now) { last_input_ = now; }
RemoteCursorShape Resolve(uint64_t now) {
RemoteCursorShape Resolve(uint64_t now, HostPlatform remote_platform) {
if (!stale_at_) return RemoteCursorShape::default_cursor;
// macOS can keep its system cursor hidden after typing even while remote
// mouse events move it. Keep a previously separate desktop locator across
// those heartbeats; the video has no pointer to fall back to. On a new
// connection, use an arrow until the first visible shape arrives. The
// protocol cannot distinguish typing from other system_hidden cases;
// restrict this policy to identified Mac hosts and stop on any explicit
// embedded cursor or other hiding policy.
if (remote_platform == HostPlatform::MacOS && system_hidden_ &&
mac_locator_allowed_) {
return last_visible_shape_;
}
const bool recent_input =
last_input_ && now - *last_input_ < kInputActivityMs;
const bool can_recover =
@@ -64,6 +85,8 @@ class CursorPresentation {
private:
bool sample_unavailable_ = false;
bool system_hidden_ = false;
bool mac_locator_allowed_ = true;
bool no_device_ = false;
bool fallback_engaged_ = false;
std::optional<uint64_t> stale_at_;
@@ -74,4 +97,4 @@ class CursorPresentation {
} // namespace crossdesk
#endif
#endif
@@ -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>
@@ -9,6 +9,7 @@
#include <chrono>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
@@ -33,12 +34,19 @@ public:
}
}
bool IsOnline(const std::string &device_id,
Clock::time_point now = Clock::now()) const {
std::optional<bool> GetOnline(const std::string &device_id,
Clock::time_point now = Clock::now()) const {
std::lock_guard<std::mutex> lock(mutex_);
const auto it = cache_.find(device_id);
return signal_connected_ && it != cache_.end() && it->second.online &&
now - it->second.updated_at < kMaxAge;
if (!signal_connected_ || it == cache_.end() ||
now - it->second.updated_at >= kMaxAge)
return std::nullopt;
return it->second.online;
}
bool IsOnline(const std::string &device_id,
Clock::time_point now = Clock::now()) const {
return GetOnline(device_id, now).value_or(false);
}
void Clear() {
@@ -58,4 +66,4 @@ private:
} // namespace crossdesk
#endif
#endif
@@ -16,22 +16,25 @@ namespace crossdesk {
inline nlohmann::json BuildDevicePresenceRequest(
const std::string& client_id, const std::vector<std::string>& recent_ids,
bool subscribe, const std::string& probe_id = {}) {
bool subscribe, const std::string& probe_id = {},
const std::vector<std::string>& additional_ids = {}) {
std::vector<std::string> devices;
for (const auto& id : recent_ids) {
const auto append = [&](const std::string& id) {
// Recent connection keys contain Y/N plus display settings after the ID.
const std::string pure_id = id.substr(0, id.find_first_of("YN"));
if (!pure_id.empty() &&
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
devices.push_back(pure_id);
}
}
};
for (const auto& id : recent_ids) append(id);
for (const auto& id : additional_ids) append(id);
if (!probe_id.empty() &&
std::find(devices.begin(), devices.end(), probe_id) == devices.end()) {
devices.push_back(probe_id);
}
// Include recent devices even in a one-shot probe: older servers ignore
// subscribe=false and replace their subscriptions with this device list.
// Keep recent and wall devices even in a one-shot probe: older servers
// ignore subscribe=false and replace subscriptions with this device list.
return {{"type", "recent_connections_presence"},
{"user_id", client_id},
{"devices", devices},
@@ -40,4 +43,4 @@ inline nlohmann::json BuildDevicePresenceRequest(
} // namespace crossdesk
#endif
#endif
+79 -4
View File
@@ -16,7 +16,7 @@
#include "localization.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#ifdef __linux__
#if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h"
#endif
@@ -26,7 +26,53 @@ GuiRuntime::GuiRuntime()
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this),
keyboard_(*this) {}
GuiRuntime::~GuiRuntime() { WaitForSessionCleanup(); }
GuiRuntime::~GuiRuntime() {
transfers_.Shutdown();
WaitForSessionCleanup();
}
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
const bool mouse = action.type == ControlType::mouse;
if (!mouse && action.type != ControlType::keyboard &&
action.type != ControlType::keyboard_state)
return;
const std::string& target_id = controlled_remote_id_.empty()
? focused_remote_id_ : controlled_remote_id_;
if (target_id.empty()) return;
const bool group = gallery_group_control_id_ == target_id;
const bool releasing = mouse
? action.m.flag == MouseFlag::left_up ||
action.m.flag == MouseFlag::right_up ||
action.m.flag == MouseFlag::middle_up
: action.type == ControlType::keyboard
? action.k.flag == KeyFlag::key_up : action.ks.pressed_count == 0;
const auto available = [](const auto& props) {
return props && props->peer_ && !props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected;
};
std::shared_lock lock(remote_sessions_mutex_);
const auto source = remote_sessions_.find(target_id);
// A lost leader must stop new input, but its followers still need key/button
// releases before group control is cleared by the UI.
if (!releasing &&
(source == remote_sessions_.end() || !available(source->second)))
return;
const auto message = action.to_json();
for (const auto& [id, props] : remote_sessions_) {
if ((!group && id != target_id) || !available(props)) continue;
if (mouse) {
std::lock_guard cursor_lock(props->remote_cursor_state_mutex_);
props->cursor_presentation_.NoteInput(SDL_GetTicks());
}
// Reuse exactly the leader's mapped coordinates and key metadata. Neither
// gallery filters nor the follower's preview dimensions affect input.
const int result = SendReliableDataFrame(
props->peer_, message.data(), message.size(),
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
if (result != 0)
LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result);
}
}
int GuiRuntime::CreateConnectionPeer() {
// Tick retries after the old peer's leave notification and socket shutdown.
@@ -97,7 +143,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);
@@ -131,7 +177,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.
@@ -206,6 +252,35 @@ int GuiRuntime::CreateConnectionPeer() {
}
void GuiRuntime::HandleRecentConnections() {
if (thumbnail_ && session_cleanup_tasks_.empty()) {
std::vector<std::shared_ptr<RemoteSession>> sessions;
{
std::shared_lock lock(remote_sessions_mutex_);
for (const auto& [_, props] : remote_sessions_)
if (props && !props->closing_ && props->was_connected_ &&
props->recent_connection_dirty_.exchange(false))
sessions.push_back(props);
}
for (const auto& props : sessions) {
std::string host_name;
HostPlatform platform;
{
std::lock_guard lock(props->remote_version_mutex_);
host_name = props->remote_host_name_;
platform = props->recent_platform_;
}
// Record a successful connection before a preview or host info arrives.
// The close path still updates its final thumbnail. Remember password
// controls only credential storage, not whether this device is listed.
if (thumbnail_->SaveToThumbnail(
nullptr, 0, 0, props->remote_id_, host_name,
props->remember_password_ ? props->remote_password_ : "",
platform) == 0)
reload_recent_connections_ = true;
else
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
}
}
if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) {
uint32_t now_time = SDL_GetTicks();
if (now_time - recent_connection_image_save_time_ >= 50) {
+12 -1
View File
@@ -51,12 +51,19 @@ class GuiRuntime : protected gui_detail::GuiState {
int CreateConnectionPeer();
int ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check = false);
bool remember_password, bool bypass_presence_check = false,
bool background = false);
// UI thread: mute this receiver without stopping audio for other controllers.
void MuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
void UnmuteRemoteAudio(const std::shared_ptr<RemoteSession>& props);
int RequestSingleDevicePresence(const std::string& remote_id,
const char* password, bool remember_password);
int ForceSignalReconnect();
void HandleRecentConnections();
void HandleConnectionStatusChange();
void HandlePendingRejoins();
void CancelPendingConnection(const std::string& remote_id);
void HandlePendingPresenceProbe();
void HandlePresenceProbeTimeout();
bool HasActiveSession();
@@ -80,6 +87,10 @@ class GuiRuntime : protected gui_detail::GuiState {
void WaitForSessionCleanup();
std::shared_ptr<RemoteSession> FindRemoteSession(
const std::string& remote_id);
// UI thread: send mapped mouse/keyboard input to the current control target,
// or every connected session while that target leads DeskHub group control.
void SendRemoteInput(const RemoteAction& action);
std::string gallery_group_control_id_;
void ResetRemoteServiceStatus(RemoteSession& props);
void ApplyRemoteServiceStatus(RemoteSession& props,
@@ -24,6 +24,8 @@ class NetTrafficStatsCache {
"Network statistics require lock-free scalar atomics");
public:
using Clock = std::chrono::steady_clock;
void Store(const MiniRtcNetTrafficStats& stats) {
video_inbound_stats_.Store(stats.video_inbound_stats);
video_outbound_stats_.Store(stats.video_outbound_stats);
@@ -36,6 +38,10 @@ class NetTrafficStatsCache {
srtp_active_.store(stats.srtp_active, std::memory_order_relaxed);
rtt_ms_.store(stats.rtt_ms, std::memory_order_relaxed);
connection_latency_.Update(stats.rtt_ms);
last_report_ms_.store(
std::chrono::duration_cast<std::chrono::milliseconds>(
Clock::now().time_since_epoch()).count(),
std::memory_order_relaxed);
}
MiniRtcNetTrafficStats Load() const {
@@ -65,9 +71,17 @@ class NetTrafficStatsCache {
void ResetLatency() { connection_latency_.Reset(); }
bool HasRecentReport(Clock::time_point now = Clock::now()) const {
const auto last_report = last_report_ms_.load(std::memory_order_relaxed);
const auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch()).count();
return last_report > 0 && now_ms - last_report < 3000;
}
void Reset() {
Store(MiniRtcNetTrafficStats{});
ResetLatency();
last_report_ms_.store(0, std::memory_order_relaxed);
}
private:
@@ -114,6 +128,7 @@ class NetTrafficStatsCache {
OutboundStats total_outbound_stats_;
std::atomic<bool> srtp_active_{false};
std::atomic<double> rtt_ms_{-1};
std::atomic<int64_t> last_report_ms_{0};
ConnectionLatencyStats connection_latency_;
};
@@ -24,6 +24,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,64 +104,110 @@ 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) {
std::string remote_user_id(user_id, user_id_size);
auto props =
runtime->FindRemoteSession(remote_user_id);
if (props && !props->enable_mouse_control_) {
if (props && !props->clipboard_allowed_.load()) {
return;
}
runtime->clipboard_.QueueRemoteText(data, size);
runtime->clipboard_.QueueRemoteText(
data, size, props ? remote_user_id : std::string{});
}
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;
}
const std::string remote_id(user_id, user_id_size);
std::string json_str(data, size);
const auto preview_json =
json_str.find("\"deskhub_preview") != std::string::npos
? nlohmann::json::parse(json_str, nullptr, false)
: nlohmann::json{};
if (preview_json.is_object() &&
preview_json.value("type", nlohmann::json{}) == "deskhub_preview") {
if (source_id != runtime->control_data_label_ ||
!runtime->IsAuthorizedController(remote_id))
return;
const auto subscription = PreviewSubscription::Parse(preview_json);
if (!subscription) return;
const auto previous = runtime->preview_subscriptions_.Get(remote_id);
bool accepted = true;
// Apply encoder quality to this controller only; other controllers retain
// their own demand. MiniRTC owns per-peer video/audio pause state.
if (!previous || subscription->IsNewerThan(*previous)) {
if (subscription->fps > 0)
accepted = UpdateVideoSettings(
runtime->peer_, remote_id.data(), remote_id.size(),
static_cast<VideoQuality>(subscription->quality),
subscription->frame_rate,
static_cast<VideoDegradationPreference>(
subscription->EncoderPreference())) == 0;
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
subscription->max_bitrate_bps.value_or(0)))
accepted = UpdateVideoBitrateLimit(
runtime->peer_, remote_id.data(), remote_id.size(),
subscription->max_bitrate_bps.value_or(0)) == 0;
// Rebalancing bandwidth alone must not force every device to send a
// keyframe. The pause API requests its own keyframes on resume.
if (accepted && (!previous || previous->width != subscription->width ||
previous->height != subscription->height))
accepted = UpdateVideoResolution(
runtime->peer_, remote_id.data(), remote_id.size(),
subscription->width, subscription->height) == 0;
if (accepted)
accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), subscription->fps == 0) == 0;
if (accepted)
accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), !subscription->audio) == 0;
if (accepted && subscription->audio)
runtime->devices_.StartSpeakerCapturer();
}
if (accepted) runtime->preview_subscriptions_.Set(remote_id, *subscription);
const auto reply = nlohmann::json{
{"type", "deskhub_preview_status"},
{"version", subscription->version},
{"request_id", subscription->request_id},
{"accepted",
accepted}}.dump();
SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(),
runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size());
return;
}
if (preview_json.is_object() &&
preview_json.value("type", nlohmann::json{}) ==
"deskhub_preview_status") {
if (auto props = runtime->FindRemoteSession(remote_id);
props && source_id == props->control_data_label_) {
try {
if ((preview_json.at("version") == 1 ||
preview_json.at("version") == 2) &&
preview_json.at("request_id").is_number_unsigned() &&
preview_json.at("request_id").get<uint64_t>() > 0 &&
preview_json.at("request_id").get<uint64_t>() <= UINT32_MAX &&
preview_json.at("accepted").is_boolean()) {
props->preview_accepted_ = preview_json.at("accepted").get<bool>();
props->preview_ack_ = preview_json.at("request_id").get<uint32_t>();
}
} catch (...) {
}
}
return;
}
RemoteAction remote_action{};
if (!remote_action.from_json(json_str)) {
LOG_ERROR("Failed to parse RemoteAction JSON payload");
return;
}
std::string remote_id(user_id, user_id_size);
if (remote_action.type == ControlType::video_settings_status) {
if (auto props = runtime->FindRemoteSession(remote_id)) {
if (source_id != props->control_data_label_) return;
@@ -262,6 +311,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;
}
@@ -342,6 +395,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
if (props && source_id == props->control_data_label_) {
std::lock_guard lock(props->video_settings_mutex_);
const bool first_advertisement = !props->video_settings_supported_;
props->preview_subscription_supported_ =
remote_action.i.supports_deskhub_preview;
props->preview_resolution_supported_ =
remote_action.i.supports_deskhub_resolution;
props->preview_fps15_supported_ = remote_action.i.supports_deskhub_fps15;
props->preview_bandwidth_supported_ = remote_action.i.supports_deskhub_bandwidth;
props->video_settings_supported_ =
remote_action.i.supports_video_settings;
if (first_advertisement && props->video_settings_supported_) {
@@ -357,17 +416,21 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
}
// client mode
if (props && props->remote_host_name_.empty()) {
props->remote_host_name_ = std::string(remote_action.i.host_name,
remote_action.i.host_name_size);
LOG_INFO("Remote hostname: [{}]", props->remote_host_name_);
if (props) {
std::lock_guard lock(props->remote_version_mutex_);
if (props->remote_host_name_.empty()) {
props->remote_host_name_ = std::string(remote_action.i.host_name,
remote_action.i.host_name_size);
LOG_INFO("Remote hostname: [{}]", props->remote_host_name_);
for (int i = 0; i < remote_action.i.display_num; i++) {
props->display_info_list_.push_back(
DisplayInfo(remote_action.i.display_list[i],
remote_action.i.left[i], remote_action.i.top[i],
remote_action.i.right[i], remote_action.i.bottom[i]));
for (int i = 0; i < remote_action.i.display_num; i++) {
props->display_info_list_.push_back(
DisplayInfo(remote_action.i.display_list[i],
remote_action.i.left[i], remote_action.i.top[i],
remote_action.i.right[i], remote_action.i.bottom[i]));
}
}
props->recent_connection_dirty_ = true;
}
FreeRemoteAction(remote_action);
} else {
@@ -455,9 +518,16 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->devices_.SendMouseCommand(remote_action,
runtime->selected_display_);
} else if (remote_action.type == ControlType::audio_capture) {
// A negotiated subscription remains authoritative if a legacy capture
// command arrives after it (e.g. when unmuting an inactive window).
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
const bool paused = subscription ? !subscription->audio : !remote_action.a;
const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), paused);
if (remote_action.a)
runtime->devices_.StartSpeakerCapturer();
else
else if (ret != 0)
// Legacy/web transports without the per-peer API retain their fallback.
runtime->devices_.StopSpeakerCapturer();
} else if (remote_action.type == ControlType::keyboard) {
runtime->keyboard_.ApplyRemoteEvent(remote_id, remote_action);
@@ -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) {
@@ -79,7 +83,12 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
return;
}
std::string type = j["type"].get<std::string>();
if (type == "announcements_changed") {
if (type == "user_join_transmission" && j.contains("status") &&
j["status"] == "failed" && j.contains("transmission_id") &&
j["transmission_id"].is_string()) {
const auto props = runtime->FindRemoteSession(j["transmission_id"].get<std::string>());
if (props && !props->closing_) props->connection_auth_.Rejected(j);
} else if (type == "announcements_changed") {
runtime->announcements_.Refresh();
} else if (type == "announcements") {
runtime->announcements_.Receive(j);
@@ -252,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_);
@@ -263,8 +275,10 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
if (props) {
runtime->is_client_mode_ = true;
runtime->show_connection_status_window_ = true;
if (!props->wall_managed_) runtime->show_connection_status_window_ = true;
props->connection_status_.store(status);
if (status != ConnectionStatus::Connecting && status != ConnectionStatus::Gathering)
props->connection_auth_.Complete();
switch (status) {
case ConnectionStatus::Connected: {
@@ -281,6 +295,10 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true;
remote_action.i.supports_deskhub_bandwidth = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]);
@@ -296,12 +314,13 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
FreeRemoteAction(remote_action);
}
if (!runtime->need_to_create_stream_window_) {
if (!props->wall_managed_ && !runtime->need_to_create_stream_window_) {
runtime->need_to_create_stream_window_ = true;
}
props->connection_established_ = true;
props->was_connected_ = true;
runtime->start_keyboard_capturer_ = true;
props->recent_connection_dirty_ = true;
if (!props->wall_managed_) runtime->start_keyboard_capturer_ = true;
break;
}
case ConnectionStatus::Disconnected:
@@ -318,7 +337,23 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
props->remote_update_dismissed_ = false;
}
props->net_traffic_stats_.Reset();
props->received_video_stats_.Reset();
{
std::lock_guard lock(props->gallery_frame_mutex_);
props->gallery_video_latency_->Reset();
props->gallery_frame_timing_ = {};
}
props->traversal_mode_ = TraversalMode::UnknownMode;
props->enable_mouse_control_ = false;
props->preview_subscription_supported_ = false;
props->preview_resolution_supported_ = false;
props->preview_fps15_supported_ = false;
props->preview_bandwidth_supported_ = false;
props->preview_needs_sync_ = true;
props->preview_ack_ = 0;
props->preview_accepted_ = false;
props->audio_allowed_ = false;
props->clipboard_allowed_ = false;
runtime->ResetRemoteServiceStatus(*props);
{
std::lock_guard lock(props->video_settings_mutex_);
@@ -385,11 +420,6 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
break;
}
case ConnectionStatus::IncorrectPassword: {
runtime->password_validating_ = false;
runtime->password_validating_time_++;
break;
}
default:
break;
}
@@ -411,6 +441,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
switch (status) {
case ConnectionStatus::Connected: {
runtime->preview_subscriptions_.Remove(remote_id);
#if _WIN32
runtime->last_windows_service_status_tick_ = 0;
#endif
@@ -420,6 +451,10 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
remote_action.i.supports_deskhub_fps15 = true;
remote_action.i.supports_deskhub_bandwidth = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
remote_action.i.display_list[i]);
@@ -525,7 +560,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
}
@@ -536,7 +571,7 @@ void PeerEventHandler::OnNetStatusReport(
if (props->traversal_mode_ != mode) {
props->net_traffic_stats_.ResetLatency();
props->traversal_mode_ = mode;
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_));
LOG_INFO("Net mode: [{}]", int(props->traversal_mode_.load()));
}
if (!net_traffic_stats) {
@@ -1,3 +1,6 @@
#include <libyuv.h>
#include <algorithm>
#include <chrono>
#include <cstring>
#include <memory>
@@ -5,6 +8,7 @@
#include <string>
#include <vector>
#include "nv12_scaler.h"
#include "platform/video_renderer.h"
#include "runtime/gui_runtime.h"
#include "runtime/peer_event_handler.h"
@@ -46,9 +50,80 @@ void PeerEventHandler::OnReceiveVideoBuffer(
if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now();
const auto timing = props->video_latency_->Frame(
video_frame->captured_timestamp, GetSystemTimeMicros(props->peer_),
// Pair the clocks before preview conversion so its cost is included.
const auto local_now_us = GetSystemTimeMicros(props->peer_);
const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record(
received_native ? received_native->width : video_frame->width,
received_native ? received_native->height : video_frame->height,
timing_now);
// Convert only visible thumbnails, off the UI thread. A session retains
// one latest RGBA image, not an unbounded queue of frames.
const int preview_width = props->gallery_preview_width_.load();
// Multiple video streams may decode concurrently for the same session.
// Serialize access to the shared conversion buffers. Visible previews
// consume incoming frames at the negotiated interactive cadence.
std::unique_lock conversion_lock(props->gallery_conversion_mutex_,
std::defer_lock);
if (preview_width > 0) conversion_lock.lock();
if (conversion_lock.owns_lock()) {
const auto gallery_timing = props->gallery_video_latency_->Frame(
video_frame->captured_timestamp, local_now_us, timing_now);
const auto* native = GetNativeVideoFrame(video_frame);
const int w = native ? native->width : video_frame->width;
const int h = native ? native->height : video_frame->height;
if (w > 0 && h > 0 && w <= 16384 && h <= 16384 && !(w % 2) && !(h % 2)) {
const size_t bytes = static_cast<size_t>(w) * h * 3 / 2;
const uint8_t* data =
reinterpret_cast<const uint8_t*>(video_frame->data);
if (native) {
props->gallery_nv12_.resize(bytes);
data = native->copy_to_nv12(native->owner,
props->gallery_nv12_.data(), bytes) == 0
? props->gallery_nv12_.data()
: nullptr;
} else if (video_frame->size < bytes)
data = nullptr;
if (data) {
const double scale = std::min(
{1.0, double(preview_width) / w, double(preview_width) / h});
const int dw = std::max(2, int(w * scale) & ~1);
const int dh = std::max(2, int(h * scale) & ~1);
auto& scaled = props->gallery_scaled_;
scaled.resize(static_cast<size_t>(dw) * dh * 3 / 2);
if (ScaleNv12ViaI420(data, w, data + w * h, w, w, h, scaled.data(),
dw, scaled.data() + dw * dh, dw, dw, dh,
libyuv::kFilterBilinear,
&props->gallery_scratch_) == 0) {
auto rgba = std::make_shared<std::vector<unsigned char>>(
static_cast<size_t>(dw) * dh * 4);
if (libyuv::NV12ToABGR(scaled.data(), dw, scaled.data() + dw * dh,
dw, rgba->data(), dw * 4, dw, dh) == 0) {
std::lock_guard lock(props->gallery_frame_mutex_);
props->gallery_rgba_ = std::move(rgba);
props->gallery_width_ = dw;
props->gallery_height_ = dh;
props->gallery_frame_at_ = timing_now;
props->gallery_frame_timing_ = gallery_timing;
++props->gallery_sequence_;
}
}
}
}
}
if (conversion_lock.owns_lock()) conversion_lock.unlock();
// A wall-only session does not need a second full-resolution CPU buffer.
if (props->wall_managed_ && !props->tab_opened_) {
{
std::lock_guard lock(props->video_frame_mutex_);
props->video_width_ = video_frame->width;
props->video_height_ = video_frame->height;
}
props->streaming_ = true;
return;
}
const auto timing = props->video_latency_->Frame(
video_frame->captured_timestamp, local_now_us, timing_now);
bool background_snapshot_only = false;
auto* native_renderer = runtime->video_renderer_.get();
if (native_renderer && native_renderer->IsActive()) {
@@ -205,9 +280,13 @@ void PeerEventHandler::OnReceiveAudioBuffer(
return;
}
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
const auto session =
runtime->FindRemoteSession(std::string(user_id, user_id_size));
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
runtime->audio_buffer_fresh_ = true;
runtime->devices_.PushAudio(data, size);
runtime->devices_.PushAudio(session->remote_id_, data, size);
}
} // namespace crossdesk
@@ -0,0 +1,86 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PREVIEW_SUBSCRIPTIONS_H_
#define _PREVIEW_SUBSCRIPTIONS_H_
#include <preview_subscription.h>
#include <chrono>
#include <mutex>
#include <string>
#include <unordered_map>
namespace crossdesk {
// Accessed by the network callback and screen-capture callback. Legacy
// controllers have no entry and continue receiving their normal stream.
class PreviewSubscriptions {
public:
using Clock = std::chrono::steady_clock;
std::optional<PreviewSubscription> Get(const std::string &id) {
std::lock_guard lock(mutex_);
const auto found = states_.find(id);
return found == states_.end() ? std::nullopt
: std::optional(found->second.request);
}
bool Set(const std::string &id, const PreviewSubscription &request) {
std::lock_guard lock(mutex_);
const auto found = states_.find(id);
if (found != states_.end() &&
!request.IsNewerThan(found->second.request))
return false;
auto &state = states_[id];
if (state.request.fps != request.fps) state.next.clear();
state.request = request;
return true;
}
bool ShouldSend(const std::string &id, Clock::time_point now = Clock::now(),
const std::string &stream = {}) {
std::lock_guard lock(mutex_);
const auto found = states_.find(id);
if (found == states_.end())
return true;
auto &state = found->second;
// MiniRTC owns pause/resume. This gate only limits preview cadence, so
// paused input reaches its cheap send gate without dividing by zero here.
if (!state.request.fps)
return true;
// Interactive streams use the encoder's own 30/60 FPS cadence. A second
// time gate here can drop valid frames because capture timing varies.
if (state.request.fps == 60)
return true;
auto &next = state.next[stream];
if (state.request.fps == 15) {
// Keep a stable 15 FPS deadline across capture jitter. Rebasing every
// frame can turn small timing errors into extra dropped capture frames.
const auto interval = std::chrono::nanoseconds(1'000'000'000 / 15);
if (now + std::chrono::milliseconds(1) < next) return false;
next = next == Clock::time_point{} || now - next > interval
? now + interval : next + interval;
return true;
}
if (now < next)
return false;
next = now + std::chrono::milliseconds(1000 / state.request.fps);
return true;
}
void Remove(const std::string &id) {
std::lock_guard lock(mutex_);
states_.erase(id);
}
private:
struct State {
PreviewSubscription request;
// One display must not consume another display's preview frame budget.
std::unordered_map<std::string, Clock::time_point> next;
};
std::mutex mutex_;
std::unordered_map<std::string, State> states_;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,72 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _RECEIVED_VIDEO_STATS_H_
#define _RECEIVED_VIDEO_STATS_H_
#include <chrono>
#include <cmath>
#include <mutex>
#include <optional>
namespace crossdesk::gui_detail {
// Measured at decode delivery, including wall-only and background sessions.
// Independent of the selected window's presentation cadence and FPS setting.
class ReceivedVideoStats {
public:
using Clock = std::chrono::steady_clock;
struct Snapshot {
int width = 0;
int height = 0;
std::optional<int> fps;
};
void Record(int width, int height, Clock::time_point now = Clock::now()) {
if (width <= 0 || height <= 0) return;
std::lock_guard lock(mutex_);
const bool restart = snapshot_.width == 0 ||
now - last_frame_ >= std::chrono::seconds(3);
snapshot_.width = width;
snapshot_.height = height;
last_frame_ = now;
if (restart) {
started_ = now;
frames_ = 0;
snapshot_.fps.reset();
return;
}
++frames_;
const double elapsed = std::chrono::duration<double>(now - started_).count();
if (elapsed >= 1.0) {
snapshot_.fps = static_cast<int>(std::lround(frames_ / elapsed));
frames_ = 0;
started_ = now;
}
}
Snapshot Get(Clock::time_point now = Clock::now()) const {
std::lock_guard lock(mutex_);
auto result = snapshot_;
if (result.width > 0 && now - last_frame_ >= std::chrono::seconds(1))
result.fps = 0;
return result;
}
void Reset() {
std::lock_guard lock(mutex_);
snapshot_ = {};
frames_ = 0;
started_ = last_frame_ = {};
}
private:
mutable std::mutex mutex_;
Snapshot snapshot_;
unsigned frames_ = 0;
Clock::time_point started_{}, last_frame_{};
};
} // namespace crossdesk::gui_detail
#endif
+40 -3
View File
@@ -20,12 +20,16 @@
#include <vector>
#include <remote_action.h>
#include <preview_subscription.h>
#include <stream_names.h>
#include "display_info.h"
#include "features/gallery/preview_resolution.h"
#include "cursor_presentation.h"
#include "minirtc.h"
#include "rendering/video_latency.h"
#include "runtime/net_traffic_stats_cache.h"
#include "runtime/received_video_stats.h"
#include "runtime/connection_auth_state.h"
namespace crossdesk { class PeerEventHandler; }
@@ -40,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 {
@@ -69,6 +73,35 @@ struct FileTransferState {
// Runtime state for one connected or connecting remote endpoint. It groups the
// connection, media, input, window and transfer data that share one lifetime.
struct RemoteSession {
// Wall membership, stream-window visibility and input ownership are separate.
std::atomic<bool> wall_managed_{false};
std::atomic<bool> preview_subscription_supported_{false};
std::atomic<bool> preview_resolution_supported_{false};
std::atomic<bool> preview_fps15_supported_{false};
std::atomic<bool> preview_bandwidth_supported_{false};
std::atomic<bool> preview_needs_sync_{true};
std::atomic<uint32_t> preview_ack_{0};
std::atomic<bool> preview_accepted_{false};
uint32_t preview_request_ = 0;
PreviewSubscription preview_requested_;
gallery::PreviewResolutionDebouncer preview_resolution_;
std::chrono::steady_clock::time_point preview_request_at_{};
std::atomic<bool> clipboard_allowed_{false};
std::atomic<uint64_t> input_generation_{0};
std::atomic<bool> audio_allowed_{false};
std::atomic<int> gallery_preview_width_{0};
std::mutex gallery_frame_mutex_;
std::shared_ptr<std::vector<unsigned char>> gallery_rgba_;
int gallery_width_ = 0, gallery_height_ = 0;
uint64_t gallery_sequence_ = 0;
std::chrono::steady_clock::time_point gallery_frame_at_{};
VideoLatencyFrame gallery_frame_timing_;
// Keep preview submission estimates separate from stream presentation times.
std::shared_ptr<VideoLatencyStats> gallery_video_latency_ =
std::make_shared<VideoLatencyStats>();
// Decoder-thread scratch memory, never shared with the UI.
std::mutex gallery_conversion_mutex_;
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
Params params_;
PeerPtr* peer_ = nullptr;
std::shared_ptr<PeerEventHandler> peer_events_;
@@ -90,6 +123,8 @@ struct RemoteSession {
SignalStatus signal_status_ = SignalStatus::SignalClosed;
bool connection_established_ = false;
std::atomic<bool> was_connected_{false};
// Transport callbacks publish changes; the UI thread persists history.
std::atomic<bool> recent_connection_dirty_{false};
bool rejoin_ = false;
bool net_traffic_stats_button_pressed_ = false;
bool enable_mouse_control_ = true;
@@ -100,6 +135,7 @@ struct RemoteSession {
bool p2p_mode_ = true;
bool remember_password_ = false;
char remote_password_[7] = "";
ConnectionAuthState connection_auth_;
// Reused across frames by the decode callback thread when a CPU NV12
// native frame has to be materialized for a renderer that rejects it.
std::vector<unsigned char> native_cpu_fallback_;
@@ -125,7 +161,7 @@ struct RemoteSession {
std::shared_ptr<VideoLatencyStats> video_latency_ =
std::make_shared<VideoLatencyStats>();
bool tab_selected_ = false;
bool tab_opened_ = true;
std::atomic<bool> tab_opened_{true};
std::string remote_host_name_;
// Host metadata and dismissal state belong to this connection and are
// protected by remote_version_mutex_.
@@ -164,13 +200,14 @@ struct RemoteSession {
CursorPresentation cursor_presentation_;
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed;
TraversalMode traversal_mode_ = TraversalMode::UnknownMode;
std::atomic<TraversalMode> traversal_mode_{TraversalMode::UnknownMode};
int fps_ = 0;
int frame_count_ = 0;
std::chrono::steady_clock::time_point last_time_;
// UI-thread snapshot refreshed with FPS once per second.
std::optional<VideoLatencyStats::Snapshot> video_latency_snapshot_;
NetTrafficStatsCache net_traffic_stats_;
ReceivedVideoStats received_video_stats_;
using QueuedFile = FileTransferState::QueuedFile;
using FileTransferStatus = FileTransferState::FileTransferStatus;
+8 -3
View File
@@ -7,6 +7,8 @@
#ifndef _RUNTIME_STATE_H_
#define _RUNTIME_STATE_H_
#include <stream_names.h>
#include <atomic>
#include <chrono>
#include <cstdint>
@@ -19,12 +21,11 @@
#include <utility>
#include <vector>
#include <stream_names.h>
#include "config_center.h"
#include "common/task_queue_lock_free.h"
#include "config_center.h"
#include "path_manager.h"
#include "runtime/device_presence_cache.h"
#include "runtime/preview_subscriptions.h"
#include "runtime/remote_session.h"
#include "runtime/session_lifecycle.h"
#include "thumbnail.h"
@@ -36,6 +37,8 @@
namespace crossdesk::gui_detail {
struct InfrastructureState {
PreviewSubscriptions preview_subscriptions_;
std::mutex audio_delivery_mutex_;
std::unique_ptr<ConfigCenter> config_center_;
ConfigCenter::LANGUAGE localization_language_ =
ConfigCenter::LANGUAGE::CHINESE;
@@ -54,6 +57,7 @@ struct RecentConnectionsState {
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>
recent_connections_;
std::vector<std::string> recent_connection_ids_;
std::vector<std::string> gallery_presence_ids_;
int recent_connection_image_width_ = 160;
int recent_connection_image_height_ = 90;
uint32_t recent_connection_image_save_time_ = 0;
@@ -190,6 +194,7 @@ struct ConnectionState {
struct PendingReconnect {
std::string password;
bool remember_password = false;
bool background = false;
};
std::unordered_map<std::string, PendingReconnect> pending_reconnects_;
+65 -35
View File
@@ -1,3 +1,4 @@
import { UiRadius } from "ui_radius.slint";
// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line
// 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
@@ -49,6 +50,7 @@ export global FontAwesomeIcons {
out property <string> signal: "\u{f012}";
out property <string> shield-halved: "\u{f3ed}";
out property <string> square-full: "\u{f45c}";
out property <string> tags: "\u{f02c}";
out property <string> trash-can: "\u{f2ed}";
out property <string> user-secret: "\u{f21b}";
out property <string> volume-high: "\u{f028}";
@@ -59,6 +61,7 @@ export global FontAwesomeIcons {
export component IconButton inherits Rectangle {
in property <string> icon;
in property <string> tooltip;
in property <bool> tooltip-above: false;
in property <bool> enabled: true;
in property <bool> danger: false;
in property <length> icon-size: 16px;
@@ -74,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 {
@@ -103,11 +106,11 @@ export component IconButton inherits Rectangle {
if touch.has-hover && root.tooltip != "": Rectangle {
x: min(root.width - self.width, 0px);
y: root.height + 3px;
y: root.tooltip-above ? -self.height - 3px : root.height + 3px;
width: tip.preferred-width + 12px;
height: 24px;
background: #22252be8;
border-radius: 4px;
border-radius: UiRadius.control;
z: 20;
tip := Text {
text: root.tooltip;
@@ -134,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;
@@ -155,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;
@@ -171,10 +174,25 @@ export component CompactComboBox inherits Rectangle {
in property <[string]> model;
in-out property <int> current-index: 0;
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;
@@ -183,29 +201,31 @@ export component CompactComboBox inherits Rectangle {
width: 73px;
height: 24px;
background: root.enabled ? white : #f1f1f1;
background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1;
border-width: 1px;
border-color: #bfc2c7;
border-radius: 3px;
border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color;
border-radius: UiRadius.control;
Text {
x: 5px;
width: parent.width - 24px;
x: root.subtle ? 10px : 5px;
width: parent.width - (root.show-arrow ? (root.subtle ? 36px : 24px) : (root.subtle ? 20px : 10px));
text: root.model[root.current-index];
color: root.enabled ? #202124 : #8b8f95;
font-size: ImGuiFontStyle.body;
font-size: root.subtle ? 12px : ImGuiFontStyle.body;
overflow: elide;
vertical-alignment: center;
}
Rectangle {
x: parent.width - 21px;
if root.show-arrow: Rectangle {
x: parent.width - (root.subtle ? 27px : 21px);
width: 20px;
height: parent.height - 2px;
y: 1px;
background: root.enabled ? #9bc9f8 : #d7d9dc;
background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc;
border-top-right-radius: UiRadius.control - 1px;
border-bottom-right-radius: UiRadius.control - 1px;
Text {
text: FontAwesomeIcons.angle-down;
color: #202124;
color: root.subtle ? #79879a : #202124;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 10px;
@@ -219,8 +239,8 @@ export component CompactComboBox inherits Rectangle {
}
}
}
TouchArea {
enabled: root.enabled;
combo-touch := TouchArea {
enabled: root.enabled; mouse-cursor: pointer;
clicked => {
root.popup-animation-enabled = false;
root.popup-progress = 0.0;
@@ -259,22 +279,25 @@ export component CompactComboBox inherits Rectangle {
opacity: root.popup-progress;
background: white;
border-width: 1px;
border-color: #bfc2c7;
border-radius: 3px;
border-color: root.normal-border-color;
border-radius: UiRadius.menu;
clip: true;
for choice[index] in root.model: Rectangle {
y: index * 23px;
width: parent.width;
height: 23px;
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
option-touch := TouchArea {
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();
}
}
@@ -284,8 +307,8 @@ export component CompactComboBox inherits Rectangle {
height: parent.height;
background: transparent;
border-width: 1px;
border-color: #bfc2c7;
border-radius: 3px;
border-color: root.normal-border-color;
border-radius: UiRadius.menu;
z: 2;
}
}
@@ -345,18 +368,19 @@ export component CompactButton inherits Rectangle {
in property <string> text;
in property <length> text-size: ImGuiFontStyle.body;
in property <bool> primary: false;
in property <bool> quiet: false;
in property <bool> enabled: true;
callback clicked;
width: 34px;
height: 24px;
background: !root.enabled ? #eceef0
background: !root.enabled ? (root.quiet ? transparent : #eceef0)
: button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd)
: button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2)
: root.primary ? #a8d0fa : #e2e4e7;
: root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7;
border-width: 1px;
border-color: root.primary ? #9bc3ed : #c8cbd0;
border-radius: 3px;
border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0;
border-radius: UiRadius.control;
Text {
text: root.text;
color: root.enabled ? #24272d : #8e9298;
@@ -380,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;
@@ -398,6 +422,7 @@ export component PrimaryButton inherits Rectangle {
export component SecondaryButton inherits Rectangle {
in property <string> text;
in property <length> text-size: ImGuiFontStyle.body;
in property <bool> enabled: true;
callback clicked;
@@ -408,13 +433,13 @@ 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 {
text: root.text;
color: root.enabled ? #24272d : #949aa5;
font-size: ImGuiFontStyle.body;
font-size: root.text-size;
horizontal-alignment: center;
vertical-alignment: center;
}
@@ -428,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;
@@ -448,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();
}
}
}
@@ -465,5 +495,5 @@ export component DialogSurface inherits Rectangle {
background: white;
border-width: 1px;
border-color: ImGuiLineStyle.border;
border-radius: 8px;
border-radius: UiRadius.surface;
}
+6 -1
View File
@@ -1,6 +1,11 @@
// Single AOT entry point. Generating one header for the entire UI avoids
// duplicate imported-component definitions when C++ uses all three windows.
export { AnnouncementItem, MessageStrings } from "message_panel.slint";
export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint";
export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint";
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint";
export { GalleryStrings } from "device_strings.slint";
export { GalleryNetworkDevice } from "gallery_network_dialog.slint";
export { DeviceGroupOption } from "device_group_dialog.slint";
@@ -0,0 +1,61 @@
import { UiRadius } from "ui_radius.slint";
import { IconButton, FontAwesomeIcons } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
export global DeviceCardStyle {
out property <length> toolbar-width: 104px;
out property <color> offline-preview: #626870;
out property <color> offline-background: #e4e6e9;
out property <color> offline-border: #c9cdd2;
out property <color> offline-text: #68707a;
out property <color> offline-placeholder: #eceef1;
}
component CardAction inherits IconButton {
width: 26px; height: 23px;
border-radius: UiRadius.control;
icon-size: 12px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
tooltip-above: true;
}
// Shared by recent connections and DeskHub; callers supply only behavior.
export component DeviceCardActions inherits Rectangle {
in property <string> remove-label;
in property <bool> connected: false;
callback edit-alias();
callback edit-groups();
callback remove-device();
callback connect-device();
callback disconnect-device();
width: DeviceCardStyle.toolbar-width; height: 23px;
background: #4a4e55dc; border-radius: UiRadius.control;
HorizontalLayout {
spacing: 0px;
CardAction {
icon: FontAwesomeIcons.pen;
tooltip: GalleryStrings.tr("修改名称", "Rename", "Переименовать");
clicked => { root.edit-alias(); }
}
CardAction {
icon: FontAwesomeIcons.tags;
tooltip: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
clicked => { root.edit-groups(); }
}
CardAction {
icon: FontAwesomeIcons.trash-can;
tooltip: root.remove-label;
clicked => { root.remove-device(); }
}
CardAction {
icon: root.connected ? FontAwesomeIcons.xmark : FontAwesomeIcons.arrow-right-long;
icon-size: 13px;
tooltip: root.connected ? GalleryStrings.tr("断开", "Disconnect", "Отключить") : GalleryStrings.tr("连接", "Connect", "Подключить");
clicked => {
if root.connected { root.disconnect-device(); }
else { root.connect-device(); }
}
}
}
}
@@ -0,0 +1,103 @@
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint";
import { CompactButton, ImGuiCheckBox } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
export struct DeviceGroupOption {
name: string,
selected: bool,
}
export component DeviceGroupDialog inherits Rectangle {
in property <string> device-name;
in property <string> device-id;
in property <[DeviceGroupOption]> options;
in property <string> error;
in-out property <string> new-group;
callback toggle-group(string);
callback save(string);
callback cancel();
background: #00000040;
TouchArea {}
dialog := Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(360px, parent.width - 40px);
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #b2b2b2;
TouchArea {}
Text {
x: 18px; y: 16px; height: 24px;
text: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
color: #24272d; font-size: 17px; font-weight: 600;
}
Text {
x: 18px; y: 47px; width: parent.width - 36px; height: 18px;
text: root.device-name; color: #30343b; font-size: 12px; overflow: elide;
}
Text {
x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
}
group-list := Rectangle {
x: 18px; y: 95px; width: parent.width - 36px;
height: min(5, max(1, root.options.length)) * 32px + 2px;
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: UiRadius.surface;
clip: true;
ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width;
VerticalLayout {
spacing: 0px;
for option[index] in root.options: Rectangle {
height: 32px;
background: row-touch.has-hover ? #eef4fc : transparent;
ImGuiCheckBox {
x: 9px; y: 6px; width: 20px; height: 20px;
checked: option.selected && (option.name != "" || root.new-group == "");
}
Text {
x: 39px; width: parent.width - 48px; height: parent.height;
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name;
color: #30343b; font-size: 12px; overflow: elide; vertical-alignment: center;
}
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
}
}
}
// Keep row hover backgrounds inside the rounded list on every renderer.
Rectangle {
x: -UiRadius.surface; y: -UiRadius.surface;
width: parent.width + 2 * UiRadius.surface;
height: parent.height + 2 * UiRadius.surface;
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white;
}
Rectangle {
width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface;
border-color: #e1e5eb;
}
}
RoundedLineEdit {
x: 18px; y: group-list.y + group-list.height + 12px;
width: parent.width - 36px; height: 30px;
text <=> root.new-group;
placeholder-text: GalleryStrings.tr("新建分组(可选)", "New group (optional)", "Новая группа (необязательно)");
}
if root.error != "": Text {
x: 18px; y: group-list.y + group-list.height + 48px;
width: parent.width - 36px; height: 32px;
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
}
HorizontalLayout {
x: 18px; y: parent.height - 46px; width: parent.width - 36px; height: 28px;
spacing: 10px;
Rectangle { horizontal-stretch: 1; }
CompactButton { width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.cancel(); } }
CompactButton { width: 88px; height: 28px; primary: true; text: GalleryStrings.tr("保存", "Save", "Сохранить"); clicked => { root.save(root.new-group); } }
}
}
}
@@ -0,0 +1,6 @@
export global GalleryStrings {
in-out property <int> language: 0;
public pure function tr(zh: string, en: string, ru: string) -> string {
return self.language == 0 ? zh : self.language == 2 ? ru : en;
}
}
@@ -0,0 +1,112 @@
import { UiRadius } from "ui_radius.slint";
import { CompactButton, FontAwesomeIcons } from "common.slint";
export struct GalleryMenuItem { label: string, enabled: bool }
export component GalleryMenuButton inherits Rectangle {
in property <string> text;
in property <bool> primary: false;
in property <bool> enabled: true;
in property <bool> opens-up: false;
in property <length> menu-width: 200px;
in property <length> horizontal-padding: 12px;
out property <length> preferred-button-width: ceil(button-label.preferred-width / 1px) * 1px + root.horizontal-padding * 2 + 18px;
in property <[GalleryMenuItem]> items;
private property <int> current-index: 0;
callback opened;
callback selected(int);
height: 24px;
forward-focus: trigger-focus;
accessible-role: button;
accessible-label: root.text;
accessible-enabled: root.enabled;
accessible-expandable: true;
accessible-expanded: popup.is-open;
accessible-action-default => { root.open-menu(); }
changed enabled => { if !root.enabled { popup.close(); } }
changed visible => { if !root.visible { popup.close(); } }
public function close-menu() { popup.close(); }
function open-menu() {
if !root.enabled || root.items.length == 0 { return; }
if popup.is-open { popup.close(); return; }
root.opened();
root.current-index = 0;
popup.show();
}
function choose(index: int) {
if !root.enabled || index < 0 || index >= root.items.length || !root.items[index].enabled { return; }
popup.close();
trigger-focus.focus();
root.selected(index);
}
function handle-key(event: KeyEvent) -> EventResult {
if !root.enabled { return reject; }
if event.text == Key.Escape && popup.is-open {
popup.close(); trigger-focus.focus(); return accept;
}
if event.text == Key.Return || event.text == " " {
if popup.is-open { root.choose(root.current-index); } else { root.open-menu(); }
return accept;
}
if event.text == Key.UpArrow || event.text == Key.DownArrow {
if !popup.is-open { root.open-menu(); }
else { root.current-index = clamp(root.current-index + (event.text == Key.UpArrow ? -1 : 1), 0, root.items.length - 1); }
return accept;
}
return reject;
}
trigger-focus := FocusScope {
enabled: root.enabled;
key-pressed(event) => { return root.handle-key(event); }
CompactButton {
width: parent.width; height: parent.height;
border-radius: UiRadius.control;
enabled: root.enabled; primary: root.primary;
clicked => { trigger-focus.focus(); root.open-menu(); }
}
button-label := Text {
x: root.horizontal-padding; width: parent.width - root.horizontal-padding * 2 - 18px; height: parent.height;
text: root.text; color: root.enabled ? #24272d : #8e9298;
font-size: 11px; overflow: elide; horizontal-alignment: center; vertical-alignment: center;
}
Text {
x: parent.width - root.horizontal-padding - 12px; width: 12px; height: parent.height;
text: root.opens-up ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 9px;
color: root.enabled ? #62666d : #8e9298;
horizontal-alignment: center; vertical-alignment: center;
}
}
popup := PopupWindow {
x: root.width - self.width;
y: root.opens-up ? -self.height - 4px : root.height + 4px;
width: root.menu-width; height: root.items.length * 30px + 8px;
close-policy: close-on-click-outside;
forward-focus: popup-focus;
popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); }
Rectangle {
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu;
for item[index] in root.items: Rectangle {
x: 4px; y: 4px + index * 30px; width: parent.width - 8px; height: 30px;
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); }
Text {
x: 10px; width: parent.width - 20px; height: parent.height;
text: item.label; color: item.enabled ? #30343b : #92979f;
font-size: 12px; vertical-alignment: center;
}
TouchArea {
enabled: item.enabled;
changed has-hover => { if self.has-hover { root.current-index = index; } }
clicked => { root.choose(index); }
}
}
}
}
}
}
@@ -0,0 +1,225 @@
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { CompactButton } from "common.slint";
import { ScrollView } from "std-widgets.slint";
export struct GalleryNetworkDevice {
name: string,
id: string,
mode: string,
encryption-known: bool,
encryption-enabled: bool,
video-latency: string,
inbound: string,
outbound: string,
loss: string,
resolution: string,
fps: string,
}
component NetworkRow inherits Rectangle {
in property <GalleryNetworkDevice> device;
in property <bool> heading: false;
// Keep the same column geometry in the header and every row. Grouping
// related values leaves room for device names even in the 640px window.
HorizontalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height;
padding-left: 12px; padding-right: 12px; spacing: 10px;
identity := Rectangle {
horizontal-stretch: 1;
device-name := Text {
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width; text: root.device.name;
font-size: root.heading ? 11px : 12px; font-weight: 600;
color: root.heading ? #6b7785 : #293746;
vertical-alignment: center; overflow: elide;
}
name-hover := TouchArea {
y: 8px; height: 20px; width: parent.width;
enabled: !root.heading;
}
if !root.heading: HorizontalLayout {
x: 0px; y: 30px; width: parent.width; height: 17px; spacing: 4px;
id-text := Text {
text: root.device.id; font-size: 10px; color: #7a8490;
vertical-alignment: center; overflow: elide;
}
Rectangle {
width: max(0px, min(mode-text.preferred-width + 12px,
parent.width - id-text.preferred-width - encryption-badge.width - 12px));
background: #edf2f7; border-radius: 3px;
mode-text := Text {
x: 6px; width: parent.width - 12px; height: parent.height;
text: root.device.mode; font-size: 10px; color: #586f87;
horizontal-alignment: center; vertical-alignment: center; overflow: elide;
}
}
encryption-badge := Rectangle {
width: encryption-text.preferred-width + 12px;
background: !root.device.encryption-known ? #f0f2f5
: root.device.encryption-enabled ? #e8f5ec : #fff3df;
border-radius: 3px;
encryption-text := Text {
x: 6px; width: parent.width - 12px; height: parent.height;
text: !root.device.encryption-known
? GalleryStrings.tr("待确认", "Unknown", "Неизвестно")
: root.device.encryption-enabled
? GalleryStrings.tr("已加密", "Encrypted", "Защищено")
: GalleryStrings.tr("未加密", "Unencrypted", "Без шифр.");
color: !root.device.encryption-known ? #7a8490
: root.device.encryption-enabled ? #18794e : #8a6428;
font-size: 10px; horizontal-alignment: center;
vertical-alignment: center; overflow: elide;
}
}
Rectangle { horizontal-stretch: 1; }
}
}
Rectangle {
width: max(108px, root.width * 0.19);
Text {
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width;
text: root.heading ? GalleryStrings.tr("画面", "Video", "Видео") : root.device.resolution;
font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400;
color: root.heading ? #6b7785 : #293746;
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
y: 30px; height: 17px; width: parent.width;
text: root.device.fps; font-size: 11px; color: #7a8490;
vertical-alignment: center; overflow: elide;
}
}
Rectangle {
width: max(100px, root.width * 0.18);
if root.heading: Text {
x: 0px; width: parent.width;
text: GalleryStrings.tr("收发速率", "Transfer rate", "Скорость");
font-size: 11px; font-weight: 600; color: #6b7785;
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
x: 0px; y: 8px; height: 20px; width: 14px;
text: "↓"; color: #2676b9; font-size: 12px; vertical-alignment: center;
}
if !root.heading: Text {
x: 17px; y: 8px; height: 20px; width: parent.width - 17px;
text: root.device.inbound; color: #293746; font-size: 12px;
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
x: 0px; y: 30px; height: 17px; width: 14px;
text: "↑"; color: #7a8490; font-size: 11px; vertical-alignment: center;
}
if !root.heading: Text {
x: 17px; y: 30px; height: 17px; width: parent.width - 17px;
text: root.device.outbound; color: #7a8490; font-size: 11px;
vertical-alignment: center; overflow: elide;
}
}
Rectangle {
width: max(94px, root.width * 0.16);
Text {
y: root.heading ? 0px : 8px; height: root.heading ? parent.height : 20px;
width: parent.width;
text: root.heading ? GalleryStrings.tr("视频延时", "Video latency", "Задержка видео") : root.device.video-latency;
font-size: root.heading ? 11px : 12px; font-weight: root.heading ? 600 : 400;
color: root.heading ? #6b7785 : #293746;
vertical-alignment: center; overflow: elide;
}
if !root.heading: Text {
y: 30px; height: 17px; width: parent.width;
text: GalleryStrings.tr("丢包 ", "Loss ", "Потери ") + root.device.loss;
font-size: 11px; color: #7a8490;
vertical-alignment: center; overflow: elide;
}
}
}
if !root.heading && name-hover.has-hover && device-name.preferred-width > identity.width: Rectangle {
x: 12px; y: 32px; width: min(name-tip.preferred-width + 20px, root.width - 24px);
height: 26px; background: #293746; border-radius: UiRadius.control; z: 10;
name-tip := Text {
x: 10px; width: parent.width - 20px; height: parent.height;
text: root.device.name; font-size: 11px; color: white;
vertical-alignment: center; overflow: elide;
}
}
}
component NetworkEmptyState inherits Rectangle {
VerticalLayout {
alignment: center; spacing: 8px;
Text {
text: GalleryStrings.tr("暂无已连接设备", "No connected devices", "Нет подключённых устройств");
font-size: 13px; color: #596878; horizontal-alignment: center;
}
Text {
text: GalleryStrings.tr("建立连接后,网络和画面信息将在此显示", "Network and video details appear here once connected", "После подключения здесь появятся данные сети и видео");
font-size: 11px; color: #8a95a1; horizontal-alignment: center;
}
}
}
export component GalleryNetworkDialog inherits Rectangle {
in property <[GalleryNetworkDevice]> devices;
private property <length> row-height: 56px;
private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
callback close();
background: #00000042;
TouchArea { clicked => { root.close(); } }
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(760px, parent.width - 32px);
height: min(120px + root.list-height, parent.height - 32px);
background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #c6cdd5;
TouchArea {}
FocusScope {
init => { self.focus(); }
key-pressed(event) => {
if event.text == Key.Escape { root.close(); }
accept
}
}
Text {
x: 20px; y: 17px; width: parent.width - 120px; height: 24px;
text: GalleryStrings.tr("设备网络状态", "Device network status", "Сеть устройств");
font-size: 17px; font-weight: 600; color: #293746;
}
CompactButton {
border-radius: UiRadius.control;
x: parent.width - 80px; y: 17px; width: 60px; height: 26px;
text: GalleryStrings.tr("关闭", "Close", "Закрыть"); clicked => { root.close(); }
}
Text {
x: 20px; y: 48px; width: parent.width - 40px; height: 18px;
text: GalleryStrings.tr("已连接设备 ", "Connected devices ", "Подключённые устройства ") + root.devices.length
+ GalleryStrings.tr(" 台", "", "");
font-size: 11px; color: #7a8490; overflow: elide;
}
NetworkRow {
x: 12px; y: 78px; width: parent.width - 38px; height: 30px;
heading: true; background: #f3f5f8; border-radius: UiRadius.control;
device: {
name: GalleryStrings.tr("设备 / ID", "Device / ID", "Устройство / ID"),
};
}
list := ScrollView {
x: 12px; y: 108px; width: parent.width - 24px;
height: parent.height - self.y - 12px;
viewport-width: self.width - 14px;
viewport-height: max(self.height, root.devices.length * root.row-height);
horizontal-scrollbar-policy: always-off;
for device[index] in root.devices: NetworkRow {
y: index * root.row-height; width: list.viewport-width; height: root.row-height;
device: device; background: mod(index, 2) == 0 ? white : #f8fafc;
Rectangle { y: parent.height - 1px; height: 1px; background: #edf0f4; }
}
}
if root.devices.length == 0: NetworkEmptyState {
x: list.x; y: list.y; width: list.width; height: list.height;
}
}
}
@@ -0,0 +1,50 @@
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { SettingsGroup, SettingsRow, SettingsSelect } from "settings_controls.slint";
export component GallerySettings inherits Rectangle {
in-out property <int> resolution-index: 0;
in-out property <int> frame-rate-index: 0;
in-out property <int> bandwidth-index: 3;
in property <string> error;
SettingsGroup {
y: 0px; width: parent.width;
title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3;
SettingsRow {
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение");
label-reserve: self.width - 112px;
SettingsSelect {
border-radius: UiRadius.control;
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.resolution-index;
model: [GalleryStrings.tr("自动", "Auto", "Авто"),
"320 × 180", "640 × 360", "1280 × 720", "1920 × 1080", "2560 × 1440", "3840 × 2160",
GalleryStrings.tr("原始分辨率", "Original resolution", "Исходное разрешение")];
}
}
SettingsRow {
label: GalleryStrings.tr("帧率", "Frame rate", "Частота кадров");
label-reserve: self.width - 112px;
SettingsSelect {
border-radius: UiRadius.control;
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.frame-rate-index;
model: [GalleryStrings.tr("跟随设备设置", "Follow device settings", "Настройки устройства"), "15 FPS", "30 FPS", "60 FPS"];
}
}
SettingsRow {
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео");
label-reserve: self.width - 112px; divider: false;
SettingsSelect {
border-radius: UiRadius.control;
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.bandwidth-index;
model: ["2 Mbps", "5 Mbps", "10 Mbps", "20 Mbps", "50 Mbps", "100 Mbps"];
}
}
}
if root.error != "": Text {
x: 2px; y: 144px; width: parent.width - 4px; height: 32px;
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
}
}
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons } from "common.slint";
// Shared by history and DeskHub; system choices precede the named groups.
export component GroupFilter inherits Rectangle {
in property <[string]> model;
in property <int> system-count: 2;
in property <bool> enabled: true;
in property <color> normal-border-color: #c9cdd2;
in property <color> hover-border-color: root.normal-border-color;
in property <color> focus-border-color: #8db5df;
in-out property <int> current-index: 0;
callback selected(int);
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
private property <length> visible-height: min(root.list-height, 162px);
private property <length> scroll-y: 0px;
private property <bool> popup-animation-enabled: false;
private property <float> popup-progress: 0;
animate popup-progress {
duration: 140ms; easing: ease-out;
enabled: root.popup-animation-enabled;
}
width: 155px; height: 28px;
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
border-width: 1px;
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color;
border-radius: UiRadius.control;
animate background, border-color { duration: 100ms; easing: ease-out; }
forward-focus: trigger-focus;
accessible-role: combobox;
accessible-enabled: root.enabled;
accessible-expandable: true;
accessible-expanded: popup.is-open;
accessible-value: root.model[root.current-index];
function reveal-selection() {
let top = root.current-index * 26px + (root.has-divider && root.current-index >= root.system-count ? 6px : 0px);
if top < -root.scroll-y { root.scroll-y = -top; }
if top + 26px > -root.scroll-y + root.visible-height {
root.scroll-y = root.visible-height - top - 26px;
}
root.scroll-y = clamp(root.scroll-y, min(0px, root.visible-height - root.list-height), 0px);
}
function choose(index: int) {
if !root.enabled || root.model.length == 0 { return; }
root.current-index = clamp(index, 0, root.model.length - 1);
root.reveal-selection();
root.selected(root.current-index);
}
function open-menu() {
if root.enabled && root.model.length > 0 {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0;
root.reveal-selection();
popup.show();
popup-open-timer.running = true;
}
}
// Defer until the popup has its initial frame; animate its contents only,
// keeping the native popup bounds and scrolling geometry stable.
popup-open-timer := Timer {
interval: 16ms;
running: false;
triggered => {
self.running = false;
if popup.is-open {
root.popup-animation-enabled = true;
root.popup-progress = 1;
}
}
}
function handle-key(event: KeyEvent) -> EventResult {
if !root.enabled { return reject; }
if event.text == Key.UpArrow { root.choose(root.current-index - 1); return accept; }
if event.text == Key.DownArrow { root.choose(root.current-index + 1); return accept; }
if event.text == Key.Return || event.text == " " {
if popup.is-open { popup.close(); } else { root.open-menu(); }
return accept;
}
if event.text == Key.Escape && popup.is-open { popup.close(); return accept; }
return reject;
}
Text {
x: 10px; width: parent.width - 36px; height: parent.height;
text: root.model[root.current-index];
color: root.enabled ? #30343b : #92979f;
font-size: 12px; overflow: elide; vertical-alignment: center;
accessible-role: none;
}
Text {
x: parent.width - 25px; width: 16px; height: parent.height;
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #858c96;
horizontal-alignment: center; vertical-alignment: center;
transform-rotation: popup.is-open ? 180deg : 0deg;
animate transform-rotation { duration: 140ms; easing: ease-out; }
accessible-role: none;
}
trigger-focus := FocusScope {
enabled: root.enabled;
key-pressed(event) => { return root.handle-key(event); }
trigger-touch := TouchArea {
enabled: root.enabled;
clicked => { trigger-focus.focus(); root.open-menu(); }
}
}
popup := PopupWindow {
x: 0px; y: root.height + 4px;
width: root.width; height: root.visible-height + 8px;
close-policy: close-on-click-outside;
forward-focus: popup-focus;
popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); }
Rectangle {
y: (1 - root.popup-progress) * -4px;
width: parent.width; height: parent.height;
opacity: root.popup-progress;
background: white; border-width: 1px;
border-color: root.normal-border-color; border-radius: UiRadius.menu;
ScrollView {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
viewport-width: self.width;
viewport-height: root.list-height;
viewport-y <=> root.scroll-y;
Rectangle {
width: parent.width; height: root.list-height;
if root.has-divider: Rectangle {
x: 6px; y: root.system-count * 26px + 3px;
width: parent.width - 12px; height: 1px; background: #e9ebee;
}
for choice[index] in root.model: Rectangle {
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
width: parent.width; height: 26px; border-radius: UiRadius.control;
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
animate background { duration: 100ms; easing: ease-out; }
accessible-role: list-item;
accessible-label: choice;
Text {
x: 8px; width: parent.width - 34px; height: parent.height;
text: choice; color: #30343b; font-size: 12px;
overflow: elide; vertical-alignment: center;
accessible-role: none;
}
if index == root.current-index: Text {
x: parent.width - 24px; width: 16px; height: parent.height;
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #4e87c0;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
option-touch := TouchArea {
clicked => { root.choose(index); popup.close(); }
}
}
}
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+163 -144
View File
@@ -1,5 +1,9 @@
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { 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 => {
+34
View File
@@ -0,0 +1,34 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _MODEL_UPDATE_H_
#define _MODEL_UPDATE_H_
#include <slint.h>
#include <utility>
#include <vector>
namespace crossdesk::ui {
// A reset invalidates row-count bindings even if the count is unchanged.
// Repeating it from a timer can continually restart dependent size animations.
template <typename Row>
void UpdateModelRows(slint::VectorModel<Row>& model, std::vector<Row> rows) {
if (model.row_count() != rows.size()) {
model.set_vector(std::move(rows));
return;
}
for (std::size_t i = 0; i < rows.size(); ++i) {
if (model.row_data(i) != rows[i]) {
model.set_row_data(i, rows[i]);
}
}
}
} // namespace crossdesk::ui
#endif
@@ -0,0 +1,90 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _RELEASE_NOTES_TEXT_H_
#define _RELEASE_NOTES_TEXT_H_
#include <slint.h>
#include <algorithm>
#include <cctype>
#include <string_view>
#include <utility>
#include <vector>
namespace crossdesk::release_notes_text {
struct Block {
slint::StyledText content;
bool section_gap = false;
};
inline bool IsThematicBreak(std::string_view line) {
const auto start = line.find_first_not_of(' ');
// Four leading spaces denote a code block, not a thematic break.
if (start == std::string_view::npos || start > 3) return false;
const char marker = line[start];
if (marker != '-' && marker != '*' && marker != '_') return false;
size_t count = 0;
for (const char ch : line.substr(start)) {
if (ch == marker)
++count;
else if (ch != ' ' && ch != '\t')
return false;
}
return count >= 3;
}
inline std::vector<Block> Parse(std::string_view markdown) {
std::vector<Block> blocks;
bool section_gap = false;
size_t line_start = 0;
while (line_start < markdown.size()) {
const size_t newline = markdown.find('\n', line_start);
const size_t line_end =
newline == std::string_view::npos ? markdown.size() : newline;
auto line = markdown.substr(line_start, line_end - line_start);
line_start = newline == std::string_view::npos ? markdown.size() : newline + 1;
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
// StyledText does not support thematic breaks. Represent them with the
// same spacing as blank lines instead of rendering raw Markdown markers.
if (std::all_of(line.begin(), line.end(), [](unsigned char ch) {
return std::isspace(ch) != 0;
}) || IsThematicBreak(line)) {
section_gap = !blocks.empty();
continue;
}
size_t heading_end = 0;
while (heading_end < line.size() && heading_end < 6 &&
line[heading_end] == '#') {
++heading_end;
}
// The dialog already supplies the release title.
if (heading_end > 0 && heading_end < line.size() &&
std::isspace(static_cast<unsigned char>(line[heading_end])) != 0) {
continue;
}
Block block;
if (const auto parsed = slint::StyledText::from_markdown(line)) {
block.content = *parsed;
} else {
// Unsupported syntax is valid release content, not a parsing failure.
// Preserve it literally when it is outside Slint's Markdown subset.
block.content = slint::StyledText::from_plain_text(line);
}
block.section_gap = section_gap;
blocks.push_back(std::move(block));
section_gap = false;
}
return blocks;
}
} // namespace crossdesk::release_notes_text
#endif
@@ -0,0 +1,34 @@
export global RemoteCursor {
// Numeric values mirror crossdesk::RemoteCursorShape on the wire.
public pure function shape(value: int) -> MouseCursor {
return value == 1 ? MouseCursor.none
: value == 2 ? MouseCursor.help
: value == 3 ? MouseCursor.pointer
: value == 4 ? MouseCursor.progress
: value == 5 ? MouseCursor.wait
: value == 6 ? MouseCursor.crosshair
: value == 7 ? MouseCursor.text
: value == 8 ? MouseCursor.alias
: value == 9 ? MouseCursor.copy
: value == 10 ? MouseCursor.move
: value == 11 ? MouseCursor.no-drop
: value == 12 ? MouseCursor.not-allowed
: value == 13 ? MouseCursor.grab
: value == 14 ? MouseCursor.grabbing
: value == 15 ? MouseCursor.col-resize
: value == 16 ? MouseCursor.row-resize
: value == 17 ? MouseCursor.n-resize
: value == 18 ? MouseCursor.e-resize
: value == 19 ? MouseCursor.s-resize
: value == 20 ? MouseCursor.w-resize
: value == 21 ? MouseCursor.ne-resize
: value == 22 ? MouseCursor.nw-resize
: value == 23 ? MouseCursor.se-resize
: value == 24 ? MouseCursor.sw-resize
: value == 25 ? MouseCursor.ew-resize
: value == 26 ? MouseCursor.ns-resize
: value == 27 ? MouseCursor.nesw-resize
: value == 28 ? MouseCursor.nwse-resize
: MouseCursor.default;
}
}
@@ -0,0 +1,50 @@
import { LineEdit } from "std-widgets.slint";
import { UiRadius } from "ui_radius.slint";
// Retain the standard editor's selection, clipboard, clear button and input
// method support. Only its outer frame is replaced by the shared shape.
export component RoundedLineEdit inherits Rectangle {
in-out property <string> text;
in property <string> placeholder-text;
in property <InputType> input-type: InputType.text;
in property <bool> enabled: true;
in property <length> font-size: 12px;
in property <color> normal-border-color: #dfe6ef;
in property <brush> surrounding-background: white;
callback accepted();
callback edited();
height: 30px;
border-radius: UiRadius.control;
background: white;
clip: true;
forward-focus: editor;
editor := LineEdit {
x: -1px; y: -1px;
width: parent.width + 2px; height: parent.height + 2px;
text <=> root.text;
placeholder-text: root.placeholder-text;
input-type: root.input-type;
enabled: root.enabled;
font-size: root.font-size;
accepted => { root.accepted(); }
edited => { root.edited(); }
}
// The software renderer clips children to rectangular bounds. Mask the
// standard editor's corners before drawing our rounded border on top.
Rectangle {
x: -root.border-radius; y: -root.border-radius;
width: parent.width + 2 * root.border-radius;
height: parent.height + 2 * root.border-radius;
border-width: root.border-radius;
border-radius: 2 * root.border-radius;
border-color: root.surrounding-background;
}
Rectangle {
width: parent.width; height: parent.height;
border-radius: root.border-radius;
border-width: editor.has-focus ? 2px : 1px;
border-color: editor.has-focus ? #3564e3 : root.normal-border-color;
background: transparent;
}
}
+6 -5
View File
@@ -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; }
}
}
@@ -0,0 +1,142 @@
import { UiRadius } from "ui_radius.slint";
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
// Shared visual treatment for the global settings pages.
export component SettingsButton inherits Rectangle {
in property <string> text;
in property <bool> primary: false;
in property <bool> quiet: false;
in property <bool> align-left: false;
in property <bool> enabled: true;
callback clicked();
width: 88px; height: 32px; border-radius: UiRadius.control;
background: !root.enabled ? #f0f3f7
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white;
border-width: root.quiet || root.primary ? 0px : 1px;
border-color: !root.enabled ? WorkbenchStyle.border
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border;
accessible-role: button; accessible-label: root.text;
accessible-action-default => { if root.enabled { root.clicked(); } }
Text {
x: 12px; width: parent.width - 24px;
text: root.text; font-size: 12px; font-weight: root.primary ? 600 : 400;
color: !root.enabled ? #98a2b3 : root.primary ? white : root.quiet ? #65758b : #3c4d63;
horizontal-alignment: root.align-left ? left : center; vertical-alignment: center; overflow: elide;
}
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;
}
export component SettingsGroup inherits Rectangle {
in property <string> title;
in property <int> rows: 1;
height: 24px + root.rows * 36px;
Text {
x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title;
font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center;
}
Rectangle {
y: 24px; width: parent.width; height: parent.height - self.y;
background: white; border-radius: UiRadius.surface; border-color: WorkbenchStyle.border; border-width: 1px;
VerticalLayout { padding: 0px; spacing: 0px; @children }
}
}
export component SettingsRow inherits Rectangle {
in property <string> label;
in property <bool> enabled: true;
in property <bool> divider: true;
in property <length> label-reserve: 128px;
height: 36px;
Text {
x: 12px; width: parent.width - root.label-reserve; height: parent.height;
text: root.label; color: root.enabled ? #34445a : #9ba5b3;
font-size: 12px; overflow: elide; vertical-alignment: center;
}
if root.divider: Rectangle {
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec;
}
@children
}
export component SettingsToggleRow inherits SettingsRow {
in-out property <bool> checked;
in property <bool> toggle-on-click: true;
callback clicked();
label-reserve: 64px;
ToggleSwitch {
x: parent.width - self.width - 12px; y: (parent.height - self.height) / 2;
checked <=> root.checked; enabled: root.enabled;
toggle-on-click: root.toggle-on-click;
clicked => { root.clicked(); }
}
}
+46 -49
View File
@@ -1,4 +1,6 @@
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";
export struct StreamTab {
@@ -97,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;
@@ -158,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;
@@ -236,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;
@@ -263,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;
@@ -390,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) => {
@@ -714,36 +728,8 @@ export component StreamWindow inherits Window {
accept
}
pointer := TouchArea {
mouse-cursor: !root.remote-cursor-active ? MouseCursor.default
: root.remote-cursor-shape == 1 ? MouseCursor.none
: root.remote-cursor-shape == 2 ? MouseCursor.help
: root.remote-cursor-shape == 3 ? MouseCursor.pointer
: root.remote-cursor-shape == 4 ? MouseCursor.progress
: root.remote-cursor-shape == 5 ? MouseCursor.wait
: root.remote-cursor-shape == 6 ? MouseCursor.crosshair
: root.remote-cursor-shape == 7 ? MouseCursor.text
: root.remote-cursor-shape == 8 ? MouseCursor.alias
: root.remote-cursor-shape == 9 ? MouseCursor.copy
: root.remote-cursor-shape == 10 ? MouseCursor.move
: root.remote-cursor-shape == 11 ? MouseCursor.no-drop
: root.remote-cursor-shape == 12 ? MouseCursor.not-allowed
: root.remote-cursor-shape == 13 ? MouseCursor.grab
: root.remote-cursor-shape == 14 ? MouseCursor.grabbing
: root.remote-cursor-shape == 15 ? MouseCursor.col-resize
: root.remote-cursor-shape == 16 ? MouseCursor.row-resize
: root.remote-cursor-shape == 17 ? MouseCursor.n-resize
: root.remote-cursor-shape == 18 ? MouseCursor.e-resize
: root.remote-cursor-shape == 19 ? MouseCursor.s-resize
: root.remote-cursor-shape == 20 ? MouseCursor.w-resize
: root.remote-cursor-shape == 21 ? MouseCursor.ne-resize
: root.remote-cursor-shape == 22 ? MouseCursor.nw-resize
: root.remote-cursor-shape == 23 ? MouseCursor.se-resize
: root.remote-cursor-shape == 24 ? MouseCursor.sw-resize
: root.remote-cursor-shape == 25 ? MouseCursor.ew-resize
: root.remote-cursor-shape == 26 ? MouseCursor.ns-resize
: root.remote-cursor-shape == 27 ? MouseCursor.nesw-resize
: root.remote-cursor-shape == 28 ? MouseCursor.nwse-resize
: MouseCursor.default;
mouse-cursor: root.remote-cursor-active
? RemoteCursor.shape(root.remote-cursor-shape) : MouseCursor.default;
pointer-event(event) => {
let over-control = self.mouse-x >= control.x
&& self.mouse-x <= control.x + control.width
@@ -994,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;
@@ -1031,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;
@@ -1160,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 }
@@ -1210,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;
@@ -1222,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;
@@ -1272,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 {
@@ -1307,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;
@@ -1331,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;
@@ -1347,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;
@@ -1382,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(); }
@@ -1391,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 {
@@ -40,11 +40,13 @@ inline int ApplyMainWindowStrings(
int language_index) {
const int language =
localization::detail::ClampLanguageIndex(language_index);
window->global<ui::GalleryStrings>().set_language(language);
auto& strings = window->global<ui::UiStrings>();
auto& messages = window->global<ui::MessageStrings>();
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]));
@@ -110,6 +112,10 @@ inline int ApplyMainWindowStrings(
strings.set_signal_disconnected(
Text(localization::signal_disconnected[language]));
strings.set_tls_error(Text(localization::signal_tls_cert_error[language]));
strings.set_signal_reconnect(
Text(localization::signal_reconnect[language]));
strings.set_signal_reconnecting(
Text(localization::signal_reconnecting[language]));
strings.set_update_available(
Text(localization::new_version_available[language]));
strings.set_new_version(Text(localization::new_version[language]));
+8
View File
@@ -0,0 +1,8 @@
// Logical pixels: compact controls, popup menus, and larger surfaces.
// Circular indicators, switches, progress bars, and native window chrome
// retain radii derived from their own geometry.
export global UiRadius {
out property <length> control: 6px;
out property <length> menu: 8px;
out property <length> surface: 12px;
}
@@ -0,0 +1,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;
}
+22
View File
@@ -6,11 +6,29 @@
#include "spdlog/sinks/rotating_file_sink.h"
#include "spdlog/sinks/stdout_color_sinks.h"
#if defined(__OHOS__)
#include <hilog/log.h>
#include "spdlog/sinks/base_sink.h"
#endif
namespace crossdesk {
namespace {
#if defined(__OHOS__)
class HarmonyLogSink final : public spdlog::sinks::base_sink<std::mutex> {
void sink_it_(const spdlog::details::log_msg& message) override {
spdlog::memory_buf_t formatted;
formatter_->format(message, formatted);
const std::string text(formatted.data(), formatted.size());
const auto level = message.level >= spdlog::level::err ? LOG_ERROR :
message.level >= spdlog::level::warn ? LOG_WARN : LOG_INFO;
OH_LOG_Print(LOG_APP, level, 0, "CrossDeskDesktop", "%{public}s", text.c_str());
}
void flush_() override {}
};
#endif
std::string g_log_dir = "logs";
std::string g_logger_name = LOGGER_NAME;
std::once_flag g_logger_once_flag;
@@ -56,7 +74,11 @@ std::shared_ptr<spdlog::logger> get_logger() {
g_log_dir + "/" + g_logger_name + ".log", 5 * 1024 * 1024, 3);
std::vector<spdlog::sink_ptr> sinks;
#if defined(__OHOS__)
sinks.push_back(std::make_shared<HarmonyLogSink>());
#else
sinks.push_back(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
#endif
sinks.push_back(file_sink);
auto logger = std::make_shared<spdlog::logger>(g_logger_name, sinks.begin(),
@@ -0,0 +1,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
@@ -2,6 +2,8 @@
#import <AppKit/AppKit.h>
#include <cmath>
@interface CrossDeskStreamFullscreenTarget : NSObject
- (void)toggleStreamFullscreen:(id)sender;
@end
@@ -17,6 +19,11 @@
namespace crossdesk {
namespace {
__weak NSWindow *gallery_window = nil;
bool gallery_fullscreen = false;
NSRect gallery_restore_frame = NSZeroRect;
NSWindowStyleMask gallery_restore_style = NSWindowStyleMaskTitled;
NSApplicationPresentationOptions gallery_restore_presentation;
__weak NSWindow *stream_window = nil;
__weak NSView *stream_slint_view = nil;
CrossDeskStreamFullscreenTarget *stream_fullscreen_target = nil;
@@ -53,6 +60,14 @@ void SetTitlebarButtonsHidden(NSWindow *window, BOOL hidden) {
[window standardWindowButton:NSWindowZoomButton].hidden = hidden;
}
void HideMainWindowZoomButton(NSWindow *window) {
NSButton *button = [window standardWindowButton:NSWindowZoomButton];
button.target = nil;
button.action = nil;
button.enabled = NO;
button.hidden = YES;
}
void CenterWindowOnScreen(NSWindow *window, NSScreen *screen) {
if (window == nil || screen == nil) {
return;
@@ -80,7 +95,17 @@ bool StartNativeWindowDrag() {
}
}
bool ConfigureMainWindowChrome(void *opaque_slint_view) {
float MainWindowInitialPositionYOffset() {
@autoreleasepool {
const NSRect content = NSMakeRect(0, 0, 640, 486);
const NSRect frame = [NSWindow frameRectForContentRect:content
styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable |
NSWindowStyleMaskMiniaturizable];
return float(NSMaxY(frame) - NSMaxY(content));
}
}
bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
@autoreleasepool {
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
NSWindow *window = slint_view.window;
@@ -91,9 +116,18 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
NSButton *zoom_button =
[window standardWindowButton:NSWindowZoomButton];
if (zoom_button == nil || zoom_button.enabled) {
return false;
}
if (zoom_button == nil) return false;
if (gallery) gallery_window = window;
if (gallery && gallery_fullscreen) return true;
if (gallery)
window.styleMask |= NSWindowStyleMaskResizable;
else
window.styleMask &= ~NSWindowStyleMaskResizable;
window.collectionBehavior =
(window.collectionBehavior & ~(NSWindowCollectionBehaviorFullScreenPrimary |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorFullScreenAllowsTiling)) |
NSWindowCollectionBehaviorFullScreenNone;
// Winit creates a transparent AppKit backing even when Slint paints an
// opaque client area. macOS 27 can expose that backing in the titlebar.
@@ -104,7 +138,7 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
window.titlebarAppearsTransparent = YES;
window.opaque = YES;
window.backgroundColor = NSColor.whiteColor;
zoom_button.hidden = YES;
HideMainWindowZoomButton(window);
NSView *frame_view = window.contentView.superview;
frame_view.needsLayout = YES;
@@ -113,6 +147,71 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) {
}
}
bool SetGalleryWindowFullscreen(bool fullscreen) {
@autoreleasepool {
if (!gallery_window) return false;
if (gallery_fullscreen == fullscreen) return true;
if (fullscreen) {
NSScreen *screen = gallery_window.screen ?: NSScreen.mainScreen;
if (!screen) return false;
gallery_restore_frame = gallery_window.frame;
gallery_restore_style = gallery_window.styleMask;
gallery_restore_presentation = NSApp.presentationOptions;
gallery_fullscreen = true;
gallery_window.styleMask |= NSWindowStyleMaskFullSizeContentView;
gallery_window.titleVisibility = NSWindowTitleHidden;
gallery_window.movable = NO;
SetTitlebarButtonsHidden(gallery_window, YES);
NSApp.presentationOptions |=
NSApplicationPresentationAutoHideDock | NSApplicationPresentationAutoHideMenuBar;
// Let the next event-loop frame paint the updated Slint layout. A
// synchronous draw here exposes stale grid geometry during the resize.
[gallery_window setFrame:screen.frame display:NO animate:NO];
} else {
gallery_fullscreen = false;
NSApp.presentationOptions = gallery_restore_presentation;
gallery_window.styleMask = gallery_restore_style;
gallery_window.titleVisibility = NSWindowTitleVisible;
gallery_window.movable = YES;
[gallery_window standardWindowButton:NSWindowCloseButton].hidden = NO;
[gallery_window standardWindowButton:NSWindowMiniaturizeButton].hidden = NO;
// DeskHub uses its in-app fullscreen action.
HideMainWindowZoomButton(gallery_window);
[gallery_window setFrame:gallery_restore_frame display:NO animate:NO];
}
gallery_window.contentView.needsDisplay = YES;
return true;
}
}
bool IsGalleryWindowFullscreen() { return gallery_fullscreen; }
bool IsGalleryWindowActive() { return gallery_window && [NSApp isActive] && [gallery_window isKeyWindow]; }
bool MaximizeGalleryWindowWithoutAnimation() {
@autoreleasepool {
if (!gallery_window || gallery_fullscreen) return false;
NSScreen *screen = gallery_window.screen ?: NSScreen.mainScreen;
if (!screen) return false;
[gallery_window setFrame:screen.visibleFrame display:YES animate:NO];
return true;
}
}
bool IsGalleryWindowMaximized() {
@autoreleasepool {
if (!gallery_window || gallery_fullscreen) return false;
NSScreen *screen = gallery_window.screen;
if (!screen) return false;
// isZoomed can report true for a constrained compact window. Only treat
// the ordinary window as maximized when it fills the usable screen.
const NSRect frame = gallery_window.frame;
const NSRect available = screen.visibleFrame;
return std::abs(frame.origin.x - available.origin.x) < 1 &&
std::abs(frame.origin.y - available.origin.y) < 1 &&
std::abs(frame.size.width - available.size.width) < 1 &&
std::abs(frame.size.height - available.size.height) < 1;
}
}
bool ConfigureStreamWindowLiveResize(void *opaque_slint_view) {
@autoreleasepool {
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
+14 -4
View File
@@ -8,10 +8,20 @@ namespace crossdesk {
// left-mouse-down event. Returns false when no suitable native event exists.
bool StartNativeWindowDrag();
// Sets a fixed white native titlebar and hides the disabled zoom button on
// the fixed-size main window. |slint_view| is its AppKit NSView*. Returns false
// while the native window or its fixed-size constraints are not ready.
bool ConfigureMainWindowChrome(void* slint_view);
// Configures the connection or DeskHub window with a white native titlebar.
// Only DeskHub is resizable. |slint_view| is its AppKit NSView*.
// Returns false while the native window or its titlebar controls are not ready.
bool ConfigureMainWindowChrome(void* slint_view, bool gallery = false);
// Winit's initial macOS position is a content origin, while subsequent moves
// use the outer frame. Return the logical titlebar inset for initial placement.
float MainWindowInitialPositionYOffset();
bool SetGalleryWindowFullscreen(bool fullscreen);
bool IsGalleryWindowFullscreen();
bool IsGalleryWindowActive();
// AppKit's animated zoom spins a nested run loop and cannot be called from a
// Slint timer. Restore maximization by setting the visible screen frame directly.
bool MaximizeGalleryWindowWithoutAnimation();
bool IsGalleryWindowMaximized();
// Configures live resize and replaces the stream window's native Space
// fullscreen action with an immediate, single-window fullscreen transition.
+1 -1
View File
@@ -57,7 +57,7 @@ std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend();
// Flush queued AppKit cleanup after joining the controller at app shutdown.
void FlushMacPrivacyTasks();
#endif
#ifdef __linux__
#if defined(__linux__) && !defined(__OHOS__)
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend();
#endif
@@ -16,7 +16,7 @@ PrivacyController::PrivacyController(Factory factory) {
if (!factory) factory = CreateWindowsPrivacyBackend;
#elif defined(__APPLE__)
if (!factory) factory = CreateMacPrivacyBackend;
#elif defined(__linux__)
#elif (defined(__linux__) && !defined(__OHOS__))
if (!factory) factory = CreateLinuxPrivacyBackend;
#endif
thread_ = std::thread([this, factory] { Run(factory); });
+3 -4
View File
@@ -203,16 +203,15 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height,
const std::string& password,
HostPlatform platform) {
std::lock_guard lock(mutex_);
// Preserve the existing opt-in via Remember password for recent connections.
if (password.empty()) return 0;
// Successful connections are history even without saved credentials.
if (!IsRemoteId(remote_id)) return -1;
RecentConnection connection;
ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"),
&connection);
connection.remote_id = remote_id;
connection.remote_host_name = host_name;
if (!host_name.empty()) connection.remote_host_name = host_name;
connection.password = password;
connection.remember_password = true;
connection.remember_password = !password.empty();
if (platform != HostPlatform::Unknown) connection.platform = platform;
if (!WriteRecord(connection)) return -1;
+2
View File
@@ -41,6 +41,8 @@ class Thumbnail {
// Serialized with writes so disabling also clears any in-flight preview.
int SetSavePreviews(bool enabled);
// Also records metadata without an image. Empty password means do not store
// credentials (and clears any remembered password from a previous session).
int SaveToThumbnail(const char* nv12, int width, int height,
const std::string& remote_id,
const std::string& host_name,
+18 -218
View File
@@ -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
+19 -72
View File
@@ -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";
@@ -83,9 +83,7 @@ int main() {
ok &= ExpectContains("minirtc.h", minirtc_api, "RemoteUnavailable");
ok &= ExpectNotContains("minirtc.h", minirtc_api, "DeviceOffline");
ok &= ExpectContains("peer_connection.cpp", peer_connection,
"\"Remote unavailable\"");
ok &= ExpectContains("peer_connection.cpp", peer_connection,
"ConnectionStatus::RemoteUnavailable");
"on_connection_status_(ParseJoinFailure(j).status,");
ok &= ExpectContains("peer_connection.cpp", peer_connection,
"IsCurrentPeerConnection");
ok &= ExpectContains(
@@ -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";
}
+485 -7
View File
@@ -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();
+28 -1
View File
@@ -230,6 +230,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 +511,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")
@@ -510,7 +537,7 @@ function setup_targets()
"apps/desktop/src/platform/common/gui/service_status_runtime.cpp",
"apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp",
"apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp",
"apps/desktop/src/gui/features/settings/*.cpp")
"apps/desktop/src/gui/features/settings/*.cpp", "apps/desktop/src/gui/features/gallery/*.cpp")
-- Only these translation units consume the application version. Keep
-- version changes out of the flags for media and the rest of the GUI.
add_files("apps/desktop/src/gui/application/gui_application.cpp",
+1
View File
@@ -0,0 +1 @@
Checks: 'misc-missing-switch-cases,misc-napi-module-name,misc-replace-if-else-with-ternary-operator,misc-unused-local-variable,misc-unused-parameters,modernize-use-auto,readability-system-capabilities,misc-napi-macro-module-name,capi-version-validation'
+16
View File
@@ -0,0 +1,16 @@
CompileFlags:
Add: [-Wunreachable-code-aggressive]
Remove: [-fsanitize=hwaddress, -fsanitize=thread]
Diagnostics:
ClangTidy:
Add: [misc-missing-switch-cases,misc-napi-module-name,misc-replace-if-else-with-ternary-operator,misc-unused-local-variable,misc-unused-parameters,modernize-use-auto,readability-system-capabilities,misc-napi-macro-module-name,capi-version-validation]
Remove: []
CheckOptions:
misc-unused-parameters.StrictMode: true
misc-unused-parameters.IgnoreVirtual: true
modernize-use-auto.MinTypeNameLength: 0
UnusedIncludes: Strict
UnusedFunctions: Check
Hover:
ShowNewHover: Yes
+25
View File
@@ -0,0 +1,25 @@
.hvigor/
.native/
.idea/
**/__pycache__/
*.py[cod]
oh_modules/
**/oh_modules/
**/build/
**/.cxx/
native/.xmake/
entry/libs/
local.properties
*.p12
*.p7b
*.cer
*.csr
# Generated mobile legal resources; regenerated from checked-in sources and the SDK.
entry/src/main/resources/rawfile/about/
entry/src/main/resources/rawfile/LICENSE
entry/src/main/resources/rawfile/PRIVACY.md
entry/src/main/resources/rawfile/OPEN_SOURCE_NOTICE.txt
entry/src/main/resources/rawfile/THIRD_PARTY_NOTICES.txt
entry/src/main/resources/rawfile/ThirdPartyLicenses.json
entry/src/main/resources/rawfile/SourceMetadata.json

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