Compare commits

...
34 Commits
Author SHA1 Message Date
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
71 changed files with 6931 additions and 1186 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
+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
+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)
@@ -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();
@@ -75,9 +95,9 @@ private:
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,6 +126,7 @@ private:
cursor_delivery_states_;
uint32_t cursor_state_sequence_ = 0;
std::chrono::steady_clock::time_point next_video_frame_time_{};
SignalConnectionIndicator signal_connection_indicator_;
#if defined(__linux__) && !defined(__APPLE__)
bool use_xwayland_gui_ = false;
bool use_x11_custom_titlebar_ = false;
@@ -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
@@ -258,6 +258,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 +275,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_;
};
@@ -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;
@@ -531,13 +539,13 @@ 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 (!open_device()) {
#if defined(__linux__) && !defined(__APPLE__)
LOG_WARN("Failed to open output stream with driver [{}]: {}",
getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER")
@@ -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());
}
}
@@ -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};
@@ -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,173 @@
/*
* @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) {
if (Restoring())
return;
if (!wall) {
connection_position = position;
return;
}
if (!fullscreen && !maximized && IsUndersized(size, scale, compact_height))
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) {
// 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))
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) const {
const float width =
wall && wall_size ? std::max(640.0f, wall_size->width) : 640;
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) {
scale = std::max(scale, 1.0f);
return double(size.width) + 1 < 640 * 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;
@@ -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,7 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
void GuiRuntime::HandleServerControllerDisconnected(
const std::string& remote_id, const char* reason) {
preview_subscriptions_.Remove(remote_id);
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
#ifdef _WIN32
devices_.ReleaseRemoteMouseButtons();
@@ -210,7 +269,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,8 +287,17 @@ 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;
MuteRemoteAudio(props);
if (props->peer_events_) props->peer_events_->Deactivate();
props->connection_established_ = false;
props->streaming_ = false;
@@ -409,7 +478,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 +509,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 +554,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 +588,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 +618,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 +638,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 +650,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 +688,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
@@ -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
@@ -28,6 +28,49 @@ GuiRuntime::GuiRuntime()
GuiRuntime::~GuiRuntime() { 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.
if (connection_peer_cleanup_.valid()) return 0;
@@ -206,6 +249,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_;
};
@@ -139,11 +139,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
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_) {
@@ -151,14 +152,90 @@ void PeerEventHandler::OnReceiveDataBuffer(
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;
@@ -342,6 +419,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 +440,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 +542,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);
@@ -79,7 +79,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);
@@ -263,8 +268,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 +288,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 +307,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 +330,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 +413,6 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
break;
}
case ConnectionStatus::IncorrectPassword: {
runtime->password_validating_ = false;
runtime->password_validating_time_++;
break;
}
default:
break;
}
@@ -411,6 +434,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 +444,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]);
@@ -536,7 +564,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
+39 -2
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; }
@@ -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_;
+27 -21
View File
@@ -49,6 +49,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 +60,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;
@@ -103,7 +105,7 @@ 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;
@@ -171,6 +173,8 @@ 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;
property <bool> popup-animation-enabled: false;
property <float> popup-progress: 0.0;
callback selected(int);
@@ -183,29 +187,29 @@ 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.subtle ? #dfe6ef : #bfc2c7;
border-radius: root.subtle ? 6px : 3px;
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;
Text {
text: FontAwesomeIcons.angle-down;
color: #202124;
color: root.subtle ? #79879a : #202124;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 10px;
@@ -220,7 +224,7 @@ export component CompactComboBox inherits Rectangle {
}
}
TouchArea {
enabled: root.enabled;
enabled: root.enabled; mouse-cursor: pointer;
clicked => {
root.popup-animation-enabled = false;
root.popup-progress = 0.0;
@@ -259,15 +263,15 @@ export component CompactComboBox inherits Rectangle {
opacity: root.popup-progress;
background: white;
border-width: 1px;
border-color: #bfc2c7;
border-radius: 3px;
border-color: root.subtle ? #dfe6ef : #bfc2c7;
border-radius: root.subtle ? 6px : 3px;
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 {
clicked => {
popup-open-timer.running = false;
@@ -284,8 +288,8 @@ export component CompactComboBox inherits Rectangle {
height: parent.height;
background: transparent;
border-width: 1px;
border-color: #bfc2c7;
border-radius: 3px;
border-color: root.subtle ? #dfe6ef : #bfc2c7;
border-radius: root.subtle ? 6px : 3px;
z: 2;
}
}
@@ -345,17 +349,18 @@ 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-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0;
border-radius: 3px;
Text {
text: root.text;
@@ -398,6 +403,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;
@@ -414,7 +420,7 @@ export component SecondaryButton inherits Rectangle {
Text {
text: root.text;
color: root.enabled ? #24272d : #949aa5;
font-size: ImGuiFontStyle.body;
font-size: root.text-size;
horizontal-alignment: center;
vertical-alignment: center;
}
@@ -4,3 +4,8 @@ export { AnnouncementItem, MessageStrings } from "message_panel.slint";
export { MainWindow, RecentConnection, UpdateCheckStatus, 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,59 @@
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;
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: 5px;
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,87 @@
import { ScrollView, LineEdit } from "std-widgets.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: 8px;
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: 5px;
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); } }
}
}
}
}
LineEdit {
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,110 @@
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;
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: 4px;
for item[index] in root.items: Rectangle {
x: 4px; y: 4px + index * 30px; width: parent.width - 8px; height: 30px;
border-radius: 3px;
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,223 @@
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: 4px; 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: 8px;
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 {
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: 4px;
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,54 @@
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 {
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 {
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 {
x: 136px; y: 4px; width: parent.width - 150px;
current-index <=> root.bandwidth-index;
model: ["2 Mbps", "5 Mbps", "10 Mbps", "20 Mbps", "50 Mbps", "100 Mbps"];
}
}
}
Text {
x: 2px; y: 144px; width: parent.width - 4px; height: 70px;
text: GalleryStrings.tr(
"可见设备共享视频预算,优先保障展开画面;不可见设备暂停视频。音频与文件传输不计入,旧版设备不支持限额。瞬时速率可能超过预算。",
"Visible devices share the video budget, with priority for expanded views. Hidden video pauses. Audio, files and older devices are excluded; brief bursts may exceed the budget.",
"Бюджет видео делится между видимыми устройствами с приоритетом крупного вида. Скрытое видео приостановлено. Звук, файлы и старые устройства не учитываются; возможны краткие превышения.");
color: #718096; font-size: 11px; wrap: word-wrap;
}
if root.error != "": Text {
x: 2px; y: 224px; width: parent.width - 4px; height: 32px;
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
}
}
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
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-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 ? #8db5df : #c9cdd2;
border-radius: 4px;
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: #c9cdd2; border-radius: 5px;
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: 3px;
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
@@ -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,73 @@
import { CompactComboBox, ToggleSwitch } from "common.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: 7px;
background: !root.enabled ? #f0f3f7
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white;
border-width: root.quiet || root.primary ? 0px : 1px;
border-color: #dce3ec;
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 SettingsSelect inherits CompactComboBox {
subtle: true;
show-arrow: false;
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: 9px; border-color: #e5eaf1; 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: #eff2f6;
}
@children
}
export component SettingsToggleRow inherits SettingsRow {
in-out property <bool> checked;
label-reserve: 64px;
ToggleSwitch {
x: parent.width - self.width - 12px; y: (parent.height - self.height) / 2;
checked <=> root.checked; enabled: root.enabled;
}
}
+3 -30
View File
@@ -1,4 +1,5 @@
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 {
@@ -714,36 +715,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
@@ -40,6 +40,7 @@ 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]));
@@ -110,6 +111,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]));
@@ -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.
+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,
@@ -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(
+1 -1
View File
@@ -510,7 +510,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",
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -4,6 +4,10 @@ This target is a native MiniRTC controller. It uses the same WebSocket
signaling, libnice ICE, SRTP/RTP and data-stream protocol as the desktop app.
There is no `WKWebView` or browser runtime.
UPnP discovery and router port mapping are disabled in iOS builds, matching
Android. Local connections, ICE/STUN hole punching, and TURN UDP/TCP relay
remain available. The native archive excludes GUPnP and its HTTP/XML dependencies.
## Background connection and Picture in Picture
An established remote desktop session supports native Picture in Picture (PiP).
@@ -265,11 +269,10 @@ in the source-notices document as well.
`licenses/sources.json` pins the reviewed source archives (SHA-256), notice-review
Git revisions, selected legal files and source-notice extraction rules.
`CrossDeskMobile/Resources/ThirdPartyLicenses.json` is the generated resource
bundled by Xcode. The catalog covers CrossDesk and 33 dependency entries for the default iOS build,
bundled by Xcode. The catalog covers CrossDesk and 25 dependency entries for the default iOS build,
including header-only dependencies, GLib's proxy-libintl, spdlog's bundled fmt,
WebRTC-derived code, inih, and the applicable patent notices. Host build tools,
OpenH264, desktop-only libraries, and the disabled libpsl built-in suffix list
are excluded.
OpenH264, desktop-only libraries, and the disabled UPnP dependency stack are excluded.
Regenerate from the pinned source distributions with Python 3.9 or newer:
+2 -203
View File
@@ -157,87 +157,6 @@
"subprojects/gvdb/"
]
},
{
"id": "gssdp",
"name": "GSSDP",
"version": "1.6.4",
"license": "LGPL-2.1-or-later",
"source": {
"kind": "archive",
"url": "https://download.gnome.org/sources/gssdp/1.6/gssdp-1.6.4.tar.xz",
"sha256": "ff97fdfb7f561d3e6813b4f6a2145259e7c2eff43cc0e63f3fd031d0b6266032"
},
"documents": [
{
"path": "COPYING",
"sha256": "dc626520dcd53a22f727af3ee42c770e56c97a64fe3adb063799d8ab032fe551"
},
{
"path": "AUTHORS",
"sha256": "66b5509fcdd21acaab5d7a3856e699bada9e9ebf0485312abbe987711bc7bd27"
}
],
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
"package": "gssdp",
"notice_prefixes": [
"libgssdp/"
]
},
{
"id": "gupnp",
"name": "GUPnP",
"version": "1.6.9",
"license": "LGPL-2.1-or-later",
"source": {
"kind": "archive",
"url": "https://download.gnome.org/sources/gupnp/1.6/gupnp-1.6.9.tar.xz",
"sha256": "2edb6ee3613558e62f538735368aee27151b7e09d4e2e2c51606833da801869b"
},
"documents": [
{
"path": "COPYING",
"sha256": "dc626520dcd53a22f727af3ee42c770e56c97a64fe3adb063799d8ab032fe551"
},
{
"path": "AUTHORS",
"sha256": "fadd2ab40439bc89d2e9540c02aef1f23edea96983af9cceaef345a9423d3c70"
}
],
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
"package": "gupnp",
"notice_prefixes": [
"libgupnp/"
]
},
{
"id": "gupnp-igd",
"name": "GUPnP IGD",
"version": "1.6.0",
"license": "LGPL-2.1-or-later",
"source": {
"kind": "archive",
"url": "https://download.gnome.org/sources/gupnp-igd/1.6/gupnp-igd-1.6.0.tar.xz",
"sha256": "4099978339ab22126d4968f2a332b6d094fc44c78797860781f1fc2f11771b74"
},
"documents": [
{
"path": "COPYING",
"sha256": "6095e9ffa777dd22839f7801aa845b31c9ed07f3d6bf8a26dc5d2dec8ccc0ef3"
},
{
"path": "AUTHORS",
"sha256": "77a943ea347578cfcffc032616707d43e5f5e03a12f864c0d421c3d632fc1582"
}
],
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
"package": "gupnp-igd",
"notice_prefixes": [
"libgupnp-igd/"
]
},
{
"id": "inih",
"name": "inih / INIReader",
@@ -378,7 +297,7 @@
"sha256": "d72eb6fa9d5e93abb11d13b6c6f04fda2ba183d0705e703887cda243f6f1eead"
}
],
"recipe_sha256": "bfb5f125dc57768fcfb7edadb4be69783d66f31e00e0576ea038e30ba593a811",
"recipe_sha256": "9c18a7313113ddfd260f345a49ebc0cdc3aeeb6c6d5679c9d3ed50365b51e271",
"recipe_path": "deps/submodules/minirtc/thirdparty/libnice/xmake.lua",
"package": "libnice",
"notice_prefixes": [
@@ -389,61 +308,6 @@
"stun/"
]
},
{
"id": "upnp-libpsl",
"name": "libpsl",
"version": "0.21.5",
"license": "MIT / BSD-3-Clause",
"source": {
"kind": "archive",
"url": "https://github.com/rockdaboot/libpsl/releases/download/0.21.5/libpsl-0.21.5.tar.gz",
"sha256": "1dcc9ceae8b128f3c0b3f654decd0e1e891afc6ff81098f227ef260449dae208"
},
"documents": [
{
"path": "LICENSE",
"sha256": "322392e6001b2b3b0fca705dc9f11f26e12441188ecde50eb1d22909980b2925"
},
{
"path": "src/LICENSE.chromium",
"sha256": "1d7f52747a9169751cdf2641a8299c0098e9ecdf9429296ffd55bdb14c9ed5b3"
}
],
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
"package": "upnp-libpsl",
"notice_prefixes": [
"src/",
"include/"
]
},
{
"id": "libsoup",
"name": "libsoup",
"version": "3.6.5",
"license": "LGPL-2.0-or-later",
"source": {
"kind": "archive",
"url": "https://download.gnome.org/sources/libsoup/3.6/libsoup-3.6.5.tar.xz",
"sha256": "6891765aac3e949017945c3eaebd8cc8216df772456dc9f460976fbdb7ada234"
},
"documents": [
{
"path": "COPYING",
"sha256": "b7993225104d90ddd8024fd838faf300bea5e83d91203eab98e29512acebd69c"
},
{
"path": "AUTHORS",
"sha256": "f8776285b4556368878a1b903a1f0e6d6cee342284961562a7716a2dacc28321"
}
],
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
"package": "libsoup",
"notice_prefixes": [
"libsoup/"
]
},
{
"id": "libsrtp",
"name": "libsrtp",
@@ -469,30 +333,6 @@
"include/"
]
},
{
"id": "upnp-libxml2",
"name": "libxml2",
"version": "2.14.6",
"license": "MIT and embedded notices",
"source": {
"kind": "archive",
"url": "https://download.gnome.org/sources/libxml2/2.14/libxml2-2.14.6.tar.xz",
"sha256": "7ce458a0affeb83f0b55f1f4f9e0e55735dbfc1a9de124ee86fb4a66b597203a"
},
"documents": [
{
"path": "Copyright",
"sha256": "5d4873884a890122a4b9b20ad56ac6f7da1d796a5bfcf04a427970ac96217626"
}
],
"recipe_sha256": "52667099a445f7004b9200df4ed771dd74e73745a5c6dc7fe728168e9f8b3e7a",
"recipe_path": "deps/submodules/minirtc/thirdparty/gupnp/xmake.lua",
"package": "upnp-libxml2",
"notice_prefixes": [
"include/"
],
"notice_root_files": true
},
{
"id": "libyuv",
"name": "libyuv",
@@ -541,29 +381,6 @@
}
]
},
{
"id": "nghttp2",
"name": "nghttp2",
"version": "1.68.0",
"license": "MIT",
"source": {
"kind": "archive",
"url": "https://github.com/nghttp2/nghttp2/releases/download/v1.68.0/nghttp2-1.68.0.tar.gz",
"sha256": "2c16ffc588ad3f9e2613c3fad72db48ecb5ce15bc362fcc85b342e48daf51013"
},
"documents": [
{
"path": "COPYING",
"sha256": "6b94f3abc1aabd0c72a7c7d92a77f79dda7c8a0cb3df839a97890b4116a2de2a"
}
],
"recipe_sha256": "eec10214a906c663f74e3e6bf8952cddec39fec3cac710c10d8b9ea669c9ab79",
"recipe_path": "xmake-repo/packages/n/nghttp2/xmake.lua",
"package": "nghttp2",
"notice_prefixes": [
"lib/"
]
},
{
"id": "openfec",
"name": "OpenFEC",
@@ -749,24 +566,6 @@
"include/"
]
},
{
"id": "sqlite3",
"name": "SQLite",
"version": "3.50.0+400",
"license": "Public domain",
"source": {
"kind": "archive",
"url": "https://sqlite.org/2025/sqlite-autoconf-3500400.tar.gz",
"sha256": "a3db587a1b92ee5ddac2f66b3edb41b26f9c867275782d46c3a088977d6a5b18"
},
"documents": [],
"recipe_sha256": "a752522259dbd722ea79beaa01e5287429ccb37643ccffbecf5fa30ec4df30fd",
"recipe_path": "xmake-repo/packages/s/sqlite3/xmake.lua",
"package": "sqlite3",
"notice_prefixes": [
"sqlite3.h"
]
},
{
"id": "svt-av1",
"name": "SVT-AV1",
@@ -898,7 +697,7 @@
]
}
],
"bundle_sha256": "f54e5e2168bf6e91d81a413931a052d29f59e0c221df5e1de3f51bd27c662c3a",
"bundle_sha256": "a27c89beaaee2397a306417ff133d7f48c74fe6db9041e145fc08f18ec69a080",
"toolchain": {
"xmake_min_version": "3.1.1",
"xmake_repository_revision": "121109352da272cd1a3d4ed52f2dcda34f839955"
-1
View File
@@ -172,7 +172,6 @@ print -r -- "${CLEAN_INFO}" > "${OUTPUT_DIR}/minirtc-target-info.txt"
REQUIRED_LINKS=(
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
gupnp-igd-1.6 gupnp-1.6 gssdp-1.6 soup-3.0 nghttp2 sqlite3 psl xml2
ffi pcre2-8 pcre2-posix z ssl crypto srtp2 openfec opus yuv kcp
datachannel usrsctp dav1d SvtAv1Enc
)
+87
View File
@@ -0,0 +1,87 @@
# 桌面墙(DeskHub)
主窗口“近期连接”标题行右侧、分组筛选左边的“桌面墙”按钮打开独立的桌面墙窗口。菜单与上方桌面标题共用一行,连接窗口保持紧凑尺寸。重复点击按钮会唤起已有桌面墙窗口,最小化时恢复显示,不创建重复窗口。每次启动软件只显示连接窗口;桌面墙首次打开时恢复保存的位置、尺寸及全屏状态;没有已保存尺寸时默认采用 960 × 720 逻辑像素(含标题栏,当前 macOS 的内容区为 960 × 688),两个窗口分别记忆位置。关闭桌面墙时释放远程操控并断开全部远程设备连接,不受当前分组、分页和选中状态限制,共用连接的独立串流窗口也随之关闭;同时取消排队连接、密码重试和待重连请求。设备列表、分组与窗口布局保留,再次点击“桌面墙”可打开并手动重新连接设备。最小化窗口不会断开连接。已断开的显示器所对应的位置会修正到可用屏幕内。
桌面墙内容区不再重复显示“桌面墙”标题,顶部仅保留一行工具栏,距窗口内容区顶部 12 逻辑像素;分组、设备数量、“添加”、“选择/完成”、纯文字“精简模式”按钮和全屏图标共用这一行,不显示菜单及消息、更新角标。“添加”、“选择”和“精简模式”默认统一使用浅灰背景、灰色边框及相同的悬停、按下样式;进入选择或精简模式后,对应按钮以蓝色高亮表示当前状态。按钮按当前语言的文字宽度加统一左右内边距自适应,普通文字按钮宽度至少为 48 逻辑像素;内边距默认为 12 逻辑像素,窗口内容区宽度小于 720 时统一收紧为 8,保证最窄窗口下的多语言操作和已选数量可见。下拉按钮额外预留箭头及间距;“选择/完成”和“精简模式/退出精简”按两种状态中较宽的文字预留空间,切换时保持宽度稳定。“选择/完成”固定在视图控制按钮左侧,设备列表紧接工具栏下方,释放原有标题行占用的空间。桌面墙可以单独调整尺寸及全屏,不改变连接窗口的尺寸和位置。全局设置从主窗口菜单进入;设备操作和桌面墙连接的密码弹窗显示在桌面墙窗口内。
全屏按钮的提示绘制在设备网格和放大视图上方,靠右对齐并保持在窗口内。全屏切换期间持续显示设备列表和已有预览画面,不再淡出内容区或等待淡入;窗口恢复完成后,在下一次 UI 更新中立即发起全屏切换,窗口尺寸变化会及时更新网格。尺寸稳定前暂时屏蔽设备操作与重复全屏点击,并暂停保存过渡尺寸。进入和退出全屏保留当前接管设备及键盘焦点,仅在切换前释放已按下的键鼠,切换期间不发送新的远程输入;尺寸稳定后直接继续操控,无需再次点击接管。真正切换到其他窗口、关闭桌面墙或设备断开时仍会释放操控。切换复用现有 UI 更新循环,不启动嵌套事件循环;失败或超时会解除交互屏蔽。
可见的普通窗口若低于连接页最小尺寸,桌面墙恢复上次有效大小,连接页恢复固定大小;最小化、隐藏和全屏窗口不会触发这一恢复。窗口恢复以实际尺寸确认完成,期间不记录过渡尺寸;macOS 的最大化恢复使用无动画原生窗口调整,避免系统缩放动画嵌套事件循环。
## 使用
- 在桌面墙中点“添加 → 通过设备 ID 添加”,填写设备 ID、密码和可选的分组名称,再点“添加并连接”。密码仅用于本次连接,勾选“记住密码”才会通过原有凭据机制保存;不会写入桌面墙配置。
- “添加 → 从近期连接添加”支持多选、全选和统一指定分组。已加入桌面墙的设备显示为灰色、保持勾选并标注“已添加”,不可操作,不受当前分组筛选影响;全选和待添加数量只计算尚未加入的设备,全部已添加时禁用添加按钮。导入后只加入桌面墙,使用“选择 → 连接”再发起连接。
- 连接成功后立即记录在连接页的“最近连接”中,无需等到断开,也不要求记住密码。失败的连接不会新增最近记录。
- “全部设备”显示保存的成员及当前活动会话。分组成员与连接相互独立:断开后成员仍在,重启后不会自动连接。
- 卡片名称栏只显示设备名,长名称省略,不再显示状态圆点。未在线的设备(离线或在线状态未知)整张卡片使用灰色,包括预览背景、边框和名称;在线但尚未连接的设备保持正常样式。选中边框、复选框和操作入口仍可用。悬停画面可查看设备名称、ID 和在线状态,精简模式也支持。在线状态订阅包含全部分组中的设备;尚未收到结果、结果过期或服务器断连时显示未知,已有活动连接的设备仍显示在线。
- 鼠标悬停时,名称栏右侧显示与近期连接一致的改名、修改分组、移除、连接/断开操作条,并显示按钮提示。两处的连接按钮均随实际会话状态切换:未连接时显示连接箭头,已连接时显示叉号,点击断开该设备;点卡片仍可查看设备。名称与近期连接共用别名;移除会先确认,只移除当前分组或桌面墙中的成员,不删除近期记录或断开会话。批量选择时隐藏操作条。
- 两处的“修改分组”共用操作条组件、分组弹窗和保存逻辑。可以选择多个已有分组、填写新分组,或选“未分组”清除分组归属。分组与桌面墙成员独立保存:近期连接修改分组不会自动加入桌面墙,也不会自动连接;通过“添加”菜单导入或按设备 ID 添加,才保存为桌面墙成员,未指定新分组时保留已有分组。从“全部设备”移除成员后仍保留其分组信息;在特定分组中移除则取消对应分组归属,移除最后一个分组后仍保留在“全部设备”中。修改分组保留墙内设备的顺序,保存失败时保留弹窗和输入。
- 近期连接卡片使用与桌面墙相同的离线灰色,不再在缩略图上叠加在线/离线标签。不在线时减淡历史缩略图并将名称栏置灰;没有缩略图时,平台占位也使用相同灰色。在线或已连接的设备正常显示,悬停仍可查看在线状态和操作设备。
- 近期连接的设备提示浮窗显示所属分组;多个分组并列显示并自动换行,没有分组时显示“未分组”。分组变更后自动刷新。
- “近期连接”标题行右侧可按“全部设备”、未分组或已有分组筛选;一个设备可出现在它所属的多个分组中。筛选与桌面墙当前分组独立,不改变历史记录、在线状态订阅或桌面墙成员。分组修改后自动更新结果;当前分组被清空删除时回到全部设备。
- 网格中每台设备的预览区域统一固定为 16:9,普通模式的设备名称栏位于画面下方;其他比例的远端桌面留边完整显示。设备信息提示显示在该设备预览区域内部左下角,宽高受画面范围限制;过长的名称省略显示,保留 ID 和在线状态,不遮挡相邻设备或底部操作栏,精简模式及放大视图的缩略设备同样适用。设备直接放在连续表格的单元格中,单元格之间没有卡片间距,共用贯通整行、整列的单条分隔线;空单元格保留表格线,不再为每台设备绘制独立边框和右下角拖拽标记。拖动任意横向、竖向分隔线或交点,可同步放大、缩小全部单元格,预览区域始终保持 16:9;分隔线两侧的拖动方向一致。普通和精简模式共用单元格宽度,表格按实际尺寸自动换行、调整分页容量;窗口较小时限制格子大小,避免超出可用区域,窗口恢复后继续使用原尺寸。宽度至少为 184 逻辑像素、至多为 960 逻辑像素,并受当前可用区域限制;松开鼠标后保存尺寸,重启后恢复。拖动分隔线不会触发展开、选择或设备操作。翻页不关闭连接,分组内顺序不会按在线状态自动重排。
- 按住单元格内的设备画面或名称拖动,可与另一台设备交换位置;目标单元格高亮,鼠标旁显示设备缩略图和松开提示。跨页时保持按住鼠标,将设备拖到窗口内容区左、右边缘内侧 32 逻辑像素范围,或上一页、下一页按钮,悬停 700 毫秒后翻页;边缘显示方向提示并在触发区域高亮,继续停留可连续翻页,到首尾页自动停止。翻页期间保留拖动源,末页设备不足一整页时也可继续拖动,移到目标设备后松开完成交换;离开翻页区域会重新计算悬停时间,在可翻页的边缘区域或按钮上松开只取消拖动,不会误与边缘下方的设备换位;窗口外不触发翻页。普通、精简及批量选择模式均支持,短距离点击仍执行原来的查看或选择操作。拖到自身、空单元格、分隔线或表格外不会换位;Esc、窗口失焦、切换分组或调整窗口大小会取消拖动。交换后的顺序保存到本地,并应用于所有分组视图,不修改分组、批量选择、连接或静音状态;保存失败时保持原顺序并提示重试。仅活动会话也可换位,排序不会自动将它加入保存的桌面墙成员。
- 顶部“精简模式”切换为仅画面的设备卡片,隐藏名称、在线状态、状态文字和悬停操作条,同时收起设备管理工具栏;画面完整等比显示,不裁切。顶部保持当前语言下宽度稳定的文字按钮高亮显示“退出精简”,不带图标;全屏入口仍可见。底部保留连接计数、网络详情入口和分页,不显示服务器状态栏。点击图片仍可展开查看和接管操控,返回后保持精简模式;模式切换会释放操控、退出批量选择并返回网格,不改变连接、分组和设备顺序。
- 主窗口菜单中的全局“设置”顶部并排提供“常规”和“桌面墙”两个选项卡;“桌面墙”可统一配置桌面墙设备的传输分辨率上限和帧率,桌面墙处于普通、精简、展开或全屏视图时,也可通过主窗口菜单打开设置。切换选项卡保留未保存修改,底部统一保存或取消。分辨率支持自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160和原始分辨率;固定上限适用于缩略图和展开画面,保持比例且不放大源画面。帧率支持跟随设备设置、15、30、60 FPS。保存后立即更新当前桌面墙会话,也用于后续桌面墙连接,不影响普通连接,跨分组和分页生效;取消不应用修改,恢复默认需保存后生效。配置保存在本地,重启后保留;保存失败时保留面板和输入。打开设置会释放远程操控。
- 服务器连接状态、指示灯和重连入口仅在主窗口显示,桌面墙的普通、精简、展开、批量选择和全屏视图均不显示服务器状态栏。桌面墙底部操作栏左侧以普通文字显示已连接设备数量,旁边是紧凑的“网络状态”按钮;点击按钮可打开当前已建立连接的设备列表,空白区域不响应点击,也不显示整条悬停提示。弹窗采用紧凑宽度,高度随设备数量调整,最多同时显示 6 行,更多设备通过滚动查看。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、控制端接收与发送速率、视频丢包率、视频延时、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。每台设备采用两行布局:画面列显示分辨率和帧率,视频延时列显示延时和丢包率,不再显示 RTT。视频延时直接显示毫秒数,取最近 1 秒内新提交预览帧的平均值;从被控端采集时间戳计时,包含编码、传输、解码、预览转换和 UI 等待,按采集到画面提交估算,不包含提交后到屏幕实际显示的等待。当前无墙内预览样本时,可使用该设备独立串流窗口的延时统计。没有新显示帧、时间戳不可用或无有效样本时显示“—”,不把 RTT 当作视频延时。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。
- 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。
- 接管期间,主画面内的鼠标形态跟随当前主选设备,支持文本输入、链接、忙碌、窗口缩放及隐藏等状态;群控时也只采用主选设备的光标状态,其他设备的反馈不会覆盖它。释放操控或主选设备断开后恢复默认指针,工具栏与缩略图保留本地鼠标形态。
- 放大视图中点击“开启群控”,以当前设备为主控,同时接管操控;鼠标移动、点击、拖拽、滚轮、键盘和组合键同步到所有其他已连接设备,包含其他分组、分页及未显示在墙内的连接。所有设备使用主控设备映射后的相同坐标和按键,不针对屏幕比例另做适配;新建立的连接也会接收后续输入,未连接和正在断开的设备跳过。点击“关闭群控”先释放各设备已按下的键鼠,再恢复当前设备的单独操控;释放操控、切换设备、窗口失焦或主控设备断开时自动结束群控。群控状态不保存,重新接管需要手动开启。
- 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。
- 放大视图使用一行设备操作栏,显示返回、设备名称、状态和接管/释放操控按钮,右侧保留精简模式及全屏入口;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看设备名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通和精简模式的缩略图预览区域均固定为 16:9,普通模式的设备名称单独显示在画面下方。普通、精简、宽窗口及全屏均保持上下布局,上下区域之间提供带抓手的分隔条:向上拖动扩大下方主画面,向下拖动同步放大上方缩略图;选中分隔条后也可用上下方向键微调。缩略图始终保持 16:9,并保留主画面的最小可用高度;窗口缩小时临时限制尺寸,恢复窗口后沿用原先尺寸。普通和精简模式共用此尺寸,切换设备后保持,松开鼠标后保存并在重启时恢复;与网格的单元格尺寸独立。同页只有一台设备时同时隐藏缩略图栏和分隔条。拖动分隔条不会切换设备或发送远程键鼠,Esc、窗口失焦及窗口大小变化会结束拖动。调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。
- 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。
- 点击“选择”进入批量模式,“完成”在同一位置退出;顶部设备管理栏原位切换为批量操作栏,从左到右显示已选数量(包含跨页数量)、“全选/取消全选”、“连接”、“断开”、“更多”和“完成”,无需往返底栏。批量模式暂时收起分组筛选和“添加”入口,退出后恢复;工具栏高度不变,设备卡片的位置、尺寸及分页容量保持不变。已选数量占用剩余空间,批量操作按钮同样按文字宽度和统一内边距自适应;“全选/取消全选”使用与“断开”、“更多”一致的浅灰背景和边框,并预留两种文字中较宽的一种,切换选中数量或按钮文字时,后续按钮保持原位。“连接”保留主操作的蓝色强调,“完成”以蓝色表示当前选择模式。点击“全选”会选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;底栏始终保留已连接数量、网络状态入口和分页;静音和文件分发收进顶部向下展开的“更多”菜单。声音菜单项根据已选且已连接设备的状态切换:全部静音时显示“取消静音”,有设备开启声音时显示“静音”,无已连接设备时禁用。点击后统一设置所有已选的已连接设备,包含跨页选择,跳过未连接设备,保留当前选择。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。
## 预览与兼容性
自动分辨率模式下,桌面墙发起或接管的会话在能力协商后默认请求最长边不超过 320 像素的画面。点卡片展开后,根据视频显示区域的实际物理像素(包含 HiDPI 缩放)请求分辨率;全屏、退出全屏和调整窗口大小后会重新计算,尺寸稳定 200 毫秒后更新订阅。返回网格或切换到其他设备后恢复缩略图尺寸。不可见设备暂停视频;如果同一设备仍在可见的独立串流窗口显示,则继续传输。同一会话同时在独立串流窗口显示时,按两个可见视频区域中较大的尺寸请求,避免小预览降低大窗口的清晰度。选择固定或原始分辨率后使用对应上限;恢复自动后重新跟随视图。普通连接页发起且未加入桌面墙管理的会话保留原有分辨率行为。
被控端按连接分别在编码前缩放,完整保留桌面比例,不放大超过源画面的尺寸,也不修改被控端屏幕的系统分辨率。请求的宽高是上限,画质和网络自适应仍可降低实际发送尺寸。默认跟随每个会话原有的 30/60 帧设置;指定帧率仅覆盖桌面墙订阅,不修改会话的画质或原有帧率偏好。声音仍需明确开启。
15 帧订阅使用编码前发送节流和稳定的逐显示流时间预算,并采用保持分辨率策略,避免低帧率触发交互视频的画质降级。30/60 帧选项使用编码器自身的交互帧率控制。仅向声明 `supports_deskhub_fps15` 的对端发送 `fps=15`;缺少该能力的 v1/v2 对端使用原有 `fps=60, frame_rate=30` 格式回退到 30 FPS,未声明预览能力的更旧版本保持原行为。协议仍接受旧控制端的 1/8 帧请求,但设置界面不再提供这两个档位。
预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;表格单元格与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。
连接旧版被控端时,若仅声明 `supports_deskhub_preview`,控制端发送原有 v1 订阅;若未声明预览能力,则不发送该订阅,沿用其视频策略。旧控制端连接新版被控端时继续使用原分辨率;v1 订阅也会清除此前的尺寸限制。只有同时声明 `supports_deskhub_preview` 与 `supports_deskhub_resolution` 的被控端才会收到 v2 尺寸请求,旧版本不承诺发送端节省资源。
### 视频带宽分配
“设置 → 桌面墙 → 视频总带宽”配置所有支持带宽协商的桌面墙会话共享的视频预算,默认 20 Mbps,可选 2、5、10、20、50、100 Mbps。保存立即应用,取消不生效,重启后保留;恢复默认后需要保存。它是视频的持续目标预算,包含发送端为视频纠错、重传及 RTP 开销预留的份额;音频、文件传输、普通连接及不支持带宽协商的旧版设备不计入,关键帧和切换期间的瞬时速率可能超过预算。
控制端根据请求的分辨率和帧率估算每台设备的需求上限。先为每台可见设备保留至多 100 Kbps 的基础份额(总预算不足时等分),再按权重分配剩余预算:展开画面或可见的独立串流窗口权重为缩略图的 4 倍。相同规格、相同优先级的设备基本均分;达到需求上限的设备让出剩余额度,不强制用满总预算。默认缩略图 320 × 320 上限、30 FPS 的需求预算为 500 Kbps。分配随连接、分页、分组、窗口可见性以及分辨率/帧率变化更新,独立串流窗口和墙内画面使用同一份设备预算。需求和总预算未变化时复用分配结果,不随 UI 刷新重复计算。
翻页、切换分组、隐藏或最小化桌面墙后,没有可见画面的墙内会话发送 `fps=0` 暂停视频,保留连接和主动开启的音频。重新可见后恢复当前需求并请求关键帧。单纯重新分配带宽不会触发所有设备同时发送关键帧,也不会重置 15 FPS 的发送节奏。
预算不代表测速结果。每条连接原有的拥塞控制继续工作,发送端使用网络可用带宽与设备预算中的较小值,并在设备的多个活动显示流之间再分配。视频探测速率受同一设备额度约束,以便网络恢复或画面展开后继续升速;额度下降会取消旧探测,暂停视频时关闭探测和填充包。只有声明 `supports_deskhub_bandwidth` 的对端会收到带宽字段;支持预览但不支持带宽协商的旧版仍能暂停不可见视频,更旧版本保持原行为。
### 协议与接口
可靠控制通道的 `deskhub_preview` v2 保留 v1 的 `request_id`、`fps`、`quality`、`frame_rate`、`preference`、`audio` 字段,新增必填 `width`、`height` 整数,单位为物理像素。两个值均为 0 表示恢复源尺寸上限,否则两个值均须在 2~16384 之间;非法或缺失的值不应用。`deskhub_preview_status` 回传请求的协议版本、请求 ID 与 `accepted`。请求 ID 跳过 0,支持 32 位回绕,重复或过期请求不会覆盖新设置。断开会话清除订阅和 15 FPS 协商能力,再连接会重新发送当前需求。15 FPS 扩展通过可选能力 `supports_deskhub_fps15` 协商,沿用 v1/v2 的字段结构,使用 `fps=15, frame_rate=30`,在编码前将实际输入限制为 15 FPS。
带宽扩展沿用 v1/v2,增加可选整数 `max_bitrate_bps`(0~100000000,单位 bps),仅向声明 `supports_deskhub_bandwidth` 的对端发送。0 或缺省表示解除额外的视频预算限制;暂停视频仍使用 `fps=0`。非法字段拒绝整个订阅,断开后清除能力,重连重新协商。MiniRTC 的 `UpdateVideoBitrateLimit(peer, remote_id, remote_id_size, max_bitrate_bps)` 为指定连接的所有视频流设置合计预算,不改变音频与数据发送;返回 0 表示已进入拥塞控制队列,-1 表示参数、连接或传输不支持。
MiniRTC 新增 `UpdateVideoResolution(peer, remote_id, remote_id_size, width, height)` 接口,返回 0 表示已接受,-1 表示参数、连接或传输类型不支持。它为指定控制端的所有显示流设置独立尺寸上限,切换时丢弃旧设置尚未发出的编码结果,并请求新关键帧;底层自适应也以这个上限作为有效源尺寸。
`SetVideoSendPaused(peer, remote_id, remote_id_size, paused)` 与 `SetAudioSendPaused(peer, remote_id, remote_id_size, paused)` 分别控制指定连接的所有视频/音频发送通道,`true` 暂停、`false` 恢复。重复设置不产生额外操作;返回 0 表示接受,-1 表示参数、连接或传输类型不支持。视频暂停阻止新帧进入编码,废弃旧编码结果,并在发送队列上清除待发视频/重传/纠错包,停止探测和填充;恢复请求新关键帧。音频暂停阻止 PCM 编码并清理待发音频,恢复时仅发送新音频。已交给网络的包无法撤回。API 保留连接、采集源、码率与画质设置以及其他媒体/数据通道,也不改变其他控制端的发送状态。
桌面墙将 `fps=0` 和 `audio=false` 分别映射到上述 API;应用层仅保留低帧率节流,不再使用 `1 bps` 代替视频暂停或在采集回调中过滤静音音频。接收端仍保留静音门控及播放队列清理,以立即停止播放网络中已在途的音频。旧版 `audio_capture` 命令在支持新 API 的传输中也按连接静音,已有订阅的音频状态优先;不支持 API 的旧/Web 传输沿用采集开关回退。
## 批量操作
连接和文件分发最多并行启动 4 个操作。尚未开始连接的设备卡片显示“排队中”,轮到后自动切换为实际连接状态。断开立即执行,同时取消该设备排队中的操作及重连请求,不等待文件传输完成。单台等待密码不会阻塞其他设备,可以从对应设备的放大视图填写密码。连接状态显示在设备卡片上,文件传输详情可在该设备的独立串流窗口中查看。
批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态(含共享格子宽度)保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。
`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID,通过可选的 `device_order` 保存手动排列顺序,并通过可选的 `stream_settings` 保存分辨率预设及帧率。没有手动顺序的旧配置继续使用原有排列。缺少或无效的设置使用自动分辨率和跟随设备帧率,不影响设备成员。已保存的 1/8 FPS 自动迁移为 15 FPS,保留原分辨率预设;原有 30/60 FPS 设置保持不变。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。
批量远程命令、重启尚未提供。实际大规模并发容量需要用真实设备测量。
## 验证
```sh
xmake build crossdesk
xmake run slint_ui_smoke_test
xmake run crossdesk_wire_test
xmake run keyboard_state_wire_test
```
+130
View File
@@ -0,0 +1,130 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PREVIEW_SUBSCRIPTION_H_
#define _PREVIEW_SUBSCRIPTION_H_
#include <cstdint>
#include <nlohmann/json.hpp>
#include <optional>
namespace crossdesk {
// Versioned independently from the interactive 30/60 FPS video settings.
struct PreviewSubscription {
uint32_t request_id = 0;
int fps =
0; // 0 pauses; 1/8 legacy previews; 15 negotiated preview; 60 interactive.
int quality = 0;
int frame_rate = 30;
int preference = 0;
bool audio = false;
// v2 bounds are physical pixels. 0/0 restores the legacy source limit.
int width = 0;
int height = 0;
int version = 1;
// Optional negotiated extension: total video budget for this peer, in bps.
// 0 restores normal congestion control; absent preserves legacy wire format.
std::optional<int> max_bitrate_bps;
static bool ValidResolution(int width, int height) {
return (width == 0 && height == 0) ||
(width >= 2 && height >= 2 && width <= 16384 && height <= 16384);
}
static PreviewSubscription Interactive(int quality, int frame_rate,
int preference, bool audio = false) {
return {0, 60, quality, frame_rate, preference, audio};
}
bool IsNewerThan(const PreviewSubscription &other) const {
return static_cast<int32_t>(request_id - other.request_id) > 0;
}
bool SameSettings(const PreviewSubscription &other) const {
return fps == other.fps && quality == other.quality &&
frame_rate == other.frame_rate &&
EncoderPreference() == other.EncoderPreference() &&
audio == other.audio && width == other.width &&
height == other.height && version == other.version &&
max_bitrate_bps == other.max_bitrate_bps;
}
int EncoderPreference() const {
// Sparse preview input is intentional. Preserve spatial detail instead of
// applying the interactive frame-rate/keyframe latency budget to it.
return fps == 1 || fps == 8 || fps == 15 ? 1 : preference;
}
nlohmann::json ToJson() const {
nlohmann::json json = {{"type", "deskhub_preview"}, {"version", version},
{"request_id", request_id}, {"fps", fps},
{"quality", quality}, {"frame_rate", frame_rate},
{"preference", EncoderPreference()}, {"audio", audio}};
if (version == 2) {
json["width"] = width;
json["height"] = height;
}
if (max_bitrate_bps) json["max_bitrate_bps"] = *max_bitrate_bps;
return json;
}
static std::optional<PreviewSubscription> Parse(const nlohmann::json &json) {
try {
if (json.at("type") != "deskhub_preview" ||
!json.at("version").is_number_integer() ||
(json.at("version") != 1 && json.at("version") != 2))
return {};
for (const char *key :
{"request_id", "fps", "quality", "frame_rate", "preference"})
if (!json.at(key).is_number_integer())
return {};
const auto id = json.at("request_id").get<int64_t>();
if (id <= 0 || id > UINT32_MAX)
return {};
for (const char *key : {"fps", "quality", "frame_rate", "preference"}) {
const auto value = json.at(key).get<int64_t>();
if (value < 0 || value > 60)
return {};
}
PreviewSubscription result{static_cast<uint32_t>(id), json.at("fps"),
json.at("quality"), json.at("frame_rate"),
json.at("preference")};
if ((result.fps != 0 && result.fps != 1 && result.fps != 8 &&
result.fps != 15 && result.fps != 60) ||
result.quality < 0 || result.quality > 2 ||
(result.frame_rate != 30 && result.frame_rate != 60) ||
result.preference < 0 || result.preference > 2)
return {};
if (json.contains("audio")) {
if (!json.at("audio").is_boolean())
return {};
result.audio = json.at("audio").get<bool>();
}
result.version = json.at("version").get<int>();
if (result.version == 2) {
for (const char *key : {"width", "height"}) {
if (!json.at(key).is_number_integer()) return {};
const auto value = json.at(key).get<int64_t>();
if (value < 0 || value > 16384) return {};
}
result.width = json.at("width").get<int>();
result.height = json.at("height").get<int>();
if (!ValidResolution(result.width, result.height)) return {};
}
if (json.contains("max_bitrate_bps")) {
if (!json.at("max_bitrate_bps").is_number_integer()) return {};
const auto bitrate = json.at("max_bitrate_bps").get<int64_t>();
if (bitrate < 0 || bitrate > 100'000'000) return {};
result.max_bitrate_bps = int(bitrate);
}
return result;
} catch (...) {
return {};
}
}
};
} // namespace crossdesk
#endif
+4
View File
@@ -137,6 +137,10 @@ struct HostInfo {
// Optional capability; absent in legacy desktop and web host information.
bool supports_privacy_screen;
bool supports_video_settings;
bool supports_deskhub_preview;
bool supports_deskhub_resolution;
bool supports_deskhub_fps15;
bool supports_deskhub_bandwidth;
};
struct ServiceStatus {
+17 -1
View File
@@ -26,6 +26,10 @@ void ResetHostInfo(HostInfo& info) {
info.bottom = nullptr;
info.supports_privacy_screen = false;
info.supports_video_settings = false;
info.supports_deskhub_preview = false;
info.supports_deskhub_resolution = false;
info.supports_deskhub_fps15 = false;
info.supports_deskhub_bandwidth = false;
}
bool AllocateHostDisplays(HostInfo& info, std::size_t count) {
@@ -219,7 +223,11 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
{"display_num", action.i.display_num},
{"displays", displays},
{"supports_privacy_screen", action.i.supports_privacy_screen},
{"supports_video_settings", action.i.supports_video_settings}};
{"supports_video_settings", action.i.supports_video_settings},
{"supports_deskhub_preview", action.i.supports_deskhub_preview},
{"supports_deskhub_resolution", action.i.supports_deskhub_resolution},
{"supports_deskhub_fps15", action.i.supports_deskhub_fps15},
{"supports_deskhub_bandwidth", action.i.supports_deskhub_bandwidth}};
break;
}
case ControlType::invalid:
@@ -412,6 +420,14 @@ bool RemoteAction::FromJson(const std::string& json_string,
privacy_support->is_boolean() && privacy_support->get<bool>();
output.i.supports_video_settings =
host_info_object.value("supports_video_settings", false);
output.i.supports_deskhub_preview =
host_info_object.value("supports_deskhub_preview", false);
output.i.supports_deskhub_resolution =
host_info_object.value("supports_deskhub_resolution", false);
output.i.supports_deskhub_fps15 =
host_info_object.value("supports_deskhub_fps15", false);
output.i.supports_deskhub_bandwidth =
host_info_object.value("supports_deskhub_bandwidth", false);
const std::string host_name =
host_info_object.at("host_name").get<std::string>();
std::strncpy(output.i.host_name, host_name.c_str(),