mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 09:53:05 +08:00
Compare commits
34
Commits
v1.6.1
...
3043b5f110
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3043b5f110 | ||
|
|
5721222724 | ||
|
|
3ee0d42494 | ||
|
|
417ef7d58c | ||
|
|
0a6356085d | ||
|
|
61ff9b2787 | ||
|
|
4855b2d3b6 | ||
|
|
2ac197c2de | ||
|
|
b90359b08d | ||
|
|
5199b2df11 | ||
|
|
fd86dba212 | ||
|
|
2a9887ab8a | ||
|
|
435feb419c | ||
|
|
4e394ca650 | ||
|
|
4dfd8c3005 | ||
|
|
de16eb3b92 | ||
|
|
3bd0676672 | ||
|
|
73b4d0f645 | ||
|
|
54dbd04a8d | ||
|
|
c8907a00f9 | ||
|
|
c9e0078bfb | ||
|
|
e156ff5277 | ||
|
|
b44a203b19 | ||
|
|
852710f428 | ||
|
|
2d51936ca8 | ||
|
|
3baedac6df | ||
|
|
a667fe3b31 | ||
|
|
05f6d3d90a | ||
|
|
0e7d175631 | ||
|
|
52ca253980 | ||
|
|
1925e5ffcc | ||
|
|
3aa0470510 | ||
|
|
fbbb8c9e2c | ||
|
|
3750687e66 |
@@ -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"),
|
||||
|
||||
@@ -346,6 +346,8 @@ jobs:
|
||||
build-android:
|
||||
name: Build Android
|
||||
uses: ./.github/workflows/build-android.yml
|
||||
with:
|
||||
patch: ${{ inputs.patch || '0' }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -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 签名
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
crossdeskVersionName=1.6.1
|
||||
crossdeskVersionCode=3
|
||||
|
||||
@@ -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()
|
||||
@@ -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_, ¶ms_, 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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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_;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: a017ce97f2...f5b53e920d
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user