Compare commits

...
Author SHA1 Message Date
dijunkun c3ea4eb8fc [ui] flatten desktop surfaces and controls 2026-10-26 14:53:09 +08:00
dijunkun 38d0ce7728 [build] remove license validation from mobile builds 2026-10-26 13:12:11 +08:00
dijunkun 778c3575cd [ui] refine desktop controls and align localized copy 2026-10-26 13:00:03 +08:00
dijunkun dad8384666 [docs] add README banner and clarify license metadata 2026-10-26 11:36:36 +08:00
dijunkun 9496bc8f1c [docs] update README screenshots for refreshed desktop UI 2026-10-26 11:23:02 +08:00
dijunkun e551e04e8f [ui] refine desktop shadows and control contrast 2026-10-26 11:18:41 +08:00
dijunkun e3b3f78b06 [fix] invalidate stale Slint build artifacts after UI changes 2026-10-26 10:29:12 +08:00
dijunkun 5db213d126 [ui] replace connection mode button with a sliding toggle 2026-10-26 10:16:17 +08:00
dijunkun bdd04603c7 [feat] enable screen lock on disconnect by default 2026-10-25 03:07:13 +08:00
dijunkun f3d8689b55 [fix] allow alphanumeric password input on iOS, fixes #127 2026-10-25 03:01:45 +08:00
dijunkun 0fb4b031c7 [fix] adapt recent connection thumbnails to display DPI 2026-10-25 02:57:33 +08:00
dijunkun 7fc21f01bc [ui] add a compact edit button for self-hosted settings 2026-10-25 02:43:24 +08:00
dijunkun 86bae6e1f4 [ui] remove TURN service toggle and simplify relay settings 2026-10-25 02:40:21 +08:00
dijunkun c03c8a63d4 [fix] avoid Windows interface macro collision 2026-10-25 02:32:42 +08:00
dijunkun ec9ac6a7cd [chore] simplify issue templates and reduce required fields 2026-10-25 01:51:48 +08:00
dijunkun 2a0b14035d [fix] preserve Unicode device names, fixes #126 2026-10-25 01:47:10 +08:00
dijunkun 652378afa8 [fix] add missing stream header and update libnice recipe checksum 2026-10-25 01:39:49 +08:00
dijunkun 0cbdbb0c96 [feat] add lock screen on disconnect option, refs #125 2026-10-25 01:35:40 +08:00
dijunkun 63bc5648c0 [chore] update MiniRTC for IP direct API grouping 2026-10-25 01:24:39 +08:00
dijunkun 859aaa29d4 [docs] document ID and direct IP connection architecture 2026-10-25 01:19:04 +08:00
dijunkun cc48eb651f [docs] document IP direct mode and refresh desktop screenshots 2026-10-25 01:05:43 +08:00
dijunkun ae6f81e83a [feat] integrate IP direct mode with desktop controls and history 2026-10-25 00:54:13 +08:00
dijunkun 2445f79f92 [feat] add authenticated LAN transport and per-IP listeners 2026-10-25 00:54:10 +08:00
dijunkun bd7f9505d9 [docs] document update public key build option 2026-10-24 21:03:55 +08:00
dijunkun 4650e48ea3 [feat] add configurable landscape entry for mobile sessions 2026-10-24 03:28:23 +08:00
dijunkun 182899d238 [fix] scope sleep inhibition to active screen sharing, refs #123 2026-10-24 01:24:38 +08:00
dijunkun 1d465239cf [chore] update MiniRTC for web file transfers 2026-10-23 23:01:39 +08:00
dijunkun 80ea75ff78 [ui] highlight disconnect buttons and confirm session closure 2026-10-23 14:19:18 +08:00
dijunkun ff7b954949 [ui] add animated logo toolbar and disconnect confirmation 2026-10-23 13:56:25 +08:00
dijunkun 0fbefd309f [fix] preserve remote mouse gestures across control overlays 2026-10-23 10:11:01 +08:00
dijunkun f269c56e5b [fix] unify network statistics resolution formatting 2026-10-22 19:30:54 +08:00
dijunkun c6a2f6a35b [feat] display video codecs and refine network statistics 2026-10-22 19:18:37 +08:00
dijunkun 193dcc4be0 [feat] prefer hardware video codecs by default 2026-10-22 17:20:48 +08:00
dijunkun f8f4aa3cea [fix] prevent remote input from blocking transport callbacks 2026-10-22 16:10:58 +08:00
dijunkun c25e7266e5 [fix] retain failed keyboard releases per controller 2026-10-22 16:10:44 +08:00
dijunkun 5e494bfa3b [fix] support selective mouse button release across desktop platforms 2026-10-22 16:10:34 +08:00
dijunkun f6a2637540 [fix] send mouse movement over unreliable transport 2026-10-22 10:59:36 +08:00
kunkundi 621f2ff205 [fix] update MiniRTC for Windows hardware encoding 2026-10-22 10:46:24 +08:00
dijunkun 5ac6e153c8 [fix] preserve Windows remote sessions during shutdown 2026-10-22 04:17:27 +08:00
dijunkun dd5c572852 [feat] enable Windows MFT codecs without CUDA 2026-10-22 03:25:24 +08:00
dijunkun 33d4199f7c [ui] turn the reconnect action into a button 2026-10-21 23:38:32 +08:00
dijunkun a6f4df405b [fix] update MiniRTC for signaling disconnect detection 2026-10-21 23:34:53 +08:00
dijunkun 97960f3a6b [ui] remove icons from about navigation rows 2026-10-21 23:34:44 +08:00
dijunkun 53c657aa55 [fix] share desktop build version across workflow jobs 2026-10-21 23:18:23 +08:00
dijunkun 941de898fb [fix] validate desktop artifact versions against release tags 2026-10-21 23:10:14 +08:00
dijunkun 6abcafc205 [fix] prevent older releases from overwriting update metadata 2026-10-21 23:03:32 +08:00
dijunkun 171e559b0d [feat] add desktop license and source information pages 2026-10-21 11:45:27 +08:00
dijunkun 41f1e29583 [fix] pin desktop and Python dependencies to OpenSSL 3.3.2 2026-10-21 11:07:16 +08:00
dijunkun 2ea462764d [fix] clear confirmed update receipts before temporary cleanup 2026-10-21 03:17:20 +08:00
dijunkun ef02ef2e6c [fix] preserve Linux desktop options when restarting after updates 2026-10-21 03:16:23 +08:00
dijunkun e45350319c [fix] authorize POSIX updates before closing CrossDesk 2026-10-21 03:10:40 +08:00
dijunkun cf98775754 [fix] secure Linux credential cache permissions 2026-10-21 02:08:31 +08:00
dijunkun 1aa23126d2 [fix] use in-app confirmation for update installation 2026-10-21 00:21:59 +08:00
dijunkun cb2ae207c4 [fix] use numeric versions for desktop branch builds 2026-10-20 21:14:38 +08:00
dijunkun a625890b28 [feat] add in-app updates for desktop installations 2026-10-20 20:11:12 +08:00
dijunkun 62ba3693af [fix] restore clipboard sync after focus returns, refs #121 2026-10-20 11:34:24 +08:00
dijunkun 4e5bf66734 [fix] support Unicode text input on Windows hosts, refs #120 2026-10-20 11:22:14 +08:00
204 changed files with 12419 additions and 2503 deletions
+12 -104
View File
@@ -7,124 +7,32 @@ body:
- type: markdown
attributes:
value: |
请先查看 [常见问题 / FAQ](https://github.com/kunkundi/crossdesk/blob/deskhub/docs/FAQ.md) 并搜索 [已有问题 / Existing issues](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
Please check the FAQ and existing issues first. You can write in Chinese or English.
只需描述问题,其余选填。中文或英文均可。
Only a problem description is required. Chinese or English is welcome.
- type: textarea
id: problem
attributes:
label: 问题与预期 / Problem and expected behavior
description: 描述实际发生的情况、错误提示,以及期望的行为。Describe what happened, any error messages, and what you expected.
placeholder: |
实际结果 / Actual behavior:
预期结果 / Expected behavior:
label: 问题描述 / Problem description
description: 遇到了什么问题?如方便,也请说明触发操作或错误提示。What went wrong? If possible, include what you were doing or any error message.
validations:
required: true
- type: textarea
id: reproduction
- type: input
id: environment
attributes:
label: 复现步骤 / Steps to reproduce
description: 说明操作步骤和出现频率;偶发问题可描述发生前的操作。Include the steps and frequency. For intermittent issues, describe what you were doing.
placeholder: |
1.
2.
3.
出现频率 / Frequency:
validations:
required: true
- type: dropdown
id: clients
attributes:
label: 涉及客户端 / Affected clients
description: 可多选;Web 与原生移动端请分别选择。Select all that apply; distinguish Web from native mobile apps.
multiple: true
options:
- 桌面客户端(Windows / macOS / Linux) / Desktop client
- iOS / iPadOS(原生 / Native)
- Android(原生 / Native)
- 鸿蒙 PC / HarmonyOS PC
- 鸿蒙手机或平板 / HarmonyOS mobile
- Web 客户端 / Web client
- 服务端 / Server
- 其他 / Other
validations:
required: true
- type: textarea
id: controller
attributes:
label: 控制端或本机环境 / Controller or local environment
description: |
控制端是发起远程操作的设备。请填写完整系统和客户端版本;Web 用户另填浏览器及其版本。自行构建请附提交号。
仅涉及安装、启动或服务端时,在此填写发生问题的本机环境。
The controller initiates remote access. Include full OS and client versions; Web users should also include the browser and its version. For source builds, include the commit.
For installation, startup, or server-only issues, describe the affected local environment here.
placeholder: |
操作系统及版本 / OS and version:
CPU 架构(x64 / arm64 等) / CPU architecture:
CrossDesk 版本或提交号 / CrossDesk version or commit:
浏览器及版本(仅 Web) / Browser and version (Web only):
validations:
required: true
- type: textarea
id: host
attributes:
label: 被控端环境 / Remote host environment
description: |
被控端是接受远程操作的电脑。连接或会话问题请补充这一端的信息;不涉及远程设备时可留空。
The host is the computer being controlled. Include its details for connection or session issues; leave empty if no remote device is involved.
placeholder: |
操作系统及版本 / OS and version:
CPU 架构(x64 / arm64 等) / CPU architecture:
CrossDesk 版本或提交号 / CrossDesk version or commit:
validations:
required: false
- type: dropdown
id: service
attributes:
label: 服务部署方式 / Server deployment
options:
- 公共服务 / Public service
- 自托管 / Self-hosted
- 不确定 / Unknown
- 不适用 / Not applicable
validations:
required: false
- type: dropdown
id: connection
attributes:
label: 连接方式 / Connection type
description: 可在会话的网络状态中查看;无法确认时选择“不确定”。Check the session's network status, or choose Unknown.
options:
- P2P 直连 / Direct P2P
- TURN 中继 / TURN relay
- 尚未建立连接 / Connection not established
- 不确定 / Unknown
- 不适用 / Not applicable
validations:
required: false
- type: textarea
id: context
attributes:
label: 补充环境 / Additional environment
description: |
按问题补充相关信息,例如普通会话或桌面墙、自托管服务端版本、局域网或公网、VPN;画面问题可补充显卡、编码格式、硬件编解码设置和显示器情况。
Include relevant details such as a normal session or DeskHub, self-hosted server version, LAN or Internet, and VPN use. For video issues, add GPU, codec, hardware codec settings, and display configuration.
label: 使用环境(选填) / Environment (optional)
description: 操作系统和 CrossDesk 版本;连接问题可填写两端信息。OS and CrossDesk version; include both devices for connection issues.
placeholder: Windows 11 → macOS 15; CrossDesk x.y.z
validations:
required: false
- type: textarea
id: attachments
attributes:
label: 日志与截图 / Logs and screenshots
label: 截图或日志(选填) / Screenshots or logs (optional)
description: |
可粘贴相关日志,或拖入日志文件、截图和录屏。上传前请隐去连接密码等敏感信息。
Paste relevant logs or attach log files, screenshots, and recordings. Redact connection passwords and other private information before uploading.
可直接拖入截图、录屏或日志文件,请隐去密码等敏感信息。
Drag in screenshots, recordings, or logs. Hide passwords and other sensitive information.
validations:
required: false
+6 -51
View File
@@ -7,66 +7,21 @@ body:
- type: markdown
attributes:
value: |
提交前请搜索 [已有建议 / Existing requests](https://github.com/kunkundi/crossdesk/issues?q=is%3Aissue)。可使用中文或英文填写。
Please search existing requests before submitting. You can write in Chinese or English.
- type: textarea
id: use-case
attributes:
label: 使用场景与问题 / Use case and problem
description: 你希望完成什么?目前遇到什么限制?What are you trying to do, and what limitation do you encounter?
validations:
required: true
只需描述你的建议,其余选填。中文或英文均可。
Only a description of your idea is required. Chinese or English is welcome.
- type: textarea
id: proposal
attributes:
label: 功能建议与预期效果 / Proposal and expected outcome
description: 描述希望新增或改进的行为,以及理想的使用方式。Describe the behavior you would like to add or improve and how you expect to use it.
label: 你的建议 / Your idea
description: 希望新增或改进什么?如方便,也请说明使用场景。What would you like to add or improve? If possible, describe when you would use it.
validations:
required: true
- type: dropdown
id: scope
attributes:
label: 适用范围 / Scope
description: 可多选;通用建议可选择“跨平台或通用”。Select all that apply, or choose Cross-platform or general.
multiple: true
options:
- 跨平台或通用 / Cross-platform or general
- 桌面客户端(Windows / macOS / Linux) / Desktop client
- iOS / iPadOS
- Android
- 鸿蒙 PC / HarmonyOS PC
- 鸿蒙手机或平板 / HarmonyOS mobile
- Web 客户端 / Web client
- 桌面墙 / DeskHub
- 自托管服务 / Self-hosted services
- 文档或安装体验 / Documentation or installation
- 其他 / Other
validations:
required: false
- type: textarea
id: impact
attributes:
label: 影响范围与使用频率 / Impact and frequency
description: 哪些用户或场景会受益?你会多频繁地使用?Who would benefit, in which situations, and how often would you use this?
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: 替代方案 / Alternatives
description: 目前如何处理这个需求?是否考虑过其他实现方式?How do you handle this today, and what other approaches have you considered?
validations:
required: false
- type: textarea
id: references
attributes:
label: 参考资料与补充说明 / References and additional context
description: 可提供参考链接、截图、示意图或其他要求。Include links, screenshots, mockups, or any additional requirements.
label: 补充说明(选填) / Additional context (optional)
description: 可附截图、参考链接或其他说明。Add screenshots, links, or any other details.
validations:
required: false
+108 -14
View File
@@ -6,6 +6,10 @@ import datetime
import json
from pathlib import Path
import re
import hashlib
import os
import subprocess
import tempfile
DOWNLOAD_BASE = "https://downloads.crossdesk.cn/"
@@ -22,8 +26,31 @@ ARTIFACTS = {
"android-arm64": ("android-arm64(?:-unsigned)?", "apk"),
}
def sign_update(payload, private_key, expected_public_key):
"""Sign exact UTF-8 JSON bytes using a PEM Ed25519 key, never shell text."""
if not re.fullmatch(r"[0-9a-f]{64}", expected_public_key):
raise ValueError("CROSSDESK_UPDATE_PUBLIC_KEY must contain 64 lowercase hex characters")
with tempfile.TemporaryDirectory(prefix="crossdesk-sign-") as directory:
root = Path(directory)
key = root / "key.pem"
key.write_text(private_key, encoding="utf-8")
key.chmod(0o600)
public = subprocess.run(["openssl", "pkey", "-in", str(key), "-pubout", "-outform", "DER"],
check=True, capture_output=True).stdout
# RFC 8410 SubjectPublicKeyInfo prefix for an Ed25519 public key.
if public != bytes.fromhex("302a300506032b6570032100" + expected_public_key):
raise ValueError("Update signing key does not match the public key built into clients")
data = root / "payload.json"
data.write_bytes(payload.encode("utf-8"))
signature = subprocess.run(
["openssl", "pkeyutl", "-sign", "-rawin", "-inkey", str(key), "-in", str(data)],
check=True, capture_output=True).stdout
if len(signature) != 64:
raise ValueError("Expected an Ed25519 signature")
return {"payload": payload, "signature": signature.hex()}
def version_info(version, fallback_date=None):
def version_fields(version):
version = version.removeprefix("v")
base = r"[0-9]+(?:\.[0-9]+){1,3}"
for pattern in (
@@ -33,20 +60,45 @@ def version_info(version, fallback_date=None):
):
match = re.fullmatch(pattern, version)
if match:
fields = match.groupdict()
date = fields.get("date")
release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
if date else fallback_date)
if not release_date:
raise ValueError(f"Release date is missing for {version}")
datetime.date.fromisoformat(release_date)
return {"latest_version": version, "version": version,
"releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
return match.groupdict()
raise ValueError(f"Invalid release version: {version}")
def version_info(version, fallback_date=None):
fields = version_fields(version)
version = version.removeprefix("v")
date = fields.get("date")
release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
if date else fallback_date)
if not release_date:
raise ValueError(f"Release date is missing for {version}")
datetime.date.fromisoformat(release_date)
return {"latest_version": version, "version": version,
"releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
def release_order(metadata):
"""Match client ordering: zero-padded numeric components, then patch, never date."""
version = metadata.get("latest_version") or metadata.get("version")
if not version:
return None
fields = version_fields(version)
numbers = tuple(int(part) for part in fields["base"].split("."))
inline_patch = fields.get("patch") or "0"
patch = int(inline_patch) if len(inline_patch) <= 4 else 0
# Legacy manifests may store the effective patch outside the version string.
explicit_patch = metadata.get("patch")
if isinstance(explicit_patch, str) and re.fullmatch(r"[0-9]+", explicit_patch):
explicit_patch = int(explicit_patch)
if type(explicit_patch) is int and 0 <= explicit_patch <= 2147483647:
patch = explicit_patch
return numbers + (0,) * (4 - len(numbers)) + (patch,)
def artifact_downloads(directory):
downloads = {}
private_key = os.environ.get("CROSSDESK_UPDATE_PRIVATE_KEY", "")
public_key = os.environ.get("CROSSDESK_UPDATE_PUBLIC_KEY", "")
for path in sorted(Path(directory).iterdir()):
if not path.is_file():
continue
@@ -65,6 +117,19 @@ def artifact_downloads(directory):
continue
downloads[key] = {"url": DOWNLOAD_BASE + path.name, "filename": path.name,
"version": metadata["version"], "releaseDate": metadata["releaseDate"]}
with path.open("rb") as artifact:
hasher = hashlib.sha256()
for chunk in iter(lambda: artifact.read(65536), b""):
hasher.update(chunk)
digest = hasher.hexdigest()
downloads[key].update(size=path.stat().st_size, sha256=digest)
if private_key and key.startswith(("windows-", "macos-", "linux-")) and "portable" not in key:
payload = json.dumps({
"schema": 1, "platform": key, "version": metadata["version"],
"url": DOWNLOAD_BASE + path.name, "filename": path.name,
"size": path.stat().st_size, "sha256": digest,
}, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
downloads[key]["update"] = sign_update(payload, private_key, public_key)
break
if not downloads:
raise ValueError("No supported release artifacts found")
@@ -87,10 +152,34 @@ def merge_release(current, tag, name, notes, downloads, published_date=None):
if not tag.startswith("v"):
raise ValueError(f"Expected a version tag, got {tag!r}")
metadata = version_info(tag, published_date)
# Clients require the full artifact version to match the advertised
# desktop version, including its date and patch spelling.
for platform, download in downloads.items():
if (platform.startswith(("windows-", "macos-", "linux-"))
and download["version"] != metadata["version"]):
raise ValueError(
f"Desktop release {tag} expects version {metadata['version']}, "
f"but {platform} artifact {download['filename']} "
f"has version {download['version']}")
current_order = release_order(current)
if current_order is not None and release_order(metadata) < current_order:
current_version = current.get("latest_version") or current.get("version")
print(f"Skipping {tag}: older than current desktop release {current_version}")
return dict(current)
accepted_downloads = {}
for platform, download in downloads.items():
previous_order = release_order(current["downloads"].get(platform, {}))
if previous_order is not None and release_order(download) < previous_order:
print(f"Skipping {tag} download for {platform}: a newer version is already published")
continue
accepted_downloads[platform] = download
if mobile and not accepted_downloads:
return dict(current)
result = dict(current)
result.pop("platforms", None)
result["downloads"] = {**current["downloads"], **downloads}
result["downloads"] = {**current["downloads"], **accepted_downloads}
if not mobile:
result.update({**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag})
return result
@@ -117,11 +206,16 @@ def main():
parser.error("Supply --release-json or --tag, --name and --notes")
tag, name, notes = args.tag, args.name, args.notes.read_text()
published_date = None
current = json.loads(args.current.read_text())
current_text = args.current.read_text()
current = json.loads(current_text)
downloads = artifact_downloads(args.artifacts)
result = merge_release(current, tag, name, notes, downloads, published_date)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(f"Updated {tag}: {', '.join(downloads)}")
if result == current:
args.output.write_text(current_text)
print(f"No changes to version.json for {tag}")
else:
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(f"Updated version.json for {tag}")
if __name__ == "__main__":
-4
View File
@@ -36,7 +36,6 @@ on:
- "apps/android/**"
- "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"
- "apps/ios/licenses/**"
- "apps/ios/scripts/generate_third_party_licenses.py"
- "deps/submodules/minirtc"
- "libs/wire/**"
@@ -155,9 +154,6 @@ jobs:
cache-provider: basic
cache-read-only: ${{ github.event_name == 'pull_request' }}
- name: Verify shared license catalog
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Resolve Android version
shell: python
env:
+1 -4
View File
@@ -169,9 +169,6 @@ jobs:
with:
python-version: "3.13"
- name: Verify iOS license catalog
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Install xmake
uses: xmake-io/github-action-setup-xmake@v1
with:
@@ -181,7 +178,7 @@ jobs:
shell: bash
run: |
xmake --version | tee "${RUNNER_TEMP}/xmake-version.txt"
python3 -B apps/ios/scripts/generate_third_party_licenses.py \
python3 -B apps/ios/scripts/verify_native_build.py \
--xmake-version "${RUNNER_TEMP}/xmake-version.txt"
- name: Pin xmake package repository
+80 -187
View File
@@ -11,6 +11,10 @@ on:
- "!android-v*"
workflow_dispatch:
inputs:
desktop_version:
description: "Desktop version for branch builds, e.g. 1.7.2. Leave blank for 0.0.0; tags use their own version."
required: false
type: string
patch:
description: "Hotfix patch number, for example 1 or 2. Use 0 for a normal build."
required: false
@@ -28,11 +32,72 @@ permissions:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
XMAKE_MIN_VERSION: "3.1.1"
CROSSDESK_UPDATE_PUBLIC_KEY: ${{ vars.CROSSDESK_UPDATE_PUBLIC_KEY }}
CROSSDESK_DESKTOP_VERSION: ${{ inputs.desktop_version }}
jobs:
prepare-version:
name: Resolve desktop version
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.version.outputs.version }}
# Resolve the date once for every desktop build and the release job.
steps:
- name: Resolve desktop version
id: version
shell: bash
env:
PATCH_NUMBER: ${{ inputs.patch || '0' }}
run: |
VERSION_REF="${GITHUB_REF##*/}"
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
echo "::error::desktop_version must be a numeric version such as 1.7.2"
exit 1
fi
elif [[ -n "${CROSSDESK_DESKTOP_VERSION}" ]]; then
echo "::error::desktop_version is only supported for branch builds; tags determine release versions"
exit 1
fi
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "version=${VERSION_NUM}" >> "$GITHUB_OUTPUT"
echo "Desktop version: ${VERSION_NUM}"
build-linux:
name: Build Linux (${{ matrix.arch }})
needs: prepare-version
runs-on: ${{ matrix.runner }}
env:
LEGAL_VERSION: ${{ needs.prepare-version.outputs.version }}
strategy:
matrix:
include:
@@ -52,59 +117,6 @@ jobs:
options: --user root
steps:
- name: Extract version number
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
PATCH_NUMBER="${{ github.event.inputs.patch }}"
BUILD_DATE_OVERRIDE=""
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
echo "VERSION_BASE=${VERSION_BASE}" >> $GITHUB_ENV
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_ENV
echo "BUILD_DATE_OVERRIDE=${BUILD_DATE_OVERRIDE}" >> $GITHUB_ENV
- name: Set legal Debian version
shell: bash
run: |
BUILD_DATE="${BUILD_DATE_OVERRIDE}"
if [[ -z "${BUILD_DATE}" ]]; then
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
fi
if [[ ! "${VERSION_BASE}" =~ ^[0-9] ]]; then
VERSION_BASE="0.0.0-${VERSION_BASE}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
LEGAL_VERSION="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
LEGAL_VERSION="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "LEGAL_VERSION=${LEGAL_VERSION}" >> $GITHUB_ENV
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v5
with:
@@ -202,8 +214,10 @@ jobs:
# macOS
build-macos:
name: Build on macOS
needs: prepare-version
runs-on: ${{ matrix.runner }}
env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
MACOSX_DEPLOYMENT_TARGET: "14.0"
EXPECTED_MACOS_SDK: "26.5"
strategy:
@@ -223,46 +237,6 @@ jobs:
package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh
steps:
- name: Extract version number
id: version
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "VERSION_NUM=${VERSION_NUM}" >> $GITHUB_ENV
echo "VERSION_NUM=${VERSION_NUM}"
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_ENV
- name: Select Xcode 26.6
shell: bash
run: |
@@ -354,50 +328,12 @@ jobs:
# Windows
build-windows-x64:
name: Build on Windows x64
needs: prepare-version
runs-on: windows-2022
env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
XMAKE_GLOBALDIR: D:\xmake_global
steps:
- name: Extract version number
shell: pwsh
run: |
$ref = $env:GITHUB_REF
$version = $ref -replace '^refs/(tags|heads)/', ''
$version = $version -replace '^v', ''
$version = $version -replace '/', '-'
$BUILD_DATE = ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId((Get-Date), "China Standard Time")).ToString("yyyyMMdd")
$PATCH_NUMBER = "${{ github.event.inputs.patch }}"
if ($PATCH_NUMBER -notmatch '^[0-9]+$') {
$PATCH_NUMBER = "0"
}
if ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$') {
$version = $Matches[1]
$PATCH_NUMBER = $Matches[3]
$BUILD_DATE = $Matches[4]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$') {
$version = $Matches[1]
$BUILD_DATE = $Matches[3]
$PATCH_NUMBER = $Matches[4]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$') {
$version = $Matches[1]
$BUILD_DATE = $Matches[3]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$' -and $PATCH_NUMBER -eq "0") {
$version = $Matches[1]
$PATCH_NUMBER = $Matches[3]
}
if ($PATCH_NUMBER -ne "0") {
$VERSION_NUM = "v$version-$PATCH_NUMBER-$BUILD_DATE"
} else {
$VERSION_NUM = "v$version-$BUILD_DATE"
}
echo "VERSION_NUM=$VERSION_NUM" >> $env:GITHUB_ENV
echo "BUILD_DATE=$BUILD_DATE" >> $env:GITHUB_ENV
echo "PATCH_NUMBER=$PATCH_NUMBER" >> $env:GITHUB_ENV
- name: Cache xmake dependencies
uses: actions/cache@v5
with:
@@ -598,7 +534,7 @@ jobs:
release:
name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-linux, build-macos, build-ios, build-android, sign-android, build-windows-x64]
needs: [prepare-version, build-linux, build-macos, build-ios, build-android, sign-android, build-windows-x64]
runs-on: ubuntu-latest
concurrency:
group: publish-release-metadata
@@ -613,50 +549,6 @@ jobs:
with:
path: artifacts
- name: Extract version number
id: version
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
BUILD_DATE_ISO="${BUILD_DATE:0:4}-${BUILD_DATE:4:2}-${BUILD_DATE:6:2}"
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="${VERSION_BASE}-${BUILD_DATE}"
fi
VERSION_WITH_V="v${VERSION_NUM}"
echo "VERSION_NUM=${VERSION_NUM}" >> $GITHUB_OUTPUT
echo "VERSION_WITH_V=${VERSION_WITH_V}" >> $GITHUB_OUTPUT
echo "VERSION_BASE=${VERSION_BASE}" >> $GITHUB_OUTPUT
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_OUTPUT
echo "BUILD_DATE_ISO=${BUILD_DATE_ISO}" >> $GITHUB_OUTPUT
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_OUTPUT
- name: Prepare release notes
run: |
cat > "$RUNNER_TEMP/release-notes.md" << EOF
@@ -686,18 +578,18 @@ jobs:
- name: Rename artifacts
run: |
mkdir -p release
cp artifacts/crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg
cp artifacts/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg
cp artifacts/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}.pkg
cp artifacts/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}.pkg
for ARCH in amd64 arm64; do
for PACKAGE in crossdesk crossdesk-virtual-desktop-xfce; do
FILE="${PACKAGE}-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}.deb"
cp "artifacts/crossdesk-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}/${FILE}" "release/${FILE}"
FILE="${PACKAGE}-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}.deb"
cp "artifacts/crossdesk-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}/${FILE}" "release/${FILE}"
done
done
cp artifacts/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}/* release/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}.zip
cp artifacts/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}/*.apk release/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}.apk
cp artifacts/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip
cp artifacts/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}.zip
- name: List release files
run: ls -lh release/
@@ -705,10 +597,10 @@ jobs:
- name: Upload to Versioned GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.VERSION_WITH_V }}
tag_name: ${{ needs.prepare-version.outputs.version }}
# The pushed tag already determines the release source commit.
target_commitish: ${{ github.event.repository.default_branch }}
name: Release ${{ steps.version.outputs.VERSION_WITH_V }}
name: Release ${{ needs.prepare-version.outputs.version }}
draft: false
prerelease: false
files: release/*
@@ -751,7 +643,8 @@ jobs:
- name: Generate version.json for all platforms
env:
RELEASE_TAG: ${{ steps.version.outputs.VERSION_WITH_V }}
RELEASE_TAG: ${{ needs.prepare-version.outputs.version }}
CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }}
run: |
python3 .github/scripts/update_version_manifest.py \
--current current-version.json --output version.json --artifacts release \
@@ -70,6 +70,9 @@ jobs:
- name: Merge release version and download URLs
shell: bash
env:
CROSSDESK_UPDATE_PUBLIC_KEY: ${{ vars.CROSSDESK_UPDATE_PUBLIC_KEY }}
CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }}
run: |
python3 .github/scripts/update_version_manifest.py \
--current current-version.json --output version.json --artifacts release \
+3
View File
@@ -1,3 +1,6 @@
CrossDesk
SPDX-License-Identifier: GPL-3.0-only
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
+30 -7
View File
@@ -1,6 +1,5 @@
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
<h1>CrossDesk</h1>
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk 标志:图标在左,双色文字在右" />
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
</div>
@@ -22,13 +21,14 @@
CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [IP 直连](#lan-direct) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
<a id="features"></a>
## 主要功能
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
@@ -61,22 +61,42 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
## 快速连接
1. **准备被控电脑。** 安装并运行 CrossDesk,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
### 通过设备 ID 连接
1. **准备被控电脑。** 安装并运行 CrossDesk,切换至 ID 连接模式,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
<a id="lan-direct"></a>
### 通过局域网 IP 直连
1. **切换模式。** 两台电脑均运行支持 IP 直连的桌面客户端,点击左上角模式按钮切换到 **IP 直连模式**。若按钮未显示,在 **设置 → 常规 → 网络连接** 中勾选 **启用 IP 直连** 并保存。
2. **填写地址。** 在控制端输入被控电脑显示的 `IP:端口`,例如 `192.168.1.20:52888`,点击“连接”。只输入 IP 时使用默认端口 **52888**;请以对方界面显示的实际端口为准。
3. **输入密码。** 输入被控电脑在 IP 直连模式下显示的 **6 位英文字母或数字密码**,可勾选“记住密码”。切换模式时,“近期连接”同步切换为对应模式的设备记录。
两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改各 IP 的端口和密码;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。
<a id="preview"></a>
## 界面预览
### 桌面客户端
本机凭据、远程连接和近期设备集中在主窗口,左上角进入桌面墙。
本机凭据、远程连接和近期设备集中在主窗口,左上角提供连接模式切换和桌面墙入口。
<p align="center">
<img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk 主窗口:本机凭据、远程连接入口和近期设备;数据为演示" />
<img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk ID 连接模式:模式切换、本机 ID、远程连接入口和近期设备;数据为演示" />
</p>
### IP 直连模式
切换本机 IP、修改端口与密码,通过对方地址连接;下方展示独立的局域网近期设备。
<p align="center">
<img src="docs/images/desktop-lan-zh.png" width="800" alt="CrossDesk IP 直连模式:本机 IP 和端口编辑入口、六位密码、远端地址输入和局域网近期设备;数据为演示" />
</p>
<a id="deskhub"></a>
@@ -96,7 +116,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
## 会话操作与设置
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置编码、中继、自托管与桌面墙画面预算。
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
@@ -151,9 +171,12 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
桌面端的 `CROSSDESK_UPDATE_PUBLIC_KEY` 是可选编译参数,用于验证应用内升级清单的 Ed25519 签名。不配置仍可正常编译、运行和检查版本,但更新只能手动下载;需要应用内自动安装更新时,须配置与发布私钥匹配的公钥。配置方式见 [更新公钥](docs/BUILD.md#update-public-key),密钥生成与发布流程见 [桌面端应用内升级](docs/UPDATER.md)。
<a id="sponsor"></a>
## 支持 CrossDesk
+30 -7
View File
@@ -1,6 +1,5 @@
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
<h1>CrossDesk</h1>
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk logo with the icon on the left and a two-tone wordmark on the right" />
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
</div>
@@ -22,13 +21,14 @@
CrossDesk connects your computers from desktop, browser, and mobile clients, with **DeskHub** for viewing and controlling multiple devices together. It is built on [MiniRTC](https://github.com/kunkundi/minirtc) and supports self-hosting.
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Direct IP](#lan-direct) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
<a id="features"></a>
## Features
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
- **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode.
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution.
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
@@ -61,22 +61,42 @@ Requirements follow the current source and build configuration. Check the releas
## Quick start
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the server-connected status, then find its ID and password under **Local Desktop**.
### Connect by device ID
1. **Prepare the host.** Install and run CrossDesk on the computer to control, select ID mode, and wait for the server-connected status, then find its ID and password under **Local Desktop**.
2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect.
3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems.
<a id="lan-direct"></a>
### Connect directly over LAN
1. **Switch modes.** Run a desktop client with direct IP support on both computers. Click the top-left mode button to select **Direct IP mode**. If the button is hidden, select **Enable direct IP** under **Settings → General → Network** and save.
2. **Enter the address.** On the controller, enter the host's displayed `IP:port`, for example `192.168.1.20:52888`, and click **Connect**. Omitting the port uses **52888**; use the actual port shown on the host.
3. **Enter the password.** Use the host's **six-character ASCII letter/digit password** shown in Direct IP mode. You can select **Remember password**. Switching modes also switches the recent device list.
Both computers must be reachable over the LAN. Internet access may be available or absent; direct sessions do not depend on a cloud signaling service. The main window lets you select the local IP, edit its port, and change the password. Disabling **Enable direct IP** stops listeners, disconnects direct IP sessions, and hides the mode button. See the [direct IP guide](docs/USER_GUIDE_EN.md#lan-direct).
<a id="preview"></a>
## Preview
### Desktop client
Local credentials, remote connections, and recent devices share the main window. Open DeskHub from the top-left corner.
Local credentials, remote connections, and recent devices share the main window. The top-left controls switch connection modes and open DeskHub.
<p align="center">
<img src="docs/images/desktop-main-en.png" width="800" alt="CrossDesk main window with local credentials, remote connection controls, and recent devices; demonstration data" />
<img src="docs/images/desktop-main-en.png" width="800" alt="CrossDesk ID mode with the mode switch, local ID, remote connection controls, and recent devices; demonstration data" />
</p>
### Direct IP mode
Select the local IP, edit its port and password, and connect using the other desktop's address. Recent LAN devices have their own list below.
<p align="center">
<img src="docs/images/desktop-lan-en.png" width="800" alt="CrossDesk Direct IP mode with local IP and port editing, a six-character password, remote address input, and recent LAN devices; demonstration data" />
</p>
<a id="deskhub"></a>
@@ -96,7 +116,7 @@ Group control sends input to all connected devices; closing DeskHub disconnects
## Session controls and settings
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure codecs, relay, self-hosting, and the DeskHub video budget.
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure direct IP availability, codecs, relay, self-hosting, and the DeskHub video budget.
- [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens.
- [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
@@ -151,9 +171,12 @@ Host your own signaling and TURN services, then enable them under **Settings →
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
- [Connection architecture](docs/connection-architecture.md) (Chinese): discovery, authentication, negotiation, data paths, and lifecycle for ID and direct IP modes.
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
- [Report an issue or suggestion](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
The optional desktop build parameter `CROSSDESK_UPDATE_PUBLIC_KEY` verifies Ed25519 signatures on in-app update manifests. Without it, the app still builds, runs, and checks for new versions, but updates require manual download. In-app update installation requires a public key matching the release signing private key. See [Update public key](docs/BUILD_EN.md#update-public-key) for configuration and the [desktop updater guide](docs/UPDATER.md) (Chinese) for key generation and publishing.
<a id="sponsor"></a>
## Support CrossDesk
+3 -2
View File
@@ -9,6 +9,7 @@
- 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
- “设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。手机进入会话后先保持横屏,检测到手机实际横过来后才恢复跟随设备方向;竖持或平放不会立即切回竖屏。关闭后沿用自动旋转,平板与多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向。
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
@@ -151,7 +152,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。Windows 通过当前已解锁用户桌面的 Unicode 键盘事件输入文本,无需切换电脑输入法或覆盖剪贴板;锁屏和 UAC 安全桌面不接受此文本通道。Windows 被控端也必须更新到包含文本输入支持的版本,仅更新 Android 不会启用该能力。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
@@ -245,4 +246,4 @@ AV1 硬件路径尚无对应实机验证;Retroid 上使用 dav1d 软件解码
设置 → 关于使用与 iOS 相同的分组及入口:版本、软件许可、开源组件、源码与构建说明。软件许可中可分别阅读 GNU GPL 第 3 版及开源软件权利说明;开源组件页单独展示 OpenFEC 告知,其余组件列出名称和许可证,详情包含版本、源码、适用的构建配方和每一份许可文档。内容按 Android 构建生成,不显示未使用的 iOS 依赖,Android 签名说明替换 Apple 签名说明。
APK 保留完整 `THIRD_PARTY_NOTICES.txt`,并生成轻量的 `ThirdPartyLicenses.json` 索引、72 份独立许可文档及 `SourceMetadata.json`。打开文档时才读取正文,长文按有限长度分块显示,支持文本选择、复制与网页链接;打包验证核对每份正文的 SHA-256。源码页面显示标签或提交版本及未发布修改提示,源码链接指向基础提交。当前 Android 构建说明、构建脚本、MiniRTC、OpenSSL 及 SVT-AV1 配方随 APK 离线提供,避免开发版链接指向基础提交中尚不存在的 Android 文件。
APK 保留完整 `THIRD_PARTY_NOTICES.txt`,并生成轻量的 `ThirdPartyLicenses.json` 索引、72 份独立许可文档及 `SourceMetadata.json`。打开文档时才读取正文,长文按有限长度分块显示,支持文本选择、复制与网页链接。构建继续生成并打包许可资料,CI 和 APK 验证不再检查许可证内容、哈希或组件清单。源码页面显示标签或提交版本及未发布修改提示,源码链接指向基础提交。当前 Android 构建说明、构建脚本、MiniRTC、OpenSSL 及 SVT-AV1 配方随 APK 离线提供,避免开发版链接指向基础提交中尚不存在的 Android 文件。
@@ -19,6 +19,7 @@ final class BackgroundSession implements NativeSession.Listener {
RemoteVideoSettings videoSettings;
int signalState, selectedDisplay, width, height;
boolean muted, remoteServiceAvailable, background, ended;
boolean waitingForLandscape;
File receivedFile;
BackgroundSession(Context context, NativeSession session, String host, int port, String remote) {
@@ -72,6 +72,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private int videoWidth,videoHeight;
private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture();
private RemoteVideoView video;
private RemoteOrientation remoteOrientation;
private SessionControls controls;
private Dialog passwordDialog, progressDialog, privacyDialog;
private AlertDialog disconnectDialog;
@@ -95,7 +96,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private Runnable backAction;
@Override public void onCreate(Bundle state){
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);remoteOrientation=new RemoteOrientation(this);
pageHost=new PageHost(this);setContentView(pageHost);
preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099);
recentPresence.sender=ids->{if(signaling!=null&&!signaling.isClosed())signaling.presenceRequest(ids);};
@@ -178,14 +179,18 @@ public final class MainActivity extends Activity implements NativeSession.Listen
TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item);
return item;
}
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
private void updateHomeOrientation(){
private boolean isTablet(){
boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600;
if(android.os.Build.VERSION.SDK_INT>=30){
android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds();
tablet|=Math.min(bounds.width(),bounds.height())>=dp(600);
}
int orientation=tablet||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
return tablet;
}
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
private void updateHomeOrientation(){
remoteOrientation.end();
int orientation=isTablet()||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation);
}
private void home(String message){
@@ -321,8 +326,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
ProgressBar spinner=new ProgressBar(this);spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));body.addView(spinner,ui.size(34,34));ui.gap(body,14);TextView title=text("正在连接",20,true);title.setGravity(Gravity.CENTER);body.addView(title);ui.gap(body,6);status=text("正在连接信令服务器…",14,false);status.setGravity(Gravity.CENTER);status.setTextColor(SECONDARY);status.setMinHeight(dp(36));body.addView(status);card.addView(body,ui.size(-1,-2));ui.divider(card);card.addView(ui.action("取消连接",RED,()->end("")),ui.size(-1,46));
dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2);
}
private void sessionScreen(){
installRoot("session",true);setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
private void sessionScreen(boolean waitForLandscape){
installRoot("session",true);remoteOrientation.begin(waitForLandscape&&!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
controls=new SessionControls(this,ui,new SessionControls.Actions(){
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
@@ -343,6 +348,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
private void end(String reason){
remoteOrientation.end();
if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);}
boolean closeWindow=isInPictureInPictureMode();
pictureInPicture.disconnect();updatePictureInPicture();
@@ -362,6 +368,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
mouse.addView(segments(new String[]{"相对位置","绝对位置"},relative?0:1,true,index->preferences.edit().putBoolean("relativeMouse",index==0).apply()));
LinearLayout picture=section(content,"画面偏好",MobileUi.SETTINGS_CARD_RADIUS);picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1)));
picture.addView(segments(RemoteVideoSettings.PREFERENCES,preference,true,index->preferences.edit().putInt("videoPreference",index).apply()));ui.gap(content,28);
LinearLayout direction=section(content,"屏幕方向",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout directionRow=row();directionRow.setPadding(dp(16),dp(10),dp(16),dp(10));directionRow.addView(text("连接后自动横屏",16,false),new LinearLayout.LayoutParams(0,-2,1));
Switch autoLandscape=new Switch(this);autoLandscape.setContentDescription("连接后自动横屏");autoLandscape.setChecked(preferences.getBoolean("autoLandscape",true));autoLandscape.setOnCheckedChangeListener((button,checked)->preferences.edit().putBoolean("autoLandscape",checked).apply());directionRow.addView(autoLandscape);direction.addView(directionRow);
footer(content,"手机连接后先进入横屏,将手机横过来后恢复跟随设备方向。关闭后始终跟随设备方向。平板和多窗口不受影响。");
LinearLayout server=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099;
link(server,"服务器",custom?"自定义":"默认",()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields);
LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text("使用自定义服务器",16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription("使用自定义服务器");toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow);
@@ -477,7 +486,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private boolean sendTextEdit(int count,String value){
if(session==null)return false;
if(session.textEdit(count,value))return true;
toast("当前远端不支持此文本,中文或表情需更新 macOS 被控端,或通过剪贴板发送");return false;
toast("当前远端不支持直接输入此文本,请使用支持文本输入的被控端版本,或通过剪贴板发送");return false;
}
private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}}
private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}}
@@ -593,7 +602,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
// Transport readiness can be reported again after ICE changes its selected path.
// The first success consumes pendingPassword; a duplicate must not save it again.
if(session==null||ready)return;ready=true;if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen();
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen(preferences.getBoolean("autoLandscape",true));
if(!canUsePictureInPicture())prepareBackgroundSession(true);
if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection);
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected();
@@ -670,6 +679,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onConfigurationChanged(config);
releaseKeys();
if(!page.equals("session"))updateHomeOrientation();
else if(!isInPictureInPictureMode()&&(isTablet()||isInMultiWindowMode()))remoteOrientation.followDevice();
sizePasswordDialog();
if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize();
if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels);
@@ -688,6 +698,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable;
retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile;
retained.width=videoWidth;retained.height=videoHeight;
retained.waitingForLandscape=remoteOrientation.waitingForLandscape();
}
private boolean prepareBackgroundSession(boolean inform){
if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false;
@@ -723,7 +734,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay;
remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile;
videoSettings=retained.videoSettings;ready=true;retained.attach(this);
if(rebuild)sessionScreen();
if(rebuild)sessionScreen(retained.waitingForLandscape);
if(retained.width>0&&retained.height>0)videoSize(retained.width,retained.height);
if(controls!=null){controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
retained.setBackground(false);signaling(signalState,localIdentity);
@@ -771,9 +782,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onPictureInPictureUiStateChanged(state);
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
}
@Override protected void onResume(){super.onResume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
@Override protected void onResume(){super.onResume();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onPause(){releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onPause(){remoteOrientation.pause();releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();}
@Override protected void onStop(){if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
if(connectionDialog!=null)connectionDialog.dismiss();
@@ -791,5 +802,5 @@ public final class MainActivity extends Activity implements NativeSession.Listen
boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
if(backgroundSession!=null)backgroundSession.detach(this);
if(!retained){if(session!=null)session.close();stopSignaling();}else{mainHandler.removeCallbacks(presenceTick);session=signaling=null;}
super.onDestroy();}
remoteOrientation.end();super.onDestroy();}
}
@@ -0,0 +1,57 @@
package cn.crossdesk.mobile;
import android.app.Activity;
import android.content.pm.ActivityInfo;
import android.view.OrientationEventListener;
/** Holds the initial landscape until the phone itself has been turned sideways. */
final class RemoteOrientation {
private final Activity activity;
private final OrientationEventListener sensor;
private boolean waitingForLandscape, resumed;
RemoteOrientation(Activity activity) {
this.activity = activity;
sensor = new OrientationEventListener(activity) {
@Override public void onOrientationChanged(int degrees) {
if (!resumed || !waitingForLandscape || activity.isInPictureInPictureMode()) return;
// Use physical orientation, never the window rotated by our own request.
// Ignore flat/unknown poses and keep a margin away from the diagonal.
if ((degrees >= 60 && degrees <= 120) || (degrees >= 240 && degrees <= 300)) {
followDevice();
}
}
};
}
void begin(boolean autoLandscape) {
sensor.disable();
waitingForLandscape = autoLandscape && sensor.canDetectOrientation();
apply();
if (resumed && waitingForLandscape) sensor.enable();
}
boolean waitingForLandscape() { return waitingForLandscape; }
void resume() {
resumed = true;
if (waitingForLandscape && !activity.isInPictureInPictureMode()) sensor.enable();
}
void pause() { resumed = false; sensor.disable(); }
void end() { waitingForLandscape = false; sensor.disable(); }
void followDevice() {
waitingForLandscape = false;
sensor.disable();
apply();
}
private void apply() {
int orientation = waitingForLandscape
? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
: ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
if (activity.getRequestedOrientation() != orientation) activity.setRequestedOrientation(orientation);
}
}
+1 -3
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
"""Package existing reviewed notices for the Android runtime dependency set."""
import json
import hashlib
import os
from pathlib import Path
import subprocess
@@ -70,8 +69,7 @@ def write_pages(output, catalog, additional, revision, minirtc):
target = output / asset
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(document['text'], encoding='utf-8')
documents.append({'title': document['title'], 'asset': asset,
'sha256': hashlib.sha256(document['text'].encode('utf-8')).hexdigest()})
documents.append({'title': document['title'], 'asset': asset})
component['documents'] = documents
(output / 'ThirdPartyLicenses.json').write_text(json.dumps(
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
+1 -20
View File
@@ -2,7 +2,6 @@
"""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
@@ -40,33 +39,15 @@ def verify(path):
name, extra = struct.unpack('<HH', raw.read(4))
if (library.header_offset + 30 + name + extra) % 16384:
raise ValueError('APK native library entry is not 16 KB aligned')
if 'assets/THIRD_PARTY_NOTICES.txt' not in archive.namelist():
raise ValueError('Missing third-party notices')
if archive.read('assets/PRIVACY.md') != (Path(__file__).resolve().parents[3] / 'PRIVACY.md').read_bytes():
raise ValueError('Missing or outdated bundled privacy policy')
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.json'))
source = json.loads(archive.read('assets/SourceMetadata.json'))
components = catalog['components']
ids = [item['id'] for item in components]
if catalog['schemaVersion'] != 1 or len(ids) != len(set(ids)) or not {
'crossdesk', 'minirtc', 'openfec', 'openh264', 'libiconv', 'android-ndk', 'svt-av1'}.issubset(ids):
raise ValueError('Incomplete About catalog')
for component in components:
if not component['documents']:
raise ValueError('Missing component documents')
for document in component['documents']:
text = archive.read('assets/'+document['asset'])
if not text or hashlib.sha256(text).hexdigest() != document['sha256']:
raise ValueError('Incomplete license document: '+document['asset'])
for document in component.get('buildDocuments', []):
if not archive.read('assets/'+document['asset']):
raise ValueError('Missing build document')
if source['schemaVersion'] != 1 or len(source['revision']) != 40:
raise ValueError('Missing source revision')
for document in source['buildDocuments']:
if not archive.read('assets/'+document['asset']):
raise ValueError('Missing source build instructions')
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, privacy policy, notices, About catalog and documents')
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, privacy policy and source metadata')
if __name__ == '__main__':
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0" name="CrossDesk.SessionHelper" type="win32" />
<description>CrossDesk Session Helper</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
@@ -4,13 +4,10 @@ IDI_ICON1 ICON "crossdesk.ico"
#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1
#define RT_MANIFEST 24
#ifdef CROSSDESK_PORTABLE
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest"
#elif defined(CROSSDESK_DEBUG)
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest"
#else
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
#endif
// The service supplies either the user's token (desktop observer) or a SYSTEM
// token (secure capture/input). Requiring elevation here prevents the observer
// from starting with CreateProcessAsUserW in installed release builds (740).
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_session_helper.manifest"
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe"
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0" name="CrossDesk.Updater" type="win32"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
@@ -0,0 +1,5 @@
1 24 "crossdesk_updater.manifest"
IDI_ICON1 ICON "crossdesk.ico"
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Updater"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_updater.exe"
#include "version_info.rcinc"
+2 -1
View File
@@ -111,6 +111,7 @@ DESKTOP_DIR="$DEB_DIR/usr/share/applications"
install -d "$DEBIAN_DIR" "$BIN_DIR" "$PRIVATE_DIR" "$DESKTOP_DIR"
install -m 0755 "$BUILD_BINARY" "$PRIVATE_DIR/crossdesk-bin"
install -m 0755 "$(dirname "$BUILD_BINARY")/crossdesk_updater" "$PRIVATE_DIR/crossdesk_updater"
# PipeWire is deliberately optional. The Wayland capturer resolves PipeWire
# 0.3 with dlopen() so the same package can run on Ubuntu 20.04 without the
@@ -156,7 +157,7 @@ for size in 16 24 32 48 64 96 128 256; do
"$ICON_BASE_DIR/${size}x${size}/apps/${PKG_NAME}.png"
done
RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS, $TRAY_RUNTIME_RECOMMENDS"
RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS, $TRAY_RUNTIME_RECOMMENDS, policykit-1"
if [[ -n "$EXTRA_RECOMMENDATION" ]]; then
RECOMMENDS="$RECOMMENDS, $EXTRA_RECOMMENDATION"
fi
+2
View File
@@ -51,6 +51,8 @@ mkdir -p build_pkg_temp
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
cp "${EXECUTABLE_PATH}" "${MACOS_DIR}/${APP_NAME_UPPER}"
cp "$(dirname "${EXECUTABLE_PATH}")/crossdesk_updater" "${MACOS_DIR}/crossdesk_updater"
chmod +x "${MACOS_DIR}/crossdesk_updater"
chmod +x "${MACOS_DIR}/${APP_NAME_UPPER}"
if [ -f "${ICON_PATH}" ]; then
+2
View File
@@ -51,6 +51,8 @@ mkdir -p build_pkg_temp
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
cp "${EXECUTABLE_PATH}" "${MACOS_DIR}/${APP_NAME_UPPER}"
cp "$(dirname "${EXECUTABLE_PATH}")/crossdesk_updater" "${MACOS_DIR}/crossdesk_updater"
chmod +x "${MACOS_DIR}/crossdesk_updater"
chmod +x "${MACOS_DIR}/${APP_NAME_UPPER}"
if [ -f "${ICON_PATH}" ]; then
+13
View File
@@ -71,6 +71,19 @@ verify_dependencies() {
}
verify_dependencies "$APP_EXECUTABLE"
UPDATER="$APP_BUNDLE/Contents/MacOS/crossdesk_updater"
if [[ ! -x "$UPDATER" ]] || ! lipo "$UPDATER" -verify_arch "$EXPECTED_ARCH" >/dev/null 2>&1; then
echo "Missing updater or incorrect updater architecture" >&2
exit 1
fi
# The updater runs outside the bundle during installation, so it must not rely
# on private frameworks that are being replaced.
while IFS= read -r dependency; do
case "$dependency" in
/System/Library/*|/usr/lib/*) ;;
*) echo "Updater has a non-system dynamic dependency: $dependency" >&2; exit 1 ;;
esac
done < <(otool -L "$UPDATER" | tail -n +2 | awk '{ print $1 }')
SLINT_LIBRARY="$(find "$FRAMEWORKS_DIR" -maxdepth 1 -type f -name 'libslint_cpp*.dylib' -print -quit 2>/dev/null || true)"
if [[ -z "$SLINT_LIBRARY" ]]; then
+29 -1
View File
@@ -39,6 +39,18 @@ RequestExecutionLevel admin
; Include LogicLib for process handling
!include "LogicLib.nsh"
!include "FileFunc.nsh"
Var UpdateMode
Function .onInit
${GetParameters} $0
ClearErrors
${GetOptions} $0 "/CROSSDESK_UPDATE" $1
StrCpy $UpdateMode 0
${IfNot} ${Errors}
StrCpy $UpdateMode 1
${EndIf}
FunctionEnd
Name "${PRODUCT_NAME} ${PRODUCT_VERSION}"
OutFile "crossdesk-win-x64-${PRODUCT_VERSION}.exe"
@@ -55,6 +67,11 @@ InstallDirRegKey HKCU "Software\${PRODUCT_NAME}" "InstallDir"
ShowInstDetails show
Section "MainSection"
; The updater has already coordinated shutdown. Never kill unrelated
; instances by name during an unattended upgrade.
${If} $UpdateMode = 1
Goto installApp
${EndIf}
; Check if CrossDesk is running
StrCpy $1 "CrossDesk.exe"
@@ -81,10 +98,12 @@ installApp:
Call StopInstalledService
SetOutPath "$INSTDIR"
SetOverwrite ifnewer
; The signed release determines file versions, not filesystem timestamps.
SetOverwrite on
; Main application executable path
File /oname=CrossDesk.exe "..\..\..\..\build\windows\x64\release\crossdesk.exe"
File "..\..\..\..\build\windows\x64\release\crossdesk_updater.exe"
; Bundle service-side binaries required by the Windows service flow
File "..\..\..\..\build\windows\x64\release\crossdesk_service.exe"
File "..\..\..\..\build\windows\x64\release\crossdesk_session_helper.exe"
@@ -116,6 +135,7 @@ installApp:
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayName" "${PRODUCT_NAME}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "UninstallString" "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayVersion" "${PRODUCT_VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "Publisher" "${PRODUCT_PUBLISHER}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "URLInfoAbout" "${PRODUCT_WEB_SITE}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayIcon" "$INSTDIR\CrossDesk.exe"
@@ -167,6 +187,7 @@ uninstallApp:
; Delete main executable and uninstaller
Delete "$INSTDIR\CrossDesk.exe"
Delete "$INSTDIR\crossdesk_updater.exe"
Delete "$INSTDIR\crossdesk_service.exe"
Delete "$INSTDIR\crossdesk_session_helper.exe"
Delete "$INSTDIR\crossdesk_privacy_cursor_helper.exe"
@@ -196,9 +217,16 @@ SectionEnd
; ------ Functions ------
Function LaunchApp
${If} $UpdateMode = 1
Return
${EndIf}
Exec "$INSTDIR\CrossDesk.exe"
FunctionEnd
Function .onInstFailed
SetErrorLevel 1
FunctionEnd
Function StopInstalledService
IfFileExists "$INSTDIR\CrossDesk.exe" 0 stop_with_sc
IfFileExists "$INSTDIR\crossdesk_service.exe" 0 stop_with_sc
+25 -2
View File
@@ -20,6 +20,7 @@
#include "platform/windows/gui/slint_backend.h"
#include "service_host.h"
#include "service_lifetime.h"
#include "shutdown_order.h"
#include "unattended_config.h"
#endif
@@ -30,6 +31,9 @@
#include "platform/single_instance.h"
#include "rd_log.h"
#include "render.h"
#if CROSSDESK_HAS_UPDATER
#include "updater/update_platform.h"
#endif
#ifdef __linux__
#include "SimpleIni.h"
@@ -204,8 +208,6 @@ int HandleServiceCliCommand(const std::string& command) {
destination.SetTurnMode(source.GetTurnMode()) == 0 &&
destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) ==
0 &&
destination.SetHardwareVideoCodec(source.IsHardwareVideoCodec()) ==
0 &&
destination.SetScreenCaptureMethod(
crossdesk::ScreenCaptureMethod::Auto) == 0 &&
destination.SetPrivacyScreen(false) == 0 &&
@@ -403,6 +405,16 @@ static int RunApplication(
}
int main(int argc, char* argv[]) {
#ifdef _WIN32
// Apply in every process, including the daemon and --child. Windows does
// not inherit shutdown priority from a parent process.
const DWORD shutdown_error = crossdesk::ConfigureWindowsShutdownOrder(
crossdesk::WindowsShutdownRole::host);
if (shutdown_error != ERROR_SUCCESS) {
std::cerr << "Could not configure Windows host shutdown order, error="
<< shutdown_error << '\n';
}
#endif
#ifdef __linux__
// Run before GUI, configuration, logging or display initialization.
if (argc == 2 &&
@@ -472,6 +484,17 @@ int main(int argc, char* argv[]) {
#endif
using crossdesk::platform::InstanceResult;
#if CROSSDESK_HAS_UPDATER
// Installer CLI commands above stay available during maintenance. Ordinary
// launches (including daemon restarts) must not reopen the installation.
try {
if (crossdesk::updater::MaintenanceActive(crossdesk::updater::ExecutablePath()))
return 0;
} catch (const std::exception& error) {
std::cerr << "Cannot check update maintenance: " << error.what() << '\n';
return 1;
}
#endif
using crossdesk::platform::InstanceRole;
crossdesk::platform::SingleInstanceGuard launcher_instance;
crossdesk::platform::SingleInstanceGuard client_instance;
+18
View File
@@ -0,0 +1,18 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SCREEN_LOCK_H_
#define _SCREEN_LOCK_H_
namespace crossdesk {
// Called on the application thread. True means the OS accepted the request,
// not that the asynchronous transition to the lock screen has finished.
bool LockScreen();
} // namespace crossdesk
#endif
@@ -41,6 +41,8 @@ int ConfigCenter::Load() {
}
bool persist_config_migration = false;
// Hardware codecs are always preferred, with software fallback in MiniRTC.
persist_config_migration |= ini_->Delete(section_, "hardware_video_codec");
// These options now belong only to the active remote connection. Discard
// legacy local defaults so they cannot affect this host or new connections.
persist_config_migration |= ini_->Delete(section_, "video_quality");
@@ -66,6 +68,13 @@ int ConfigCenter::Load() {
language_ = static_cast<LANGUAGE>(language_value);
}
enable_ip_direct_ = ini_->GetBoolValue(section_, "enable_ip_direct", true);
connection_mode_ =
enable_ip_direct_ &&
std::strcmp(ini_->GetValue(section_, "connection_mode", "id"), "ip") == 0
? CONNECTION_MODE::DIRECT_IP
: CONNECTION_MODE::DEVICE_ID;
const long screen_capture_method_value = ini_->GetLongValue(
section_, "screen_capture_method",
static_cast<long>(ScreenCaptureMethod::Auto));
@@ -78,8 +87,6 @@ int ConfigCenter::Load() {
ini_->GetLongValue(section_, "video_encode_format",
static_cast<long>(video_encode_format_)));
hardware_video_codec_ = ini_->GetBoolValue(section_, "hardware_video_codec",
hardware_video_codec_);
const char* turn_mode_value = ini_->GetValue(section_, "turn_mode", nullptr);
if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) {
const long parsed_turn_mode = ini_->GetLongValue(
@@ -130,6 +137,8 @@ int ConfigCenter::Load() {
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
enable_privacy_screen_.store(
ini_->GetBoolValue(section_, "enable_privacy_screen", true));
lock_on_disconnect_.store(
ini_->GetBoolValue(section_, "lock_on_disconnect", true));
save_remote_previews_.store(
ini_->GetBoolValue(section_, "save_remote_previews", true));
portable_service_prompt_suppressed_ =
@@ -201,11 +210,13 @@ int ConfigCenter::StoreValues(
int ConfigCenter::Save() {
ini_->SetLongValue(section_, "language", static_cast<long>(language_));
ini_->SetValue(section_, "connection_mode",
connection_mode_ == CONNECTION_MODE::DIRECT_IP ? "ip" : "id");
ini_->SetBoolValue(section_, "enable_ip_direct", enable_ip_direct_);
ini_->SetLongValue(section_, "screen_capture_method",
static_cast<long>(screen_capture_method_));
ini_->SetLongValue(section_, "video_encode_format",
static_cast<long>(video_encode_format_));
ini_->SetBoolValue(section_, "hardware_video_codec", hardware_video_codec_);
ini_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
ini_->SetBoolValue(section_, "enable_turn",
turn_mode_ != TURN_MODE::DISABLED);
@@ -223,6 +234,7 @@ int ConfigCenter::Save() {
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
ini_->SetBoolValue(section_, "enable_privacy_screen",
enable_privacy_screen_.load());
ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
ini_->SetBoolValue(section_, "save_remote_previews",
save_remote_previews_.load());
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
@@ -250,6 +262,34 @@ int ConfigCenter::SetLanguage(LANGUAGE language) {
return 0;
}
int ConfigCenter::SetConnectionMode(CONNECTION_MODE mode) {
if (mode != CONNECTION_MODE::DEVICE_ID && mode != CONNECTION_MODE::DIRECT_IP) {
return -1;
}
if (mode == CONNECTION_MODE::DIRECT_IP && !enable_ip_direct_) return -1;
if (mode == connection_mode_) return 0;
if (StoreValues({{"connection_mode",
mode == CONNECTION_MODE::DIRECT_IP ? "ip" : "id"}}) != 0) {
return -1;
}
connection_mode_ = mode;
return 0;
}
int ConfigCenter::SetIpDirectEnabled(bool enabled) {
if (enabled == enable_ip_direct_) return 0;
const auto mode = enabled ? connection_mode_ : CONNECTION_MODE::DEVICE_ID;
// Persist availability and the fallback mode together, so a failed save
// leaves both the current runtime and the next launch unchanged.
if (StoreValues({{"enable_ip_direct", enabled ? "true" : "false"},
{"connection_mode", mode == CONNECTION_MODE::DIRECT_IP
? "ip" : "id"}}) != 0)
return -1;
enable_ip_direct_ = enabled;
connection_mode_ = mode;
return 0;
}
int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
return -1;
@@ -271,13 +311,6 @@ int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) {
return 0;
}
int ConfigCenter::SetHardwareVideoCodec(bool enabled) {
if (enabled && !IsHardwareVideoCodecAvailable()) return -1;
if (StoreValues({{"hardware_video_codec", enabled ? "true" : "false"}}) != 0) return -1;
hardware_video_codec_ = enabled;
return 0;
}
int ConfigCenter::SetTurnMode(TURN_MODE mode) {
if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1;
if (StoreValues({{"turn_mode", std::to_string(static_cast<int>(mode))},
@@ -286,10 +319,7 @@ int ConfigCenter::SetTurnMode(TURN_MODE mode) {
return 0;
}
int ConfigCenter::SetTurn(bool enable_turn, bool force_relay) {
if (!enable_turn) {
return SetTurnMode(TURN_MODE::DISABLED);
}
int ConfigCenter::SetForceRelay(bool force_relay) {
if (!force_relay) {
return SetTurnMode(TURN_MODE::AUTO_UDP_TCP);
}
@@ -379,6 +409,17 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
return 0;
}
int ConfigCenter::SetLockOnDisconnect(bool enabled) {
if (StoreValues({{"lock_on_disconnect", enabled ? "true" : "false"}}) != 0)
return -1;
lock_on_disconnect_.store(enabled);
return 0;
}
bool ConfigCenter::IsLockOnDisconnect() const {
return lock_on_disconnect_.load();
}
int ConfigCenter::SetSaveRemotePreviews(bool enabled) {
if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) {
return -1;
@@ -417,6 +458,12 @@ int ConfigCenter::SetSponsorshipPromptDismissedVersion(
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; }
ConfigCenter::CONNECTION_MODE ConfigCenter::GetConnectionMode() const {
return connection_mode_;
}
bool ConfigCenter::IsIpDirectEnabled() const { return enable_ip_direct_; }
ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
return screen_capture_method_;
}
@@ -425,28 +472,10 @@ ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
return video_encode_format_;
}
bool ConfigCenter::IsHardwareVideoCodecAvailable() {
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \
!defined(__arm__) && USE_CUDA) || \
defined(__APPLE__))
return true;
#else
return false;
#endif
}
bool ConfigCenter::IsHardwareVideoCodec() const {
return IsHardwareVideoCodecAvailable() && hardware_video_codec_;
}
ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
return turn_mode_;
}
bool ConfigCenter::IsEnableTurn() const {
return turn_mode_ != TURN_MODE::DISABLED;
}
bool ConfigCenter::IsForceRelay() const {
return turn_mode_ == TURN_MODE::FORCE_UDP || turn_mode_ == TURN_MODE::FORCE_TCP;
}
+11 -7
View File
@@ -21,6 +21,7 @@ namespace crossdesk {
class ConfigCenter {
public:
enum class LANGUAGE { CHINESE = 0, ENGLISH = 1, RUSSIAN = 2 };
enum class CONNECTION_MODE { DEVICE_ID = 0, DIRECT_IP = 1 };
enum class VIDEO_ENCODE_FORMAT { H264 = 0, AV1 = 1 };
enum class TURN_MODE {
DISABLED = 0,
@@ -35,11 +36,12 @@ class ConfigCenter {
// write config
int SetLanguage(LANGUAGE language);
int SetConnectionMode(CONNECTION_MODE mode);
int SetIpDirectEnabled(bool enabled);
int SetScreenCaptureMethod(ScreenCaptureMethod method);
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
int SetHardwareVideoCodec(bool hardware_video_codec);
int SetTurnMode(TURN_MODE turn_mode);
int SetTurn(bool enable_turn, bool force_relay);
int SetForceRelay(bool force_relay);
int SetServerHost(const std::string& signal_server_host);
int SetServerPort(int signal_server_port);
int SetServerEndpoint(const std::string& address, int port);
@@ -47,6 +49,7 @@ class ConfigCenter {
int SetAutostart(bool enable_autostart);
int SetDaemon(bool enable_daemon);
int SetPrivacyScreen(bool enable_privacy_screen);
int SetLockOnDisconnect(bool enabled);
int SetSaveRemotePreviews(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed);
int SetSponsorshipPromptDismissedVersion(const std::string& version);
@@ -55,13 +58,11 @@ class ConfigCenter {
// read config
LANGUAGE GetLanguage() const;
CONNECTION_MODE GetConnectionMode() const;
bool IsIpDirectEnabled() const;
ScreenCaptureMethod GetScreenCaptureMethod() const;
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
// Whether this build includes a hardware video codec backend.
static bool IsHardwareVideoCodecAvailable();
bool IsHardwareVideoCodec() const;
TURN_MODE GetTurnMode() const;
bool IsEnableTurn() const;
bool IsForceRelay() const;
bool IsEnableSrtp() const;
std::string GetSignalServerHost() const;
@@ -72,6 +73,7 @@ class ConfigCenter {
bool IsEnableAutostart() const;
bool IsEnableDaemon() const;
bool IsEnablePrivacyScreen() const;
bool IsLockOnDisconnect() const;
bool IsSaveRemotePreviews() const;
bool IsPortableServicePromptSuppressed() const;
std::string GetSponsorshipPromptDismissedVersion() const;
@@ -90,9 +92,10 @@ class ConfigCenter {
const char* section_ = "Settings";
LANGUAGE language_ = LANGUAGE::CHINESE;
CONNECTION_MODE connection_mode_ = CONNECTION_MODE::DEVICE_ID;
bool enable_ip_direct_ = true;
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
bool hardware_video_codec_ = false;
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
std::string signal_server_host_ = "";
std::string signal_server_host_default_ = "api.crossdesk.cn";
@@ -102,6 +105,7 @@ class ConfigCenter {
bool enable_autostart_ = false;
bool enable_daemon_ = false;
std::atomic<bool> enable_privacy_screen_{true};
std::atomic<bool> lock_on_disconnect_{true};
std::atomic<bool> save_remote_previews_{true};
bool portable_service_prompt_suppressed_ = false;
std::string sponsorship_prompt_dismissed_version_;
@@ -32,6 +32,9 @@ class MouseController : public DeviceController {
virtual int Destroy() = 0;
virtual int SendMouseCommand(RemoteAction remote_action,
int display_index) = 0;
// Bits 0/1/2 are left/right/middle. Return the subset confirmed released,
// including buttons already up. Must not require valid display geometry.
virtual unsigned ReleasePressedButtons(unsigned mask = 7) = 0;
virtual void UpdateDisplayInfoList(
const std::vector<DisplayInfo>&) {}
};
@@ -32,7 +32,7 @@ struct InteractionState {
bool audio_capture_ = false;
int screen_width_ = 1280;
int screen_height_ = 720;
int selected_display_ = 0;
std::atomic<int> selected_display_{0};
char remote_id_display_[12] = "";
unsigned char audio_buffer_[720]{};
int audio_len_ = 0;
@@ -77,7 +77,7 @@ struct UiState {
bool show_connection_status_window_ = false;
bool fullscreen_button_pressed_ = false;
bool is_client_mode_ = false;
bool is_server_mode_ = false;
std::atomic<bool> is_server_mode_{false};
bool reload_recent_connections_ = true;
bool show_offline_warning_window_ = false;
std::string offline_warning_text_;
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,7 @@
namespace crossdesk {
class UpdateChecker;
namespace updater { class Controller; }
// Slint application shell. GuiRuntime remains the owner of transport, media,
// device, clipboard, transfer and settings behavior.
@@ -126,6 +127,9 @@ private:
std::unique_ptr<SlintUi> ui_;
std::unique_ptr<UpdateChecker> update_checker_;
#if CROSSDESK_HAS_UPDATER
std::unique_ptr<updater::Controller> updater_;
#endif
CursorStateProvider cursor_state_provider_;
std::unordered_map<std::string, CursorDeliveryState>
cursor_delivery_states_;
@@ -21,23 +21,30 @@ struct TranslationRow {
// Single source of truth for all UI strings.
#define CROSSDESK_LOCALIZATION_ALL(X) \
X(window_minimize, u8"最小化", "Minimize", u8"Свернуть") \
X(window_maximize, u8"最大化", "Maximize", u8"Развернуть") \
X(window_restore, u8"还原窗口", "Restore window", u8"Восстановить окно") \
X(window_close, u8"关闭窗口", "Close window", u8"Закрыть окно") \
X(save, u8"保存", "Save", u8"Сохранить") \
X(copyright_notice, u8"© 2026 JUNKUN DI。保留所有权利。", "© 2026 by JUNKUN DI. All rights reserved.", u8"© 2026 JUNKUN DI. Все права защищены.") \
X(license_notice, u8"依据 GNU GPL v3 许可发布。", "Licensed under GNU GPL v3.", u8"Распространяется по лицензии GNU GPL v3.") \
X(messages, u8"消息", "Messages", u8"Сообщения") \
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
X(message_read, u8"已读", "Read", u8"Прочитано") \
X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление для просмотра") \
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения. Повторите попытку.") \
X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения локально. Повторите попытку.") \
X(message_close, u8"关闭", "Close", u8"Закрыть") \
X(message_delete, u8"删除", "Delete", u8"Удалить") \
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
X(message_delete_confirmation, u8"删除这条公告?", "Delete this announcement?", u8"Удалить это объявление?") \
X(message_delete_error, u8"无法删除这条公告,请重试。", "Unable to delete this announcement. Please try again.", u8"Не удалось удалить объявление. Повторите попытку.") \
X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \
X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk") \
X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk без графического интерфейса") \
X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \
X(console_help, u8"命令: status 查看ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status, settings, password, random, help, quit", u8"Команды: status, settings, password, random, help, quit") \
X(console_help, u8"命令: status 查看 ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status — show ID/password/status; settings — configure; password — change password; random — random password; help — help; quit — exit", u8"Команды: status — ID/пароль/состояние; settings — настройки; password — сменить пароль; random — случайный пароль; help — справка; quit — выход") \
X(console_status, u8"状态", "Status", u8"Состояние") \
X(console_online, u8"在线", "Online", u8"В сети") \
X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
@@ -77,14 +84,12 @@ struct TranslationRow {
X(console_settings_next_connection, u8"已保存,将用于后续连接。", "Saved; applies to subsequent connections.", u8"Сохранено. Будет применено к следующим подключениям.") \
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \
X(console_settings_autostart, u8"已保存,在下次图形桌面登录时生效。", "Saved; applies at the next graphical desktop login.", u8"Сохранено. Будет применено при следующем входе в графический сеанс.") \
X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль остаётся на переднем плане.") \
X(console_settings_hardware_unavailable, u8"当前构建不支持硬件编解码,未保存。", "Hardware codecs are unavailable in this build; not saved.", u8"Аппаратные кодеки недоступны в этой сборке. Не сохранено.") \
X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль без графического интерфейса остаётся на переднем плане.") \
X(console_settings_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", u8"Укажите адрес и порт сервера перед включением собственного сервера.") \
X(console_settings_path_invalid, u8"请选择已存在且可写的绝对目录路径;default 使用默认目录。", "Use an existing writable absolute directory, or default for the default location.", u8"Укажите существующий доступный для записи абсолютный путь к каталогу или default.") \
X(console_settings_readonly, u8"该选项由系统或控制端管理,不可在此修改。", "This option is managed by the system or controller and is read-only here.", u8"Этот параметр управляется системой или клиентом и здесь недоступен для изменения.") \
X(console_setting_language, u8"语言", "Language", u8"Язык") \
X(console_setting_codec, u8"视频编码", "Video codec", u8"Видеокодек") \
X(console_setting_hardware, u8"硬件编解码", "Hardware codecs", u8"Аппаратные кодеки") \
X(console_setting_turn, u8"中继模式", "TURN relay mode", u8"Режим ретрансляции TURN") \
X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \
X(console_setting_host, u8"服务器地址", "Server host", u8"Адрес сервера") \
@@ -93,7 +98,7 @@ struct TranslationRow {
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \
X(save_remote_previews, u8"保存远程画面预览:", "Save remote screen previews:", u8"Сохранять превью экрана:") \
X(save_remote_previews, u8"保存远程画面预览:", "Save remote screen previews:", u8"Сохранять превью удалённого экрана:") \
X(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
@@ -102,32 +107,35 @@ struct TranslationRow {
X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \
X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \
X(local_id, u8"本机ID", "Local ID", u8"Локальный ID") \
X(local_id, u8"本机 ID", "Local ID", u8"Локальный ID") \
X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
u8"Скопировано в буфер обмена") \
X(password, u8"密码", "Password", u8"Пароль") \
X(refresh_password, u8"刷新随机密码", "Refresh random password", \
u8"Обновить случайный пароль") \
X(max_password_len, u8"最大6个字符", "Max 6 chars", u8"Макс. 6 символов") \
X(password_format_invalid, u8"密码必须为 6 位英文字母或数字。", "Use exactly 6 letters (A–Z, a–z) or digits (0–9).", u8"Пароль должен содержать ровно 6 латинских букв или цифр.") \
X(remote_desktop, u8"远程桌面", "Remote Desktop", \
u8"Удаленный рабочий стол") \
X(device_name, u8"设备名称", "Device Name", u8"Имя устройства") \
X(remote_id, u8"对端ID", "Remote ID", u8"Удаленный ID") \
X(remote_id, u8"对端 ID", "Remote ID", u8"Удаленный ID") \
X(connect, u8"连接", "Connect", u8"Подключиться") \
X(recent_connections, u8"近期连接", "Recent Connections", \
u8"Недавние подключения") \
X(disconnect, u8"断开连接", "Disconnect", u8"Отключить") \
X(stream_confirm_disconnect, u8"断开当前连接?", "Disconnect this session?", \
u8"Отключить текущий сеанс?") \
X(stream_disconnect_description, u8"当前远程控制会话将结束。", \
"The current remote session will end.", \
u8"Текущий сеанс удалённого управления будет завершён.") \
X(select_display, u8"选择显示器", "Select Display", u8"Выбрать дисплей") \
X(display_screen, u8"显示屏", "Display", u8"Экран") \
X(expand_control_bar, u8"展开控制栏", "Expand Control Bar", \
u8"Развернуть панель управления") \
X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \
u8"Свернуть панель управления") \
X(fullscreen, u8"全屏", " Fullscreen", u8"Полный экран") \
X(show_net_traffic_stats, u8"显示网络状态", "Show Net Traffic Stats", \
u8"Показать статистику трафика") \
X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide Net Traffic Stats", \
u8"Скрыть статистику трафика") \
X(fullscreen, u8"全屏", "Fullscreen", u8"На весь экран") \
X(show_net_traffic_stats, u8"显示网络状态", "Show network status", u8"Показать состояние сети") \
X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide network status", u8"Скрыть состояние сети") \
X(video, u8"视频", "Video", u8"Видео") \
X(audio, u8"音频", "Audio", u8"Аудио") \
X(data, u8"数据", "Data", u8"Данные") \
@@ -139,19 +147,15 @@ struct TranslationRow {
u8"Выйти из полноэкранного режима") \
X(control_mouse, u8"控制鼠标", "Control Mouse", u8"Управление мышью") \
X(release_mouse, u8"释放鼠标", "Release Mouse", u8"Освободить мышь") \
X(audio_capture, u8"播放声音", "Audio Capture", u8"Воспроизведение звука") \
X(mute, u8" 静音", " Mute", u8"Без звука") \
X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Сочетания клавиш") \
X(send_sas, u8"发送SAS", "Send SAS", u8"Отправить SAS") \
X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать") \
X(remote_password_box_visible, u8"远端密码框已出现", \
"Remote password box visible", u8"Окно ввода пароля видно") \
X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送SAS", \
"Remote lock screen visible, send SAS", \
u8"Видна блокировка, отправьте SAS") \
X(remote_secure_desktop_active, u8"远端已进入安全桌面", \
"Remote secure desktop active", u8"Активен защищенный рабочий стол") \
X(remote_service_unavailable, u8"远端Windows服务不可用", \
X(audio_capture, u8"播放声音", "Play audio", u8"Воспроизведение звука") \
X(mute, u8"静音", "Mute", u8"Без звука") \
X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Отправить сочетание клавиш") \
X(send_sas, u8"发送 SAS", "Send SAS", u8"Отправить SAS") \
X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать удалённое устройство") \
X(remote_password_box_visible, u8"远端密码框已出现", "Remote password box visible", u8"На удалённом устройстве появилось поле пароля") \
X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送 SAS", "Remote lock screen visible, send SAS", u8"Удалённый экран заблокирован, можно отправить SAS") \
X(remote_secure_desktop_active, u8"远端已进入安全桌面", "Remote secure desktop active", u8"На удалённом устройстве активен защищённый рабочий стол") \
X(remote_service_unavailable, u8"远端 Windows 服务不可用", \
"Remote Windows service unavailable", \
u8"Служба Windows на удаленной стороне недоступна") \
X(windows_service_setup_title, u8"安装 CrossDesk Service", \
@@ -170,8 +174,7 @@ struct TranslationRow {
u8"нормально; будет недоступно только управление этим компьютером на " \
u8"экране блокировки.") \
X(install_windows_service, u8"安装", "Install", u8"Установить") \
X(windows_service_settings_label, u8"锁屏控制服务:", \
"Lock Screen Service:", u8"Служба блокировки экрана:") \
X(windows_service_settings_label, u8"锁屏控制服务:", "Lock-screen control service:", u8"Служба управления при блокировке:") \
X(windows_service_installed, u8"已安装", "Installed", u8"Установлена") \
X(do_not_remind_again, u8"不再提醒", "Do not remind again", \
u8"Больше не напоминать") \
@@ -207,13 +210,8 @@ struct TranslationRow {
u8"Способ захвата:") \
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
X(remote_update_title, u8"建议升级被控端", "Remote update recommended", \
u8"Рекомендуется обновление") \
X(remote_update, \
u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", \
"Update the remote device to the latest version for improved features and performance.", \
u8"В новой версии улучшены функции и производительность. " \
u8"Рекомендуем обновить CrossDesk на удалённом устройстве.") \
X(remote_update_title, u8"建议升级被控端", "Remote update recommended", u8"Рекомендуем обновить удалённое устройство") \
X(remote_update, u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", "Update the remote device to the latest version for improved features and performance.", u8"В новой версии улучшены функции и производительность. Рекомендуем обновить CrossDesk на удалённом устройстве до последней версии.") \
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \
@@ -230,48 +228,34 @@ struct TranslationRow {
X(video_quality_high, u8"高", "High", u8"Высокое") \
X(video_quality_medium, u8"中", "Medium", u8"Среднее") \
X(video_quality_low, u8"低", "Low", u8"Низкое") \
X(video_encode_format, u8"视频编码格式:", \
"Video Encode Format:", u8"Формат кодека видео:") \
X(video_encode_format, u8"视频编码格式:", "Video codec:", u8"Видеокодек:") \
X(av1, u8"AV1", "AV1", "AV1") \
X(h264, u8"H.264", "H.264", "H.264") \
X(enable_hardware_video_codec, u8"启用硬件编解码器:", \
"Enable Hardware Video Codec:", u8"Использовать аппаратный кодек:") \
X(enable_turn, u8"启用中继服务:", \
"Enable TURN Service:", u8"Включить TURN-сервис:") \
X(force_relay, u8"强制中继连接:", "Force Relay:", u8"Только через реле:") \
X(self_hosted_server_config, u8"自托管配置", "Self-Hosted Config", \
u8"Конфигурация self-hosted") \
X(self_hosted_server_settings, u8"自托管设置", "Self-Hosted Settings", \
u8"Настройки self-hosted") \
X(force_relay, u8"强制中继连接:", "Force relay connection:", u8"Только через ретранслятор:") \
X(self_hosted_server_config, u8"自托管配置", "Self-hosted server", u8"Собственный сервер") \
X(self_hosted_server_settings, u8"自托管设置", "Self-hosted server settings", u8"Настройки своего сервера") \
X(self_hosted_server_address, u8"服务器地址:", \
"Server Address:", u8"Адрес сервера:") \
X(self_hosted_server_port, u8"信令服务端口:", \
"Signal Service Port:", u8"Порт сигнального сервиса:") \
X(self_hosted_server_port, u8"信令服务端口:", "Signaling port:", u8"Порт сигнализации:") \
X(self_hosted_address_invalid, u8"请输入有效的主机名、IP 或 HTTPS/WSS 信令地址,可包含路径。", "Enter a valid host, IP, or HTTPS/WSS signaling URL. Paths are supported.", u8"Введите имя хоста, IP или HTTPS/WSS-адрес сигнального сервиса. Путь допустим.") \
X(self_hosted_port_invalid, u8"请输入 1–65535 范围内的信令端口。", "Enter a signaling port between 1 and 65535.", u8"Введите порт сигнального сервиса от 1 до 65535.") \
X(self_hosted_save_failed, u8"配置保存失败,请检查配置目录是否可写后重试。", "Could not save settings. Check that the configuration directory is writable and retry.", u8"Не удалось сохранить настройки. Проверьте доступ на запись в каталог конфигурации.") \
X(self_hosted_save_failed, u8"配置保存失败,请检查配置目录是否可写后重试。", "Could not save settings. Check that the configuration directory is writable and retry.", u8"Не удалось сохранить настройки. Проверьте доступ на запись в каталог конфигурации и повторите попытку.") \
X(ok, u8"确认", "OK", u8"ОК") \
X(cancel, u8"取消", "Cancel", u8"Отмена") \
X(new_password, u8"请输入六位密码:", \
"Please input a six-char password:", u8"Введите шестизначный пароль:") \
X(new_password, u8"请输入六位密码:", "Enter a six-character password:", u8"Введите пароль из 6 символов:") \
X(input_password, u8"请输入密码:", \
"Please input password:", u8"Введите пароль:") \
X(validate_password, u8"验证密码中...", "Validate password ...", \
u8"Проверка пароля...") \
X(validate_password, u8"验证密码中...", "Validating password...", u8"Проверка пароля...") \
X(reinput_password, u8"请重新输入密码", "Please input password again", \
u8"Повторно введите пароль") \
X(remember_password, u8"记住密码", "Remember password", \
u8"Запомнить пароль") \
X(signal_connected, u8"已连接服务器", "Connected", u8"Подключено к серверу") \
X(signal_disconnected, u8"未连接服务器", "Disconnected", \
u8"Нет подключения к серверу") \
X(signal_connected, u8"已连接服务器", "Connected to server", u8"Подключено к серверу") \
X(signal_disconnected, u8"未连接服务器", "Disconnected from server", 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.", \
u8"Ошибка проверки сертификата. Переустановите корневой сертификат.") \
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", "Certificate verification failed. Reinstall the self-hosted root certificate.", u8"Ошибка проверки сертификата. Переустановите корневой сертификат собственного сервера.") \
X(p2p_connected, u8"对等连接已建立", "P2P Connected", u8"P2P подключено") \
X(p2p_disconnected, u8"对等连接已断开", "P2P Disconnected", \
u8"P2P отключено") \
@@ -282,12 +266,12 @@ 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_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(no_such_id, u8"无此 ID", "No such ID", u8"ID не найден") \
X(about, u8"关于", "About", u8"О программе") \
X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \
X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
@@ -317,20 +301,25 @@ struct TranslationRow {
X(access_website, u8"访问官网: ", \
"Access Website: ", u8"Официальный сайт: ") \
X(update, u8"更新", "Update", u8"Обновить") \
X(connection_alias, u8"修改名称", "Edit Alias", \
u8"Изменить имя подключения") \
X(install_update, u8"安装更新", "Install update", u8"Установить обновление") \
X(manual_download, u8"手动下载", "Download manually", u8"Скачать вручную") \
X(install_update_confirm, u8"下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?", \
"CrossDesk will close after downloading the update. Connections and file transfers will be interrupted. Installation requires system authorization, then CrossDesk will restart. Continue?", \
u8"После загрузки CrossDesk закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \
X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \
X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \
X(connection_alias, u8"修改名称", "Rename", u8"Переименовать") \
X(delete_connection, u8"删除连接", "Delete Connection", \
u8"Удалить подключение") \
X(connect_to_this_connection, u8"发起连接", "Connect to this connection", \
u8"Подключиться") \
X(connect_to_this_connection, u8"发起连接", "Connect", u8"Подключиться") \
X(input_connection_alias, u8"请输入连接名称:", \
"Please input connection name:", u8"Введите имя подключения:") \
X(confirm_delete_connection, u8"确认删除此连接", \
"Confirm to delete this connection", u8"Удалить это подключение?") \
X(confirm_delete_connection, u8"确认删除此连接", "Delete this connection?", u8"Удалить это подключение?") \
X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \
X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on connect:", \
u8"Приватность при подключении:") \
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on incoming connection:", u8"Приватный экран при подключении:") \
X(lock_on_disconnect, u8"连接断开后锁屏:", "Lock screen on disconnect:", \
u8"Блокировка при отключении:") \
X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \
u8"Вступит в силу после перезапуска") \
X(select_file, u8"选择文件发送", "Select File to Send", \
@@ -363,17 +352,19 @@ struct TranslationRow {
"File Transfer Save Path:", u8"Путь сохранения файлов:") \
X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \
X(resolution, u8"分辨率", "Res", u8"Разрешение") \
X(video_latency, u8"视频延时", "Video delay", u8"Задержка") \
X(connection_latency, u8"连接延时", "Connection RTT", u8"Задержка соединения") \
X(connection_mode, u8"连接模式", "Mode", u8"Режим") \
X(video_latency, u8"视频延时", "Video latency", u8"Задержка видео") \
X(connection_latency, u8"连接延时", "Connection latency", u8"Задержка соединения") \
X(connection_mode, u8"连接模式", "Connection mode", u8"Режим подключения") \
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \
X(connection_mode_relay, u8"中继", "Relay", u8"Ретрансляция") \
X(transport_encryption, u8"加密传输", "Encryption", u8"Шифрование") \
X(remote_video_encoder, u8"被控端编码器", "Remote encoder", u8"Кодер удалённой стороны") \
X(local_video_decoder, u8"控制端解码器", "Local decoder", u8"Локальный декодер") \
X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
X(online, u8"在线", "Online", u8"Онлайн") \
X(offline, u8"离线", "Offline", u8"Офлайн") \
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство офлайн") \
X(online, u8"在线", "Online", u8"В сети") \
X(offline, u8"离线", "Offline", u8"Не в сети") \
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство не в сети") \
X(request_permissions, u8"权限请求", "Request Permissions", \
u8"Запрос разрешений") \
X(screen_recording_permission, u8"屏幕录制权限", \
@@ -0,0 +1,115 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-21
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include "about_catalog.h"
#include <nlohmann/json.hpp>
#include <iterator>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include "about_resources.h"
namespace crossdesk::about {
namespace {
slint::SharedString Text(const nlohmann::json& object, const char* key) {
return slint::SharedString(object.value(key, std::string{}));
}
std::string_view TrimLayoutWhitespace(std::string_view line) {
const auto first = line.find_first_not_of(" \t\r");
if (first == std::string_view::npos) return {};
return line.substr(first, line.find_last_not_of(" \t\r") - first + 1);
}
slint::SharedString DocumentText(const nlohmann::json& document) {
const auto title = document.value("title", std::string{});
const auto text = document.value("text", std::string{});
if (title != "GNU GPL v3" && title != "GNU LGPL v3") {
return slint::SharedString(text);
}
// GNU's plain-text copies use fixed-width wrapping and space-aligned headings.
// Reflow prose for the reader, retaining paragraph boundaries, notice examples,
// and every non-whitespace character. The embedded source stays untouched.
std::string formatted;
std::vector<std::string_view> paragraph;
auto append_paragraph = [&] {
if (paragraph.empty()) return;
if (!formatted.empty()) formatted += "\n\n";
const bool preserve_lines = paragraph.front().starts_with("GNU ") ||
paragraph.front().starts_with("<");
for (size_t index = 0; index < paragraph.size(); ++index) {
if (index > 0) {
const bool after_copyright =
paragraph[index - 1].starts_with("Copyright (C)");
formatted += preserve_lines || after_copyright ? '\n' : ' ';
}
formatted += paragraph[index];
}
paragraph.clear();
};
std::string_view remaining(text);
while (!remaining.empty()) {
const auto end = remaining.find('\n');
const auto line = TrimLayoutWhitespace(remaining.substr(0, end));
if (line.empty()) {
append_paragraph();
} else {
paragraph.push_back(line);
}
if (end == std::string_view::npos) break;
remaining.remove_prefix(end + 1);
}
append_paragraph();
return slint::SharedString(formatted);
}
} // namespace
void Populate(const slint::ComponentHandle<ui::MainWindow>& window) {
// Both the main window and DeskHub share the immutable, embedded catalog.
static const auto catalog = nlohmann::json::parse(
std::begin(kAboutResources), std::end(kAboutResources));
static const auto model = [] {
std::vector<ui::AboutComponent> components;
for (const auto& item : catalog.at("components")) {
ui::AboutComponent component;
component.id = Text(item, "id");
component.name = Text(item, "name");
component.version = Text(item, "version");
component.license = Text(item, "license");
component.source_url = Text(item, "sourceURL");
std::vector<ui::AboutDocument> documents;
for (const auto& document : item.at("documents")) {
ui::AboutDocument entry;
entry.title = Text(document, "title");
entry.text = DocumentText(document);
documents.push_back(std::move(entry));
}
component.documents =
std::make_shared<slint::VectorModel<ui::AboutDocument>>(documents);
components.push_back(std::move(component));
}
return std::make_shared<slint::VectorModel<ui::AboutComponent>>(components);
}();
window->set_about_components(model);
for (size_t index = 0; index < model->row_count(); ++index) {
if (model->row_data(index)->id == "openfec") {
window->set_about_openfec_index(static_cast<int>(index));
break;
}
}
window->set_about_revision(Text(catalog, "revision"));
window->set_about_source_url(Text(catalog, "sourceURL"));
window->set_about_build_url(Text(catalog, "buildURL"));
}
} // namespace crossdesk::about
@@ -0,0 +1,18 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-21
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _ABOUT_CATALOG_H_
#define _ABOUT_CATALOG_H_
#include "crossdesk_ui.h"
namespace crossdesk::about {
void Populate(const slint::ComponentHandle<ui::MainWindow>& window);
} // namespace crossdesk::about
#endif
@@ -30,6 +30,7 @@ uint32_t ClipboardController::event_type() const { return event_type_; }
void ClipboardController::Initialize() {
last_text_.clear();
deferred_local_session_.reset();
char *clipboard_text = SDL_GetClipboardText();
if (clipboard_text) {
@@ -42,6 +43,7 @@ void ClipboardController::Initialize() {
void ClipboardController::Shutdown() {
events_enabled_.store(false, std::memory_order_release);
deferred_local_session_.reset();
std::lock_guard<std::mutex> lock(pending_mutex_);
pending_remote_text_.reset();
}
@@ -126,6 +128,7 @@ void ClipboardController::ApplyPendingRemoteText() {
// SDL will normally emit SDL_EVENT_CLIPBOARD_UPDATE for this write. Update
// the baseline first so that event cannot echo the text back to the sender.
last_text_ = std::move(*pending_text);
deferred_local_session_.reset();
}
void ClipboardController::HandleLocalUpdate() {
@@ -134,6 +137,7 @@ void ClipboardController::HandleLocalUpdate() {
}
if (!SDL_HasClipboardText()) {
last_text_.clear();
deferred_local_session_.reset();
return;
}
@@ -146,12 +150,14 @@ void ClipboardController::HandleLocalUpdate() {
std::string text(clipboard_text);
SDL_free(clipboard_text);
if (text == last_text_) {
SendDeferredLocalText();
return;
}
// Record the value before sending. Duplicate SDL notifications and a
// remote write of the same value must not create a clipboard feedback loop.
last_text_ = text;
deferred_local_session_.reset();
if (text.empty()) {
return;
}
@@ -163,11 +169,50 @@ void ClipboardController::HandleLocalUpdate() {
SendToPeers(text);
}
void ClipboardController::SendDeferredLocalText() {
const auto session = deferred_local_session_.lock();
if (!session) {
return;
}
std::shared_lock lock(owner_.remote_sessions_mutex_);
const auto current = owner_.remote_sessions_.find(session->remote_id_);
if (current == owner_.remote_sessions_.end() || current->second != session ||
session->closing_ || !session->peer_ || !session->connection_established_ ||
!session->tab_opened_ || !session->tab_selected_ ||
!session->control_mouse_) {
deferred_local_session_.reset();
return;
}
if (!session->clipboard_allowed_.load()) {
return;
}
const int ret = SendReliableDataFrame(
session->peer_, last_text_.data(), last_text_.size(),
session->clipboard_label_.c_str());
if (ret != 0) {
LOG_WARN("Failed to send deferred clipboard data to peer [{}], ret={}",
session->remote_id_, ret);
return;
}
deferred_local_session_.reset();
}
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->clipboard_allowed_.load()) {
if (!props || props->closing_ || !props->peer_ ||
!props->connection_established_) {
continue;
}
if (!props->clipboard_allowed_.load()) {
// Copying in another local app temporarily releases keyboard control.
// Keep this copy for the same selected stream, without sharing it with
// background tabs or DeskHub previews.
if (props->tab_opened_ && props->tab_selected_ && props->control_mouse_) {
deferred_local_session_ = props;
}
continue;
}
@@ -181,15 +226,11 @@ int ClipboardController::SendToPeers(const std::string &text) {
}
}
if (owner_.peer_) {
const int ret =
SendReliableDataFrame(owner_.peer_, text.data(), text.size(),
owner_.clipboard_label_.c_str());
if (ret != 0) {
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
owner_.remote_id_display_, ret);
return ret;
}
lock.unlock();
for (const auto& id : owner_.ConnectedControllers()) {
const int ret = owner_.SendControllerData(id, text.data(), text.size(),
owner_.clipboard_label_.c_str());
if (ret != 0) return ret;
}
return 0;
@@ -41,6 +41,7 @@ public:
private:
int SendToPeers(const std::string &text);
void SendDeferredLocalText();
GuiRuntime &owner_;
uint32_t event_type_ = 0;
@@ -51,8 +52,11 @@ private:
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
uint64_t pending_generation_ = 0;
std::string last_text_;
// UI thread only. Retain the destination of a local copy while its stream
// window is unfocused; last_text_ holds the latest value to send on return.
std::weak_ptr<gui_detail::RemoteSession> deferred_local_session_;
};
} // namespace crossdesk
#endif // _CLIPBOARD_CONTROLLER_H_
#endif // _CLIPBOARD_CONTROLLER_H_
@@ -0,0 +1,245 @@
#include "features/devices/screen_sharing_power.h"
#include "rd_log.h"
#ifdef _WIN32
#include <windows.h>
#elif defined(__APPLE__)
#include <IOKit/pwr_mgt/IOPMLib.h>
#elif defined(__linux__) && !defined(__OHOS__)
#include <dbus/dbus.h>
#include <unistd.h>
#else
#include <SDL3/SDL.h>
#endif
namespace crossdesk {
#if defined(__linux__) && !defined(__OHOS__)
namespace {
constexpr const char* kApplication = "CrossDesk";
constexpr const char* kReason = "Sharing the desktop with a remote controller";
constexpr int kDbusTimeoutMs = 1000;
struct ConnectionCloser {
void operator()(DBusConnection* connection) const {
dbus_connection_close(connection);
dbus_connection_unref(connection);
}
};
using BusConnection = std::unique_ptr<DBusConnection, ConnectionCloser>;
using BusMessage = std::unique_ptr<DBusMessage, decltype(&dbus_message_unref)>;
BusConnection OpenBus(DBusBusType type) {
DBusError error;
dbus_error_init(&error);
BusConnection connection(dbus_bus_get_private(type, &error));
if (connection) {
dbus_connection_set_exit_on_disconnect(connection.get(), false);
} else {
LOG_WARN("Cannot connect to power management bus: {}",
error.message ? error.message : "unknown error");
}
dbus_error_free(&error);
return connection;
}
BusMessage Call(DBusConnection* connection, DBusMessage* message) {
return BusMessage(dbus_connection_send_with_reply_and_block(
connection, message, kDbusTimeoutMs, nullptr),
dbus_message_unref);
}
} // namespace
#endif
struct ScreenSharingPower::Impl {
#ifdef _WIN32
HANDLE request = INVALID_HANDLE_VALUE;
bool display = false;
bool system = false;
void Start() {
wchar_t reason_text[] = L"CrossDesk is sharing the desktop";
REASON_CONTEXT reason{};
reason.Version = POWER_REQUEST_CONTEXT_VERSION;
reason.Flags = POWER_REQUEST_CONTEXT_SIMPLE_STRING;
reason.Reason.SimpleReasonString = reason_text;
request = PowerCreateRequest(&reason);
if (request == INVALID_HANDLE_VALUE) {
LOG_WARN("Cannot create screen sharing power request: {}", GetLastError());
return;
}
// Handle-based requests also work in the unattended host, which has no
// SDL video subsystem, and do not depend on the calling thread's lifetime.
system = PowerSetRequest(request, PowerRequestSystemRequired) != 0;
if (!system)
LOG_WARN("Cannot prevent system idle sleep: {}", GetLastError());
display = PowerSetRequest(request, PowerRequestDisplayRequired) != 0;
if (!display)
LOG_WARN("Cannot prevent display idle sleep: {}", GetLastError());
}
void Stop() {
if (request == INVALID_HANDLE_VALUE) return;
if (display) PowerClearRequest(request, PowerRequestDisplayRequired);
if (system) PowerClearRequest(request, PowerRequestSystemRequired);
CloseHandle(request);
request = INVALID_HANDLE_VALUE;
display = system = false;
}
#elif defined(__APPLE__)
IOPMAssertionID display = kIOPMNullAssertionID;
IOPMAssertionID system = kIOPMNullAssertionID;
void Start() {
const auto reason = CFSTR("CrossDesk is sharing the desktop");
auto result = IOPMAssertionCreateWithName(
kIOPMAssertionTypePreventUserIdleSystemSleep, kIOPMAssertionLevelOn,
reason, &system);
if (result != kIOReturnSuccess)
LOG_WARN("Cannot prevent system idle sleep: {}", result);
result = IOPMAssertionCreateWithName(
kIOPMAssertionTypePreventUserIdleDisplaySleep, kIOPMAssertionLevelOn,
reason, &display);
if (result != kIOReturnSuccess)
LOG_WARN("Cannot prevent display idle sleep: {}", result);
}
void Stop() {
if (display != kIOPMNullAssertionID) IOPMAssertionRelease(display);
if (system != kIOPMNullAssertionID) IOPMAssertionRelease(system);
display = system = kIOPMNullAssertionID;
}
#elif defined(__linux__) && !defined(__OHOS__)
BusConnection session;
dbus_uint32_t cookie = 0;
bool desktop_inhibited = false;
bool gnome = false;
int sleep_fd = -1;
bool InhibitDesktop(bool use_gnome) {
const char* service = use_gnome ? "org.gnome.SessionManager"
: "org.freedesktop.ScreenSaver";
const char* path = use_gnome ? "/org/gnome/SessionManager"
: "/org/freedesktop/ScreenSaver";
BusMessage message(dbus_message_new_method_call(service, path, service,
"Inhibit"),
dbus_message_unref);
if (!message) return false;
dbus_bool_t appended;
if (use_gnome) {
const dbus_uint32_t window = 0;
const dbus_uint32_t flags = 4 | 8; // Suspend and session idle.
appended = dbus_message_append_args(
message.get(), DBUS_TYPE_STRING, &kApplication, DBUS_TYPE_UINT32,
&window, DBUS_TYPE_STRING, &kReason, DBUS_TYPE_UINT32, &flags,
DBUS_TYPE_INVALID);
} else {
appended = dbus_message_append_args(
message.get(), DBUS_TYPE_STRING, &kApplication, DBUS_TYPE_STRING,
&kReason, DBUS_TYPE_INVALID);
}
if (!appended) return false;
auto reply = Call(session.get(), message.get());
if (!reply || !dbus_message_get_args(reply.get(), nullptr, DBUS_TYPE_UINT32,
&cookie, DBUS_TYPE_INVALID))
return false;
gnome = use_gnome;
desktop_inhibited = true;
return true;
}
void Start() {
if (!dbus_threads_init_default()) {
LOG_WARN("Cannot initialize D-Bus for screen sharing power management");
return;
}
// These session APIs work without an SDL window, including native Wayland
// and a Slint window hidden in the tray. GNOME's API inhibits both kinds of
// idle sleep; other desktops use ScreenSaver plus logind below.
session = OpenBus(DBUS_BUS_SESSION);
if (session && InhibitDesktop(true)) return;
if (!session || !InhibitDesktop(false))
LOG_WARN("Cannot inhibit desktop idle while sharing the screen");
auto system_bus = OpenBus(DBUS_BUS_SYSTEM);
if (!system_bus) return;
BusMessage message(dbus_message_new_method_call(
"org.freedesktop.login1", "/org/freedesktop/login1",
"org.freedesktop.login1.Manager", "Inhibit"),
dbus_message_unref);
const char* what = "sleep:idle";
const char* mode = "block";
if (!message || !dbus_message_append_args(
message.get(), DBUS_TYPE_STRING, &what,
DBUS_TYPE_STRING, &kApplication, DBUS_TYPE_STRING,
&kReason, DBUS_TYPE_STRING, &mode, DBUS_TYPE_INVALID)) {
LOG_WARN("Cannot create system sleep inhibition request");
return;
}
auto reply = Call(system_bus.get(), message.get());
// get_args duplicates the returned fd with CLOEXEC. Holding this fd keeps
// the inhibitor alive independently of the reply or D-Bus connection.
if (!reply || !dbus_message_get_args(reply.get(), nullptr, DBUS_TYPE_UNIX_FD,
&sleep_fd, DBUS_TYPE_INVALID))
LOG_WARN("Cannot inhibit system sleep while sharing the screen");
}
void Stop() {
if (sleep_fd >= 0) {
close(sleep_fd);
sleep_fd = -1;
}
if (session && desktop_inhibited &&
dbus_connection_get_is_connected(session.get())) {
const char* service = gnome ? "org.gnome.SessionManager"
: "org.freedesktop.ScreenSaver";
const char* path = gnome ? "/org/gnome/SessionManager"
: "/org/freedesktop/ScreenSaver";
BusMessage message(dbus_message_new_method_call(
service, path, service,
gnome ? "Uninhibit" : "UnInhibit"),
dbus_message_unref);
if (message && dbus_message_append_args(
message.get(), DBUS_TYPE_UINT32, &cookie,
DBUS_TYPE_INVALID)) {
dbus_message_set_no_reply(message.get(), true);
dbus_connection_send(session.get(), message.get(), nullptr);
dbus_connection_flush(session.get());
}
}
// A private connection also revokes the desktop inhibitor if UnInhibit
// could not be sent. Nothing is retained by unrelated D-Bus clients.
session.reset();
desktop_inhibited = false;
gnome = false;
cookie = 0;
}
#else
void Start() {
if (!SDL_DisableScreenSaver())
LOG_WARN("Cannot prevent display idle sleep: {}", SDL_GetError());
}
void Stop() { SDL_EnableScreenSaver(); }
#endif
};
ScreenSharingPower::ScreenSharingPower() : impl_(std::make_unique<Impl>()) {}
ScreenSharingPower::~ScreenSharingPower() { SetSharing(false); }
void ScreenSharingPower::SetSharing(bool sharing) {
if (sharing_ == sharing) return;
sharing_ = sharing;
if (sharing) {
impl_->Start();
LOG_INFO("Requested display and system idle-sleep inhibition for screen sharing");
} else {
impl_->Stop();
LOG_INFO("Released screen sharing power inhibitors");
}
}
} // namespace crossdesk
@@ -0,0 +1,33 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SCREEN_SHARING_POWER_H_
#define _SCREEN_SHARING_POWER_H_
#include <memory>
namespace crossdesk {
// Owns temporary display/system idle-sleep inhibitors for the shared capture
// source. Call on the UI thread; repeated updates do not acquire extra requests.
class ScreenSharingPower {
public:
ScreenSharingPower();
~ScreenSharingPower();
ScreenSharingPower(const ScreenSharingPower&) = delete;
ScreenSharingPower& operator=(const ScreenSharingPower&) = delete;
void SetSharing(bool sharing);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
bool sharing_ = false;
};
} // namespace crossdesk
#endif
@@ -21,7 +21,6 @@
#ifdef _WIN32
#include <nlohmann/json.hpp>
#include "platform/windows/screen_capturer/screen_capturer_win.h"
#include "platform/windows/input/mouse/mouse_controller.h"
#include "service_host.h"
#endif
@@ -44,18 +43,17 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
SpeakerCapturerFactory().Create());
},
[this](unsigned char *data, size_t size, const char *) {
if (!owner_.peer_) return;
MiniRtcAudioFrame frame{};
frame.data = reinterpret_cast<const char *>(data);
frame.size = size;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
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());
}
for (const auto& id : owner_.ConnectedControllers())
owner_.WithController(
id, [&](PeerPtr* peer, const std::string& wire) {
frame.captured_timestamp = GetSystemTimeMicros(peer);
return SendAudioFrameToPeer(peer, &frame,
owner_.audio_label_.c_str(),
wire.data(), wire.size());
});
}) {}
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
@@ -133,6 +131,10 @@ void SessionDeviceManager::Initialize() {
DeviceControllerFactory::Device::Keyboard));
}
int SessionDeviceManager::EnsureCaptureReady() {
return screen_capturer_ ? 0 : InitializeScreenCapturer();
}
int SessionDeviceManager::InitializeScreenCapturer() {
#ifdef __APPLE__
if (!owner_.EnsureMacScreenRecordingPermission()) {
@@ -194,16 +196,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
.count()
: 0;
std::vector<std::string> connected_remote_ids;
{
std::shared_lock lock(owner_.connection_status_mutex_);
connected_remote_ids.reserve(owner_.connection_status_.size());
for (const auto &[remote_id, status] : owner_.connection_status_) {
if (status == ConnectionStatus::Connected) {
connected_remote_ids.push_back(remote_id);
}
}
}
const auto connected_remote_ids = owner_.ConnectedControllers();
// Capture can still deliver frames while ICE is gathering or after
// the final controller disconnects. Do not broadcast those frames to
@@ -233,28 +226,27 @@ int SessionDeviceManager::InitializeScreenCapturer() {
has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs;
const bool stream_changed = !last_video_frame_stream_id_.empty() &&
last_video_frame_stream_id_ != stream_id;
if (resumed_after_gap || stream_changed) {
if (RequestVideoKeyFrame(owner_.peer_, stream_id.c_str()) == 0) {
LOG_DEBUG("Request video key frame before sending captured frame, "
"stream='{}', gap_ms={}, stream_changed={}",
stream_id, duration_ms, stream_changed);
}
}
MiniRtcVideoFrame frame{};
frame.data = reinterpret_cast<const char *>(data);
frame.size = size;
frame.width = width;
frame.height = height;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
frame.native_frame = native_frame;
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)
if (owner_.WithController(
remote_id, [&](PeerPtr* peer, const std::string& wire) {
if (resumed_after_gap || stream_changed)
RequestVideoKeyFrame(peer, stream_id.c_str());
frame.captured_timestamp = GetSystemTimeMicros(peer);
return SendVideoFrameToPeer(peer, &frame, stream_id.c_str(),
wire.data(), wire.size());
}) != 0)
++send_errors;
}
const auto send_us =
@@ -299,6 +291,21 @@ bool SessionDeviceManager::ShouldCaptureCursor() const {
});
}
bool SessionDeviceManager::HasConnectedController() const {
std::shared_lock lock(owner_.connection_status_mutex_);
return std::any_of(owner_.connection_status_.begin(),
owner_.connection_status_.end(), [](const auto& entry) {
return entry.second == ConnectionStatus::Connected;
});
}
void SessionDeviceManager::UpdateScreenSharingPower() {
// Wayland keeps capture warm after the last disconnect. Only live viewers
// justify keeping the host awake; an outgoing viewing session does not.
screen_sharing_power_.SetSharing(owner_.screen_capturer_is_started_ &&
HasConnectedController());
}
int SessionDeviceManager::StartScreenCapturer() {
#ifdef __APPLE__
if (!owner_.EnsureMacScreenRecordingPermission()) {
@@ -318,8 +325,10 @@ int SessionDeviceManager::StartScreenCapturer() {
cursor_capture_dirty_.store(false, std::memory_order_relaxed);
const bool show_cursor = ShouldCaptureCursor();
LOG_INFO("Start screen capturer, show cursor: {}", show_cursor);
screen_sharing_power_.SetSharing(HasConnectedController());
const int ret = screen_capturer_->Start(show_cursor);
if (ret != 0) {
screen_sharing_power_.SetSharing(false);
LOG_ERROR("Start screen capturer failed: {}", ret);
}
return ret;
@@ -330,29 +339,49 @@ int SessionDeviceManager::StopScreenCapturer() {
LOG_INFO("Stop screen capturer");
screen_capturer_->Stop();
}
owner_.screen_capturer_is_started_ = false;
screen_sharing_power_.SetSharing(false);
return 0;
}
bool SessionDeviceManager::ReleaseRemoteMouseButtons() {
return remote_mouse_buttons_.ReleaseAll(
[this](unsigned mask) { return ReleaseMouseButtonMask(mask); });
}
bool SessionDeviceManager::ReleaseRemoteMouseButtons(const std::string& remote_id,
unsigned mask) {
return remote_mouse_buttons_.ReleaseController(
remote_id, [this](unsigned buttons) { return ReleaseMouseButtonMask(buttons); }, mask);
}
unsigned SessionDeviceManager::ReleaseMouseButtonMask(unsigned mask) {
unsigned released = mask;
{
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) released &= mouse_controller_->ReleasePressedButtons(mask);
}
#ifdef _WIN32
void SessionDeviceManager::ReleaseRemoteMouseButtons() {
if (auto mouse = dynamic_cast<PlatformMouseController*>(mouse_controller_))
mouse->ReleasePressedButtons();
std::lock_guard lock(service_mouse_mutex_);
for (int button = 0; button < 3; ++button) {
const unsigned bit = 1u << button;
if (!(service_mouse_buttons_ & bit)) continue;
if (!(service_mouse_buttons_ & mask & bit)) continue;
const auto response = SendCrossDeskSecureDesktopMouseInput(
service_mouse_x_, service_mouse_y_, 0, (button + 1) * 2, 1000);
const auto json = nlohmann::json::parse(response, nullptr, false);
if (json.is_object() && json.value("ok", false)) {
service_mouse_buttons_ &= ~bit;
} else {
released &= ~bit;
LOG_WARN("Service mouse button release failed: button={}, response={}",
button, response);
}
}
#endif
return released;
}
#ifdef _WIN32
std::string SessionDeviceManager::SendServiceMouseCommand(int x, int y,
int wheel, int flag) {
std::lock_guard lock(service_mouse_mutex_);
@@ -398,7 +427,10 @@ bool SessionDeviceManager::SyncDisplayInfo() {
});
if (changed) display_info_list_ = displays;
}
if (changed && mouse_controller_) mouse_controller_->UpdateDisplayInfoList(displays);
if (changed) {
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) mouse_controller_->UpdateDisplayInfoList(displays);
}
return true;
}
@@ -434,6 +466,7 @@ int SessionDeviceManager::StartMouseController() {
#endif
SyncDisplayInfo();
std::lock_guard lock(mouse_controller_mutex_);
mouse_controller_ =
static_cast<MouseController *>(device_controller_factory_->Create(
DeviceControllerFactory::Device::Mouse));
@@ -452,10 +485,16 @@ int SessionDeviceManager::StartMouseController() {
return init_ret;
}
int SessionDeviceManager::StopMouseController() {
#ifdef _WIN32
int SessionDeviceManager::StopMouseController(bool force) {
// Retain the native button state across transient cleanup failures. The UI
// can keep pumping while the input worker retries, including with no peers.
if (!force && owner_.remote_input_.HasPendingReleases()) return 1;
// A disconnect has revoked queued input. Finish its running injection and
// release callback before destroying the native controller.
owner_.remote_input_.WaitIdle();
if (!force && owner_.remote_input_.HasPendingReleases()) return 1;
ReleaseRemoteMouseButtons();
#endif
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) {
mouse_controller_->Destroy();
delete mouse_controller_;
@@ -620,6 +659,8 @@ void SessionDeviceManager::PushAudio(const std::string& remote_id,
}
void SessionDeviceManager::UpdateInteractions() {
// Release even when pending remote-input cleanup makes this tick return early.
UpdateScreenSharingPower();
#ifdef _WIN32
if (owner_.screen_capturer_is_started_ &&
cursor_capture_dirty_.load(std::memory_order_relaxed)) {
@@ -636,9 +677,9 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.screen_capturer_is_started_ && !owner_.start_mouse_controller_ &&
owner_.mouse_controller_is_started_;
if (stop_wayland_mouse_before_screen) {
LOG_INFO("Stopping Wayland mouse controller before screen capturer to "
if (StopMouseController() != 0) return;
LOG_INFO("Stopped Wayland mouse controller before screen capturer to "
"cleanly release the shared portal session");
StopMouseController();
owner_.mouse_controller_is_started_ = false;
}
#endif
@@ -653,14 +694,15 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.screen_capturer_is_started_ = false;
}
UpdateScreenSharingPower();
if (owner_.start_mouse_controller_ && !owner_.mouse_controller_is_started_) {
if (StartMouseController() == 0) {
owner_.mouse_controller_is_started_ = true;
}
} else if (!owner_.start_mouse_controller_ &&
owner_.mouse_controller_is_started_) {
StopMouseController();
owner_.mouse_controller_is_started_ = false;
if (StopMouseController() == 0) owner_.mouse_controller_is_started_ = false;
}
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
@@ -750,6 +792,7 @@ bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
bool SessionDeviceManager::SendMouseCommand(const RemoteAction &action,
int selected_display) {
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) {
return mouse_controller_->SendMouseCommand(action, selected_display) == 0;
}
@@ -763,19 +806,20 @@ int SessionDeviceManager::SwitchDisplay(int display_id) {
return ret;
}
void SessionDeviceManager::ResetToInitialDisplay() {
InputOutcome SessionDeviceManager::ResetToInitialDisplay() {
if (!screen_capturer_) {
return;
return InputOutcome::ignored;
}
const int ret = screen_capturer_->ResetToInitialMonitor();
if (ret != 0) {
LOG_WARN("Reset to initial display failed, ret={}", ret);
return;
return InputOutcome::failed;
}
// Capture backends start at display 0. Reset the input mapping as well so
// the next connection cannot show that display while controlling another.
owner_.selected_display_ = 0;
SyncDisplayInfo();
return InputOutcome::applied;
}
std::vector<DisplayInfo> SessionDeviceManager::display_info_list() const {
@@ -795,8 +839,10 @@ std::vector<HostDisplay> SessionDeviceManager::host_display_list() const {
}
void SessionDeviceManager::DestroyDevices() {
owner_.screen_capturer_is_started_ = false;
screen_sharing_power_.SetSharing(false);
speaker_capture_.Shutdown();
StopMouseController();
StopMouseController(true);
if (screen_capturer_) {
screen_capturer_->Destroy();
delete screen_capturer_;
@@ -14,6 +14,7 @@
#include <mutex>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include <remote_action.h>
@@ -21,6 +22,8 @@
#include "device_controller.h"
#include "display_info.h"
#include "features/input/captured_keyboard_queue.h"
#include "features/input/remote_mouse_buttons.h"
#include "screen_sharing_power.h"
#include "speaker_capture_controller.h"
struct SDL_AudioStream;
@@ -51,10 +54,18 @@ public:
void DestroyFactories();
int InitializeScreenCapturer();
int EnsureCaptureReady();
int StartScreenCapturer();
int StopScreenCapturer();
bool ReleaseRemoteMouseButtons();
bool ReleaseRemoteMouseButtons(const std::string& remote_id,
unsigned mask = 7);
template <typename Send>
InputOutcome ApplyRemoteMouse(const std::string& remote_id, MouseFlag flag,
Send send) {
return remote_mouse_buttons_.Apply(remote_id, flag, std::move(send));
}
#ifdef _WIN32
void ReleaseRemoteMouseButtons();
std::string SendServiceMouseCommand(int x, int y, int wheel, int flag);
#endif
// Requests are asynchronous; Stop drains/revokes audio delivery before
@@ -62,7 +73,9 @@ public:
void StartSpeakerCapturer();
void StopSpeakerCapturer();
int StartMouseController();
int StopMouseController();
// Returns 1 while background releases are pending. force is only used
// during final destruction after the input dispatcher has been stopped.
int StopMouseController(bool force = false);
int StartKeyboardCapturer();
int StopKeyboardCapturer();
@@ -79,7 +92,7 @@ public:
bool extended);
bool SendMouseCommand(const RemoteAction &action, int selected_display);
int SwitchDisplay(int display_id);
void ResetToInitialDisplay();
InputOutcome ResetToInitialDisplay();
// Snapshot of the capture displays. The UI thread refreshes the list every
// tick while transport threads build host information and map mouse input
@@ -89,10 +102,13 @@ public:
std::vector<HostDisplay> host_display_list() const;
private:
unsigned ReleaseMouseButtonMask(unsigned mask);
void DrainCapturedKeyboardInput();
bool ShouldSendCapturedFrame(std::chrono::steady_clock::time_point now,
int fps);
bool ShouldCaptureCursor() const;
bool HasConnectedController() const;
void UpdateScreenSharingPower();
bool SyncDisplayInfo();
void SetDisplayInfoList(std::vector<DisplayInfo> displays);
void RecordCaptureCadence(std::chrono::steady_clock::time_point now, int fps,
@@ -103,10 +119,14 @@ private:
std::unordered_map<std::string, SDL_AudioStream*> output_streams_;
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
ScreenCapturer *screen_capturer_ = nullptr;
ScreenSharingPower screen_sharing_power_;
std::atomic<bool> cursor_capture_dirty_{true};
SpeakerCaptureController speaker_capture_;
DeviceControllerFactory *device_controller_factory_ = nullptr;
MouseController *mouse_controller_ = nullptr;
RemoteMouseButtons remote_mouse_buttons_;
// Input is executed on a worker while the UI starts/stops this controller.
std::mutex mouse_controller_mutex_;
KeyboardCapturer *keyboard_capturer_ = nullptr;
// Guards display_info_list_ (UI thread writes, transport threads read).
mutable std::mutex display_info_mutex_;
@@ -94,13 +94,19 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_;
}
if (!props && file.remote_id.rfind("lan/", 0) == 0) {
events = owner_.ControllerEvents(file.remote_id);
peer = events ? events->DirectPeer() : nullptr;
}
auto session = std::make_shared<file_transfer::Session>(
[peer, events, file, props](const char*, const char* data, size_t size) {
if (!peer || !events || (props && props->closing_)) return -1;
auto callback = events->EnterCallback();
if (!callback) return -1;
return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(),
file.remote_id.c_str(), file.remote_id.size());
if (!callback || !events->DataAllowed()) return -1;
const auto wire_id = events->WireId(file.remote_id);
return SendReliableDataFrameToPeer(
peer, data, size, file.file_label.c_str(),
wire_id.data(), wire_id.size());
}, file_transfer::Session::Progress{});
state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0;
state.current_file_id_ = id;
@@ -313,6 +319,10 @@ void FileTransferManager::Receive(const char* data, size_t size,
peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_;
}
if (!props && remote.rfind("lan/", 0) == 0) {
events = owner_.ControllerEvents(remote);
peer = events ? events->DirectPeer() : nullptr;
}
if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
std::shared_ptr<FileReceiver> receiver;
std::filesystem::path default_directory;
@@ -333,10 +343,12 @@ void FileTransferManager::Receive(const char* data, size_t size,
});
entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) {
auto callback = events->EnterCallback();
if (!callback) return -1;
if (!callback || !events->DataAllowed()) return -1;
const auto bytes = EncodeFileTransferAck(ack);
return SendReliableDataFrameToPeer(peer, bytes.data(), bytes.size(), kFileFeedbackStream,
remote.c_str(), remote.size());
const auto wire_id = events->WireId(remote);
return SendReliableDataFrameToPeer(
peer, bytes.data(), bytes.size(), kFileFeedbackStream,
wire_id.data(), wire_id.size());
});
}
receiver = entry.session;
@@ -0,0 +1,29 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _INPUT_OUTCOME_H_
#define _INPUT_OUTCOME_H_
namespace crossdesk {
// Applied means the native/service API accepted the operation. It does not
// assert that the foreground application has consumed the injected event.
enum class InputOutcome { applied, ignored, failed };
inline const char* InputOutcomeName(InputOutcome outcome) {
switch (outcome) {
case InputOutcome::applied:
return "applied";
case InputOutcome::ignored:
return "ignored";
case InputOutcome::failed:
return "failed";
}
return "failed";
}
} // namespace crossdesk
#endif
@@ -4,6 +4,7 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <cstdint>
#include <mutex>
#include <nlohmann/json.hpp>
@@ -249,14 +250,14 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
extended);
}
void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
InputOutcome KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_);
const int key_code = static_cast<int>(action.k.key_value);
const bool is_down = action.k.flag == KeyFlag::key_down;
const bool injected =
InjectRemoteKey(key_code, is_down, action.k.scan_code, action.k.extended);
if (injected) pending_key_releases_.erase(key_code);
if (injected) pending_key_releases_.Supersede(key_code);
auto& state = remote_states_[remote_id];
state.last_seen_tick = static_cast<uint32_t>(SDL_GetTicks());
@@ -266,24 +267,28 @@ void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
} else if (!is_down && injected) {
state.pressed_keys.erase(key_code);
}
return injected ? InputOutcome::applied : InputOutcome::failed;
}
void KeyboardController::ApplyRemoteText(const std::string& remote_id,
InputOutcome KeyboardController::ApplyRemoteText(const std::string& remote_id,
const std::string& text) {
std::lock_guard input_lock(remote_input_mutex_);
if (!owner_.IsAuthorizedController(remote_id) ||
!owner_.devices_.SupportsTextInput()) return;
if (!owner_.devices_.SendTextCommand(text))
if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
if (!owner_.devices_.SendTextCommand(text)) {
LOG_WARN("Failed to inject committed remote text");
return InputOutcome::failed;
}
return InputOutcome::applied;
}
void KeyboardController::ApplyRemoteState(const std::string& remote_id,
InputOutcome KeyboardController::ApplyRemoteState(const std::string& remote_id,
const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_);
auto& state = remote_states_[remote_id];
if (action.ks.seq != 0 && state.last_seq != 0 &&
static_cast<int32_t>(action.ks.seq - state.last_seq) <= 0) {
return;
return InputOutcome::ignored;
}
state.last_seq = action.ks.seq;
@@ -300,56 +305,60 @@ void KeyboardController::ApplyRemoteState(const std::string& remote_id,
}
const auto changes = ReconcileKeyboardState(state.pressed_keys, desired_keys);
bool can_press = true;
bool applied = true;
for (const auto& key : changes) {
if (key.is_down && !can_press) continue;
if (!InjectRemoteKey(key.key_code, key.is_down, key.scan_code, key.extended)) {
// Do not replay a shortcut with a stale/missing modifier. The next
// heartbeat will retry from the state that was actually injected.
can_press = false;
applied = false;
continue;
}
pending_key_releases_.erase(key.key_code);
pending_key_releases_.Supersede(key.key_code);
if (key.is_down)
state.pressed_keys[key.key_code] =
{key.key_code, key.scan_code, key.extended};
else
state.pressed_keys.erase(key.key_code);
}
return applied ? InputOutcome::applied : InputOutcome::failed;
}
void KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
bool KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
const char* reason) {
std::lock_guard input_lock(remote_input_mutex_);
ReleaseRemotePressedKeysLocked(remote_id, reason);
return ReleaseRemotePressedKeysLocked(remote_id, reason);
}
void KeyboardController::ReleaseRemotePressedKeysLocked(
bool KeyboardController::ReleaseRemotePressedKeysLocked(
std::string remote_id, const char* reason) {
const auto state_it = remote_states_.find(remote_id);
if (state_it == remote_states_.end()) return;
auto keys_to_release = std::move(state_it->second.pressed_keys);
remote_states_.erase(state_it);
if (!keys_to_release.empty()) {
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
keys_to_release.size(), remote_id, reason ? reason : "unknown");
}
for (const auto& [code, key] : keys_to_release) {
if (!InjectRemoteKey(key.key_code, false, key.scan_code, key.extended)) {
pending_key_releases_[code] = key;
} else {
pending_key_releases_.erase(code);
if (state_it != remote_states_.end()) {
const auto& keys = state_it->second.pressed_keys;
if (!keys.empty()) {
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
keys.size(), remote_id, reason ? reason : "unknown");
}
// Stage before erasing: subsequent dispatcher retries must still see the
// keys if injection fails, even though active/heartbeat state is gone.
pending_key_releases_.Queue(remote_id, keys);
remote_states_.erase(state_it);
}
return pending_key_releases_.ReleaseController(
remote_id, [this](const KeyboardKey& key) {
return InjectRemoteKey(key.key_code, false, key.scan_code, key.extended);
});
}
void KeyboardController::ReleaseAllRemotePressedKeys(const char* reason) {
{
std::lock_guard input_lock(remote_input_mutex_);
while (!remote_states_.empty())
ReleaseRemotePressedKeysLocked(remote_states_.begin()->first, reason);
}
CheckRemoteTimeouts();
std::lock_guard input_lock(remote_input_mutex_);
auto ids = pending_key_releases_.ControllerIds();
for (const auto& [id, _] : remote_states_) ids.push_back(id);
std::sort(ids.begin(), ids.end());
ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
// A failure remains pending; shutdown must never spin until it succeeds.
for (const auto& id : ids) ReleaseRemotePressedKeysLocked(id, reason);
}
void KeyboardController::CheckRemoteTimeouts() {
@@ -359,7 +368,7 @@ void KeyboardController::CheckRemoteTimeouts() {
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
// Retain failed releases in ordinary mode as well as privacy mode. Retry
// only on the ordinary desktop, never through the security UI helper.
if (!pending_key_releases_.empty() &&
if (!pending_key_releases_.Empty() &&
(last_release_retry_tick_ == 0 ||
now - last_release_retry_tick_ >= kReleaseRetryIntervalMs)
#ifdef _WIN32
@@ -367,15 +376,10 @@ void KeyboardController::CheckRemoteTimeouts() {
#endif
) {
last_release_retry_tick_ = now;
for (auto it = pending_key_releases_.begin();
it != pending_key_releases_.end();) {
const auto& key = it->second;
if (owner_.devices_.SendKeyboardCommand(
key.key_code, false, key.scan_code, key.extended))
it = pending_key_releases_.erase(it);
else
++it;
}
pending_key_releases_.RetryAll([this](const KeyboardKey& key) {
return owner_.devices_.SendKeyboardCommand(
key.key_code, false, key.scan_code, key.extended);
});
}
// Keep the timeout decision and release under the same lock, so a fresh
@@ -9,6 +9,8 @@
#include <remote_action.h>
#include "keyboard_state.h"
#include "input_outcome.h"
#include "pending_key_releases.h"
namespace crossdesk {
@@ -25,12 +27,12 @@ public:
void ForceReleasePressedKeys();
void RecoverCapturedState(const PressedKeys& pressed);
void SendHeartbeat(bool force);
void ApplyRemoteEvent(const std::string &remote_id,
InputOutcome ApplyRemoteEvent(const std::string &remote_id,
const RemoteAction &remote_action);
void ApplyRemoteText(const std::string& remote_id, const std::string& text);
void ApplyRemoteState(const std::string &remote_id,
InputOutcome ApplyRemoteText(const std::string& remote_id, const std::string& text);
InputOutcome ApplyRemoteState(const std::string &remote_id,
const RemoteAction &remote_action);
void ReleaseRemotePressedKeys(const std::string &remote_id,
bool ReleaseRemotePressedKeys(const std::string &remote_id,
const char *reason);
void ReleaseAllRemotePressedKeys(const char* reason);
void CheckRemoteTimeouts();
@@ -48,7 +50,7 @@ private:
bool InjectRemoteKey(int key_code, bool is_down, uint32_t scan_code,
bool extended);
// Caller holds remote_input_mutex_. Copy the id because this erases state.
void ReleaseRemotePressedKeysLocked(std::string remote_id, const char* reason);
bool ReleaseRemotePressedKeysLocked(std::string remote_id, const char* reason);
GuiRuntime &owner_;
PressedKeys pressed_keys_;
@@ -59,7 +61,7 @@ private:
// Guards all remote key state and serializes injection, cleanup and retries.
std::mutex remote_input_mutex_;
// Successful new input supersedes cleanup of an older press of that key.
PressedKeys pending_key_releases_;
PendingKeyReleases pending_key_releases_;
uint32_t last_release_retry_tick_ = 0;
};
@@ -0,0 +1,79 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PENDING_KEY_RELEASES_H_
#define _PENDING_KEY_RELEASES_H_
#include <string>
#include <unordered_map>
#include <vector>
#include "keyboard_state.h"
namespace crossdesk {
// Caller serializes access with KeyboardController's remote-input mutex.
// Pending releases outlive the peer's active key/heartbeat state, but retain
// their controller identity so cleanup cannot report success prematurely.
class PendingKeyReleases {
public:
void Queue(const std::string& id, const PressedKeys& keys) {
if (keys.empty()) return;
auto& pending = peers_[id];
for (const auto& [code, key] : keys) pending[code] = key;
}
bool Empty() const { return peers_.empty(); }
bool Contains(const std::string& id) const { return peers_.count(id) != 0; }
std::vector<std::string> ControllerIds() const {
std::vector<std::string> ids;
ids.reserve(peers_.size());
for (const auto& [id, _] : peers_) ids.push_back(id);
return ids;
}
// A confirmed new event supersedes an older pending release of that key,
// including one belonging to a disconnected controller.
void Supersede(int key_code) {
for (auto it = peers_.begin(); it != peers_.end();) {
it->second.erase(key_code);
if (it->second.empty())
it = peers_.erase(it);
else
++it;
}
}
template <typename SendUp>
bool ReleaseController(const std::string& id, SendUp send_up) {
auto peer = peers_.find(id);
if (peer == peers_.end()) return true;
auto& keys = peer->second;
for (auto it = keys.begin(); it != keys.end();) {
if (send_up(it->second))
it = keys.erase(it);
else
++it;
}
if (!keys.empty()) return false;
peers_.erase(peer);
return true;
}
template <typename SendUp>
void RetryAll(SendUp send_up) {
// One bounded pass, even when every native release fails.
for (const auto& id : ControllerIds()) ReleaseController(id, send_up);
}
private:
std::unordered_map<std::string, PressedKeys> peers_;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,343 @@
#include "remote_input_dispatcher.h"
#include <algorithm>
#include <utility>
namespace crossdesk {
RemoteInputDispatcher::RemoteInputDispatcher(Apply apply, Release release,
Report report,
ResetDisplay reset_display,
ReleaseMouse release_mouse)
: apply_(std::move(apply)),
release_(std::move(release)),
report_(std::move(report)),
reset_display_(std::move(reset_display)),
release_mouse_(std::move(release_mouse)),
worker_([this] { Run(); }) {}
RemoteInputDispatcher::~RemoteInputDispatcher() { Stop(); }
bool RemoteInputDispatcher::Accepts(const RemoteAction& action) {
return action.type == ControlType::mouse ||
action.type == ControlType::keyboard ||
action.type == ControlType::keyboard_state ||
action.type == ControlType::text_input ||
action.type == ControlType::display_id ||
action.type == ControlType::service_command;
}
void RemoteInputDispatcher::Connect(const std::string& id) {
if (id.empty()) return;
std::lock_guard lock(mutex_);
if (stopping_ || !connected_.insert(id).second) return;
logs_.erase(id); // A new session must get its first diagnostic.
// In-flight resets finish before this session's input; queued old resets
// must not change the new session's display. Failed releases are retained.
RemoveResetsLocked();
ready_.notify_one();
}
RemoteInputDispatcher::Result RemoteInputDispatcher::Push(
const std::string& id, const RemoteAction& action) {
// HostInfo owns raw arrays; it must never enter this value-copy queue.
if (!Accepts(action)) return Result::rejected;
std::lock_guard lock(mutex_);
if (stopping_ || !connected_.count(id)) return Result::rejected;
if (IsMove(action) && !pending_.empty()) {
auto& last = pending_.back();
if (last.id == id && last.IsInput() && IsMove(*last.action)) {
last.action = action;
last.queued_at = Clock::now();
return Result::coalesced;
}
}
if (pending_counts_[id] >= kMaxPendingPerController) {
QueueReleaseLocked(id);
return Result::overflow;
}
pending_.push_back({id, action, Clock::now(), ItemKind::input});
++pending_counts_[id];
ready_.notify_one();
return Result::queued;
}
void RemoteInputDispatcher::Disconnect(const std::string& id) {
std::lock_guard lock(mutex_);
if (stopping_ || !connected_.erase(id)) return;
QueueReleaseLocked(id);
if (connected_.empty() && reset_display_) {
RemoveResetsLocked();
pending_.push_back(
{{}, std::nullopt, Clock::now(), ItemKind::reset_display});
}
ready_.notify_one();
}
void RemoteInputDispatcher::WaitIdle() {
std::unique_lock lock(mutex_);
idle_.wait(lock, [this] { return pending_.empty() && !executing_; });
}
bool RemoteInputDispatcher::HasPendingReleases() {
std::lock_guard lock(mutex_);
return !blocked_.empty();
}
void RemoteInputDispatcher::Stop() {
std::call_once(stop_once_, [this] {
{
std::lock_guard lock(mutex_);
stopping_ = true;
auto ids = std::move(connected_);
connected_.clear();
ids.insert(blocked_.begin(), blocked_.end());
for (const auto& item : pending_)
if (!item.IsReset()) ids.insert(item.id);
pending_.clear();
pending_counts_.clear();
retries_.clear();
for (const auto& id : ids) {
pending_.push_back({id, std::nullopt, Clock::now()});
pending_counts_[id] = 1;
}
}
ready_.notify_one();
worker_.join();
});
}
bool RemoteInputDispatcher::IsMove(const RemoteAction& action) {
return action.type == ControlType::mouse && action.m.flag == MouseFlag::move;
}
unsigned RemoteInputDispatcher::MouseUpMask(const RemoteAction& action) {
if (action.type != ControlType::mouse) return 0;
switch (action.m.flag) {
case MouseFlag::left_up:
return 1;
case MouseFlag::right_up:
return 2;
case MouseFlag::middle_up:
return 4;
default:
return 0;
}
}
void RemoteInputDispatcher::RemoveResetsLocked() {
pending_.erase(
std::remove_if(pending_.begin(), pending_.end(),
[](const Item& item) { return item.IsReset(); }),
pending_.end());
if (pending_.empty() && !executing_) idle_.notify_all();
}
void RemoteInputDispatcher::QueueReleaseLocked(const std::string& id) {
pending_.erase(
std::remove_if(pending_.begin(), pending_.end(),
[&](const Item& item) { return item.id == id; }),
pending_.end());
pending_counts_.erase(id);
blocked_.insert(id);
// Repeated overflow/disconnect must not defeat the retry backoff.
if (auto retry = retries_.find(id); retry != retries_.end()) {
retry->second.mouse_mask = 0; // Escalate a button repair on disconnect.
} else {
pending_.push_back({id, std::nullopt, Clock::now()});
pending_counts_[id] = 1;
}
ready_.notify_one();
}
void RemoteInputDispatcher::QueueMouseReleaseLocked(const std::string& id,
unsigned mask) {
// A concurrent disconnect/overflow already requested the broader cleanup.
if (!blocked_.insert(id).second) return;
// Keep subsequent clicks/keys queued, but don't apply them until the old
// button is confirmed up. Never replay a timed-out down/click operation.
pending_.push_front(
{id, std::nullopt, Clock::now(), ItemKind::cleanup, mask});
++pending_counts_[id];
ready_.notify_one();
}
std::optional<RemoteInputDispatcher::Item> RemoteInputDispatcher::NextLocked() {
const auto now = Clock::now();
auto next = retries_.end();
for (auto it = retries_.begin(); it != retries_.end(); ++it)
if (next == retries_.end() || it->second.due < next->second.due) next = it;
auto it =
std::find_if(pending_.begin(), pending_.end(), [&](const Item& item) {
return !item.IsInput() || !blocked_.count(item.id);
});
// Alternate due retries with runnable queued work. Multiple slow cleanup
// calls must not monopolize this worker; continuous input must not starve
// recovery either. Input for the recovering controller remains blocked.
if (next != retries_.end() && next->second.due <= now &&
(it == pending_.end() || !last_was_retry_)) {
last_was_retry_ = true;
return Item{next->first, std::nullopt, next->second.due, ItemKind::cleanup,
next->second.mouse_mask};
}
if (it == pending_.end()) return std::nullopt;
last_was_retry_ = false;
Item item = std::move(*it);
pending_.erase(it);
if (!item.IsReset() && --pending_counts_.at(item.id) == 0)
pending_counts_.erase(item.id);
return item;
}
bool RemoteInputDispatcher::ShouldReportLocked(const Item& item,
Clock::time_point now,
int64_t queued_ms,
int64_t execution_ms,
InputOutcome outcome) {
if (!report_) return false;
auto& state = logs_[item.id];
auto& last = outcome == InputOutcome::failed
? state.failure[item.IsInput() ? 0 : 1]
: state.slow;
if (outcome != InputOutcome::failed && queued_ms < 50 && execution_ms < 50)
return false;
if (last != Clock::time_point{} && now - last < std::chrono::seconds(2))
return false;
last = now;
return true;
}
void RemoteInputDispatcher::RemoveQueuedCleanupLocked(const std::string& id) {
// Cleanup queued during the running call covers the same old state. Merge
// duplicates so they cannot bypass backoff or release a later session twice.
const size_t before = pending_.size();
pending_.erase(std::remove_if(pending_.begin(), pending_.end(),
[&](const Item& queued) {
return queued.id == id && queued.IsCleanup();
}),
pending_.end());
const size_t removed = before - pending_.size();
if (removed && (pending_counts_.at(id) -= removed) == 0)
pending_counts_.erase(id);
}
void RemoteInputDispatcher::ScheduleRetryLocked(const std::string& id,
unsigned mouse_mask,
Clock::time_point finished) {
auto [retry, inserted] = retries_.try_emplace(id);
retry->second.mouse_mask = mouse_mask;
if (!inserted)
retry->second.delay =
std::min(retry->second.delay * 2, std::chrono::milliseconds(1000));
retry->second.due = finished + retry->second.delay;
blocked_.insert(id);
}
void RemoteInputDispatcher::CompleteCleanupLocked(const Item& item,
InputOutcome outcome,
Clock::time_point finished) {
if (stopping_) {
retries_.erase(item.id);
blocked_.erase(item.id);
return;
}
// A successful button repair cannot discharge a concurrent request to
// release all keyboard/mouse state after disconnect or queue overflow.
const bool queued_full_cleanup =
item.mouse_mask &&
std::any_of(pending_.begin(), pending_.end(), [&](const Item& queued) {
return queued.id == item.id && queued.IsCleanup() && !queued.mouse_mask;
});
if (queued_full_cleanup) {
retries_.erase(item.id); // The queued full cleanup owns recovery now.
return;
}
auto retry = retries_.find(item.id);
if (item.mouse_mask && retry != retries_.end() && !retry->second.mouse_mask) {
if (outcome == InputOutcome::failed)
ScheduleRetryLocked(item.id, 0, finished);
else
retry->second.due = finished;
return;
}
RemoveQueuedCleanupLocked(item.id);
if (outcome == InputOutcome::failed) {
ScheduleRetryLocked(item.id, item.mouse_mask, finished);
} else {
retries_.erase(item.id);
blocked_.erase(item.id);
}
}
InputOutcome RemoteInputDispatcher::Execute(const Item& item) {
if (item.IsReset()) return reset_display_();
if (item.IsInput()) return apply_(item.id, *item.action);
if (item.mouse_mask && release_mouse_)
return release_mouse_(item.id, item.mouse_mask);
return release_(item.id);
}
void RemoteInputDispatcher::Run() {
std::unique_lock lock(mutex_);
for (;;) {
auto next = NextLocked();
if (!next) {
if (stopping_) return;
auto due = Clock::time_point::max();
for (const auto& [_, retry] : retries_) due = std::min(due, retry.due);
if (due == Clock::time_point::max())
ready_.wait(lock);
else
ready_.wait_until(lock, due);
continue;
}
Item item = std::move(*next);
executing_ = true;
lock.unlock();
const auto started = Clock::now();
InputOutcome outcome = InputOutcome::failed;
bool threw = false;
try {
outcome = Execute(item);
} catch (...) {
threw = true;
}
const auto finished = Clock::now();
const auto queued_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(started -
item.queued_at)
.count();
const auto execution_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(finished -
started)
.count();
lock.lock();
if (item.IsCleanup()) {
CompleteCleanupLocked(item, outcome, finished);
} else if (item.IsInput() && !stopping_) {
if (threw && connected_.count(item.id)) {
QueueReleaseLocked(item.id);
} else if (outcome == InputOutcome::failed) {
if (const unsigned mask = MouseUpMask(*item.action))
QueueMouseReleaseLocked(item.id, mask);
}
}
const bool report =
ShouldReportLocked(item, finished, queued_ms, execution_ms, outcome);
if (!connected_.count(item.id) && !blocked_.count(item.id) &&
!pending_counts_.count(item.id))
logs_.erase(item.id);
lock.unlock();
if (report) {
try {
report_(item.id, queued_ms, execution_ms, outcome);
} catch (...) {
} // Diagnostics must not terminate the input worker.
}
lock.lock();
executing_ = false;
if (pending_.empty()) idle_.notify_all();
}
}
} // namespace crossdesk
@@ -0,0 +1,123 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _REMOTE_INPUT_DISPATCHER_H_
#define _REMOTE_INPUT_DISPATCHER_H_
#include <remote_action.h>
#include <array>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <functional>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include "input_outcome.h"
namespace crossdesk {
// Serializes native input, recovery and display resets off the RTC thread.
class RemoteInputDispatcher {
public:
using Apply =
std::function<InputOutcome(const std::string&, const RemoteAction&)>;
using Release = std::function<InputOutcome(const std::string&)>;
using Report =
std::function<void(const std::string&, int64_t, int64_t, InputOutcome)>;
using ResetDisplay = std::function<InputOutcome()>;
// Optional targeted adapter; legacy callers fall back to full Release.
using ReleaseMouse =
std::function<InputOutcome(const std::string&, unsigned)>;
enum class Result { queued, coalesced, rejected, overflow };
static constexpr size_t kMaxPendingPerController = 128;
RemoteInputDispatcher(Apply apply, Release release, Report report = {},
ResetDisplay reset_display = {},
ReleaseMouse release_mouse = {});
~RemoteInputDispatcher();
static bool Accepts(const RemoteAction& action);
void Connect(const std::string& id);
Result Push(const std::string& id, const RemoteAction& action);
void Disconnect(const std::string& id);
// Scheduled recovery is not idle work. Check HasPendingReleases() as well,
// or Stop() before destroying the devices used by callbacks.
void WaitIdle();
bool HasPendingReleases();
// Cancels pending input/timers and makes a final bounded cleanup attempt.
void Stop();
private:
using Clock = std::chrono::steady_clock;
enum class ItemKind { input, cleanup, reset_display };
struct Item {
std::string id;
std::optional<RemoteAction> action; // Present only for ordinary input.
Clock::time_point queued_at;
ItemKind kind = ItemKind::cleanup;
// Zero requests full controller cleanup; otherwise only these mouse
// buttons.
unsigned mouse_mask = 0;
bool IsReset() const { return kind == ItemKind::reset_display; }
bool IsInput() const { return kind == ItemKind::input; }
bool IsCleanup() const { return kind == ItemKind::cleanup; }
};
struct Retry {
Clock::time_point due;
std::chrono::milliseconds delay{100};
unsigned mouse_mask = 0;
};
struct LogState {
Clock::time_point slow{};
std::array<Clock::time_point, 2> failure{};
};
static bool IsMove(const RemoteAction& action);
static unsigned MouseUpMask(const RemoteAction& action);
void RemoveResetsLocked();
void QueueReleaseLocked(const std::string& id);
void QueueMouseReleaseLocked(const std::string& id, unsigned mask);
void RemoveQueuedCleanupLocked(const std::string& id);
void ScheduleRetryLocked(const std::string& id, unsigned mouse_mask,
Clock::time_point finished);
void CompleteCleanupLocked(const Item& item, InputOutcome outcome,
Clock::time_point finished);
std::optional<Item> NextLocked();
InputOutcome Execute(const Item& item);
bool ShouldReportLocked(const Item& item, Clock::time_point now,
int64_t queued_ms, int64_t execution_ms,
InputOutcome outcome);
void Run();
Apply apply_;
Release release_;
Report report_;
ResetDisplay reset_display_;
ReleaseMouse release_mouse_;
std::mutex mutex_;
std::condition_variable ready_, idle_;
std::deque<Item> pending_;
std::unordered_set<std::string> connected_, blocked_;
std::unordered_map<std::string, size_t> pending_counts_;
std::unordered_map<std::string, Retry> retries_;
std::unordered_map<std::string, LogState> logs_;
bool last_was_retry_ = false;
bool stopping_ = false;
bool executing_ = false;
std::once_flag stop_once_;
std::thread worker_;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,78 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _REMOTE_MOUSE_BUTTONS_H_
#define _REMOTE_MOUSE_BUTTONS_H_
#include <remote_action.h>
#include <array>
#include <mutex>
#include <string>
#include "input_outcome.h"
namespace crossdesk {
// One physical button cannot belong to two controllers at once. A second
// controller's down/up must not steal or release an existing drag.
class RemoteMouseButtons {
public:
template <typename Send>
InputOutcome Apply(const std::string& id, MouseFlag flag, Send send) {
if (id.empty()) return InputOutcome::ignored;
std::lock_guard lock(mutex_);
const int value = static_cast<int>(flag);
if (value < 1 || value > 6) return send();
auto& owner = owners_[(value - 1) / 2];
const bool down = value % 2 == 1;
if ((!owner.empty() && owner != id) || (!down && owner.empty()))
return InputOutcome::ignored;
// A service timeout may follow successful injection. Retain an attempted
// down until an up or explicit cleanup is confirmed, including exceptions.
if (down) owner = id;
const auto outcome = send();
if (!down && outcome == InputOutcome::applied) owner.clear();
return outcome;
}
template <typename Release>
bool ReleaseController(const std::string& id, Release release,
unsigned requested_mask = 7) {
if (id.empty()) return true;
std::lock_guard lock(mutex_);
unsigned mask = 0;
for (size_t i = 0; i < owners_.size(); ++i)
if (owners_[i] == id && (requested_mask & (1u << i))) mask |= 1u << i;
return ReleaseLocked(mask, release);
}
template <typename Release>
bool ReleaseAll(Release release) {
std::lock_guard lock(mutex_);
// Also clear any backend state retained after a desktop/route transition.
return ReleaseLocked(7, release);
}
private:
template <typename Release>
bool ReleaseLocked(unsigned mask, Release release) {
if (!mask) return true;
// The sink returns the subset confirmed released, so one failed button
// does not retain ownership of other successfully released buttons.
const unsigned released = release(mask) & mask;
for (size_t i = 0; i < owners_.size(); ++i)
if (released & (1u << i)) owners_[i].clear();
return released == mask;
}
std::mutex mutex_;
std::array<std::string, 3> owners_;
};
} // namespace crossdesk
#endif
@@ -1,5 +1,6 @@
#include "features/settings/settings_manager.h"
#include <cerrno>
#include <chrono>
#include <cstddef>
#include <cstdio>
@@ -11,6 +12,7 @@
#include <memory>
#include <nlohmann/json.hpp>
#include <string>
#include <system_error>
#include <thread>
#if defined(_WIN32)
@@ -20,6 +22,9 @@
#include <fcntl.h>
#include <unistd.h>
#endif
#if defined(__linux__) && !defined(__OHOS__)
#include <sys/stat.h>
#endif
#include "localization.h"
#include "rd_log.h"
@@ -31,6 +36,92 @@ namespace {
constexpr uint32_t kCacheV3Magic = 0x33444358; // "XCD3"
constexpr uint32_t kCacheV3Version = 3;
#if defined(__linux__) && !defined(__OHOS__)
constexpr const char *kCredentialCacheNames[] = {
"secure_cache_v3.enc", "secure_cache_v2.enc", "secure_cache.enc"};
bool RestrictCachePermissions(const std::filesystem::path &path,
bool directory = false) {
const int flags = O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK |
(directory ? O_DIRECTORY : 0);
const int fd = open(path.c_str(), flags);
if (fd < 0 && errno == ENOENT) return true;
int error = fd < 0 ? errno : 0;
if (fd >= 0) {
struct stat info {};
if (fstat(fd, &info) != 0) {
error = errno;
} else if (info.st_uid != geteuid() ||
(directory ? !S_ISDIR(info.st_mode) : !S_ISREG(info.st_mode))) {
error = EPERM;
} else {
const mode_t mode = directory ? 0700 : 0600;
if ((info.st_mode & 07777) != mode && fchmod(fd, mode) != 0)
error = errno;
}
close(fd);
}
if (error) {
LOG_WARN("Cannot restrict credential cache permissions for {}: {}",
path.string(),
std::error_code(error, std::generic_category()).message());
}
return error == 0;
}
[[noreturn]] void CacheReadError(const std::filesystem::path &path, int error) {
const std::system_error failure(
error, std::generic_category(),
"Cannot read credential cache; existing data was kept: " + path.string());
LOG_ERROR("{}", failure.what());
throw failure;
}
bool ReadCredentialCache(const std::filesystem::path &path, void *data,
size_t size) {
const int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
if (fd < 0) {
if (errno == ENOENT) return false;
CacheReadError(path, errno);
}
struct stat info {};
if (fstat(fd, &info) != 0) {
const int error = errno;
close(fd);
CacheReadError(path, error);
}
if (!S_ISREG(info.st_mode)) {
close(fd);
CacheReadError(path, EINVAL);
}
size_t received = 0;
while (received < size) {
const auto count =
read(fd, static_cast<char *>(data) + received, size - received);
if (count < 0) {
if (errno == EINTR) continue;
const int error = errno;
close(fd);
CacheReadError(path, error);
}
if (count == 0) break;
received += static_cast<size_t>(count);
}
close(fd);
return received == size;
}
bool HasExistingCredentialCache(const std::filesystem::path &directory) {
for (const char *name : kCredentialCacheNames) {
const auto path = directory / name;
struct stat info {};
if (lstat(path.c_str(), &info) == 0) return true;
if (errno != ENOENT) CacheReadError(path, errno);
}
return false;
}
#endif
template <size_t Size>
void CopyString(char (&destination)[Size], const char *source) {
static_assert(Size > 0);
@@ -110,11 +201,41 @@ bool WriteFileAtomically(const std::filesystem::path &target,
if (ec) {
return false;
}
#if defined(__linux__) && !defined(__OHOS__)
if (!RestrictCachePermissions(parent, true)) return false;
#endif
}
#if defined(__linux__) && !defined(__OHOS__)
// Never adopt another user's file or replace a link while saving credentials.
struct stat previous {};
if (lstat(target.c_str(), &previous) == 0) {
if (!S_ISREG(previous.st_mode) || previous.st_uid != geteuid()) return false;
} else if (errno != ENOENT) {
return false;
}
#endif
const std::filesystem::path temporary = TemporaryPathFor(target);
#if defined(_WIN32)
FILE *file = _wfopen(temporary.c_str(), L"wb");
#elif defined(__linux__) && !defined(__OHOS__)
// Set permissions before writing any secret, including under umask(0000).
// Exclusive creation also prevents following/reusing a pre-existing temp file.
const int fd = open(temporary.c_str(),
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
if (fd < 0) return false;
if (fchmod(fd, 0600) != 0) {
close(fd);
std::filesystem::remove(temporary, ec);
return false;
}
FILE *file = fdopen(fd, "wb");
if (!file) {
close(fd);
std::filesystem::remove(temporary, ec);
return false;
}
#else
FILE *file = std::fopen(temporary.c_str(), "wb");
#endif
@@ -184,16 +305,26 @@ int SettingsManager::SaveLocked() {
}
bool SettingsManager::ReadV3Locked() {
CacheV3 loaded{};
#if defined(__linux__) && !defined(__OHOS__)
if (!ReadCredentialCache(owner_.cache_path_ + "/secure_cache_v3.enc",
&loaded, sizeof(loaded))) {
return false;
}
#else
std::ifstream cache_file(owner_.cache_path_ + "/secure_cache_v3.enc",
std::ios::binary);
if (!cache_file) {
return false;
}
CacheV3 loaded{};
cache_file.read(reinterpret_cast<char *>(&loaded), sizeof(loaded));
if (cache_file.gcount() != static_cast<std::streamsize>(sizeof(loaded)) ||
loaded.magic != kCacheV3Magic ||
if (cache_file.gcount() != static_cast<std::streamsize>(sizeof(loaded))) {
LOG_WARN("Ignore invalid v3 credential cache");
return false;
}
#endif
if (loaded.magic != kCacheV3Magic ||
loaded.version != kCacheV3Version ||
loaded.checksum != CacheChecksum(&loaded, offsetof(CacheV3, checksum))) {
LOG_WARN("Ignore invalid v3 credential cache");
@@ -212,6 +343,12 @@ bool SettingsManager::ReadV3Locked() {
}
bool SettingsManager::ReadV2Locked() {
#if defined(__linux__) && !defined(__OHOS__)
if (!ReadCredentialCache(owner_.cache_path_ + "/secure_cache_v2.enc",
&cache_v2_, sizeof(cache_v2_))) {
return false;
}
#else
std::ifstream cache_v2_file(owner_.cache_path_ + "/secure_cache_v2.enc",
std::ios::binary);
if (!cache_v2_file) {
@@ -222,6 +359,7 @@ bool SettingsManager::ReadV2Locked() {
static_cast<std::streamsize>(sizeof(cache_v2_))) {
return false;
}
#endif
cache_v2_
.client_id_with_password[sizeof(cache_v2_.client_id_with_password) - 1] =
'\0';
@@ -239,6 +377,14 @@ std::string SettingsManager::ActiveIdentity() const {
int SettingsManager::Load() {
std::unique_lock<std::mutex> lock(cache_mutex_);
#if defined(__linux__) && !defined(__OHOS__)
// Migrate all generations in place, even when only v3 is used. A chmod
// failure must not turn a readable identity into a missing/invalid cache.
RestrictCachePermissions(owner_.cache_path_, true);
for (const char *name : kCredentialCacheNames)
RestrictCachePermissions(std::filesystem::path(owner_.cache_path_) / name);
#endif
const bool loaded_v3 = ReadV3Locked();
if (loaded_v3 || ReadV2Locked()) {
CopyString(owner_.client_id_with_password_,
@@ -254,9 +400,19 @@ int SettingsManager::Load() {
LOG_INFO("Migrated settings from v2 to v3 cache file");
}
} else {
#if defined(__linux__) && !defined(__OHOS__)
const bool loaded_v1 = ReadCredentialCache(
owner_.cache_path_ + "/secure_cache.enc", &cache_v1_, sizeof(cache_v1_));
if (!loaded_v1) {
// Permission/I/O errors throw above. Invalid existing files also must
// never trigger first-run identity generation or thumbnail deletion.
if (HasExistingCredentialCache(owner_.cache_path_))
CacheReadError(owner_.cache_path_, EINVAL);
#else
std::ifstream cache_v1_file(owner_.cache_path_ + "/secure_cache.enc",
std::ios::binary);
if (!cache_v1_file) {
#endif
lock.unlock();
std::memset(owner_.password_saved_, 0, sizeof(owner_.password_saved_));
@@ -273,7 +429,9 @@ int SettingsManager::Load() {
return -1;
}
#if !defined(__linux__) || defined(__OHOS__)
cache_v1_file.read(reinterpret_cast<char *>(&cache_v1_), sizeof(cache_v1_));
#endif
cache_v1_
.client_id_with_password[sizeof(cache_v1_.client_id_with_password) -
1] = '\0';
@@ -307,9 +465,6 @@ int SettingsManager::Load() {
static_cast<int>(owner_.config_center_->GetLanguage()));
owner_.video_encode_format_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat());
owner_.enable_hardware_video_codec_ =
owner_.config_center_->IsHardwareVideoCodec();
owner_.enable_turn_ = owner_.config_center_->IsEnableTurn();
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
@@ -328,9 +483,6 @@ int SettingsManager::Load() {
owner_.language_button_value_last_ = owner_.language_button_value_;
owner_.video_encode_format_button_value_last_ =
owner_.video_encode_format_button_value_;
owner_.enable_hardware_video_codec_last_ =
owner_.enable_hardware_video_codec_;
owner_.enable_turn_last_ = owner_.enable_turn_;
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
owner_.enable_autostart_last_ = owner_.enable_autostart_;
@@ -590,6 +742,8 @@ std::string SettingsManager::RecentConnectionDisplayName(
connection.remote_host_name != "unknown") {
return connection.remote_host_name;
}
if (!connection.lan.device_id.empty())
return connection.lan.address + ":" + std::to_string(connection.lan.port);
return connection.remote_id;
}
@@ -63,7 +63,7 @@ private:
int legacy_video_quality;
int legacy_video_frame_rate;
int video_encode_format;
bool enable_hardware_video_codec;
bool legacy_enable_hardware_video_codec;
bool enable_turn;
bool enable_srtp;
unsigned char key[16];
@@ -77,7 +77,7 @@ private:
int legacy_video_quality;
int legacy_video_frame_rate;
int video_encode_format;
bool enable_hardware_video_codec;
bool legacy_enable_hardware_video_codec;
bool enable_turn;
bool enable_srtp;
unsigned char key[16];
@@ -14,6 +14,7 @@
#include "platform.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#include "screen_lock.h"
#include "runtime/device_presence_request.h"
#include "runtime/gui_runtime.h"
@@ -26,6 +27,7 @@ constexpr auto kPresenceRetryInterval = std::chrono::seconds(1);
} // namespace
bool GuiRuntime::HasActiveSession() {
if (lan_ && !lan_->Quiescent()) return true;
if (stream_window_inited_) return true;
std::shared_lock lock(connection_status_mutex_);
return std::any_of(connection_status_.begin(), connection_status_.end(),
@@ -188,24 +190,21 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
void GuiRuntime::HandleServerControllerDisconnected(
const std::string& remote_id, const char* reason) {
transfers_.Cancel(peer_events_.get(), remote_id);
if (auto events = ControllerEvents(remote_id))
transfers_.Cancel(events.get(), remote_id);
preview_subscriptions_.Remove(remote_id);
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
#ifdef _WIN32
devices_.ReleaseRemoteMouseButtons();
#endif
{
std::lock_guard lock(remote_pointer_input_mutex_);
last_remote_pointer_input_time_.erase(remote_id);
}
bool has_connected_controller = false;
std::string remaining_controller_id;
{
std::unique_lock lock(connection_status_mutex_);
connection_status_.erase(remote_id);
disconnect_lock_.Disconnected(remote_id,
config_center_->IsLockOnDisconnect());
remote_input_.Disconnect(remote_id);
devices_.OnControllerConnectionsChanged();
connection_host_names_.erase(remote_id);
codec_status_controllers_.erase(remote_id);
privacy_sessions_.Disconnected(remote_id);
for (const auto& [id, status] : connection_status_) {
if (status != ConnectionStatus::Connected) {
@@ -249,10 +248,28 @@ void GuiRuntime::HandleServerControllerDisconnected(
#endif
devices_.StopSpeakerCapturer();
start_mouse_controller_ = false;
start_keyboard_capturer_ = false;
{
std::shared_lock lock(remote_sessions_mutex_);
start_keyboard_capturer_ = std::any_of(
remote_sessions_.begin(), remote_sessions_.end(), [](const auto& item) {
const auto& session = item.second;
return session && !session->closing_ &&
session->connection_established_ && !session->wall_managed_;
});
}
remote_client_id_.clear();
audio_capture_ = false;
devices_.ResetToInitialDisplay();
}
void GuiRuntime::HandleScreenLock() {
// Serialize request consumption with incoming admission so a reconnect
// cancels a pending lock before it can interrupt the new controller.
std::unique_lock lock(connection_status_mutex_);
if (!disconnect_lock_.TakeRequest(config_center_->IsLockOnDisconnect()))
return;
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
if (LockScreen()) LOG_INFO("Requested screen lock after last controller disconnected");
#endif
}
int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
@@ -301,10 +318,15 @@ int GuiRuntime::ForceSignalReconnect() {
}
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (!props || props->closing_.exchange(true)) return;
if (!props) return;
if (props->lan_session_ && !props->lan_session_->closing) {
if (lan_) lan_->Close(props->lan_session_);
return;
}
if (props->closing_.exchange(true)) return;
MuteRemoteAudio(props);
if (props->peer_events_) {
props->peer_events_->Deactivate();
props->peer_events_->StopAccepting();
transfers_.Cancel(props->peer_events_.get());
}
props->connection_established_ = false;
@@ -357,7 +379,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask(
[props, peer, frame_snapshot, video_width, video_height, platform,
thumbnail = thumbnail_, queued_at]() mutable {
if (peer) {
if (props->peer_events_) props->peer_events_->Deactivate();
if (peer && !props->lan_session_) {
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
props->remote_id_);
LeaveConnection(peer, props->remote_id_.c_str());
@@ -366,11 +389,20 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (thumbnail && props->was_connected_) {
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr;
const auto direct = props->lan_session_;
const Thumbnail::LanTarget target =
direct ? Thumbnail::LanTarget{direct->target.address,
direct->target.device_id,
direct->target.port}
: Thumbnail::LanTarget{};
const auto record_id = direct
? Thumbnail::LanRecordId(target.device_id)
: props->remote_id_;
if (thumbnail->SaveToThumbnail(
nv12, video_width, video_height, props->remote_id_,
nv12, video_width, video_height, record_id,
props->remote_host_name_,
props->remember_password_ ? props->remote_password_ : "",
platform) != 0) {
platform, direct ? &target : nullptr) != 0) {
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
}
}
@@ -382,6 +414,17 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
}
void GuiRuntime::CloseServerController(const std::string& remote_id) {
if (remote_id.rfind("lan/", 0) == 0) {
std::shared_ptr<lan::Session> session;
{
std::shared_lock lock(remote_sessions_mutex_);
if (auto it = lan_controllers_.find(remote_id);
it != lan_controllers_.end())
session = it->second.session;
}
if (session && lan_) lan_->Close(session);
return;
}
if (!peer_ || remote_id.empty() || controller_cleanup_tasks_.count(remote_id))
return;
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
@@ -399,20 +442,17 @@ void GuiRuntime::CloseConnectionPeer() {
announcements_.Reset();
return;
}
peer_events_->Deactivate();
peer_events_->StopAccepting();
transfers_.Cancel(peer_events_.get());
announcements_.Reset();
std::vector<std::string> controllers;
{
std::shared_lock lock(connection_status_mutex_);
for (const auto& [id, status] : connection_status_) controllers.push_back(id);
for (const auto& [id, status] : connection_status_)
if (id.rfind("lan/", 0) != 0) controllers.push_back(id);
}
for (const auto& id : controllers)
HandleServerControllerDisconnected(id, "peer_closed");
devices_.StopMouseController();
devices_.StopScreenCapturer();
devices_.StopSpeakerCapturer();
devices_.StopKeyboardCapturer();
signal_connected_ = false;
device_presence_cache_.SetSignalConnected(false);
signal_status_ = SignalStatus::SignalClosed;
@@ -425,6 +465,7 @@ void GuiRuntime::CloseConnectionPeer() {
// these finish before the shared peer is destroyed.
connection_peer_cleanup_ = session_cleanup_queue_.PostTask(
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
handler->Deactivate();
LOG_INFO("[{}] Background connection peer cleanup", id);
LeaveConnection(peer, id.c_str());
DestroyPeer(&peer);
@@ -558,6 +599,12 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
std::shared_lock lock(remote_sessions_mutex_);
auto it = remote_sessions_.find(remote_id);
if (it == remote_sessions_.end()) {
if (remote_id.rfind("lan-", 0) == 0)
for (const auto& [_, props] : remote_sessions_)
if (props->lan_session_ && !props->closing_ &&
Thumbnail::LanRecordId(props->lan_session_->target.device_id) ==
remote_id)
return props;
return nullptr;
}
return it->second;
@@ -22,6 +22,7 @@ inline nlohmann::json BuildDevicePresenceRequest(
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.rfind("lan-", 0) == 0 || pure_id.rfind("lan/", 0) == 0) return;
if (!pure_id.empty() &&
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
devices.push_back(pure_id);
@@ -0,0 +1,41 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DISCONNECT_LOCK_POLICY_H_
#define _DISCONNECT_LOCK_POLICY_H_
#include <string>
#include <unordered_set>
#include <utility>
namespace crossdesk {
// Guarded by GuiRuntime's connection_status_mutex_. Only incoming sessions
// that reached Connected can request a lock; terminal callbacks may repeat.
class DisconnectLockPolicy {
public:
void Connected(const std::string& remote_id) {
controllers_.insert(remote_id);
pending_ = false;
}
void Disconnected(const std::string& remote_id, bool enabled) {
if (controllers_.erase(remote_id) && controllers_.empty() && enabled)
pending_ = true;
}
bool TakeRequest(bool enabled) {
return std::exchange(pending_, false) && enabled && controllers_.empty();
}
private:
std::unordered_set<std::string> controllers_;
bool pending_ = false;
};
} // namespace crossdesk
#endif
+78 -13
View File
@@ -9,6 +9,7 @@
#include <mutex>
#include <shared_mutex>
#include <string>
#include <system_error>
#include <thread>
#include <vector>
@@ -16,6 +17,9 @@
#include "localization.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#ifdef _WIN32
#include "platform/windows/input/windows_input_injector.h"
#endif
#if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h"
#endif
@@ -24,20 +28,56 @@ namespace crossdesk {
GuiRuntime::GuiRuntime()
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this),
keyboard_(*this) {}
keyboard_(*this),
remote_input_(
[this](const std::string& id, const RemoteAction& action) {
#ifdef _WIN32
ScopedUserDesktopInputThread input_thread;
#endif
return ApplyRemoteInput(id, action);
},
[this](const std::string& id) {
#ifdef _WIN32
ScopedUserDesktopInputThread input_thread;
#endif
const bool keys_released =
keyboard_.ReleaseRemotePressedKeys(id, "input_queue_reset");
const bool mouse_released = devices_.ReleaseRemoteMouseButtons(id);
std::lock_guard lock(remote_pointer_input_mutex_);
last_remote_pointer_input_time_.erase(id);
return keys_released && mouse_released ? InputOutcome::applied
: InputOutcome::failed;
},
[](const std::string& id, int64_t queued_ms, int64_t execution_ms,
InputOutcome outcome) {
LOG_WARN("Remote input result: peer={} queue_ms={} execution_ms={} "
"result={}",
id, queued_ms, execution_ms, InputOutcomeName(outcome));
},
[this] { return devices_.ResetToInitialDisplay(); },
[this](const std::string& id, unsigned mask) {
#ifdef _WIN32
ScopedUserDesktopInputThread input_thread;
#endif
return devices_.ReleaseRemoteMouseButtons(id, mask)
? InputOutcome::applied : InputOutcome::failed;
}) {}
GuiRuntime::~GuiRuntime() {
remote_input_.Stop();
#if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
if (windows_service_worker_) windows_service_worker_->Reset();
#endif
lan_.reset();
transfers_.Shutdown();
WaitForSessionCleanup();
}
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
const bool mouse = action.type == ControlType::mouse;
const bool mouse_move = mouse && action.m.flag == MouseFlag::move;
if (!mouse && action.type != ControlType::keyboard &&
action.type != ControlType::keyboard_state)
return;
@@ -71,9 +111,15 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
}
// 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());
// Move updates supersede old positions and must not wait for KCP retries.
// Use the negotiated unreliable stream; clicks and wheel events remain
// on the reliable mouse stream, with their own absolute coordinates.
const int result = mouse_move
? SendDataFrame(props->peer_, message.data(), message.size(),
props->data_label_.c_str())
: 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);
}
@@ -96,7 +142,18 @@ int GuiRuntime::CreateConnectionPeer() {
signal_server_port = config_center_->GetSignalServerPort();
std::string current_self_hosted_ip = config_center_->GetSignalServerHost();
const bool use_cached_id = settings_.LoadCachedSelfHostedIdentity();
bool use_cached_id = false;
#if defined(__linux__) && !defined(__OHOS__)
try {
use_cached_id = settings_.LoadCachedSelfHostedIdentity();
} catch (const std::system_error&) {
// Keep the cached identity and avoid requesting a replacement identity
// when its file is temporarily inaccessible. SettingsManager logs why.
return -1;
}
#else
use_cached_id = settings_.LoadCachedSelfHostedIdentity();
#endif
if (!use_cached_id) {
LOG_INFO(
"secure_cache_v2.enc not found, will use empty id to get new id from "
@@ -181,7 +238,8 @@ int GuiRuntime::CreateConnectionPeer() {
strncpy(params_.log_path, dll_log_path_.c_str(),
sizeof(params_.log_path) - 1);
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
params_.hardware_acceleration = config_center_->IsHardwareVideoCodec();
// MiniRTC selects the available hardware backend and falls back to software.
params_.hardware_acceleration = true;
#if defined(_WIN32) || (defined(__linux__) && !defined(__OHOS__)) || defined(__APPLE__)
// Windows and Linux retain pooled CPU NV12 frames or CUDA device frames;
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
@@ -235,7 +293,7 @@ int GuiRuntime::CreateConnectionPeer() {
LOG_INFO("Create peer [{}] instance failed", client_id_);
}
if (0 == devices_.InitializeScreenCapturer()) {
if (0 == devices_.EnsureCaptureReady()) {
const auto &displays = devices_.display_info_list();
for (size_t index = 0; index < displays.size(); ++index) {
const std::string stream_id = MakeDisplayStreamId(index);
@@ -277,10 +335,18 @@ void GuiRuntime::HandleRecentConnections() {
// 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.
const auto direct = props->lan_session_;
const Thumbnail::LanTarget target =
direct ? Thumbnail::LanTarget{direct->target.address,
direct->target.device_id,
direct->target.port}
: Thumbnail::LanTarget{};
const auto record_id =
direct ? Thumbnail::LanRecordId(target.device_id) : props->remote_id_;
if (thumbnail_->SaveToThumbnail(
nullptr, 0, 0, props->remote_id_, host_name,
nullptr, 0, 0, record_id, host_name,
props->remember_password_ ? props->remote_password_ : "",
platform) == 0)
platform, direct ? &target : nullptr) == 0)
reload_recent_connections_ = true;
else
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
@@ -289,9 +355,7 @@ void GuiRuntime::HandleRecentConnections() {
if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) {
uint32_t now_time = SDL_GetTicks();
if (now_time - recent_connection_image_save_time_ >= 50) {
int ret = thumbnail_->LoadThumbnail(recent_connections_,
&recent_connection_image_width_,
&recent_connection_image_height_);
int ret = thumbnail_->LoadThumbnail(recent_connections_);
if (!ret) {
LOG_INFO("Load recent connection thumbnails");
}
@@ -299,7 +363,8 @@ void GuiRuntime::HandleRecentConnections() {
recent_connection_ids_.clear();
for (const auto &conn : recent_connections_) {
recent_connection_ids_.push_back(conn.first);
if (conn.second.lan.device_id.empty())
recent_connection_ids_.push_back(conn.first);
}
need_to_send_recent_connections_ = true;
}
@@ -17,9 +17,12 @@
#include "features/devices/session_device_manager.h"
#include "features/file_transfer/file_transfer_manager.h"
#include "features/input/keyboard_controller.h"
#include "features/input/remote_input_dispatcher.h"
#include "features/settings/settings_manager.h"
#include "network/lan/lan_session_manager.h"
#include "privacy_controller.h"
#include "runtime/gui_state.h"
#include "runtime/disconnect_lock_policy.h"
#include "runtime/announcement_inbox.h"
#include "runtime/peer_event_handler.h"
#include "runtime/privacy_session_policy.h"
@@ -49,6 +52,35 @@ class GuiRuntime : protected gui_detail::GuiState {
static void SdlCaptureAudioIn(void* userdata, uint8_t* stream, int len);
static void SdlCaptureAudioOut(void* userdata, uint8_t* stream, int len);
void InitializeLan();
void ApplyLanSettings();
bool LanAvailable() const;
void TickLan();
void BeginShutdown();
bool ShutdownDrained();
void RegisterStreams(PeerPtr* peer, size_t display_count);
void ConnectLan(const std::string& address, unsigned port,
std::string password, bool remember_password = false);
int WithController(
const std::string& id,
const std::function<int(PeerPtr*, const std::string&)>& operation);
int SendControllerData(const std::string& id, const char* data, size_t size,
const char* label);
std::shared_ptr<PeerEventHandler> ControllerEvents(const std::string& id);
std::vector<std::string> ConnectedControllers();
std::unique_ptr<lan::SessionManager> lan_;
std::string lan_error_;
std::shared_ptr<lan::Session> lan_connecting_;
bool lan_stopping_ = false;
bool stopping_ = false;
std::chrono::steady_clock::time_point shutdown_report_at_{};
struct LanController {
std::shared_ptr<lan::Session> session;
std::shared_ptr<PeerEventHandler> events;
};
// Protected by remote_sessions_mutex_; snapshots acquire callback leases
// before releasing that lock, so removal cannot race a raw Peer use.
std::map<std::string, LanController> lan_controllers_;
int CreateConnectionPeer();
int ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check = false,
@@ -71,6 +103,7 @@ class GuiRuntime : protected gui_detail::GuiState {
const char* reason);
void HandleWindowsServiceIntegration();
void HandlePrivacy();
void HandleScreenLock();
bool IsAuthorizedController(const std::string& remote_id);
void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
// Caller holds connection_status_mutex_ exclusively. Turns privacy off when
@@ -90,6 +123,8 @@ class GuiRuntime : protected gui_detail::GuiState {
// 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);
InputOutcome ApplyRemoteInput(const std::string& remote_id, const RemoteAction& action);
InputOutcome InjectRemoteMouse(const RemoteAction& action, int display);
std::string gallery_group_control_id_;
void ResetRemoteServiceStatus(RemoteSession& props);
@@ -124,6 +159,7 @@ class GuiRuntime : protected gui_detail::GuiState {
bool unattended_host_ = false;
AnnouncementInbox announcements_;
PrivacySessionPolicy privacy_sessions_;
DisconnectLockPolicy disconnect_lock_;
std::mutex privacy_commands_mutex_;
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
uint32_t last_privacy_revision_ = 0;
@@ -133,6 +169,7 @@ class GuiRuntime : protected gui_detail::GuiState {
FileTransferManager transfers_;
SettingsManager settings_;
KeyboardController keyboard_;
RemoteInputDispatcher remote_input_;
std::shared_ptr<PeerEventHandler> peer_events_;
std::atomic<bool> video_frame_dirty_{false};
std::unique_ptr<VideoRenderer> video_renderer_;
@@ -0,0 +1,236 @@
#include <display_stream_id.h>
#include "rd_log.h"
#include "runtime/gui_runtime.h"
#if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h"
#endif
namespace crossdesk {
void GuiRuntime::RegisterStreams(PeerPtr* peer, size_t displays) {
for (size_t i = 0; i < displays; ++i) {
const auto id = MakeDisplayStreamId(i);
AddVideoStream(peer, id.c_str());
}
AddAudioStream(peer, audio_label_.c_str());
AddDataStream(peer, data_label_.c_str(), false);
for (const auto& label :
{mouse_label_, keyboard_label_, control_data_label_, file_label_,
file_feedback_label_, clipboard_label_})
AddDataStream(peer, label.c_str(), true);
}
void GuiRuntime::InitializeLan() {
if (unattended_host_ || stopping_ || lan_ ||
!config_center_->IsIpDirectEnabled()) return;
#if defined(__linux__) && !defined(__OHOS__)
if (HeadlessConsole::Instance().active()) return;
#endif
lan_error_.clear();
lan::SessionManager::Hooks hooks;
hooks.error = [this](const std::string& error) {
if (!lan_stopping_ && !stopping_) lan_error_ = error;
};
hooks.prepare = [this](const std::shared_ptr<lan::Session>& session) {
if (stopping_ || lan_stopping_ || devices_.EnsureCaptureReady() != 0)
throw std::runtime_error("Capture initialization failed");
const size_t displays = devices_.display_info_list().size();
auto events = std::make_shared<PeerEventHandler>(*this);
events->BindDirect(session);
// Direct initialization must not depend on cloud login/configuration having
// successfully populated the managed peer's Params snapshot.
Params params{};
std::snprintf(params.log_path, sizeof(params.log_path), "%s",
dll_log_path_.c_str());
params.hardware_acceleration = true;
params.native_video_output = true;
params.av1_encoding = config_center_->GetVideoEncodeFormat() ==
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
params.enable_srtp = true;
params.turn_mode = TurnMode::TurnDisabled;
params.video_content_type = VideoContentType::ScreenContent;
params.video_quality = VideoQuality::QualityHigh;
params.video_frame_rate = 60;
params.video_degradation_preference =
VideoDegradationPreference::MaintainResolution;
params.on_receive_video_frame = PeerEventHandler::OnReceiveVideoBuffer;
params.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer;
params.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer;
params.on_connection_status = PeerEventHandler::OnConnectionStatus;
params.on_net_status_report = PeerEventHandler::OnNetStatusReport;
params.user_id = session->local_id.c_str();
params.user_data = events.get();
if (!session->incoming) {
auto props = FindRemoteSession(session->key);
if (!props) throw std::runtime_error("LAN session cancelled");
props->peer_events_ = events;
props->local_id_ = session->local_id;
}
return [this, session, events, params, displays] {
lan::SessionManager::Prepared result;
result.callback_owner = events;
result.drain_callbacks = [events] { events->Deactivate(); };
result.peer = CreatePeer(&params);
events->SetDirectPeer(result.peer);
if (result.peer) RegisterStreams(result.peer, displays);
return result;
};
};
hooks.attached = [this](const std::shared_ptr<lan::Session>& session) {
auto events =
std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
std::unique_lock lock(remote_sessions_mutex_);
if (session->incoming)
lan_controllers_[session->key] = {session, events};
else if (auto it = remote_sessions_.find(session->key);
it != remote_sessions_.end())
it->second->peer_ = session->peer;
};
hooks.connected = [this](const std::shared_ptr<lan::Session>& session) {
auto events =
std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
events->ApplyConnectionStatus(ConnectionStatus::Connected, session->key);
events->FlushEarlyData();
};
hooks.closed = [this](const std::shared_ptr<lan::Session>& session) {
auto events =
std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
bool attached = !session->incoming;
if (session->incoming) {
std::shared_lock lock(remote_sessions_mutex_);
attached = lan_controllers_.count(session->key) != 0;
}
if (events) {
if (attached)
events->ApplyConnectionStatus(ConnectionStatus::Closed, session->key);
events->StopAccepting();
transfers_.Cancel(events.get());
}
if (session->incoming) {
std::unique_lock lock(remote_sessions_mutex_);
lan_controllers_.erase(session->key);
} else if (auto props = FindRemoteSession(session->key))
CloseRemoteSession(props);
};
try {
lan_ = std::make_unique<lan::SessionManager>(
path_manager_->GetConfigPath() / "lan", std::move(hooks));
} catch (const std::exception& error) {
lan_error_ = error.what();
LOG_ERROR("LAN initialization failed: {}", error.what());
}
}
void GuiRuntime::ApplyLanSettings() {
if (config_center_->IsIpDirectEnabled()) {
InitializeLan();
} else {
lan_connecting_.reset();
lan_error_.clear();
if (lan_ && !lan_stopping_) {
lan_stopping_ = true;
lan_->Stop();
}
}
}
bool GuiRuntime::LanAvailable() const {
return lan_ && !lan_stopping_ && !stopping_;
}
void GuiRuntime::TickLan() {
if (lan_) lan_->Tick();
if (lan_ && lan_stopping_ && lan_->Quiescent()) {
// Drain async authentication, media preparation and callbacks before
// releasing the manager. A quick off/on waits here before rebinding.
lan_.reset();
lan_stopping_ = false;
InitializeLan();
}
}
void GuiRuntime::ConnectLan(const std::string& address, unsigned port,
std::string password, bool remember_password) {
if (!LanAvailable() || (lan_connecting_ && !lan_connecting_->closing))
return;
try {
auto props = std::make_shared<RemoteSession>();
props->remember_password_ = remember_password;
if (remember_password && lan::ValidPassword(password))
std::memcpy(props->remote_password_, password.data(), password.size());
auto session = lan_->Connect(address, port, std::move(password));
lan_connecting_ = session;
props->lan_session_ = session;
props->remote_id_ = session->key;
props->connection_status_ = ConnectionStatus::Connecting;
props->connection_auth_.Complete();
{
std::unique_lock lock(remote_sessions_mutex_);
remote_sessions_[session->key] = props;
}
focused_remote_id_ = session->key;
show_connection_status_window_ = false;
lan_error_.clear();
} catch (const std::exception& error) {
lan_error_ = error.what();
}
}
std::shared_ptr<PeerEventHandler> GuiRuntime::ControllerEvents(
const std::string& id) {
std::shared_lock lock(remote_sessions_mutex_);
auto it = lan_controllers_.find(id);
return it == lan_controllers_.end()
? (id.rfind("lan/", 0) == 0 ? nullptr : peer_events_)
: it->second.events;
}
int GuiRuntime::WithController(
const std::string& id,
const std::function<int(PeerPtr*, const std::string&)>& operation) {
std::shared_lock lock(remote_sessions_mutex_);
auto it = lan_controllers_.find(id);
auto events = it == lan_controllers_.end() ? peer_events_ : it->second.events;
if (it == lan_controllers_.end() && id.rfind("lan/", 0) == 0) return -1;
PeerPtr* peer =
it == lan_controllers_.end() ? peer_ : it->second.session->peer;
if (!peer || !events || !events->DataAllowed()) return -1;
auto callback = events->EnterCallback();
if (!callback) return -1;
const auto wire_id = events->WireId(id);
lock.unlock();
return operation(peer, wire_id);
}
int GuiRuntime::SendControllerData(const std::string& id, const char* data,
size_t size, const char* label) {
return WithController(id, [&](PeerPtr* peer, const std::string& wire) {
return SendReliableDataFrameToPeer(peer, data, size, label, wire.data(),
wire.size());
});
}
std::vector<std::string> GuiRuntime::ConnectedControllers() {
std::vector<std::string> ids;
std::shared_lock lock(connection_status_mutex_);
ids.reserve(connection_status_.size());
for (const auto& [id, status] : connection_status_)
if (status == ConnectionStatus::Connected) ids.push_back(id);
return ids;
}
void GuiRuntime::BeginShutdown() {
if (stopping_) return;
stopping_ = true;
shutdown_report_at_ =
std::chrono::steady_clock::now() + std::chrono::seconds(5);
if (lan_) lan_->Stop();
CloseAllRemoteSessions();
}
bool GuiRuntime::ShutdownDrained() {
TickLan();
HandleSessionCleanup();
const bool drained =
(!lan_ || lan_->Quiescent()) && !connection_peer_cleanup_.valid() &&
session_cleanup_tasks_.empty() && controller_cleanup_tasks_.empty();
if (!drained && std::chrono::steady_clock::now() >= shutdown_report_at_) {
LOG_WARN(
"Shutdown waiting: LAN [{}], cloud peer={}, viewers={}, controllers={}",
lan_ ? lan_->PendingWork() : "none", connection_peer_cleanup_.valid(),
session_cleanup_tasks_.size(), controller_cleanup_tasks_.size());
shutdown_report_at_ =
std::chrono::steady_clock::now() + std::chrono::seconds(5);
}
return drained;
}
} // namespace crossdesk
@@ -8,6 +8,9 @@
#define _NET_TRAFFIC_STATS_CACHE_H_
#include <atomic>
#include <cstring>
#include <mutex>
#include <string>
#include "minirtc.h"
#include "runtime/connection_latency.h"
@@ -15,7 +18,8 @@
namespace crossdesk::gui_detail {
// Fields are independent observations and may come from different reports.
// Relaxed accesses keep each field safe without publishing any other state.
// Relaxed accesses keep scalar fields safe without publishing other state;
// codec names are copied under their own mutex.
class NetTrafficStatsCache {
static_assert(std::atomic<uint32_t>::is_always_lock_free &&
std::atomic<float>::is_always_lock_free &&
@@ -26,7 +30,31 @@ class NetTrafficStatsCache {
public:
using Clock = std::chrono::steady_clock;
struct VideoCodecNames {
std::string remote_encoder;
std::string local_decoder;
};
void StoreRemoteVideoEncoder(const std::string& encoder) {
std::lock_guard lock(codec_mutex_);
codecs_.remote_encoder = encoder;
}
VideoCodecNames LoadVideoCodecs() const {
std::lock_guard lock(codec_mutex_);
return codecs_;
}
void Store(const MiniRtcNetTrafficStats& stats) {
{
std::lock_guard lock(codec_mutex_);
local_encoder_.assign(
stats.video_encoder,
strnlen(stats.video_encoder, sizeof(stats.video_encoder)));
codecs_.local_decoder.assign(
stats.video_decoder,
strnlen(stats.video_decoder, sizeof(stats.video_decoder)));
}
video_inbound_stats_.Store(stats.video_inbound_stats);
video_outbound_stats_.Store(stats.video_outbound_stats);
audio_inbound_stats_.Store(stats.audio_inbound_stats);
@@ -46,6 +74,11 @@ class NetTrafficStatsCache {
MiniRtcNetTrafficStats Load() const {
MiniRtcNetTrafficStats stats{};
{
std::lock_guard lock(codec_mutex_);
local_encoder_.copy(stats.video_encoder, sizeof(stats.video_encoder) - 1);
codecs_.local_decoder.copy(stats.video_decoder, sizeof(stats.video_decoder) - 1);
}
stats.video_inbound_stats = video_inbound_stats_.Load();
stats.video_outbound_stats = video_outbound_stats_.Load();
stats.audio_inbound_stats = audio_inbound_stats_.Load();
@@ -80,6 +113,10 @@ class NetTrafficStatsCache {
void Reset() {
Store(MiniRtcNetTrafficStats{});
{
std::lock_guard lock(codec_mutex_);
codecs_ = {};
}
ResetLatency();
last_report_ms_.store(0, std::memory_order_relaxed);
}
@@ -130,6 +167,9 @@ class NetTrafficStatsCache {
std::atomic<double> rtt_ms_{-1};
std::atomic<int64_t> last_report_ms_{0};
ConnectionLatencyStats connection_latency_;
mutable std::mutex codec_mutex_;
std::string local_encoder_;
VideoCodecNames codecs_;
};
} // namespace crossdesk::gui_detail
@@ -84,17 +84,52 @@ void LogSecureDesktopInputBlocked(uint32_t *last_tick, const char *side,
} // namespace
bool PeerEventHandler::QueueEarlyData(const char* data, size_t size,
const char* label, size_t label_size) {
if (!direct_bound_) return false;
auto session = direct_.lock();
if (!session || session->closing) return true;
std::lock_guard lock(early_data_mutex_);
if (session->authorized) return false;
// Keep bounded reliable metadata until the manager commits Connected. Mouse,
// keyboard, clipboard and files never enter this pre-authorization queue.
if (std::string_view(label, label_size) != owner_.control_data_label_)
return true;
if (size > 8192 || early_data_.size() >= 16 ||
early_data_bytes_ + size > 65536) {
session->failed = true;
return true;
}
early_data_bytes_ += size;
early_data_.emplace_back(data, size);
return true;
}
void PeerEventHandler::FlushEarlyData() {
std::vector<std::string> queued;
{
std::lock_guard lock(early_data_mutex_);
queued.swap(early_data_);
early_data_bytes_ = 0;
}
const auto session = direct_.lock();
if (!session || !session->authorized || session->closing) return;
const auto& label = owner_.control_data_label_;
for (const auto& data : queued)
OnReceiveDataBuffer(data.data(), data.size(), session->remote_id.data(),
session->remote_id.size(), label.data(), label.size(),
this);
}
void PeerEventHandler::OnReceiveDataBuffer(
const char *data, size_t size, const char *user_id, size_t user_id_size,
const char *src_id, size_t src_id_size, void *user_data) {
auto *handler = static_cast<PeerEventHandler *>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime *runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) {
if (!callback || handler->QueueEarlyData(data, size, src_id, src_id_size) ||
!handler->DataAllowed())
return;
}
GuiRuntime* runtime = &handler->owner_;
std::string source_id = std::string(src_id, src_id_size);
// The service-owned host runs as SYSTEM. Do not expose filesystem or
@@ -105,11 +140,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
source_id == runtime->clipboard_label_))
return;
if (source_id == runtime->file_label_) {
runtime->transfers_.Receive(data, size, handler, std::string(user_id, user_id_size));
runtime->transfers_.Receive(data, size, handler,
handler->ConnectionKey(user_id, user_id_size));
return;
} else if (source_id == runtime->clipboard_label_) {
if (size > 0) {
std::string remote_user_id(user_id, user_id_size);
std::string remote_user_id =
handler->ConnectionKey(user_id, user_id_size);
auto props =
runtime->FindRemoteSession(remote_user_id);
if (props && !props->clipboard_allowed_.load()) {
@@ -121,11 +158,15 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
return;
} else if (source_id == runtime->file_feedback_label_) {
runtime->transfers_.HandleAck(data, size, handler, std::string(user_id, user_id_size));
runtime->transfers_.HandleAck(
data, size, handler, handler->ConnectionKey(user_id, user_id_size));
return;
}
const std::string remote_id(user_id, user_id_size);
const std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
const std::string wire_id = handler->WireId(remote_id);
PeerPtr* bound_peer =
handler->IsDirect() ? handler->DirectPeer() : runtime->peer_;
std::string json_str(data, size);
const auto preview_json =
json_str.find("\"deskhub_preview") != std::string::npos
@@ -145,29 +186,31 @@ void PeerEventHandler::OnReceiveDataBuffer(
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;
bound_peer, wire_id.data(), wire_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(),
bound_peer, wire_id.data(), wire_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;
accepted = UpdateVideoResolution(bound_peer, wire_id.data(),
wire_id.size(), subscription->width,
subscription->height) == 0;
if (accepted)
accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), subscription->fps == 0) == 0;
accepted =
SetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(),
subscription->fps == 0) == 0;
if (accepted)
accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(),
remote_id.size(), !subscription->audio) == 0;
accepted =
SetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(),
!subscription->audio) == 0;
if (accepted && subscription->audio)
runtime->devices_.StartSpeakerCapturer();
}
@@ -178,9 +221,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
{"request_id", subscription->request_id},
{"accepted",
accepted}}.dump();
SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(),
SendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(),
runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size());
wire_id.data(), wire_id.size());
return;
}
if (preview_json.is_object() &&
@@ -209,6 +252,16 @@ void PeerEventHandler::OnReceiveDataBuffer(
return;
}
if (remote_action.type == ControlType::video_codec_status) {
if (auto props = runtime->FindRemoteSession(remote_id);
props && source_id == props->control_data_label_ &&
!props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected) {
props->net_traffic_stats_.StoreRemoteVideoEncoder(remote_action.vcs.encoder);
}
return;
}
if (remote_action.type == ControlType::video_settings_status) {
if (auto props = runtime->FindRemoteSession(remote_id)) {
if (source_id != props->control_data_label_) return;
@@ -243,7 +296,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
const auto& settings = remote_action.vs;
remote_action.vs.accepted =
UpdateVideoSettings(
runtime->peer_, remote_id.data(), remote_id.size(),
bound_peer, wire_id.data(), wire_id.size(),
static_cast<VideoQuality>(settings.quality), settings.frame_rate,
static_cast<VideoDegradationPreference>(settings.preference)) == 0;
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} "
@@ -252,9 +305,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
settings.frame_rate, settings.preference, remote_action.vs.accepted);
remote_action.type = ControlType::video_settings_status;
const auto message = remote_action.to_json();
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
SendReliableDataFrameToPeer(bound_peer, message.data(), message.size(),
runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size());
wire_id.data(), wire_id.size());
return;
}
if (remote_action.type == ControlType::privacy_status) {
@@ -280,29 +333,22 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
return;
}
if (remote_action.type == ControlType::text_input) {
// Share the ordered keyboard stream with delete/return and shortcuts.
if (source_id == runtime->keyboard_label_ &&
runtime->IsAuthorizedController(remote_id))
runtime->keyboard_.ApplyRemoteText(remote_id, remote_action.text);
if (RemoteInputDispatcher::Accepts(remote_action)) {
if (!runtime->IsAuthorizedController(remote_id)) return;
if (remote_action.type == ControlType::text_input &&
source_id != runtime->keyboard_label_) return;
if (runtime->remote_input_.Push(remote_id, remote_action) ==
RemoteInputDispatcher::Result::overflow)
LOG_WARN("Remote input queue overflow: peer={}, clearing stale input and "
"releasing keys/buttons",
remote_id);
return;
}
const bool operates_host = remote_action.type == ControlType::mouse ||
remote_action.type == ControlType::keyboard || remote_action.type == ControlType::keyboard_state ||
remote_action.type == ControlType::service_command || remote_action.type == ControlType::display_id ||
remote_action.type == ControlType::audio_capture;
if (operates_host) {
if (remote_action.type == ControlType::audio_capture) {
if (!runtime->IsAuthorizedController(remote_id)) return;
#ifdef _WIN32
// KeyboardController checks the desktop immediately before each native
// key injection, including state reconciliation and cleanup. Avoid a
// second synchronous session query for every received keyboard packet.
const bool keyboard_input = remote_action.type == ControlType::keyboard ||
remote_action.type == ControlType::keyboard_state;
if (!keyboard_input && runtime->privacy_.Engaged() &&
!IsWindowsPrivacyDesktopAvailable()) {
if (runtime->privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable())
runtime->privacy_.SuspendForDesktop();
}
#endif
}
if (remote_action.type == ControlType::service_status) {
@@ -312,28 +358,6 @@ void PeerEventHandler::OnReceiveDataBuffer(
return;
}
if (remote_action.type == ControlType::service_command) {
if (runtime->privacy_.Engaged()) {
runtime->privacy_.Fail("Security shortcut requested; privacy screen will turn off");
}
#if _WIN32
if (remote_action.c.flag == ServiceCommandFlag::send_sas) {
runtime->pending_windows_service_sas_.store(true,
std::memory_order_relaxed);
} else if (remote_action.c.flag == ServiceCommandFlag::lock_workstation) {
if (!LockWorkStation()) {
LOG_WARN("Remote lock workstation request failed, error={}",
GetLastError());
}
}
#elif defined(__OHOS__)
if (!SendHarmonySystemShortcut(remote_action.c.flag))
LOG_WARN("Harmony system shortcut unsupported or unauthorized, command={}",
static_cast<int>(remote_action.c.flag));
#endif
return;
}
if (remote_action.type == ControlType::cursor_state) {
std::shared_ptr<GuiRuntime::RemoteSession> props;
{
@@ -426,8 +450,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
props->video_settings_request_tick_ = SDL_GetTicks();
settings.vs = props->video_settings_;
const auto message = settings.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(),
props->control_data_label_.c_str());
SendReliableDataFrame(
handler->IsDirect() ? handler->DirectPeer() : props->peer_,
message.data(), message.size(),
props->control_data_label_.c_str());
}
}
// client mode
@@ -455,6 +481,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
{
std::unique_lock lock(runtime->connection_status_mutex_);
runtime->connection_host_names_[remote_id] = host_name;
if (source_id == runtime->control_data_label_) {
if (remote_action.i.supports_video_codec_status)
runtime->codec_status_controllers_.insert(remote_id);
else
runtime->codec_status_controllers_.erase(remote_id);
}
}
LOG_INFO("Remote hostname: [{}]", host_name);
if (source_id == runtime->control_data_label_) {
@@ -464,100 +496,151 @@ void PeerEventHandler::OnReceiveDataBuffer(
FreeRemoteAction(remote_action);
}
} else {
// remote
if (runtime->is_server_mode_ &&
remote_action.type == ControlType::mouse) {
std::lock_guard lock(runtime->remote_pointer_input_mutex_);
runtime->last_remote_pointer_input_time_[remote_id] =
std::chrono::steady_clock::now();
}
#if _WIN32
if (runtime->is_server_mode_ && remote_action.type == ControlType::mouse) {
static const bool process_elevated = IsAdministratorProcess();
std::string response;
const bool delivered = DispatchDesktopInput(
[&] {
return PreferUserDesktopInput(
IsCurrentSessionUserDesktopActive(),
runtime->windows_consent_ui_.load(),
runtime->local_service_available_.load(), process_elevated);
},
[&] {
SetLastError(ERROR_SUCCESS);
const bool sent = runtime->devices_.SendMouseCommand(
remote_action, runtime->selected_display_);
const DWORD error = GetLastError();
const bool blocked =
error == ERROR_ACCESS_DENIED || error == ERROR_GEN_FAILURE;
return DesktopInputResult{
sent, IsDesktopTransitionInputError(error) || blocked, blocked};
},
[&]() -> DesktopInputResult {
int absolute_x = 0;
int absolute_y = 0;
if (!BuildAbsoluteMousePosition(
runtime->devices_.display_info_list(),
runtime->selected_display_, remote_action.m.x,
remote_action.m.y, &absolute_x, &absolute_y)) {
LOG_WARN(
"Secure desktop mouse injection skipped, invalid display "
"mapping: display_index={}, x={}, y={}",
runtime->selected_display_, remote_action.m.x,
remote_action.m.y);
return {};
}
response = runtime->devices_.SendServiceMouseCommand(
absolute_x, absolute_y, remote_action.m.s,
static_cast<int>(remote_action.m.flag));
auto json = nlohmann::json::parse(response, nullptr, false);
const bool sent = json.is_object() && json.value("ok", false);
const std::string error =
json.is_object() ? json.value("error", "") : "";
return {sent, IsDesktopInputSetupPending(error)};
});
if (!delivered && !response.empty()) {
LogSecureDesktopInputBlocked(
&runtime->last_local_secure_input_block_log_tick_, "local",
runtime->WindowsInputStage().c_str());
LOG_WARN(
"Secure desktop mouse injection failed, x={}, y={}, wheel={}, "
"flag={}, response={}",
remote_action.m.x, remote_action.m.y, remote_action.m.s,
static_cast<int>(remote_action.m.flag), response);
}
return;
}
#endif
if (remote_action.type == ControlType::mouse) {
runtime->devices_.SendMouseCommand(remote_action,
runtime->selected_display_);
} else if (remote_action.type == ControlType::audio_capture) {
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);
const int ret = SetAudioSendPaused(bound_peer, wire_id.data(),
wire_id.size(), paused);
if (remote_action.a)
runtime->devices_.StartSpeakerCapturer();
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);
} else if (remote_action.type == ControlType::keyboard_state) {
runtime->keyboard_.ApplyRemoteState(remote_id, remote_action);
} else if (remote_action.type == ControlType::display_id) {
const int ret = runtime->devices_.SwitchDisplay(remote_action.d);
if (ret == 0) {
runtime->selected_display_ = remote_action.d;
} else {
LOG_WARN("Display switch skipped, invalid display_id={}",
remote_action.d);
}
}
}
}
} // namespace crossdesk
InputOutcome GuiRuntime::ApplyRemoteInput(const std::string& remote_id,
const RemoteAction& remote_action) {
if (!IsAuthorizedController(remote_id)) return InputOutcome::ignored;
if (remote_action.type == ControlType::text_input) {
return keyboard_.ApplyRemoteText(remote_id, remote_action.text);
}
#ifdef _WIN32
const bool keyboard_input = remote_action.type == ControlType::keyboard ||
remote_action.type == ControlType::keyboard_state;
if (!keyboard_input && privacy_.Engaged() &&
!IsWindowsPrivacyDesktopAvailable())
privacy_.SuspendForDesktop();
#endif
if (remote_action.type == ControlType::service_command) {
if (privacy_.Engaged()) {
privacy_.Fail(
"Security shortcut requested; privacy screen will turn off");
}
#if _WIN32
if (remote_action.c.flag == ServiceCommandFlag::send_sas) {
pending_windows_service_sas_.store(true, std::memory_order_relaxed);
return InputOutcome::applied;
} else if (remote_action.c.flag == ServiceCommandFlag::lock_workstation) {
if (!LockWorkStation()) {
LOG_WARN("Remote lock workstation request failed, error={}",
GetLastError());
return InputOutcome::failed;
}
return InputOutcome::applied;
}
#elif defined(__OHOS__)
if (!SendHarmonySystemShortcut(remote_action.c.flag)) {
LOG_WARN(
"Harmony system shortcut unsupported or unauthorized, command={}",
static_cast<int>(remote_action.c.flag));
return InputOutcome::failed;
}
return InputOutcome::applied;
#endif
return InputOutcome::ignored;
}
const int display = selected_display_.load();
if (is_server_mode_ && remote_action.type == ControlType::mouse) {
std::lock_guard lock(remote_pointer_input_mutex_);
last_remote_pointer_input_time_[remote_id] =
std::chrono::steady_clock::now();
}
if (remote_action.type == ControlType::mouse) {
return devices_.ApplyRemoteMouse(remote_id, remote_action.m.flag,
[this, &remote_action, display] {
return InjectRemoteMouse(remote_action, display);
});
} else if (remote_action.type == ControlType::keyboard) {
return keyboard_.ApplyRemoteEvent(remote_id, remote_action);
} else if (remote_action.type == ControlType::keyboard_state) {
return keyboard_.ApplyRemoteState(remote_id, remote_action);
} else if (remote_action.type == ControlType::display_id) {
const int ret = devices_.SwitchDisplay(remote_action.d);
if (ret == 0) {
selected_display_ = remote_action.d;
return InputOutcome::applied;
} else {
LOG_WARN("Display switch skipped, invalid display_id={}",
remote_action.d);
return InputOutcome::failed;
}
}
return InputOutcome::ignored;
}
InputOutcome GuiRuntime::InjectRemoteMouse(const RemoteAction& remote_action,
int display) {
#if _WIN32
if (is_server_mode_) {
static const bool process_elevated = IsAdministratorProcess();
std::string response;
const bool delivered = DispatchDesktopInput(
[&] {
return PreferUserDesktopInput(
IsCurrentSessionUserDesktopActive(), windows_consent_ui_.load(),
local_service_available_.load(), process_elevated);
},
[&] {
SetLastError(ERROR_SUCCESS);
const bool sent = devices_.SendMouseCommand(remote_action, display);
const DWORD error = GetLastError();
const bool blocked =
error == ERROR_ACCESS_DENIED || error == ERROR_GEN_FAILURE;
return DesktopInputResult{
sent, IsDesktopTransitionInputError(error) || blocked, blocked};
},
[&]() -> DesktopInputResult {
int absolute_x = 0;
int absolute_y = 0;
if (!BuildAbsoluteMousePosition(devices_.display_info_list(), display,
remote_action.m.x, remote_action.m.y,
&absolute_x, &absolute_y)) {
LOG_WARN(
"Secure desktop mouse injection skipped, invalid display "
"mapping: display_index={}, x={}, y={}",
display, remote_action.m.x, remote_action.m.y);
return {};
}
response = devices_.SendServiceMouseCommand(
absolute_x, absolute_y, remote_action.m.s,
static_cast<int>(remote_action.m.flag));
auto json = nlohmann::json::parse(response, nullptr, false);
const bool sent = json.is_object() && json.value("ok", false);
const std::string error =
json.is_object() ? json.value("error", "") : "";
return {sent, IsDesktopInputSetupPending(error)};
});
if (!delivered && !response.empty()) {
LogSecureDesktopInputBlocked(&last_local_secure_input_block_log_tick_,
"local", WindowsInputStage().c_str());
LOG_WARN(
"Secure desktop mouse injection failed, x={}, y={}, wheel={}, "
"flag={}, response={}",
remote_action.m.x, remote_action.m.y, remote_action.m.s,
static_cast<int>(remote_action.m.flag), response);
}
return delivered ? InputOutcome::applied : InputOutcome::failed;
}
#endif
return devices_.SendMouseCommand(remote_action, display)
? InputOutcome::applied : InputOutcome::failed;
}
} // namespace crossdesk
@@ -5,6 +5,7 @@
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <memory>
@@ -48,6 +49,22 @@ constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
} // namespace
std::string PeerEventHandler::ConnectionKey(const char* id, size_t size) const {
if (auto session = direct_.lock()) return session->key;
return direct_bound_ ? std::string{} : std::string(id, size);
}
std::string PeerEventHandler::WireId(const std::string& key) const {
if (auto session = direct_.lock()) return session->remote_id;
return key;
}
bool PeerEventHandler::DataAllowed() const {
if (!direct_bound_) return true;
auto session = direct_.lock();
return session && session->authorized && !session->closing;
}
void PeerEventHandler::SendClientInfo(PeerPtr* peer,
const std::string& client_id) {
if (!peer) {
@@ -73,8 +90,8 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime || !message || size == 0) {
GuiRuntime* runtime = &handler->owner_;
if (!message || size == 0) {
return;
}
std::string s(message, size);
@@ -178,10 +195,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) {
return;
}
GuiRuntime* runtime = &handler->owner_;
std::string client_id(user_id, user_id_size);
if (client_id == runtime->client_id_) {
@@ -250,17 +264,30 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
}
void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
const char* user_id,
const size_t user_id_size,
void* user_data) {
const char* user_id, size_t size,
void* context) {
auto* handler = static_cast<PeerEventHandler*>(context);
if (!handler) return;
if (handler->direct_bound_) {
auto callback = handler->EnterCallback();
if (callback)
if (auto session = handler->direct_.lock()) session->Status(status);
return;
}
DispatchConnectionStatus(status, user_id, size, context);
}
void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
const char* user_id,
const size_t user_id_size,
void* user_data) {
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) return;
GuiRuntime* runtime = &handler->owner_;
std::string remote_id(user_id, user_id_size);
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
@@ -295,6 +322,7 @@ 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_video_codec_status = true;
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
@@ -430,6 +458,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
{
std::unique_lock lock(runtime->connection_status_mutex_);
if (status == ConnectionStatus::Connected) {
runtime->disconnect_lock_.Connected(remote_id);
// Local privacy requires explicit desktop capability. Host info may
// arrive before Connected; consume that saved capability here.
const bool automatic_enable = runtime->privacy_sessions_.Connected(
@@ -437,6 +466,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->ApplyPrivacyAdmission(automatic_enable);
}
runtime->connection_status_[remote_id] = status;
if (status == ConnectionStatus::Connected)
runtime->remote_input_.Connect(remote_id);
#if _WIN32
runtime->windows_rdp_handoff_connected_->store(std::any_of(
runtime->connection_status_.begin(), runtime->connection_status_.end(),
@@ -458,6 +489,7 @@ 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_video_codec_status = true;
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
@@ -469,8 +501,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
}
std::string msg = remote_action.to_json();
const int ret =
SendReliableDataFrame(runtime->peer_, msg.data(), msg.size(),
runtime->control_data_label_.c_str());
runtime->SendControllerData(remote_id, msg.data(), msg.size(),
runtime->control_data_label_.c_str());
if (ret != 0) {
LOG_WARN("[{}] host information send failed, ret={}", remote_id,
ret);
@@ -511,21 +543,12 @@ void PeerEventHandler::OnNetStatusReport(
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) {
return;
}
GuiRuntime* runtime = &handler->owner_;
if (strchr(client_id, '@') != nullptr && strchr(user_id, '-') == nullptr) {
std::string id, password;
const char* at_pos = strchr(client_id, '@');
if (at_pos == nullptr) {
id = client_id;
password.clear();
} else {
id.assign(client_id, at_pos - client_id);
password = at_pos + 1;
}
if (const char* at_pos = handler->IsDirect() ? nullptr : strchr(client_id, '@');
at_pos && strchr(user_id, '-') == nullptr) {
const std::string id(client_id, at_pos - client_id);
const std::string password(at_pos + 1);
bool is_self_hosted = runtime->config_center_->IsSelfHosted();
@@ -573,9 +596,30 @@ void PeerEventHandler::OnNetStatusReport(
#endif
}
std::string remote_id(user_id, user_id_size);
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
auto props = runtime->FindRemoteSession(remote_id);
if (!props) return;
if (!props) {
bool report_codecs = false;
{
std::shared_lock lock(runtime->connection_status_mutex_);
const auto status = runtime->connection_status_.find(remote_id);
report_codecs = runtime->codec_status_controllers_.count(remote_id) != 0 &&
status != runtime->connection_status_.end() &&
status->second == ConnectionStatus::Connected;
}
if (net_traffic_stats && report_codecs) {
RemoteAction action{};
action.type = ControlType::video_codec_status;
std::snprintf(action.vcs.encoder, sizeof(action.vcs.encoder), "%.*s",
static_cast<int>(sizeof(net_traffic_stats->video_encoder)),
net_traffic_stats->video_encoder);
const auto message = action.to_json();
// Report only to this controller, after releasing the status lock.
runtime->SendControllerData(remote_id, message.data(), message.size(),
runtime->control_data_label_.c_str());
}
return;
}
if (props->traversal_mode_ != mode) {
props->net_traffic_stats_.ResetLatency();
props->traversal_mode_ = mode;
@@ -8,7 +8,10 @@
#define _PEER_EVENT_HANDLER_H_
#include <cstddef>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "minirtc.h"
#include "runtime/session_lifecycle.h"
@@ -16,12 +19,30 @@
namespace crossdesk {
class GuiRuntime;
namespace lan {
struct Session;
}
// Adapts MiniRTC C callbacks to the GUI runtime.
class PeerEventHandler {
public:
explicit PeerEventHandler(GuiRuntime &owner);
void BindDirect(const std::shared_ptr<lan::Session>& session) {
direct_ = session;
direct_bound_ = true;
}
std::string ConnectionKey(const char* id, size_t size) const;
std::string WireId(const std::string& key) const;
bool DataAllowed() const;
bool IsDirect() const { return direct_bound_; }
void SetDirectPeer(PeerPtr* peer) { direct_peer_ = peer; }
PeerPtr* DirectPeer() const { return direct_peer_; }
void FlushEarlyData();
void ApplyConnectionStatus(ConnectionStatus status, const std::string& id) {
DispatchConnectionStatus(status, id.data(), id.size(), this);
}
void StopAccepting() { callbacks_.StopAccepting(); }
bool IsActive() const { return callbacks_.IsActive(); }
auto EnterCallback() { return callbacks_.Enter(); }
void Deactivate() { callbacks_.Close(); }
@@ -51,12 +72,22 @@ public:
void *user_data);
private:
static void SendClientInfo(PeerPtr *peer, const std::string &client_id);
static void DispatchConnectionStatus(ConnectionStatus status, const char* id,
size_t size, void* context);
static void SendClientInfo(PeerPtr* peer, const std::string& client_id);
bool QueueEarlyData(const char* data, size_t size, const char* label,
size_t label_size);
std::weak_ptr<lan::Session> direct_;
bool direct_bound_ = false;
PeerPtr* direct_peer_ = nullptr;
std::mutex early_data_mutex_;
std::vector<std::string> early_data_;
size_t early_data_bytes_ = 0;
GuiRuntime &owner_;
GuiRuntime& owner_;
gui_detail::SessionCallbackGate callbacks_;
};
} // namespace crossdesk
#endif
#endif
@@ -38,20 +38,21 @@ void PeerEventHandler::OnReceiveVideoBuffer(
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime || !video_frame) {
if (!callback || !handler->DataAllowed()) return;
GuiRuntime* runtime = &handler->owner_;
if (!video_frame) {
return;
}
std::string remote_id(user_id, user_id_size);
std::string remote_id = handler->ConnectionKey(user_id, user_id_size);
auto props = runtime->FindRemoteSession(remote_id);
if (!props) return;
if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now();
// Pair the clocks before preview conversion so its cost is included.
const auto local_now_us = GetSystemTimeMicros(props->peer_);
const auto local_now_us = GetSystemTimeMicros(
handler->IsDirect() ? handler->DirectPeer() : props->peer_);
const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record(
received_native ? received_native->width : video_frame->width,
@@ -274,15 +275,15 @@ void PeerEventHandler::OnReceiveAudioBuffer(
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime || runtime->unattended_host_) {
if (!callback || !handler->DataAllowed()) return;
GuiRuntime* runtime = &handler->owner_;
if (runtime->unattended_host_) {
return;
}
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
const auto session =
runtime->FindRemoteSession(std::string(user_id, user_id_size));
runtime->FindRemoteSession(handler->ConnectionKey(user_id, user_id_size));
if (!session || session->closing_ || !session->audio_allowed_.load()) return;
runtime->audio_buffer_fresh_ = true;
@@ -15,6 +15,10 @@
namespace crossdesk {
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
if (remote_id.rfind("lan/", 0) == 0) {
const auto events = ControllerEvents(remote_id);
if (!events || !events->DataAllowed()) return false;
}
// Connections appear here only after the existing password/signalling/RTC
// admission path. Outgoing/viewer sessions live in remote_sessions_ instead.
std::shared_lock lock(connection_status_mutex_);
@@ -109,9 +113,9 @@ void GuiRuntime::HandlePrivacy() {
"A connected controller does not support privacy screen");
}
}
if (!peer_ || (last_privacy_status_tick_ != 0 &&
status.revision == last_privacy_revision_ &&
now - last_privacy_status_tick_ < 1000))
if (last_privacy_status_tick_ != 0 &&
status.revision == last_privacy_revision_ &&
now - last_privacy_status_tick_ < 1000)
return;
RemoteAction action{};
action.type = ControlType::privacy_status;
@@ -119,9 +123,8 @@ void GuiRuntime::HandlePrivacy() {
const auto message = action.to_json();
bool sent = true;
for (const auto& id : controllers) {
const int result = SendReliableDataFrameToPeer(
peer_, message.data(), message.size(), control_data_label_.c_str(),
id.data(), id.size());
const int result = SendControllerData(id, message.data(), message.size(),
control_data_label_.c_str());
if (result != 0) {
sent = false;
LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result);
@@ -31,7 +31,12 @@
#include "runtime/received_video_stats.h"
#include "runtime/connection_auth_state.h"
namespace crossdesk { class PeerEventHandler; }
namespace crossdesk {
class PeerEventHandler;
namespace lan {
struct Session;
}
} // namespace crossdesk
namespace crossdesk::gui_detail {
@@ -105,6 +110,7 @@ struct RemoteSession {
// Decoder-thread scratch memory, never shared with the UI.
std::mutex gallery_conversion_mutex_;
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
std::shared_ptr<lan::Session> lan_session_;
Params params_;
PeerPtr* peer_ = nullptr;
std::shared_ptr<PeerEventHandler> peer_events_;
+2 -6
View File
@@ -18,6 +18,7 @@
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
@@ -58,8 +59,6 @@ struct RecentConnectionsState {
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;
DevicePresenceCache device_presence_cache_;
std::atomic<bool> need_to_send_recent_connections_{true};
@@ -149,15 +148,11 @@ struct UserSettingsState {
char connection_login_identity_[17] = "";
int language_button_value_ = 0;
int video_encode_format_button_value_ = 0;
bool enable_hardware_video_codec_ = true;
bool enable_turn_ = true;
char signal_server_ip_[256] = "api.crossdesk.cn";
char signal_server_port_[6] = "9099";
bool enable_self_hosted_ = false;
int language_button_value_last_ = 0;
int video_encode_format_button_value_last_ = 0;
bool enable_hardware_video_codec_last_ = false;
bool enable_turn_last_ = true;
bool enable_self_hosted_last_ = false;
bool enable_autostart_ = false;
bool enable_autostart_last_ = false;
@@ -208,6 +203,7 @@ struct ConnectionState {
std::shared_mutex connection_status_mutex_;
std::unordered_map<std::string, ConnectionStatus> connection_status_;
std::unordered_map<std::string, std::string> connection_host_names_;
std::unordered_set<std::string> codec_status_controllers_;
// Cursor position is sampled asynchronously after remote mouse input has
// been injected. Remember the input source so the sampled position is not
// immediately echoed back to that same controller as stale feedback.
@@ -37,6 +37,11 @@ void SessionCallbackGate::EndCallback() {
}
}
void SessionCallbackGate::StopAccepting() {
std::lock_guard lock(mutex_);
accepting_callbacks_ = false;
}
void SessionCallbackGate::Close() {
std::unique_lock lock(mutex_);
accepting_callbacks_ = false;
@@ -39,6 +39,7 @@ class SessionCallbackGate {
CallbackGuard Enter();
// Called by the session owner, never from within this session's callbacks.
void StopAccepting();
void Close();
private:
+236
View File
@@ -0,0 +1,236 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { ScrollView, TextEdit } from "std-widgets.slint";
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { FontAwesomeIcons, ControlStyle } from "common.slint";
import "../assets/fonts/message_spacing.ttf";
export enum AboutPage { overview, components, component, document, source }
export struct AboutDocument { title: string, text: string }
export struct AboutComponent {
id: string,
name: string,
version: string,
license: string,
source-url: string,
documents: [AboutDocument],
}
export global AboutStrings {
out property <string> software-license: GalleryStrings.tr("软件许可", "Software license", "Лицензия программы");
out property <string> components: GalleryStrings.tr("开源组件", "Open-source components", "Компоненты с открытым кодом");
out property <string> source: GalleryStrings.tr("源码与构建说明", "Source code and build instructions", "Исходный код и сборка");
out property <string> notices: GalleryStrings.tr("许可与版权声明", "Licenses and copyright notices", "Лицензии и авторские права");
out property <string> view-source: GalleryStrings.tr("查看源码", "View source code", "Открыть исходный код");
out property <string> back: GalleryStrings.tr("返回", "Back", "Назад");
}
export component AboutNavigationGroup inherits FlatSurface {
height: rows.preferred-height + 2px;
background: WorkbenchStyle.surface;
border-radius: UiRadius.control;
outline-color: UiSurface.edge;
clip: true;
rows := VerticalLayout {
x: 1px; y: 1px; width: parent.width - 2px;
height: self.preferred-height; padding: 0px; spacing: 0px;
@children
}
}
export component AboutRowSeparator inherits Rectangle {
height: 1px;
SurfaceSeparator {
x: 14px; width: parent.width - 30px; height: 1px;
outline-color: UiSurface.separator;
}
}
export component AboutNavigationRow inherits FlatSurface {
in property <string> title;
in property <string> subtitle;
in property <string> icon;
in property <string> detail;
in property <bool> grouped: false;
in property <bool> external: false;
callback clicked;
min-width: 0px;
min-height: max(44px, labels.preferred-height + 20px);
background: touch.pressed ? WorkbenchStyle.pressed-surface
: touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: root.grouped ? 0px : UiRadius.control;
outline-enabled: !root.grouped; outline-color: UiSurface.edge;
accessible-role: button;
accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "")
+ (root.detail != "" ? ", " + root.detail : "");
accessible-action-default => { root.clicked(); }
if root.icon != "": Rectangle {
x: 12px; y: (parent.height - self.height) / 2;
width: 28px; height: 28px;
border-radius: UiRadius.control;
background: ControlStyle.surface;
Text {
text: root.icon; font-family: "Font Awesome 6 Free"; font-weight: 900;
font-size: 13px; color: ControlStyle.icon;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
}
labels := VerticalLayout {
x: root.icon != "" ? 52px : 14px;
y: (parent.height - self.height) / 2;
width: parent.width - self.x - 36px - (root.detail != "" ? detail-label.width + 12px : 0px);
height: self.preferred-height; spacing: 4px;
Text { text: root.title; color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap; }
if root.subtitle != "": Text {
text: root.subtitle; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
}
}
detail-label := Text {
x: parent.width - self.width - 36px; height: parent.height;
width: min(100px, self.preferred-width);
text: root.detail; color: WorkbenchStyle.muted; font-size: 11px;
vertical-alignment: center; overflow: elide;
accessible-role: none;
}
Text {
x: parent.width - 30px; width: 18px; height: parent.height;
text: root.external ? FontAwesomeIcons.arrow-up-right-from-square : FontAwesomeIcons.angle-right;
font-family: "Font Awesome 6 Free"; font-weight: 900;
color: touch.has-hover ? WorkbenchStyle.accent : WorkbenchStyle.muted;
font-size: 11px; vertical-alignment: center; horizontal-alignment: center;
accessible-role: none;
}
touch := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } }
}
export component AboutPanel inherits SettingsDialog {
show-close-button: false;
in-out property <AboutPage> page: AboutPage.components;
in-out property <int> component-index: 0;
private property <int> document-index: 0;
in property <[AboutComponent]> components;
in property <int> openfec-index: -1;
in property <string> revision;
in property <string> source-url;
in property <string> build-url;
private property <AboutComponent> selected: root.components[root.component-index];
callback back;
callback open-link(string);
title: root.page == AboutPage.components ? AboutStrings.components
: root.page == AboutPage.source ? AboutStrings.source
: root.page == AboutPage.document ? AboutStrings.notices
: root.selected.name;
if root.page != AboutPage.document: ScrollView {
x: 24px; y: root.content-top; width: parent.width - 48px + 22px;
height: footer.y - self.y - 16px;
viewport-width: self.visible-width;
viewport-height: content.preferred-height;
horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed;
content := VerticalLayout {
width: parent.width; height: self.preferred-height;
padding: 0px; padding-right: 22px; spacing: 10px;
if root.page == AboutPage.components: VerticalLayout {
padding: 0px; spacing: 10px;
Text {
text: GalleryStrings.tr("以下列出本版本的主要开源组件。各组件遵循各自的开源许可。", "Main open-source components used in this build. Each component is covered by its own license.", "Основные открытые компоненты этой сборки. Каждый распространяется по своей лицензии.");
color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap;
}
Rectangle {
height: notice.preferred-height + 32px;
background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
notice := VerticalLayout {
x: 16px; y: 16px; width: parent.width - 32px;
height: self.preferred-height; spacing: 12px;
Text { text: GalleryStrings.tr("OpenFEC 告知", "OpenFEC notice", "Уведомление OpenFEC"); color: WorkbenchStyle.text; font-size: 14px; font-weight: 600; }
Text {
text: GalleryStrings.tr("本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。", "This application uses OpenFEC for forward error correction under CeCILL-C 1.0, which provides limited warranties and limits liability. See the full license for details.", "Приложение использует OpenFEC для прямого исправления ошибок по лицензии CeCILL-C 1.0 с ограниченной гарантией и ответственностью. Подробности — в тексте лицензии.");
color: WorkbenchStyle.muted; font-size: 13px; wrap: word-wrap;
font-family: "CrossDesk Message Spacing";
}
Text {
text: "(c) Copyright 2009 - 2012 INRIA - All rights reserved";
color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
font-family: "CrossDesk Message Spacing";
}
if root.openfec-index >= 0: AboutNavigationRow {
title: GalleryStrings.tr("OpenFEC 许可与源码", "OpenFEC license and source", "Лицензия и исходный код OpenFEC");
clicked => { root.component-index = root.openfec-index; root.page = AboutPage.component; }
}
}
}
for component[index] in root.components: VerticalLayout {
padding: 0px; spacing: 0px;
if component.id != "crossdesk" && component.id != "openfec": AboutNavigationRow {
title: component.name;
subtitle: component.version + " · " + component.license;
clicked => { root.component-index = index; root.page = AboutPage.component; }
}
}
}
if root.page == AboutPage.component: VerticalLayout {
padding: 0px; spacing: 12px;
Text { text: root.selected.version + " · " + root.selected.license; color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap; }
AboutNavigationRow {
title: AboutStrings.view-source; external: true;
clicked => { root.open-link(root.selected.source-url); }
}
Text { text: AboutStrings.notices; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; }
for document[index] in root.selected.documents: AboutNavigationRow {
title: document.title;
clicked => { root.document-index = index; root.page = AboutPage.document; }
}
if root.selected.documents.length == 0: Text {
text: GalleryStrings.tr("本组件未内置许可文本,请通过“查看源码”查阅许可与版权声明。", "License text is not bundled for this component. Use View source code to read its license and copyright notices.", "Текст лицензии не включён. Откройте исходный код для просмотра лицензии и авторских прав.");
color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap;
}
}
if root.page == AboutPage.source: VerticalLayout {
padding: 0px; spacing: 12px;
Text {
text: root.revision != "" ? GalleryStrings.tr("源码版本:", "Source revision: ", "Ревизия исходного кода: ") + root.revision
: GalleryStrings.tr("本版本源码信息暂不可用。", "Source revision is unavailable for this build.", "Ревизия исходного кода этой сборки недоступна.");
color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap;
}
AboutNavigationRow { title: AboutStrings.view-source; external: true; clicked => { root.open-link(root.source-url); } }
AboutNavigationRow {
title: GalleryStrings.tr("构建说明", "Build instructions", "Инструкции по сборке"); external: true;
clicked => { root.open-link(root.build-url); }
}
AboutNavigationRow {
title: GalleryStrings.tr("项目主页", "Project website", "Сайт проекта"); external: true;
clicked => { root.open-link("https://crossdesk.cn"); }
}
}
}
}
if root.page == AboutPage.document: VerticalLayout {
x: 24px; y: root.content-top; width: parent.width - 48px;
height: footer.y - self.y - 16px; spacing: 12px;
Text { text: root.selected.documents[root.document-index].title; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; wrap: word-wrap; }
TextEdit {
text: root.selected.documents[root.document-index].text;
read-only: true; wrap: word-wrap; font-size: 14px;
// Use the same 1.5em line spacing as other long-form reading views.
font-family: "CrossDesk Message Spacing";
vertical-stretch: 1;
key-pressed(event) => {
if event.text == Key.Escape { root.back(); return accept; }
return reject;
}
}
}
footer := VerticalLayout {
x: 24px; y: parent.height - self.height - 24px;
width: parent.width - 48px; height: self.preferred-height; spacing: 12px;
SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
HorizontalLayout {
alignment: end;
SettingsButton { text: AboutStrings.back; clicked => { root.back(); } }
}
}
}
+145 -89
View File
@@ -1,17 +1,42 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line
// colors in one place so the Slint renderer produces the same visual weight.
export global ImGuiLineStyle {
out property <color> border: #0000004d;
out property <color> separator: #6363639e;
out property <color> divider: #0000007a;
out property <color> control-separator: #b2b2b2;
}
export global FloatingBarStyle {
out property <length> corner-radius: UiRadius.menu;
}
// Fields and selection controls share a muted fill.
export global ControlStyle {
out property <color> surface: #dce6f3;
out property <color> hover: #cedef1;
out property <color> pressed: #bfd2ec;
out property <color> selected: #bcd4f7;
out property <color> selected-hover: #adcaf1;
out property <color> selected-pressed: #99bce8;
out property <color> selected-text: #1d4e9e;
out property <color> disabled: #f0f2f5;
out property <color> icon: #52657f;
out property <color> input-surface: #dee7f2;
out property <color> input-focused: #e5ecf5;
out property <color> input-placeholder: #566a84;
// These tints over white match the field fills for standard LineEdit.
out property <color> input-tint: #235eaa26;
out property <color> input-focus-tint: #235eaa1e;
}
// Action buttons use a light surface and a crisp edge, distinct from fields.
export global ButtonStyle {
out property <color> surface: #ffffff;
out property <color> hover: #f0f5ff;
out property <color> pressed: #dfe9fa;
out property <color> border: #b8c7db;
out property <color> hover-border: #7e9fd1;
out property <color> pressed-border: #6488bf;
out property <color> disabled-border: #dce2eb;
out property <color> selected: #3564e3;
out property <color> selected-hover: #4574f0;
out property <color> selected-pressed: #254cc0;
}
// ImGui loads a 32px font atlas and applies per-window font scales. These
// logical sizes reproduce those visual tiers on Slint's HiDPI renderer.
export global ImGuiFontStyle {
@@ -32,9 +57,11 @@ export global FontAwesomeIcons {
out property <string> angle-right: "\u{f105}";
out property <string> angle-up: "\u{f106}";
out property <string> arrow-right-long: "\u{f178}";
out property <string> arrow-up-right-from-square: "\u{f35d}";
out property <string> bars: "\u{f0c9}";
out property <string> check: "\u{f00c}";
out property <string> circle-arrow-up: "\u{f0aa}";
out property <string> code: "\u{f121}";
out property <string> compress: "\u{f066}";
out property <string> computer-mouse: "\u{f8cc}";
out property <string> copy: "\u{f0c5}";
@@ -42,8 +69,10 @@ export global FontAwesomeIcons {
out property <string> expand: "\u{f065}";
out property <string> eye: "\u{f06e}";
out property <string> eye-slash: "\u{f070}";
out property <string> file-lines: "\u{f15c}";
out property <string> folder-open: "\u{f07c}";
out property <string> keyboard: "\u{f11c}";
out property <string> layer-group: "\u{f5fd}";
out property <string> minus: "\u{f068}";
out property <string> pen: "\u{f304}";
out property <string> rotate: "\u{f2f1}";
@@ -65,10 +94,10 @@ export component IconButton inherits Rectangle {
in property <bool> enabled: true;
in property <bool> danger: false;
in property <length> icon-size: 16px;
in property <color> icon-color: #24272d;
in property <color> icon-color: ControlStyle.icon;
in property <color> normal-background: transparent;
in property <color> hover-background: #edf0f5;
in property <color> pressed-background: #d9dde5;
in property <color> hover-background: ControlStyle.hover;
in property <color> pressed-background: ControlStyle.pressed;
in property <bool> slashed: false;
callback clicked;
@@ -134,8 +163,8 @@ export component ImageButton inherits Rectangle {
width: 30px;
height: 30px;
background: touch.pressed ? (danger ? #d92d20 : #d9dde5)
: touch.has-hover ? (danger ? #f04438 : #edf0f5)
background: touch.pressed ? (danger ? #d92d20 : ControlStyle.pressed)
: touch.has-hover ? (danger ? #f04438 : ControlStyle.hover)
: transparent;
border-radius: UiRadius.control;
@@ -170,14 +199,14 @@ export component ImageButton inherits Rectangle {
}
}
export component CompactComboBox inherits Rectangle {
export component CompactComboBox inherits FlatSurface {
in property <[string]> model;
in-out property <int> current-index: 0;
in property <bool> enabled: true;
in property <bool> subtle: false;
in property <bool> show-arrow: true;
in property <color> normal-border-color: root.subtle ? #dfe6ef : #bfc2c7;
in property <color> hover-border-color: root.normal-border-color;
in property <color> normal-outline-color: ButtonStyle.border;
in property <color> hover-outline-color: ButtonStyle.hover-border;
property <bool> popup-animation-enabled: false;
property <float> popup-progress: 0.0;
callback selected(int);
@@ -201,43 +230,45 @@ export component CompactComboBox inherits Rectangle {
width: 73px;
height: 24px;
background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1;
border-width: 1px;
border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color;
background: !root.enabled ? ControlStyle.disabled
: combo-touch.pressed ? ButtonStyle.pressed
: combo-popup.is-open || combo-touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
outline-focused: root.enabled && combo-popup.is-open;
outline-color: !root.enabled ? ButtonStyle.disabled-border
: combo-popup.is-open ? UiSurface.focus
: combo-touch.pressed ? ButtonStyle.pressed-border
: combo-touch.has-hover ? root.hover-outline-color : root.normal-outline-color;
border-radius: UiRadius.control;
animate background, outline-color { duration: 120ms; }
accessible-role: combobox;
accessible-enabled: root.enabled;
accessible-expandable: true;
accessible-expanded: combo-popup.is-open;
accessible-value: root.model[root.current-index];
Text {
x: root.subtle ? 10px : 5px;
width: parent.width - (root.show-arrow ? (root.subtle ? 36px : 24px) : (root.subtle ? 20px : 10px));
x: root.subtle ? 10px : 8px;
width: parent.width - self.x - (root.show-arrow ? 28px : self.x);
height: parent.height;
text: root.model[root.current-index];
color: root.enabled ? #202124 : #8b8f95;
font-size: root.subtle ? 12px : ImGuiFontStyle.body;
overflow: elide;
vertical-alignment: center;
accessible-role: none;
}
if root.show-arrow: Rectangle {
x: parent.width - (root.subtle ? 27px : 21px);
width: 20px;
height: parent.height - 2px;
y: 1px;
background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc;
border-top-right-radius: UiRadius.control - 1px;
border-bottom-right-radius: UiRadius.control - 1px;
Text {
text: FontAwesomeIcons.angle-down;
color: root.subtle ? #79879a : #202124;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 10px;
horizontal-alignment: center;
vertical-alignment: center;
transform-rotation: combo-popup.is-open ? 180deg : 0deg;
animate transform-rotation {
duration: 140ms;
easing: ease-out;
enabled: combo-popup.is-open;
}
}
if root.show-arrow: Text {
x: parent.width - 26px;
width: 18px; height: parent.height;
// Switch glyphs so the software renderer also shows the open state.
text: combo-popup.is-open ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down;
color: !root.enabled ? #98a2b3 : combo-popup.is-open ? UiSurface.focus : ControlStyle.icon;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 10px;
horizontal-alignment: center;
vertical-alignment: center;
accessible-role: none;
}
combo-touch := TouchArea {
enabled: root.enabled; mouse-cursor: pointer;
@@ -265,12 +296,12 @@ export component CompactComboBox inherits Rectangle {
combo-popup := PopupWindow {
x: 0px;
y: root.height;
y: root.height + 4px;
width: root.width;
height: root.model.length * 23px;
height: root.model.length * 23px + 8px;
close-policy: close-on-click-outside;
Rectangle {
FlatSurface {
// Keep the reveal anchored to the popup's top edge while the
// content settles downward into its final position.
y: (1.0 - root.popup-progress) * -8px;
@@ -278,18 +309,33 @@ export component CompactComboBox inherits Rectangle {
height: parent.height * root.popup-progress;
opacity: root.popup-progress;
background: white;
border-width: 1px;
border-color: root.normal-border-color;
outline-color: root.normal-outline-color;
border-radius: UiRadius.menu;
clip: true;
for choice[index] in root.model: Rectangle {
y: index * 23px;
width: parent.width;
x: 4px; y: 4px + index * 23px;
width: parent.width - 8px;
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: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
border-radius: UiRadius.control;
background: index == root.current-index
? (option-touch.pressed ? ButtonStyle.pressed : ButtonStyle.hover)
: option-touch.pressed ? ButtonStyle.pressed : option-touch.has-hover ? ButtonStyle.hover : white;
Text {
x: 6px; width: parent.width - 28px; height: parent.height; text: choice;
color: index == root.current-index ? ControlStyle.selected-text : #202124;
font-weight: index == root.current-index ? 600 : 400;
font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center;
}
if index == root.current-index: Text {
x: parent.width - 20px; width: 14px; height: parent.height;
text: FontAwesomeIcons.check; color: UiSurface.focus;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
option-touch := TouchArea {
enabled: root.enabled;
mouse-cursor: pointer;
clicked => {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
@@ -302,12 +348,12 @@ export component CompactComboBox inherits Rectangle {
}
}
}
Rectangle {
FlatSurface {
width: parent.width;
height: parent.height;
background: transparent;
border-width: 1px;
border-color: root.normal-border-color;
clip: true;
outline-color: root.normal-outline-color;
border-radius: UiRadius.menu;
z: 2;
}
@@ -315,19 +361,20 @@ export component CompactComboBox inherits Rectangle {
}
}
export component ImGuiCheckBox inherits Rectangle {
export component ImGuiCheckBox inherits FlatSurface {
in-out property <bool> checked;
in property <bool> enabled: true;
width: 21px;
height: 21px;
background: root.enabled ? white : #f1f1f1;
border-width: 1px;
border-color: check-touch.has-hover && root.enabled ? #8cbef2 : #b9bdc2;
background: !root.enabled ? ControlStyle.disabled
: root.checked ? (check-touch.pressed ? #254cc0 : check-touch.has-hover ? #4574f0 : #3564e3)
: check-touch.pressed ? ControlStyle.pressed : check-touch.has-hover ? ControlStyle.hover : ControlStyle.surface;
outline-color: check-touch.has-hover && root.enabled ? UiSurface.focus : UiSurface.edge;
border-radius: 4px;
if root.checked: Text {
text: FontAwesomeIcons.check;
color: root.enabled ? #2e86de : #8b8f95;
color: root.enabled ? white : #8b8f95;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 16px;
@@ -364,7 +411,7 @@ export component MiniToggleSwitch inherits Rectangle {
}
}
export component CompactButton inherits Rectangle {
export component CompactButton inherits FlatSurface {
in property <string> text;
in property <length> text-size: ImGuiFontStyle.body;
in property <bool> primary: false;
@@ -374,13 +421,17 @@ export component CompactButton inherits Rectangle {
width: 34px;
height: 24px;
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 : root.quiet ? transparent : #e2e4e7;
border-width: 1px;
border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0;
background: !root.enabled ? (root.quiet ? transparent : ControlStyle.disabled)
: button-touch.pressed ? (root.primary ? #82b8f2 : ButtonStyle.pressed)
: button-touch.has-hover ? (root.primary ? #b8d9fb : ButtonStyle.hover)
: root.primary ? #a8d0fa : root.quiet ? transparent : ButtonStyle.surface;
outline-enabled: !root.quiet;
outline-color: !root.enabled ? ButtonStyle.disabled-border
: root.primary ? UiSurface.focus
: button-touch.pressed ? ButtonStyle.pressed-border
: button-touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
border-radius: UiRadius.control;
animate background, outline-color { duration: 100ms; }
Text {
text: root.text;
color: root.enabled ? #24272d : #8e9298;
@@ -389,27 +440,30 @@ export component CompactButton inherits Rectangle {
horizontal-alignment: center;
vertical-alignment: center;
}
button-touch := TouchArea { enabled: root.enabled; clicked => { root.clicked(); } }
button-touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } }
}
export component PrimaryButton inherits Rectangle {
export component PrimaryButton inherits FlatSurface {
in property <string> text;
in property <length> text-size: ImGuiFontStyle.body;
in property <length> text-size: 12px;
in property <bool> enabled: true;
callback clicked;
min-width: 72px;
height: 30px;
height: 32px;
background: !root.enabled ? #b8c1d1
: touch.pressed ? #1849a9
: touch.has-hover ? #2970d6
: #2463c7;
: touch.pressed ? #254cc0
: touch.has-hover ? #4574f0
: #3564e3;
outline-enabled: false;
border-radius: UiRadius.control;
animate background { duration: 100ms; }
Text {
text: root.text;
color: white;
font-size: root.text-size;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
@@ -420,32 +474,35 @@ export component PrimaryButton inherits Rectangle {
}
}
export component SecondaryButton inherits Rectangle {
export component SecondaryButton inherits FlatSurface {
in property <string> text;
in property <length> text-size: ImGuiFontStyle.body;
in property <length> text-size: 12px;
in property <bool> enabled: true;
callback clicked;
min-width: 72px;
height: 30px;
background: !root.enabled ? #f1f2f4
: touch.pressed ? #dfe3e9
: touch.has-hover ? #f3f4f6 : white;
border-width: 1px;
border-color: #c9ced7;
height: 32px;
background: !root.enabled ? ControlStyle.disabled
: touch.pressed ? ButtonStyle.pressed
: touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
outline-color: !root.enabled ? ButtonStyle.disabled-border
: touch.pressed ? ButtonStyle.pressed-border
: touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
border-radius: UiRadius.control;
clip: true;
animate background, outline-color { duration: 100ms; }
Text {
text: root.text;
color: root.enabled ? #24272d : #949aa5;
color: root.enabled ? #303f55 : #949aa5;
font-size: root.text-size;
font-weight: 500;
horizontal-alignment: center;
vertical-alignment: center;
}
touch := TouchArea {
enabled: root.enabled;
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
@@ -491,9 +548,8 @@ export component SectionTitle inherits Text {
vertical-alignment: center;
}
export component DialogSurface inherits Rectangle {
export component DialogSurface inherits FlatSurface {
background: white;
border-width: 1px;
border-color: ImGuiLineStyle.border;
outline-color: UiSurface.edge;
border-radius: UiRadius.surface;
}
@@ -1,6 +1,7 @@
// Single AOT entry point. Generating one header for the entire UI avoids
// duplicate imported-component definitions when C++ uses all three windows.
export { AnnouncementItem, MessageStrings } from "message_panel.slint";
export { AboutComponent, AboutDocument, AboutPage } from "about_panel.slint";
export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint";
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
@@ -1,7 +1,8 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint";
import { CompactButton, ImGuiCheckBox } from "common.slint";
import { CompactButton, ImGuiCheckBox, ControlStyle } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
export struct DeviceGroupOption {
@@ -20,13 +21,13 @@ export component DeviceGroupDialog inherits Rectangle {
callback cancel();
background: #00000040;
TouchArea {}
dialog := Rectangle {
dialog := FlatSurface {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(360px, parent.width - 40px);
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #b2b2b2;
outline-color: UiSurface.edge;
TouchArea {}
Text {
x: 18px; y: 16px; height: 24px;
@@ -41,10 +42,10 @@ export component DeviceGroupDialog inherits Rectangle {
x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
}
group-list := Rectangle {
group-list := FlatSurface {
x: 18px; y: 95px; width: parent.width - 36px;
height: min(5, max(1, root.options.length)) * 32px + 2px;
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: UiRadius.surface;
background: #fbfcfd; outline-color: UiSurface.edge; border-radius: UiRadius.surface;
clip: true;
ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
@@ -52,16 +53,21 @@ export component DeviceGroupDialog inherits Rectangle {
VerticalLayout {
spacing: 0px;
for option[index] in root.options: Rectangle {
private property <bool> selected: option.selected && (option.name != "" || root.new-group == "");
height: 32px;
background: row-touch.has-hover ? #eef4fc : transparent;
background: self.selected
? (row-touch.pressed ? ControlStyle.selected-pressed : row-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
: row-touch.pressed ? ControlStyle.pressed : row-touch.has-hover ? ControlStyle.hover : transparent;
ImGuiCheckBox {
x: 9px; y: 6px; width: 20px; height: 20px;
checked: option.selected && (option.name != "" || root.new-group == "");
checked: parent.selected;
}
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;
color: parent.selected ? ControlStyle.selected-text : #30343b;
font-weight: parent.selected ? 600 : 400;
font-size: 12px; overflow: elide; vertical-alignment: center;
}
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
}
@@ -75,10 +81,10 @@ export component DeviceGroupDialog inherits Rectangle {
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white;
}
Rectangle {
FlatSurface {
width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface;
border-color: #e1e5eb;
border-radius: UiRadius.surface;
outline-color: UiSurface.edge;
}
}
RoundedLineEdit {
@@ -3,4 +3,16 @@ export global GalleryStrings {
public pure function tr(zh: string, en: string, ru: string) -> string {
return self.language == 0 ? zh : self.language == 2 ? ru : en;
}
public pure function device-count(count: int) -> string {
if self.language == 0 { return count + " 台设备"; }
if self.language == 2 {
let last-two = mod(count, 100);
let last = mod(count, 10);
if last-two >= 11 && last-two <= 14 { return count + " устройств"; }
if last == 1 { return count + " устройство"; }
if last >= 2 && last <= 4 { return count + " устройства"; }
return count + " устройств";
}
return count + (count == 1 ? " device" : " devices");
}
}
@@ -1,3 +1,4 @@
import { SurfaceSeparator, UiSurface } from "ui_surface.slint";
import { FileTransferEntry } from "stream_window.slint";
import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint";
import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint";
@@ -26,7 +27,7 @@ export component FileTransferNotification inherits Window {
DialogSurface {
width: parent.width; height: parent.height;
background: WorkbenchStyle.surface;
border-color: WorkbenchStyle.separator;
outline-color: UiSurface.edge;
Text {
x: 16px; y: 8px;
width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px);
@@ -50,9 +51,9 @@ export component FileTransferNotification inherits Window {
pressed-background: WorkbenchStyle.pressed-surface;
clicked => { root.dismissed(); }
}
Rectangle {
SurfaceSeparator {
x: 16px; y: 40px; width: parent.width - 32px; height: 1px;
background: WorkbenchStyle.separator;
outline-color: UiSurface.separator;
}
Text {
x: 16px; y: 47px; width: parent.width - 32px; height: 18px;
@@ -96,7 +97,7 @@ export component FileTransferNotification inherits Window {
text: root.cancel-text;
text-size: ImGuiFontStyle.base;
text-color: WorkbenchStyle.muted;
normal-border-color: WorkbenchStyle.border;
normal-outline-color: UiSurface.edge;
clicked => { root.cancel-requested(); }
}
}
@@ -1,5 +1,6 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { CompactButton, FontAwesomeIcons } from "common.slint";
import { CompactButton, FontAwesomeIcons, ControlStyle } from "common.slint";
export struct GalleryMenuItem { label: string, enabled: bool }
@@ -88,12 +89,11 @@ export component GalleryMenuButton inherits Rectangle {
color: root.enabled ? #62666d : #8e9298;
horizontal-alignment: center; vertical-alignment: center;
}
if root.compact-trigger: Rectangle {
background: more-touch.pressed || popup.is-open ? #e5edfc : more-touch.has-hover ? white : #fffffff0;
if root.compact-trigger: FlatSurface {
background: more-touch.pressed || popup.is-open ? ControlStyle.selected : more-touch.has-hover ? ControlStyle.hover : white;
border-radius: UiRadius.menu;
border-width: 1px;
border-color: trigger-focus.has-focus ? #3564e3 : #ffffff80;
animate background, border-color { duration: 120ms; }
outline-color: trigger-focus.has-focus ? UiSurface.focus : UiSurface.edge;
animate background, outline-color { duration: 120ms; }
for dot in 3: Rectangle {
x: parent.width / 2 - 6px + dot * 5px;
y: (parent.height - self.height) / 2;
@@ -117,11 +117,12 @@ export component GalleryMenuButton inherits Rectangle {
forward-focus: popup-focus;
popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); }
Rectangle {
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu;
if root.destructive-index > 0: Rectangle {
FlatSurface {
background: white; outline-color: UiSurface.edge; border-radius: UiRadius.menu;
clip: true;
if root.destructive-index > 0: SurfaceSeparator {
x: 10px; y: root.menu-padding + root.destructive-index * root.row-height + (root.divider-spacing - 1px) / 2;
width: parent.width - 20px; height: 1px; background: #e8edf4;
width: parent.width - 20px; height: 1px; outline-color: UiSurface.separator;
}
for item[index] in root.items: Rectangle {
private property <bool> destructive: index == root.destructive-index;
@@ -132,7 +133,9 @@ export component GalleryMenuButton inherits Rectangle {
+ (root.destructive-index > 0 && index >= root.destructive-index ? root.divider-spacing : 0px);
width: parent.width - root.menu-padding * 2; height: root.row-height;
border-radius: UiRadius.control;
background: item.enabled && index == root.current-index ? (self.destructive ? #fff0f0 : #edf4fc) : transparent;
background: item.enabled && index == root.current-index
? (self.destructive ? (item-touch.pressed ? #f6babb : #fbe0e0)
: item-touch.pressed ? ControlStyle.pressed : ControlStyle.hover) : transparent;
accessible-role: button; accessible-label: item.label; accessible-enabled: item.enabled;
accessible-action-default => { root.choose(index); }
if self.has-icon: Text {
@@ -148,7 +151,7 @@ export component GalleryMenuButton inherits Rectangle {
text: item.label; color: parent.foreground;
font-size: root.compact-menu ? 11px : 12px; vertical-alignment: center;
}
TouchArea {
item-touch := TouchArea {
enabled: item.enabled;
mouse-cursor: pointer;
changed has-hover => { if self.has-hover { root.current-index = index; } }
@@ -1,3 +1,4 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { CompactButton } from "common.slint";
@@ -15,6 +16,8 @@ export struct GalleryNetworkDevice {
loss: string,
resolution: string,
fps: string,
encoder: string,
decoder: string,
}
component NetworkRow inherits Rectangle {
@@ -64,7 +67,7 @@ component NetworkRow inherits Rectangle {
text: !root.device.encryption-known
? GalleryStrings.tr("待确认", "Unknown", "Неизвестно")
: root.device.encryption-enabled
? GalleryStrings.tr("已加密", "Encrypted", "Защищено")
? GalleryStrings.tr("已加密", "Encrypted", "Зашифровано")
: GalleryStrings.tr("未加密", "Unencrypted", "Без шифр.");
color: !root.device.encryption-known ? #7a8490
: root.device.encryption-enabled ? #18794e : #8a6428;
@@ -145,6 +148,26 @@ component NetworkRow inherits Rectangle {
vertical-alignment: center; overflow: elide;
}
}
if !root.heading: HorizontalLayout {
x: 12px; y: 52px; width: parent.width - 24px; height: 20px;
spacing: 12px;
Rectangle {
width: (parent.width - 12px) / 2;
Text {
width: parent.width; height: parent.height;
text: GalleryStrings.tr("被控端编码器 ", "Remote encoder ", "Удалённый кодер ") + root.device.encoder;
font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide;
}
}
Rectangle {
width: (parent.width - 12px) / 2;
Text {
width: parent.width; height: parent.height;
text: GalleryStrings.tr("控制端解码器 ", "Local decoder ", "Локальный декодер ") + root.device.decoder;
font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide;
}
}
}
}
component NetworkEmptyState inherits Rectangle {
@@ -163,18 +186,18 @@ component NetworkEmptyState inherits Rectangle {
export component GalleryNetworkDialog inherits Rectangle {
in property <[GalleryNetworkDevice]> devices;
private property <length> row-height: 56px;
private property <length> row-height: 78px;
private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
callback close();
background: #00000042;
TouchArea { clicked => { root.close(); } }
Rectangle {
FlatSurface {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(760px, parent.width - 32px);
height: min(120px + root.list-height, parent.height - 32px);
background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #c6cdd5;
outline-color: UiSurface.edge;
TouchArea {}
FocusScope {
init => { self.focus(); }
@@ -185,7 +208,7 @@ export component GalleryNetworkDialog inherits Rectangle {
}
Text {
x: 20px; y: 17px; width: parent.width - 120px; height: 24px;
text: GalleryStrings.tr("设备网络状态", "Device network status", "Сеть устройств");
text: GalleryStrings.tr("设备网络状态", "Device network status", "Состояние сети устройств");
font-size: 17px; font-weight: 600; color: #293746;
}
CompactButton {
@@ -215,7 +238,7 @@ export component GalleryNetworkDialog inherits Rectangle {
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; }
SurfaceSeparator { y: parent.height - 1px; height: 1px; outline-color: UiSurface.separator; }
}
}
if root.devices.length == 0: NetworkEmptyState {
@@ -11,7 +11,7 @@ export component GallerySettings inherits Rectangle {
y: 0px; width: parent.width;
title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3;
SettingsRow {
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение");
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Макс. разрешение");
label-reserve: self.width - 112px;
SettingsSelect {
border-radius: UiRadius.control;
@@ -33,7 +33,7 @@ export component GallerySettings inherits Rectangle {
}
}
SettingsRow {
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео");
label: GalleryStrings.tr("视频总带宽", "Total bandwidth", "Общий битрейт");
label-reserve: self.width - 112px; divider: false;
SettingsSelect {
border-radius: UiRadius.control;
+71 -66
View File
@@ -1,3 +1,4 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { RemoteCursor } from "remote_cursor.slint";
@@ -6,7 +7,7 @@ import { GalleryMenuButton } from "gallery_menu_button.slint";
import { GroupFilter } from "group_filter.slint";
import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.slint";
import { CompactButton, SecondaryButton, IconButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint";
import { CompactButton, SecondaryButton, IconButton, FontAwesomeIcons, ImGuiCheckBox, ControlStyle } from "common.slint";
component GalleryButton inherits CompactButton {
border-radius: UiRadius.control;
@@ -289,7 +290,7 @@ export component GalleryView inherits Rectangle {
selected(index) => { root.action("group", index); }
}
Text {
text: root.count + GalleryStrings.tr(" 台设备", " devices", " устройств");
text: GalleryStrings.device-count(root.count);
min-width: 0px; overflow: elide; font-size: 11px; color: #858c96; vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; min-width: 0px; }
@@ -335,8 +336,8 @@ export component GalleryView inherits Rectangle {
: GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор");
clicked => { root.action("all-select", ""); }
}
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; primary: true; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } }
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; primary: true; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action("connect", ""); } }
GalleryToolbarButton { horizontal-padding: root.toolbar-button-padding; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Disconnect", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
batch-menu := GalleryMenuButton {
horizontal-padding: root.toolbar-button-padding;
width: self.preferred-button-width; enabled: root.selected-count > 0;
@@ -473,7 +474,7 @@ export component GalleryView inherits Rectangle {
height: root.card-height;
opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1;
background: !tile.online ? DeviceCardStyle.offline-background
: tile.selected ? #eaf3ff : root.batch && tile-touch.has-hover ? #f3f8fe : white;
: tile.selected ? ControlStyle.selected : root.batch && tile-touch.has-hover ? ControlStyle.hover : white;
Rectangle {
x: root.card-inset; y: self.x;
width: parent.width - self.x * 2;
@@ -491,12 +492,13 @@ export component GalleryView inherits Rectangle {
width: parent.width - self.x - (tile-touch.has-hover && !root.batch ? DeviceCardStyle.toolbar-width + 12px : root.batch ? 35px : 9px);
height: 24px; text: tile.name; color: tile.online ? #24272d : DeviceCardStyle.offline-text; font-size: 12px; overflow: elide; vertical-alignment: center;
}
if root.batch && !root.simplified: Rectangle {
if root.batch && !root.simplified: FlatSurface {
x: parent.width - 27px; y: parent.height - 27px;
width: 18px; height: 18px; border-radius: 4px; border-width: 1px;
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white;
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8;
animate background, border-color { duration: 120ms; }
width: 18px; height: 18px; border-radius: 4px;
background: tile.selected ? (tile-touch.pressed ? #254cc0 : tile-touch.has-hover ? #4574f0 : #3564e3)
: tile-touch.pressed ? ControlStyle.pressed : tile-touch.has-hover ? ControlStyle.hover : ControlStyle.surface;
outline-color: tile.selected ? UiSurface.focus : tile-touch.has-hover ? UiSurface.hover : UiSurface.edge;
animate background, outline-color { duration: 120ms; }
if tile.selected: Text {
text: FontAwesomeIcons.check; color: white;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px;
@@ -531,27 +533,27 @@ export component GalleryView inherits Rectangle {
}
}
// Draw each table line once, continuously across occupied and empty cells.
for column in root.columns + 1: Rectangle {
for column in root.columns + 1: SurfaceSeparator {
visible: root.count > 0;
x: min(column * root.card-width, grid.table-width - 1px);
y: 0px;
width: 1px; height: grid.table-height;
background: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column)
? #76aeea : #c9cdd2;
vertical: true; outline-color: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column)
? UiSurface.focus : UiSurface.separator;
}
for row in root.rows + 1: Rectangle {
for row in root.rows + 1: SurfaceSeparator {
visible: root.count > 0;
x: 0px;
y: min(row * root.card-height, grid.table-height - 1px);
width: grid.table-width; height: 1px;
background: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row)
? #76aeea : #c9cdd2;
outline-color: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row)
? UiSurface.focus : UiSurface.separator;
}
if root.drag-target >= 0: Rectangle {
if root.drag-target >= 0: FlatSurface {
x: mod(root.drag-target, root.columns) * root.card-width + 1px;
y: floor(root.drag-target / root.columns) * root.card-height + 1px;
width: root.card-width - 2px; height: root.card-height - 2px;
background: #76aeea26; border-width: 2px; border-color: #4a94de;
background: #76aeea26; outline-color: UiSurface.focus;
}
if root.count == 0: Text {
text: root.simplified
@@ -577,7 +579,7 @@ export component GalleryView inherits Rectangle {
background: root.controlling ? #e7f1fd : #f0f2f5;
focus-state := Text {
x: 8px; width: parent.width - 16px; height: parent.height;
text: root.group-controlling ? GalleryStrings.tr("群控中", "Group control", "Групповое")
text: root.group-controlling ? GalleryStrings.tr("群控中", "Group control", "Групп. управление")
: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
: root.focused.connected && root.focused.has-frame && !root.focused.stale
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
@@ -588,8 +590,8 @@ export component GalleryView inherits Rectangle {
horizontal-padding: root.toolbar-button-padding;
primary: root.group-controlling;
enabled: root.group-controlling || (root.focused.connected && root.focused.has-frame && !root.focused.stale);
text: root.group-controlling ? GalleryStrings.tr("关闭群控", "Stop group", "Откл. группу") : GalleryStrings.tr("开启群控", "Group control", "Групп. ввод");
alternate-text: root.group-controlling ? GalleryStrings.tr("开启群控", "Group control", "Групп. ввод") : GalleryStrings.tr("关闭群控", "Stop group", "Откл. группу");
text: root.group-controlling ? GalleryStrings.tr("关闭群控", "Stop group control", "Откл. групп. ввод") : GalleryStrings.tr("开启群控", "Group control", "Групп. ввод");
alternate-text: root.group-controlling ? GalleryStrings.tr("开启群控", "Group control", "Групп. ввод") : GalleryStrings.tr("关闭群控", "Stop group control", "Откл. групп. ввод");
accessible-role: button;
accessible-label: self.text + GalleryStrings.tr(":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах");
accessible-action-default => { self.clicked(); }
@@ -597,11 +599,11 @@ export component GalleryView inherits Rectangle {
}
GalleryButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
}
Rectangle {
FlatSurface {
x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px;
width: parent.width; height: parent.height - self.y;
background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: UiRadius.surface;
border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : root.focused.online ? #d5dbe3 : DeviceCardStyle.offline-border;
outline-color: root.controlling ? UiSurface.focus : UiSurface.edge;
video := Rectangle {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
background: root.focused.online ? #24272d : DeviceCardStyle.offline-preview;
@@ -668,12 +670,12 @@ export component GalleryView inherits Rectangle {
// Collapse the focused tile and its gap without changing the page model.
width: tile.id == root.focused.id ? 0px : root.rail-slot-width;
height: root.rail-card-height; visible: tile.id != root.focused.id;
rail-card := Rectangle {
rail-card := FlatSurface {
private property <string> presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети")
: tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
private property <string> details: tile.name + "\n" + tile.host-name + "\nID: " + tile.id + "\n" + tile.group-names + "\n" + self.presence;
width: root.rail-preview-width + 10px; height: root.rail-card-height; border-radius: UiRadius.surface;
background: tile.online ? #f5f6f8 : DeviceCardStyle.offline-background; border-width: 1px; border-color: tile.online ? #d5dbe3 : DeviceCardStyle.offline-border;
background: tile.online ? #f5f6f8 : DeviceCardStyle.offline-background; outline-color: UiSurface.edge;
accessible-role: button;
accessible-label: self.details;
accessible-action-default => { root.action("focus", tile.id); }
@@ -714,9 +716,9 @@ export component GalleryView inherits Rectangle {
visible: root.focus-rail-visible;
x: 0px; y: rail.y + rail.height; width: parent.width; height: 12px;
private property <bool> highlighted: divider-touch.has-hover || root.focus-resizing || divider-focus.has-focus;
Rectangle {
SurfaceSeparator {
x: 0px; y: 5px; width: parent.width; height: 1px;
background: focus-divider.highlighted ? #a6c9ed : #e6e9ed;
outline-color: focus-divider.highlighted ? UiSurface.focus : UiSurface.separator;
}
Rectangle {
x: (parent.width - self.width) / 2; y: 4px; width: 36px; height: 3px; border-radius: 1.5px;
@@ -775,7 +777,7 @@ export component GalleryView inherits Rectangle {
}
Rectangle {
y: parent.height - 44px; height: 44px; background: #fafbfc;
Rectangle { y: 0px; width: parent.width; height: 1px; background: #e1e4e8; }
SurfaceSeparator { y: 0px; width: parent.width; height: 1px; outline-color: UiSurface.separator; }
}
// Connection status and pagination remain available in every selection state.
footer := Rectangle {
@@ -792,8 +794,8 @@ export component GalleryView inherits Rectangle {
}
network-button := SecondaryButton {
x: connection-count.x + connection-count.width + 12px;
min-width: 64px; width: 64px; height: parent.height;
border-color: #c8cbd0; border-radius: UiRadius.control;
width: 64px; height: parent.height;
outline-color: UiSurface.edge; border-radius: UiRadius.control;
text: GalleryStrings.tr("网络状态", "Network", "Сеть");
accessible-role: button;
accessible-label: self.text;
@@ -823,6 +825,7 @@ export component GalleryView inherits Rectangle {
IconButton {
width: 24px; height: 24px;
border-radius: UiRadius.control;
normal-background: ControlStyle.surface;
icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
clicked => { root.toggle-fullscreen(); }
@@ -840,11 +843,11 @@ export component GalleryView inherits Rectangle {
horizontal-alignment: center; vertical-alignment: center;
}
}
if root.dragging: Rectangle {
if root.dragging: FlatSurface {
x: clamp(grid.x + root.drag-x + 14px, 8px, parent.width - self.width - 8px);
y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px);
width: 224px; height: 54px; z: 10;
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: UiRadius.surface;
width: 224px; height: max(54px, drag-hint.y + drag-hint.height + 9px); z: 10;
background: white; outline-color: UiSurface.focus; border-radius: UiRadius.surface;
Rectangle {
x: 8px; y: 9px; width: 64px; height: 36px;
background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview;
@@ -854,28 +857,28 @@ export component GalleryView inherits Rectangle {
}
}
Text { x: 80px; y: 8px; width: parent.width - 88px; height: 18px; text: root.dragged-tile.name; font-size: 12px; color: #303943; overflow: elide; }
Text {
x: 80px; y: 29px; width: parent.width - 88px; height: 16px;
text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page", "Удерживайте для перелистывания")
drag-hint := Text {
x: 80px; y: 29px; width: parent.width - 88px; height: max(16px, self.preferred-height);
text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page; release to cancel", "Удерживайте для перелистывания; отпустите для отмены")
: root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена")
: GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "На другое устройство");
font-size: 10px; color: #527ca8; overflow: elide;
: GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "Перетащите на другое устройство");
font-size: 10px; color: #527ca8; wrap: word-wrap;
}
}
if root.swap-save-failed: Rectangle {
if root.swap-save-failed: FlatSurface {
x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px;
width: min(420px, parent.width - 32px); height: 36px; z: 10;
background: #fff2f0; border-width: 1px; border-color: #e9b7b0; border-radius: UiRadius.surface;
background: #fff2f0; outline-color: UiSurface.edge; border-radius: UiRadius.surface;
Text { x: 12px; y: 0px; width: parent.width - 24px; height: parent.height; font-size: 12px; color: #9d4339; vertical-alignment: center;
text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide;
}
}
// Device details are rendered by MainWindow; resize hints stay local.
if root.device-tooltip-visible && (root.device-tooltip-id == "resize-grid" || root.device-tooltip-id == "resize-focus"): Rectangle {
if root.device-tooltip-visible && (root.device-tooltip-id == "resize-grid" || root.device-tooltip-id == "resize-focus"): FlatSurface {
x: clamp(root.device-tooltip-x, 8px, parent.width - self.width - 8px);
y: clamp(root.device-tooltip-y + self.height > footer.y ? root.device-tooltip-y - self.height - 8px : root.device-tooltip-y, 4px, parent.height - self.height - 4px);
width: min(320px, parent.width - 16px); height: device-tooltip-label.preferred-height + 16px;
background: #22252bf2; border-width: 1px; border-color: #ffffff38; border-radius: UiRadius.control;
background: #22252bf2; outline-color: UiSurface.edge; border-radius: UiRadius.control;
device-tooltip-label := Text {
x: 8px; y: 8px; width: parent.width - 16px;
text: root.device-tooltip-text; color: white; font-size: 11px; wrap: word-wrap;
@@ -884,9 +887,9 @@ export component GalleryView inherits Rectangle {
if root.adding: Rectangle {
background: #00000042;
TouchArea {}
add-dialog := Rectangle {
add-dialog := FlatSurface {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px;
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
function submit() {
if root.add-device(device-id.text, password.text, group.text, remember.checked) {
password.text = "";
@@ -919,10 +922,10 @@ export component GalleryView inherits Rectangle {
TouchArea { clicked => { remember.checked = !remember.checked; } }
}
Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; }
GalleryButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
GalleryButton { x: 136px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
GalleryButton {
x: 254px; y: 260px; width: 128px; height: 28px; primary: true;
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить");
x: 218px; y: 260px; width: 164px; height: 28px; primary: true;
text: GalleryStrings.tr("添加并连接", "Add and connect", "Добавить и подключить");
enabled: device-id.text != "" && password.text != "";
clicked => { add-dialog.submit(); }
}
@@ -931,25 +934,27 @@ export component GalleryView inherits Rectangle {
if root.importing: Rectangle {
background: #00000042;
TouchArea {}
Rectangle {
FlatSurface {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px;
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; }
background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从近期连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add to DeskHub, then select devices to connect.", "Добавьте в DeskHub, затем выберите устройства для подключения."); color: #62666d; font-size: 11px; }
ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-available > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-available; }
Text { x: 45px; y: 74px; height: 21px; text: GalleryStrings.tr("全选", "Select all", "Выбрать все"); color: root.recent-available > 0 ? #24272d : #92969e; font-size: 12px; vertical-alignment: center; }
TouchArea { x: 18px; y: 74px; width: 144px; height: 21px; enabled: root.recent-available > 0; clicked => { root.action("recent-all", ""); } }
Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("待添加 ", "To add ", "К добавлению ") + root.recent-selected + " / " + root.recent-available; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; }
Rectangle {
FlatSurface {
x: 18px; y: 102px; width: 404px; height: 160px;
border-width: 1px; border-color: #d8dadd; border-radius: UiRadius.surface; clip: true;
outline-color: UiSurface.edge; border-radius: UiRadius.surface; clip: true;
recent-list := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px;
viewport-height: max(self.height, root.recent-devices.length * 44px);
for device[index] in root.recent-devices: Rectangle {
y: index * 44px; width: recent-list.viewport-width; height: 44px;
background: device.added ? #f1f2f4 : device.selected ? #edf5fe : row-touch.has-hover ? #f4f5f6 : white;
background: device.added ? ControlStyle.disabled
: device.selected ? (row-touch.pressed ? ControlStyle.selected-pressed : row-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
: row-touch.pressed ? ControlStyle.pressed : row-touch.has-hover ? ControlStyle.hover : white;
ImGuiCheckBox { x: 8px; y: 11px; enabled: !device.added; checked: device.added || device.selected; }
Text { x: 36px; y: 3px; width: parent.width - 126px; height: 20px; text: device.name; color: device.added ? #92969e : #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; }
Text { x: 36px; y: 23px; width: parent.width - 126px; height: 16px; text: device.id; font-size: 11px; color: device.added ? #92969e : #808080; }
@@ -962,7 +967,7 @@ export component GalleryView inherits Rectangle {
row-touch := TouchArea { enabled: !device.added; clicked => { root.action("recent-select", device.id); } }
}
}
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
Rectangle {
x: -UiRadius.surface; y: -UiRadius.surface;
width: parent.width + 2 * UiRadius.surface;
@@ -970,25 +975,25 @@ export component GalleryView inherits Rectangle {
border-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white;
}
Rectangle {
FlatSurface {
width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface;
border-color: #d8dadd;
border-radius: UiRadius.surface;
outline-color: UiSurface.edge;
}
}
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; }
import-group := RoundedLineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; }
GalleryButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
GalleryButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
GalleryButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add to DeskHub", "В DeskHub"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
}
}
if root.confirming: Rectangle {
background: #00000042; TouchArea {}
Rectangle {
FlatSurface {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
width: 360px; height: confirmation-list.height + 118px;
background: white; border-radius: UiRadius.surface; border-width: 1px; border-color: #b2b2b2;
background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
Text {
x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
@@ -997,12 +1002,12 @@ export component GalleryView inherits Rectangle {
Rectangle {
x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
background: #eff3f8; border-radius: UiRadius.surface;
Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
Text { text: GalleryStrings.device-count(root.selected-count); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
}
confirmation-list := Rectangle {
confirmation-list := FlatSurface {
x: 20px; y: 58px; width: parent.width - 40px;
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px);
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: UiRadius.surface; clip: true;
background: #fcfdfe; outline-color: UiSurface.edge; border-radius: UiRadius.surface; clip: true;
confirmation-scroll := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px;
@@ -1016,9 +1021,9 @@ export component GalleryView inherits Rectangle {
width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px;
background: device.connected ? #eaf6ee : #f0f2f5;
Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; }
Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет связи"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; }
Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет соединения"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; }
}
if index + 1 < root.selected-devices.length: Rectangle { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #edf0f3; }
if index + 1 < root.selected-devices.length: SurfaceSeparator { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; outline-color: UiSurface.separator; }
}
}
}
+53 -26
View File
@@ -1,16 +1,27 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons } from "common.slint";
import { FontAwesomeIcons, ControlStyle } from "common.slint";
// Shared by history and DeskHub; system choices precede the named groups.
export component GroupFilter inherits Rectangle {
export component GroupFilter inherits FlatSurface {
in property <[string]> model;
in property <int> system-count: 2;
in property <bool> enabled: true;
in property <color> normal-border-color: #c9cdd2;
in property <color> hover-border-color: root.normal-border-color;
in property <color> focus-border-color: #8db5df;
// An embedded picker shares its enclosing field's outline and focus state.
in property <bool> embedded: false;
in property <bool> show-indicator: true;
in property <length> popup-min-width: 0px;
in property <length> text-size: 12px;
in property <int> text-weight: 400;
in property <color> text-color: #30343b;
in property <color> normal-background: ControlStyle.surface;
in property <color> normal-outline-color: UiSurface.edge;
in property <color> hover-outline-color: UiSurface.hover;
in property <color> focus-outline-color: UiSurface.focus;
in-out property <int> current-index: 0;
out property <bool> has-focus: trigger-focus.has-focus || popup.is-open;
out property <bool> has-hover: root.enabled && trigger-touch.has-hover && !popup.is-open;
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);
@@ -25,12 +36,16 @@ export component GroupFilter inherits Rectangle {
}
width: 155px; height: 28px;
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
border-width: 1px;
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color;
background: root.embedded ? (root.enabled && trigger-touch.has-hover ? ControlStyle.hover : transparent)
: !root.enabled ? ControlStyle.disabled
: trigger-touch.pressed || popup.is-open ? ControlStyle.pressed
: trigger-touch.has-hover ? ControlStyle.hover : root.normal-background;
outline-enabled: !root.embedded;
outline-focused: popup.is-open || trigger-focus.has-focus;
outline-color: popup.is-open || trigger-focus.has-focus ? root.focus-outline-color
: trigger-touch.has-hover ? root.hover-outline-color : root.normal-outline-color;
border-radius: UiRadius.control;
animate background, border-color { duration: 100ms; easing: ease-out; }
animate background, outline-color { duration: 100ms; easing: ease-out; }
forward-focus: trigger-focus;
accessible-role: combobox;
accessible-enabled: root.enabled;
@@ -52,16 +67,18 @@ export component GroupFilter inherits Rectangle {
root.reveal-selection();
root.selected(root.current-index);
}
function open-menu() {
public 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();
trigger-focus.focus();
popup.show();
popup-open-timer.running = true;
}
}
public function close-menu() { popup.close(); }
// 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 {
@@ -87,14 +104,19 @@ export component GroupFilter inherits Rectangle {
return reject;
}
Text {
x: 10px; width: parent.width - 36px; height: parent.height;
x: root.embedded ? 8px : 10px;
width: parent.width - (root.show-indicator
? (root.embedded ? 26px : 36px) : (root.embedded ? 16px : 20px));
height: parent.height;
text: root.model[root.current-index];
color: root.enabled ? #30343b : #92979f;
font-size: 12px; overflow: elide; vertical-alignment: center;
color: root.enabled ? root.text-color : #92979f;
font-size: root.text-size; font-weight: root.text-weight;
overflow: elide; vertical-alignment: center;
accessible-role: none;
}
Text {
x: parent.width - 25px; width: 16px; height: parent.height;
if root.show-indicator: Text {
x: parent.width - (root.embedded ? 16px : 25px);
width: root.embedded ? 10px : 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;
@@ -107,22 +129,24 @@ export component GroupFilter inherits Rectangle {
key-pressed(event) => { return root.handle-key(event); }
trigger-touch := TouchArea {
enabled: root.enabled;
clicked => { trigger-focus.focus(); root.open-menu(); }
mouse-cursor: pointer;
clicked => { root.open-menu(); }
}
}
popup := PopupWindow {
x: 0px; y: root.height + 4px;
width: root.width; height: root.visible-height + 8px;
width: max(root.width, root.popup-min-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 {
FlatSurface {
y: (1 - root.popup-progress) * -4px;
width: parent.width; height: parent.height;
opacity: root.popup-progress;
background: white; border-width: 1px;
border-color: root.normal-border-color; border-radius: UiRadius.menu;
background: white;
clip: true;
outline-color: root.normal-outline-color; border-radius: UiRadius.menu;
ScrollView {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
viewport-width: self.width;
@@ -130,27 +154,30 @@ export component GroupFilter inherits Rectangle {
viewport-y <=> root.scroll-y;
Rectangle {
width: parent.width; height: root.list-height;
if root.has-divider: Rectangle {
if root.has-divider: SurfaceSeparator {
x: 6px; y: root.system-count * 26px + 3px;
width: parent.width - 12px; height: 1px; background: #e9ebee;
width: parent.width - 12px; height: 1px; outline-color: UiSurface.separator;
}
for choice[index] in root.model: Rectangle {
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
width: parent.width; height: 26px; border-radius: UiRadius.control;
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
background: index == root.current-index
? (option-touch.pressed ? ControlStyle.selected-pressed : option-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
: option-touch.pressed ? ControlStyle.pressed : option-touch.has-hover ? ControlStyle.hover : 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;
text: choice; color: index == root.current-index ? ControlStyle.selected-text : #30343b; font-size: 12px;
font-weight: index == root.current-index ? 600 : 400;
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;
font-weight: 900; font-size: 10px; color: ControlStyle.selected-text;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="31" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 139 B

+58
View File
@@ -0,0 +1,58 @@
import { GalleryStrings } from "device_strings.slint";
export global LanStrings {
// Short localized lines let the warning dialog add leading even though
// Slint Text does not expose a line-height property.
public pure function access-error-lines(code: string) -> [string] {
if code == "LanPortInUse" { return [
GalleryStrings.tr("此端口已被占用,", "This port is in use.", "Порт занят."),
GalleryStrings.tr("请更换端口。", "Choose another port.", "Выберите другой порт."),
GalleryStrings.tr("原设置已保留。", "Your previous settings are unchanged.", "Прежние настройки сохранены.")
]; }
if code == "LanPortCheckFailed" { return [
GalleryStrings.tr("无法确认此端口是否可用,", "Could not check this port.", "Не удалось проверить порт."),
GalleryStrings.tr("请重试或检查本机防火墙。", "Retry or check the local firewall.", "Повторите попытку или проверьте брандмауэр."),
GalleryStrings.tr("原设置已保留。", "Your previous settings are unchanged.", "Прежние настройки сохранены.")
]; }
if code == "LanPortPermissionDenied" { return [
GalleryStrings.tr("无法使用此端口,", "Cannot use this port.", "Порт недоступен."),
GalleryStrings.tr("请选择其他端口或检查系统权限。", "Choose another port or check system permissions.", "Смените порт или проверьте разрешения."),
GalleryStrings.tr("原设置已保留。", "Your previous settings are unchanged.", "Прежние настройки сохранены.")
]; }
if code == "Selected LAN interface is unavailable" { return [
GalleryStrings.tr("未找到可用的局域网连接。", "No local network connection was found.", "Нет доступного подключения к LAN."),
GalleryStrings.tr("请连接 Wi-Fi 或网线,", "Connect to Wi-Fi or Ethernet,", "Подключитесь к Wi-Fi или Ethernet"),
GalleryStrings.tr("或在主界面选择本机 IP。", "or select a local IP on the main screen.", "или выберите локальный IP в главном окне.")
]; }
// Keep all branches the same size for Slint's C++ model generator.
return ["", "", ""];
}
public pure function error-message(code: string) -> string {
let lines = LanStrings.access-error-lines(code);
if lines[0] != "" {
let separator = GalleryStrings.tr("", " ", " ");
return lines[0] + separator + lines[1] + separator + lines[2];
}
if code == "" { return ""; }
if code == "InvalidCredentials" { return GalleryStrings.tr("密码不正确,或对方密码已更新。请查看对方的局域网访问密码后重试。", "The password is incorrect or has changed. Check the other desktop's current LAN password and try again.", "Пароль неверен или изменён. Проверьте текущий пароль LAN другого компьютера и повторите попытку."); }
if code == "AuthenticationRateLimited" { return GalleryStrings.tr("尝试次数过多,请等待倒计时结束后重试。", "Too many attempts. Try again when the countdown ends.", "Слишком много попыток. Дождитесь окончания обратного отсчёта."); }
if code == "CredentialsChanged" { return GalleryStrings.tr("对方密码已更新,请使用新密码重试。", "The password changed. Try again with the new password.", "Пароль изменился. Повторите с новым паролем."); }
if code == "Busy" { return GalleryStrings.tr("设备正忙,请稍后重试。", "The desktop is busy. Try again shortly.", "Компьютер занят. Повторите позже."); }
if code == "LAN password must contain exactly 6 ASCII letters or digits" { return GalleryStrings.tr("访问密码必须为 6 位英文字母或数字,区分大小写。", "Use exactly 6 letters (A–Z, a–z) or digits (0–9). Case-sensitive.", "Пароль: ровно 6 латинских букв или цифр. Регистр учитывается."); }
if code == "ConnectionFailed" || code == "ConnectionTimeout" || code == "IceConnectionTimeout" || code == "Timeout" || code == "ConnectionClosed" || code == "HeartbeatTimeout" || code == "SignalSendFailed" { return GalleryStrings.tr("无法连接,请检查对方 IP、程序是否运行,以及防火墙是否放行。", "Could not connect. Check the IP, whether the other desktop is running CrossDesk, and its firewall settings.", "Не удалось подключиться. Проверьте IP, работу CrossDesk на другом компьютере и брандмауэр."); }
if code == "Enter a numeric IPv4 address or IPv4:port" || code == "Invalid LAN endpoint" { return GalleryStrings.tr("请输入有效的 IPv4,例如 192.168.1.20;指定端口可用 IPv4:端口,端口范围 1–65535。", "Enter a valid IPv4 address such as 192.168.1.20, or IPv4:port (1–65535).", "Введите IPv4, например 192.168.1.20, или IPv4:порт (1–65535)."); }
if code == "Invalid LAN port" { return GalleryStrings.tr("请输入 1–65535 之间的整数端口。", "Enter a port number from 1 to 65535.", "Введите целый номер порта от 1 до 65535."); }
if code == "LAN password is not ready" { return GalleryStrings.tr("请点击主界面密码旁的修改按钮设置密码。", "Set a password using Edit next to the password on the main screen.", "Задайте пароль кнопкой редактирования рядом с паролем в главном окне."); }
if code == "LAN connection limit reached" { return GalleryStrings.tr("IP 连接数量已达上限,请先断开部分连接后重试。", "The IP connection limit has been reached. Disconnect some sessions and try again.", "Достигнут лимит IP-подключений. Завершите часть сеансов и повторите попытку."); }
if code == "UnsupportedNetworkPath" || code == "NetworkPolicyFailed" { return GalleryStrings.tr("当前网络路径不支持 IP 连接,请检查双方的局域网连接。", "This network path does not support IP connections. Check both devices' LAN connections.", "Этот сетевой маршрут не поддерживает IP-подключения. Проверьте подключения обоих устройств к LAN."); }
if code == "IncompatibleProtocol" || code == "RequiredCryptoUnsupported" || code == "TLS 1.3 is required for LAN connections" { return GalleryStrings.tr("双方的协议或加密能力不兼容,请将双方 CrossDesk 升级至最新版本后重试。", "The protocol or encryption capabilities are incompatible. Update CrossDesk on both devices and try again.", "Протоколы или возможности шифрования несовместимы. Обновите CrossDesk на обоих устройствах и повторите попытку."); }
if code == "InvalidCertificate" || code == "FingerprintMissing" || code == "FingerprintMismatch" { return GalleryStrings.tr("无法验证对方设备的身份,请确认连接地址和对方设备后重试。", "Could not verify the remote device's identity. Check the address and the remote device before retrying.", "Не удалось проверить подлинность удалённого устройства. Проверьте адрес и устройство перед повторной попыткой."); }
if code == "DtlsHandshakeFailed" || code == "SrtpInstallFailed" || code == "SrtpKeyExportFailed" || code == "SecureTransportFailed" { return GalleryStrings.tr("无法建立加密连接,请检查双方网络并重试。", "Could not establish an encrypted connection. Check both devices' networks and try again.", "Не удалось установить зашифрованное соединение. Проверьте сеть на обоих устройствах и повторите попытку."); }
if code == "InvalidSignal" || code == "InvalidLocalSignal" || code == "Invalid LAN protocol message" { return GalleryStrings.tr("连接协商数据无效,请重试并确认双方 CrossDesk 版本兼容。", "Invalid connection negotiation data. Try again and check that both CrossDesk versions are compatible.", "Неверные данные согласования соединения. Повторите попытку и проверьте совместимость версий CrossDesk."); }
if code == "ListenerChanged" { return GalleryStrings.tr("连接地址或监听端口已更改,请确认最新地址后重试。", "The connection address or listening port has changed. Check the current address and try again.", "Адрес подключения или порт прослушивания изменился. Проверьте текущий адрес и повторите попытку."); }
if code == "PeerPreparationFailed" { return GalleryStrings.tr("无法准备远程会话,请稍后重试。", "Could not prepare the remote session. Try again shortly.", "Не удалось подготовить удалённый сеанс. Повторите позже."); }
if code == "Cannot copy text" { return GalleryStrings.tr("复制失败,请重试。", "Could not copy. Try again.", "Не удалось скопировать. Повторите попытку."); }
// Keep diagnostic details for unexpected system errors, with a localized explanation.
return GalleryStrings.tr("IP 连接操作失败,详情:", "IP connection operation failed. Details: ", "Ошибка операции IP-подключения. Подробности: ") + code;
}
}
File diff suppressed because it is too large Load Diff
+13 -11
View File
@@ -1,6 +1,7 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { IconButton, FontAwesomeIcons } from "common.slint";
import { IconButton, FontAwesomeIcons, ControlStyle } from "common.slint";
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
import "../assets/fonts/message_spacing.ttf";
@@ -90,11 +91,11 @@ export component MessagePanel inherits SettingsDialog {
text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e;
}
}
message-content := Rectangle {
message-content := FlatSurface {
x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
width: parent.width - 48px; height: parent.height - self.y - 24px;
background: WorkbenchStyle.surface;
border-radius: UiRadius.surface; border-width: 1px; border-color: WorkbenchStyle.border;
border-radius: UiRadius.surface; outline-color: UiSurface.edge;
if root.items.length > 0 || root.selected-id != "": Rectangle {
x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
background: WorkbenchStyle.field-surface;
@@ -112,19 +113,19 @@ export component MessagePanel inherits SettingsDialog {
announcement-entries := VerticalLayout {
width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
alignment: start;
for item in root.items: Rectangle {
for item in root.items: FlatSurface {
height: 64px;
background: root.selected-id == item.id ? #e8efff
background: root.selected-id == item.id
? (entry-touch.pressed ? ControlStyle.selected-pressed : entry-touch.has-hover ? ControlStyle.selected-hover : ControlStyle.selected)
: entry-touch.pressed ? WorkbenchStyle.pressed-surface
: entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: UiRadius.control;
border-width: 1px;
border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border;
outline-color: root.selected-id == item.id ? UiSurface.focus : UiSurface.edge;
Text {
x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
text: item.title; overflow: elide; font-size: 13px;
font-weight: item.read ? 400 : 600;
color: root.selected-id == item.id ? WorkbenchStyle.accent : WorkbenchStyle.text;
color: root.selected-id == item.id ? ControlStyle.selected-text : WorkbenchStyle.text;
vertical-alignment: center;
}
if !item.read: Rectangle {
@@ -154,10 +155,10 @@ export component MessagePanel inherits SettingsDialog {
color: WorkbenchStyle.muted; font-size: 13px;
horizontal-alignment: center; vertical-alignment: center;
}
content-divider := Rectangle {
content-divider := SurfaceSeparator {
x: announcement-list.x + announcement-list.width + 8px;
y: 1px; width: 1px; height: parent.height - 2px;
background: WorkbenchStyle.separator;
vertical: true; outline-color: UiSurface.separator;
visible: root.items.length > 0 || root.selected-id != "";
accessible-role: none;
}
@@ -199,13 +200,14 @@ export component MessagePanel inherits SettingsDialog {
x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
icon: FontAwesomeIcons.trash-can; icon-size: 12px;
icon-color: WorkbenchStyle.muted; danger: true;
normal-background: ControlStyle.surface;
accessible-role: button;
accessible-label: MessageStrings.delete-message + " " + root.selected-title;
accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); }
clicked => { root.request-delete(root.selected-id, root.selected-revision); }
}
}
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; }
StyledText {
vertical-stretch: 0;
text: root.selected-body;
@@ -1,15 +1,17 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { LineEdit } from "std-widgets.slint";
import { UiRadius } from "ui_radius.slint";
import { ControlStyle } from "common.slint";
// Retain the standard editor's selection, clipboard, clear button and input
// method support. Only its outer frame is replaced by the shared shape.
// method support. Its frame receives the shared field tint and rounded shape.
export component RoundedLineEdit inherits Rectangle {
in-out property <string> text;
in property <string> placeholder-text;
in property <InputType> input-type: InputType.text;
in property <bool> enabled: true;
in property <length> font-size: 12px;
in property <color> normal-border-color: #dfe6ef;
in property <color> normal-outline-color: UiSurface.edge;
in property <brush> surrounding-background: white;
callback accepted();
callback edited();
@@ -20,8 +22,8 @@ export component RoundedLineEdit inherits Rectangle {
forward-focus: editor;
editor := LineEdit {
x: -1px; y: -1px;
width: parent.width + 2px; height: parent.height + 2px;
x: -2px; y: -2px;
width: parent.width + 4px; height: parent.height + 4px;
text <=> root.text;
placeholder-text: root.placeholder-text;
input-type: root.input-type;
@@ -30,8 +32,15 @@ export component RoundedLineEdit inherits Rectangle {
accepted => { root.accepted(); }
edited => { root.edited(); }
}
// Fluent's LineEdit paints its own background and exposes no fill override.
// A visual-only tint preserves the standard editor and its built-in actions.
Rectangle {
width: parent.width; height: parent.height;
background: !root.enabled ? transparent
: editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint;
}
// The software renderer clips children to rectangular bounds. Mask the
// standard editor's corners before drawing our rounded border on top.
// standard editor's corners before drawing the focus outline on top.
Rectangle {
x: -root.border-radius; y: -root.border-radius;
width: parent.width + 2 * root.border-radius;
@@ -40,11 +49,12 @@ export component RoundedLineEdit inherits Rectangle {
border-radius: 2 * root.border-radius;
border-color: root.surrounding-background;
}
Rectangle {
FlatSurface {
width: parent.width; height: parent.height;
border-radius: root.border-radius;
border-width: editor.has-focus ? 2px : 1px;
border-color: editor.has-focus ? #3564e3 : root.normal-border-color;
clip: true;
outline-color: editor.has-focus ? UiSurface.focus : root.normal-outline-color;
outline-focused: editor.has-focus;
background: transparent;
}
}
+17 -13
View File
@@ -1,6 +1,7 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint";
import { FontAwesomeIcons, FloatingBarStyle, ButtonStyle } from "common.slint";
export struct ControllerEntry {
remote-id: string,
@@ -19,7 +20,7 @@ export global ServerStrings {
in property <string> cancel: "取消";
}
component SessionButton inherits Rectangle {
component SessionButton inherits FlatSurface {
in property <string> text;
in property <bool> danger: false;
in property <bool> enabled: true;
@@ -29,10 +30,13 @@ component SessionButton inherits Rectangle {
width: label.preferred-width + 20px;
height: 28px;
border-radius: UiRadius.control;
border-width: 1px;
border-color: root.danger ? #d92d20 : #c9ced7;
outline-enabled: !root.danger;
outline-color: root.pointer-feedback && touch.pressed ? ButtonStyle.pressed-border
: root.pointer-feedback && touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20)
: (root.pointer-feedback && touch.pressed ? #dfe3e9 : root.pointer-feedback && touch.has-hover ? #f3f4f6 : white);
: (root.pointer-feedback && touch.pressed ? ButtonStyle.pressed
: root.pointer-feedback && touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface);
animate background, outline-color { duration: 120ms; }
opacity: root.enabled ? 1 : 0.5;
accessible-role: button;
accessible-label: root.text;
@@ -72,9 +76,9 @@ component SessionButton inherits Rectangle {
// Restore hover feedback only after a fresh pointer move or press.
clicked => { root.pointer-feedback = false; root.keyboard-focus = false; keyboard.focus(); root.clicked(); }
}
if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle {
if root.enabled && keyboard.has-focus && root.keyboard-focus: FlatSurface {
x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px;
border-radius: UiRadius.control - 2px; border-width: 1px; border-color: root.danger ? white : #2463c7;
border-radius: UiRadius.control - 2px; outline-color: root.danger ? white : UiSurface.focus;
}
}
@@ -107,10 +111,10 @@ export component ServerStatusBar inherits Window {
return reject;
}
}
Rectangle {
FlatSurface {
x: 0px; y: 0px; width: parent.width; height: parent.height;
background: touch.pressed ? #dfe3e9 : touch.has-hover ? #f3f4f6 : white;
border-width: 1px; border-color: ImGuiLineStyle.border;
outline-color: UiSurface.edge;
border-radius: FloatingBarStyle.corner-radius;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
@@ -170,9 +174,9 @@ export component ServerWindow inherits Window {
if event.text == Key.Escape { root.dismiss-panel(); return accept; }
return reject;
}
Rectangle {
FlatSurface {
width: root.width; height: root.height;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
background: white; outline-color: UiSurface.edge;
border-radius: UiRadius.surface;
Text {
x: 10px; y: 10px; width: parent.width - 20px; height: 24px;
@@ -192,9 +196,9 @@ export component ServerWindow inherits Window {
width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px);
height: parent.height; border-radius: UiRadius.control;
background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent;
if root.controllers.length > 1: Rectangle {
if root.controllers.length > 1: FlatSurface {
x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px;
border-radius: 7px; border-width: 1px; border-color: index == root.selected-controller ? #2463c7 : #8c939e;
border-radius: 7px; outline-color: index == root.selected-controller ? UiSurface.focus : UiSurface.edge;
if index == root.selected-controller: Rectangle { x: 3px; y: 3px; width: 7px; height: 7px; border-radius: 4px; background: #2463c7; }
}
name := Text {
+21 -18
View File
@@ -1,9 +1,10 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint";
import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons, ControlStyle, ButtonStyle } from "common.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
// Shared visual treatment for the global settings pages.
export component SettingsButton inherits Rectangle {
export component SettingsButton inherits FlatSurface {
in property <string> text;
in property <image> icon;
in property <bool> primary: false;
@@ -14,13 +15,17 @@ export component SettingsButton inherits Rectangle {
private property <length> content-padding: root.has-icon ? 8px : 12px;
callback clicked();
width: 88px; height: 32px; border-radius: UiRadius.control;
background: !root.enabled ? #f0f3f7
background: !root.enabled ? ControlStyle.disabled
: root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white;
border-width: root.quiet || root.primary ? 0px : 1px;
border-color: !root.enabled ? WorkbenchStyle.border
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border;
: touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover
: root.quiet ? transparent : ButtonStyle.surface;
outline-enabled: !root.quiet && !root.primary;
outline-color: !root.enabled ? ButtonStyle.disabled-border
: touch.pressed ? ButtonStyle.pressed-border
: touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
animate background, outline-color { duration: 120ms; }
accessible-role: button; accessible-label: root.text;
accessible-enabled: root.enabled;
accessible-action-default => { if root.enabled { root.clicked(); } }
Rectangle {
x: root.align-left ? root.content-padding : (parent.width - self.width) / 2;
@@ -59,10 +64,7 @@ export component SettingsDialog inherits DialogSurface {
callback action();
callback close();
background: #f8fafc;
border-color: WorkbenchStyle.separator;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
outline-color: UiSurface.edge;
TouchArea { }
HorizontalLayout {
x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
@@ -97,6 +99,7 @@ export component SettingsDialog inherits DialogSurface {
width: 28px; height: 28px;
icon: FontAwesomeIcons.xmark; icon-size: 14px;
icon-color: WorkbenchStyle.muted;
normal-background: ControlStyle.surface;
hover-background: WorkbenchStyle.hover-surface;
pressed-background: WorkbenchStyle.pressed-surface;
accessible-role: button; accessible-label: root.close-label;
@@ -109,9 +112,9 @@ export component SettingsDialog inherits DialogSurface {
export component SettingsSelect inherits CompactComboBox {
subtle: true;
show-arrow: false;
normal-border-color: WorkbenchStyle.control-border;
hover-border-color: WorkbenchStyle.hover-border;
show-arrow: true;
normal-outline-color: UiSurface.edge;
hover-outline-color: UiSurface.hover;
height: 28px;
}
@@ -123,9 +126,9 @@ export component SettingsGroup inherits Rectangle {
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 {
FlatSurface {
y: 24px; width: parent.width; height: parent.height - self.y;
background: white; border-radius: UiRadius.surface; border-color: WorkbenchStyle.border; border-width: 1px;
background: white; border-radius: UiRadius.surface; outline-color: UiSurface.edge;
VerticalLayout { padding: 0px; spacing: 0px; @children }
}
}
@@ -141,8 +144,8 @@ export component SettingsRow inherits Rectangle {
text: root.label; color: root.enabled ? #34445a : #9ba5b3;
font-size: 12px; overflow: elide; vertical-alignment: center;
}
if root.divider: Rectangle {
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec;
if root.divider: SurfaceSeparator {
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; outline-color: UiSurface.separator;
}
@children
}
File diff suppressed because it is too large Load Diff
+22 -5
View File
@@ -42,6 +42,11 @@ inline int ApplyMainWindowStrings(
localization::detail::ClampLanguageIndex(language_index);
window->global<ui::GalleryStrings>().set_language(language);
auto& strings = window->global<ui::UiStrings>();
strings.set_window_minimize(Text(localization::window_minimize[language]));
strings.set_window_close(Text(localization::window_close[language]));
strings.set_save(Text(localization::save[language]));
strings.set_copyright(Text(localization::copyright_notice[language]));
strings.set_license(Text(localization::license_notice[language]));
auto& messages = window->global<ui::MessageStrings>();
messages.set_messages(Text(localization::messages[language]));
messages.set_unread(Text(localization::message_unread[language]));
@@ -81,7 +86,7 @@ inline int ApplyMainWindowStrings(
strings.set_ok(Text(localization::ok[language]));
strings.set_cancel(Text(localization::cancel[language]));
strings.set_new_password(Text(localization::new_password[language]));
strings.set_invalid_password(Text(localization::max_password_len[language]));
strings.set_invalid_password(Text(localization::password_format_invalid[language]));
strings.set_edit_alias(
Text(localization::input_connection_alias[language]));
strings.set_delete_connection(
@@ -93,9 +98,6 @@ inline int ApplyMainWindowStrings(
strings.set_capture_method_auto(
Text(localization::screen_capture_method_auto[language]));
strings.set_codec(Text(localization::video_encode_format[language]));
strings.set_hardware_codec(
Text(localization::enable_hardware_video_codec[language]));
strings.set_turn_relay(Text(localization::enable_turn[language]));
strings.set_force_relay(Text(localization::force_relay[language]));
strings.set_self_hosted(
Text(localization::self_hosted_server_config[language]));
@@ -104,6 +106,7 @@ inline int ApplyMainWindowStrings(
strings.set_save_remote_previews(Text(localization::save_remote_previews[language]));
strings.set_unknown_platform(Text(localization::unknown_platform[language]));
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language]));
strings.set_lock_on_disconnect(Text(localization::lock_on_disconnect[language]));
strings.set_file_save_path(
Text(localization::file_transfer_save_path[language]));
strings.set_default_desktop(Text(localization::default_desktop[language]));
@@ -131,7 +134,10 @@ inline int ApplyMainWindowStrings(
Text(localization::update_check_failed[language]));
strings.set_release_notes(Text(localization::release_notes[language]));
strings.set_view_release_notes(Text(localization::view_release_notes[language]));
strings.set_download(Text(localization::update[language]));
strings.set_download(Text(localization::manual_download[language]));
strings.set_install_update(Text(localization::install_update[language]));
strings.set_install_update_confirm(
Text(localization::install_update_confirm[language]));
strings.set_input_password(Text(localization::input_password[language]));
strings.set_reinput_password(Text(localization::reinput_password[language]));
strings.set_remember_password(
@@ -191,6 +197,10 @@ inline int ApplyStreamWindowStrings(
const int language =
localization::detail::ClampLanguageIndex(language_index);
auto& strings = window->template global<ui::StreamStrings>();
strings.set_window_minimize(Text(localization::window_minimize[language]));
strings.set_window_maximize(Text(localization::window_maximize[language]));
strings.set_window_restore(Text(localization::window_restore[language]));
strings.set_window_close(Text(localization::window_close[language]));
strings.set_remote_update_title(Text(localization::remote_update_title[language]));
strings.set_remote_update(Text(localization::remote_update[language]));
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
@@ -227,6 +237,11 @@ inline int ApplyStreamWindowStrings(
strings.set_fullscreen(Text(localization::fullscreen[language]));
strings.set_exit_fullscreen(Text(localization::exit_fullscreen[language]));
strings.set_disconnect(Text(localization::disconnect[language]));
strings.set_confirm_disconnect(
Text(localization::stream_confirm_disconnect[language]));
strings.set_disconnect_description(
Text(localization::stream_disconnect_description[language]));
strings.set_cancel(Text(localization::cancel[language]));
strings.set_file_transfer(
Text(localization::file_transfer_progress[language]));
strings.set_expand_control(Text(localization::expand_control_bar[language]));
@@ -240,6 +255,8 @@ inline int ApplyStreamWindowStrings(
strings.set_stats_connection_latency(Text(localization::connection_latency[language]));
strings.set_stats_connection_mode(
Text(localization::connection_mode[language]));
strings.set_stats_encoder(Text(localization::remote_video_encoder[language]));
strings.set_stats_decoder(Text(localization::local_video_decoder[language]));
strings.set_stats_encryption(
Text(localization::transport_encryption[language]));
strings.set_stats_encryption_enabled(
+35
View File
@@ -0,0 +1,35 @@
// Flat surfaces share solid outlines in software and GPU renderers.
// Interaction states use their actual colors, including focus and validation.
export global UiSurface {
out property <color> edge: #d7dfe9;
out property <color> hover: #8eabd2;
out property <color> focus: #3564e3;
out property <color> error: #d65252;
out property <color> separator: #e1e7ef;
}
export component FlatSurface inherits Rectangle {
in property <color> outline-color: UiSurface.edge;
in property <bool> outline-enabled: true;
in property <bool> outline-focused: false;
border-width: !root.outline-enabled ? 0px : root.outline-focused ? 2px : 1px;
border-color: root.outline-color;
border-top-left-radius: root.border-radius;
border-top-right-radius: root.border-radius;
border-bottom-left-radius: root.border-radius;
border-bottom-right-radius: root.border-radius;
@children
}
export component SurfaceSeparator inherits Rectangle {
in property <bool> vertical: false;
in property <color> outline-color: UiSurface.separator;
height: 1px;
Rectangle {
width: root.vertical ? 1px : root.width;
height: root.vertical ? root.height : 1px;
background: root.outline-color;
}
}
@@ -1,14 +1,13 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { ControlStyle, ButtonStyle } from "common.slint";
// Colors and spacing for the connection workbench; shapes are shared app-wide.
export global WorkbenchStyle {
out property <color> canvas: #f2f5f9;
out property <color> canvas: #edf2f8;
out property <color> surface: #ffffff;
out property <color> border: #c6cfdd;
out property <color> control-border: #b2bfd1;
out property <color> hover-border: #7f97b8;
out property <color> field-surface: #f5f7fb;
out property <color> hover-surface: #eef3fb;
out property <color> pressed-surface: #e4ebf6;
out property <color> hover-surface: ControlStyle.hover;
out property <color> pressed-surface: ControlStyle.pressed;
out property <color> separator: #e1e7ef;
out property <color> text: #1c2940;
out property <color> muted: #69788e;
@@ -16,19 +15,21 @@ export global WorkbenchStyle {
out property <color> success: #18835a;
}
export component WorkbenchButton inherits Rectangle {
export component WorkbenchButton inherits FlatSurface {
in property <string> text;
in property <length> text-size: 13px;
in property <color> text-color: root.primary ? white : WorkbenchStyle.text;
in property <color> normal-border-color: WorkbenchStyle.control-border;
in property <color> text-color: root.destructive || root.primary ? white : WorkbenchStyle.text;
in property <color> normal-outline-color: ButtonStyle.border;
in property <color> normal-background: ButtonStyle.surface;
in property <string> icon;
in property <string> tooltip;
in property <bool> tooltip-above: false;
in property <bool> tooltip-suppressed: false;
in property <color> icon-color: root.primary
in property <color> icon-color: root.destructive ? white : root.primary
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
: WorkbenchStyle.muted;
: ControlStyle.icon;
in property <bool> primary: false;
in property <bool> destructive: false;
in property <bool> soft: false;
in property <bool> quiet: false;
in property <bool> enabled: true;
@@ -44,20 +45,23 @@ export component WorkbenchButton inherits Rectangle {
width: 112px;
height: 36px;
border-radius: UiRadius.control;
border-width: root.show-focus ? 2px : root.soft ? 0px : 1px;
border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent)
: root.primary ? WorkbenchStyle.accent
: touch.has-hover ? WorkbenchStyle.hover-border
: root.quiet ? transparent : root.normal-border-color;
background: root.soft
outline-enabled: root.show-focus || (!root.quiet && !root.soft && !root.primary && !root.destructive);
outline-focused: root.show-focus;
outline-color: !root.enabled ? ButtonStyle.disabled-border
: root.show-focus ? WorkbenchStyle.accent
: touch.pressed ? ButtonStyle.pressed-border
: touch.has-hover ? ButtonStyle.hover-border : root.normal-outline-color;
background: root.destructive
? (touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20)
: root.soft
? (root.primary
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #edf2ff)
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.field-surface))
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #dce7ff)
: (touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface))
: root.primary
? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent)
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : root.quiet ? transparent : white);
: (touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover : root.quiet ? transparent : root.normal-background);
opacity: root.enabled ? 1 : 0.45;
animate background, border-color { duration: 120ms; }
animate background, outline-color { duration: 120ms; }
forward-focus: button-focus;
accessible-role: button;
accessible-label: root.tooltip != "" ? root.tooltip : root.text;
@@ -87,6 +91,11 @@ export component WorkbenchButton inherits Rectangle {
accessible-role: none;
}
}
if root.show-focus && (root.primary || root.destructive): Rectangle {
x: 3px; y: 3px; width: parent.width - 6px; height: parent.height - 6px;
border-radius: max(0px, root.border-radius - 3px);
border-width: 1px; border-color: white;
}
button-focus := FocusScope {
enabled: root.enabled;
focus-gained(reason) => {
@@ -145,7 +154,7 @@ export component WorkbenchButton inherits Rectangle {
}
// A raised surface keeps transient action feedback distinct from the workbench.
export component WorkbenchNotice inherits Rectangle {
export component WorkbenchNotice inherits FlatSurface {
in property <string> text;
in property <string> icon;
in property <bool> error: false;
@@ -154,12 +163,8 @@ export component WorkbenchNotice inherits Rectangle {
width: min(root.width-limit, max(280px, message.preferred-width + 64px));
height: 64px;
background: root.error ? #fff1f0 : #e9f6ef;
border-width: 1px;
border-color: root.error ? #f0c4bf : #b9dfc9;
outline-color: root.error ? UiSurface.error : UiSurface.edge;
border-radius: UiRadius.surface;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 6px;
accessible-role: AccessibleRole.text;
accessible-label: root.text;
Text {
@@ -187,9 +192,8 @@ export component WorkbenchNotice inherits Rectangle {
}
}
export component WorkbenchCard inherits Rectangle {
export component WorkbenchCard inherits FlatSurface {
background: WorkbenchStyle.surface;
border-width: 1px;
border-color: WorkbenchStyle.border;
outline-color: UiSurface.edge;
border-radius: UiRadius.surface;
}
@@ -0,0 +1,134 @@
#include "lan_auth_limiter.h"
#include <algorithm>
#include <exception>
#include <stdexcept>
#include <utility>
namespace crossdesk::lan {
DeviceAuthLimiter::DeviceAuthLimiter(Now now, size_t source_failure_limit,
size_t target_failure_limit)
: now_(std::move(now)),
source_failure_limit_(source_failure_limit),
target_failure_limit_(target_failure_limit) {
if (source_failure_limit == 0 || target_failure_limit == 0)
throw std::invalid_argument(
"Authentication failure limits must be positive");
}
void DeviceAuthLimiter::Prune(Buckets& buckets, Clock::time_point now) {
for (auto it = buckets.begin(); it != buckets.end();) {
if (it->second.expires <= now && it->second.pending == 0)
it = buckets.erase(it);
else
++it;
}
}
DeviceAuthLimiter::Result DeviceAuthLimiter::Verify(
const std::string& source, const std::string& target,
const std::function<bool()>& verify) {
// Reserve both budgets atomically, then release the lock for slow hashing.
std::unique_lock<std::mutex> lock(mutex_);
const auto now = now_();
if (now >= next_cleanup_) {
Prune(sources_, now);
Prune(targets_, now);
next_cleanup_ = now + std::chrono::minutes(1);
}
// Missing source metadata must not become a per-connection bypass.
const std::string source_key = source.empty() ? "unknown" : source;
auto retry = [now](Buckets& buckets, const std::string& key, size_t limit) {
const auto it = buckets.find(key);
if (it == buckets.end()) return 0;
if (it->second.expires <= now) {
it->second.count = 0;
it->second.expires = now + kWindow;
}
if (it->second.count < limit)
return it->second.count + it->second.pending >= limit ? 1 : 0;
return static_cast<int>(
std::chrono::ceil<std::chrono::seconds>(it->second.expires - now)
.count());
};
const int source_retry = retry(sources_, source_key, source_failure_limit_);
const int target_retry = retry(targets_, target, target_failure_limit_);
const int retry_after = std::max(source_retry, target_retry);
if (retry_after > 0) return {false, retry_after};
// Do not evict live failures when flooded with new IDs/IPs. Deny new keys
// until expiry frees capacity, bounding memory without resetting lockouts.
if ((!sources_.count(source_key) && sources_.size() >= kMaxEntries) ||
(!targets_.count(target) && targets_.size() >= kMaxEntries)) {
return {false, static_cast<int>(kWindow.count())};
}
for (auto entry : {std::make_pair(&sources_, source_key),
std::make_pair(&targets_, target)}) {
auto& bucket =
entry.first->try_emplace(entry.second, Failures{0, 0, now + kWindow})
.first->second;
++bucket.pending;
}
lock.unlock();
bool authenticated = false;
std::exception_ptr error;
try {
authenticated = verify();
} catch (...) {
error = std::current_exception();
}
lock.lock();
const auto finished = now_();
auto finish = [&](Buckets& buckets, const std::string& key, size_t limit) {
auto& bucket = buckets.at(key);
--bucket.pending;
if (bucket.expires <= finished) {
bucket.count = 0;
bucket.expires = finished + kWindow;
}
// The reservation already owns this bucket. Start a full cooldown at the
// threshold; blocked requests do not extend it.
if (!authenticated && ++bucket.count == limit)
bucket.expires = finished + kWindow;
if (bucket.count == 0 && bucket.pending == 0) buckets.erase(key);
};
finish(sources_, source_key, source_failure_limit_);
finish(targets_, target, target_failure_limit_);
lock.unlock();
if (error) std::rethrow_exception(error);
return {authenticated, 0};
}
int CredentialWorkLimiter::Admit(const std::string& source) {
std::lock_guard<std::mutex> lock(mutex_);
const auto now = now_();
constexpr auto window = std::chrono::minutes(1);
if (now >= next_cleanup_) {
for (auto it = sources_.begin(); it != sources_.end();) {
if (it->second.expires <= now)
it = sources_.erase(it);
else
++it;
}
next_cleanup_ = now + window;
}
const auto key = source.empty() ? "unknown" : source;
if (!sources_.count(key) && sources_.size() >= DeviceAuthLimiter::kMaxEntries)
return 60;
auto& local =
sources_.try_emplace(key, Window{0, now + window}).first->second;
if (local.expires <= now) local = {0, now + window};
auto retry = [now](const Window& value, size_t limit) {
return value.count < limit
? 0
: static_cast<int>(std::chrono::ceil<std::chrono::seconds>(
value.expires - now)
.count());
};
const int delay = retry(local, source_limit_);
if (delay > 0) return delay;
++local.count;
return 0;
}
} // namespace crossdesk::lan
@@ -0,0 +1,86 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_AUTH_LIMITER_H_
#define _LAN_AUTH_LIMITER_H_
#include <chrono>
#include <cstddef>
#include <functional>
#include <mutex>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
namespace crossdesk::lan {
// Keep password verification and retry policy aligned with crossdesk-server.
// Failure budgets keyed by transport peer IP and credential target, never a
// connection handle. Use separate instances for independent auth domains.
class DeviceAuthLimiter {
public:
using Clock = std::chrono::steady_clock;
using Now = std::function<Clock::time_point()>;
static constexpr size_t kSourceFailures = 20;
static constexpr size_t kTargetFailures = 5;
static constexpr size_t kMaxEntries = 16384; // Per dimension.
static constexpr std::chrono::seconds kWindow{900};
struct Result {
bool authenticated = false;
int retry_after = 0; // Positive only when verification was not attempted.
};
explicit DeviceAuthLimiter(Now now = Clock::now,
size_t source_failure_limit = kSourceFailures,
size_t target_failure_limit = kTargetFailures);
Result Verify(const std::string& source, const std::string& target,
const std::function<bool()>& verify);
private:
struct Failures {
size_t count = 0;
size_t pending = 0;
Clock::time_point expires;
};
using Buckets = std::unordered_map<std::string, Failures>;
void Prune(Buckets& buckets, Clock::time_point now);
Now now_;
const size_t source_failure_limit_, target_failure_limit_;
std::mutex mutex_;
Buckets sources_, targets_;
Clock::time_point next_cleanup_{};
};
// Admission before expensive work, including successful authentication,
// registration and password changes. Separate from credential failure budgets.
class CredentialWorkLimiter {
public:
explicit CredentialWorkLimiter(
DeviceAuthLimiter::Now now = DeviceAuthLimiter::Clock::now,
size_t source_limit = 60)
: now_(std::move(now)), source_limit_(source_limit) {
if (!source_limit_)
throw std::invalid_argument("Credential source limit must be positive");
}
int Admit(const std::string& source);
private:
struct Window {
size_t count = 0;
DeviceAuthLimiter::Clock::time_point expires{};
};
DeviceAuthLimiter::Now now_;
std::mutex mutex_;
const size_t source_limit_;
DeviceAuthLimiter::Clock::time_point next_cleanup_{};
std::unordered_map<std::string, Window> sources_;
};
} // namespace crossdesk::lan
#endif
@@ -0,0 +1,656 @@
#include "lan_identity.h"
#include <openssl/crypto.h>
#include <openssl/pem.h>
#include <openssl/rand.h>
#include <openssl/x509v3.h>
#include <algorithm>
#include <array>
#include <charconv>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <string_view>
#include "lan_password.h"
#ifdef _WIN32
// Winsock must precede windows.h; the networking/ACL headers need its types.
// clang-format off
#include <winsock2.h>
#include <windows.h>
#include <ws2tcpip.h>
#include <Aclapi.h>
#include <iphlpapi.h>
#include <netioapi.h>
#include <sddl.h>
// clang-format on
#else
#include <arpa/inet.h>
#include <fcntl.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <sys/stat.h>
#include <unistd.h>
#ifdef __APPLE__
#include <net/if_dl.h>
#include <net/if_types.h>
#endif
#endif
namespace crossdesk::lan {
namespace {
void Require(bool ok, const char* error) {
if (!ok) throw std::runtime_error(error);
}
std::string Hex(const unsigned char* data, size_t size) {
static constexpr char digits[] = "0123456789abcdef";
std::string result(size * 2, '0');
for (size_t i = 0; i < size; ++i) {
result[2 * i] = digits[data[i] >> 4];
result[2 * i + 1] = digits[data[i] & 15];
}
return result;
}
bool IsHex(const std::string& text, size_t size) {
return text.size() == size &&
std::all_of(text.begin(), text.end(), [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
});
}
void PrivateWrite(const std::filesystem::path& path, const std::string& text) {
const auto temporary = path.parent_path() / (".write-" + RandomHex(16));
#ifdef _WIN32
HANDLE token = nullptr;
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
"Cannot open user token");
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<unsigned char> data(size);
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
CloseHandle(token);
Require(ok, "Cannot read user token");
LPWSTR sid = nullptr;
Require(ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(data.data())->User.Sid, &sid),
"Cannot read user SID");
std::wstring acl = L"D:P(A;;FA;;;" + std::wstring(sid) + L")";
LocalFree(sid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
"Cannot protect LAN file");
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, &security,
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
LocalFree(descriptor);
Require(file != INVALID_HANDLE_VALUE, "Cannot create LAN file");
DWORD written = 0;
ok = WriteFile(file, text.data(), static_cast<DWORD>(text.size()), &written,
nullptr) &&
written == text.size() && FlushFileBuffers(file);
CloseHandle(file);
if (!ok || !MoveFileExW(temporary.c_str(), path.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
DeleteFileW(temporary.c_str());
throw std::runtime_error("Cannot persist LAN file");
}
#else
int fd =
open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
Require(fd >= 0, "Cannot create private LAN file");
size_t offset = 0;
while (offset < text.size()) {
const auto count = write(fd, text.data() + offset, text.size() - offset);
if (count <= 0) break;
offset += static_cast<size_t>(count);
}
bool ok = offset == text.size() && fsync(fd) == 0;
close(fd);
if (!ok || rename(temporary.c_str(), path.c_str()) != 0) {
unlink(temporary.c_str());
throw std::runtime_error("Cannot persist LAN file");
}
#endif
}
#ifdef _WIN32
std::vector<unsigned char> CurrentUser() {
HANDLE token = nullptr;
Require(OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token),
"Cannot open user token");
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<unsigned char> data(size);
bool ok = GetTokenInformation(token, TokenUser, data.data(), size, &size);
CloseHandle(token);
Require(ok, "Cannot read user token");
return data;
}
void CheckPrivateWindowsPath(const std::filesystem::path& path) {
const auto attributes = GetFileAttributesW(path.c_str());
Require(attributes != INVALID_FILE_ATTRIBUTES &&
!(attributes & FILE_ATTRIBUTE_REPARSE_POINT),
"LAN path cannot be a reparse point");
auto user = CurrentUser();
auto sid = reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid;
PSID owner = nullptr;
PACL acl = nullptr;
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(GetNamedSecurityInfoW(
const_cast<wchar_t*>(path.c_str()), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS,
"Cannot read LAN file permissions");
bool ok = owner && EqualSid(owner, sid) && acl && acl->AceCount > 0;
for (DWORD i = 0; ok && i < acl->AceCount; ++i) {
void* raw = nullptr;
if (!GetAce(acl, i, &raw)) {
ok = false;
break;
}
const auto* ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
EqualSid(const_cast<DWORD*>(&ace->SidStart), sid);
}
LocalFree(descriptor);
Require(ok, "LAN files must be accessible only by the current user");
}
void CreatePrivateDirectory(const std::filesystem::path& path) {
if (!std::filesystem::exists(path)) {
auto user = CurrentUser();
LPWSTR sid = nullptr;
Require(ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(user.data())->User.Sid, &sid),
"Cannot read user SID");
const auto acl = L"D:P(A;OICI;FA;;;" + std::wstring(sid) + L")";
LocalFree(sid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
Require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
acl.c_str(), SDDL_REVISION_1, &descriptor, nullptr),
"Cannot protect LAN directory");
SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE};
bool ok = CreateDirectoryW(path.c_str(), &security);
LocalFree(descriptor);
Require(ok, "Cannot create LAN directory");
}
CheckPrivateWindowsPath(path);
}
#endif
std::string Read(const std::filesystem::path& path) {
#ifdef _WIN32
CheckPrivateWindowsPath(path);
#else
struct stat status{};
Require(lstat(path.c_str(), &status) == 0 && S_ISREG(status.st_mode) &&
status.st_uid == getuid() && (status.st_mode & 0077) == 0,
"LAN file must be owned by this user with mode 0600");
#endif
Require(std::filesystem::file_size(path) <= 64 * 1024, "LAN file too large");
std::ifstream input(path, std::ios::binary);
Require(input.good(), "Cannot read LAN file");
return {std::istreambuf_iterator<char>(input), {}};
}
std::string BioText(BIO* bio) {
char* data = nullptr;
long size = BIO_get_mem_data(bio, &data);
Require(size > 0, "Cannot encode LAN identity");
return {data, static_cast<size_t>(size)};
}
struct Secret {
std::string value;
~Secret() { OPENSSL_cleanse(value.data(), value.size()); }
};
std::string Unhex(const std::string& value, size_t bytes) {
Require(IsHex(value, bytes * 2), "Invalid saved LAN password");
std::string result(bytes, '\0');
auto digit = [](char c) { return c <= '9' ? c - '0' : c - 'a' + 10; };
for (size_t i = 0; i < bytes; ++i)
result[i] =
static_cast<char>((digit(value[2 * i]) << 4) | digit(value[2 * i + 1]));
return result;
}
std::string PasswordKey(const std::filesystem::path& directory, bool create) {
const auto path = directory / "password-key";
if (create && !std::filesystem::exists(path)) {
Secret key{std::string(32, '\0')};
Require(
RAND_bytes(reinterpret_cast<unsigned char*>(key.value.data()), 32) == 1,
"Cannot generate LAN password storage key");
PrivateWrite(path, key.value);
}
auto key = Read(path);
Require(key.size() == 32, "Invalid LAN password storage key");
return key;
}
// The independent local key and credential files use owner-only permissions.
// Encryption avoids plaintext in identity.json; it does not protect against
// an attacker who can read both this user's key and credential files.
nlohmann::json EncryptPassword(const std::string& password,
const std::string& key,
const std::string& context) {
const auto nonce = Unhex(RandomHex(12), 12);
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> cipher(
EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free);
std::array<unsigned char, 32> data{}, tag{};
int size = 0, tail = 0;
Require(
cipher &&
EVP_EncryptInit_ex(
cipher.get(), EVP_aes_256_gcm(), nullptr,
reinterpret_cast<const unsigned char*>(key.data()),
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
EVP_EncryptUpdate(
cipher.get(), nullptr, &size,
reinterpret_cast<const unsigned char*>(context.data()),
static_cast<int>(context.size())) == 1 &&
EVP_EncryptUpdate(
cipher.get(), data.data(), &size,
reinterpret_cast<const unsigned char*>(password.data()),
static_cast<int>(password.size())) == 1 &&
EVP_EncryptFinal_ex(cipher.get(), data.data() + size, &tail) == 1 &&
EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_GET_TAG, 16,
tag.data()) == 1,
"Cannot save LAN password");
return {{"version", 1},
{"algorithm", "aes-256-gcm"},
{"nonce", Hex(reinterpret_cast<const unsigned char*>(nonce.data()),
nonce.size())},
{"data", Hex(data.data(), size + tail)},
{"tag", Hex(tag.data(), 16)}};
}
std::string DecryptPassword(const nlohmann::json& record,
const std::string& key,
const std::string& context) {
Require(record.at("version") == 1 && record.at("algorithm") == "aes-256-gcm",
"Invalid saved LAN password");
const auto nonce = Unhex(record.at("nonce"), 12);
const auto data = Unhex(record.at("data"), 6);
auto tag = Unhex(record.at("tag"), 16);
Secret plain{std::string(32, '\0')};
std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)> cipher(
EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free);
int size = 0, tail = 0;
Require(cipher &&
EVP_DecryptInit_ex(
cipher.get(), EVP_aes_256_gcm(), nullptr,
reinterpret_cast<const unsigned char*>(key.data()),
reinterpret_cast<const unsigned char*>(nonce.data())) == 1 &&
EVP_DecryptUpdate(
cipher.get(), nullptr, &size,
reinterpret_cast<const unsigned char*>(context.data()),
static_cast<int>(context.size())) == 1 &&
EVP_DecryptUpdate(
cipher.get(),
reinterpret_cast<unsigned char*>(plain.value.data()), &size,
reinterpret_cast<const unsigned char*>(data.data()),
static_cast<int>(data.size())) == 1 &&
EVP_CIPHER_CTX_ctrl(cipher.get(), EVP_CTRL_GCM_SET_TAG, 16,
tag.data()) == 1 &&
EVP_DecryptFinal_ex(
cipher.get(),
reinterpret_cast<unsigned char*>(plain.value.data()) + size,
&tail) == 1 &&
size + tail == 6,
"Cannot read saved LAN password");
auto password = plain.value.substr(0, 6);
Require(ValidPassword(password), "Invalid saved LAN password");
return password;
}
void CreateCertificate(const std::filesystem::path& key_path,
const std::filesystem::path& cert_path) {
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
EVP_PKEY_Q_keygen(nullptr, nullptr, "EC", "prime256v1"), EVP_PKEY_free);
std::unique_ptr<X509, decltype(&X509_free)> cert(X509_new(), X509_free);
Require(key && cert, "Cannot generate LAN identity");
X509_set_version(cert.get(), 2);
ASN1_INTEGER_set(X509_get_serialNumber(cert.get()), 1);
X509_gmtime_adj(X509_getm_notBefore(cert.get()), -300);
X509_gmtime_adj(X509_getm_notAfter(cert.get()), 10L * 365 * 86400);
X509_set_pubkey(cert.get(), key.get());
auto* name = X509_get_subject_name(cert.get());
X509_NAME_add_entry_by_txt(
name, "CN", MBSTRING_ASC,
reinterpret_cast<const unsigned char*>("CrossDesk LAN"), -1, -1, 0);
X509_set_issuer_name(cert.get(), name);
for (const auto& extension :
{std::pair<int, const char*>{NID_basic_constraints, "critical,CA:FALSE"},
{NID_key_usage, "critical,digitalSignature"},
{NID_ext_key_usage, "serverAuth"}}) {
X509_EXTENSION* ext = X509V3_EXT_conf_nid(
nullptr, nullptr, extension.first, const_cast<char*>(extension.second));
Require(ext != nullptr, "Cannot create certificate extension");
const int result = X509_add_ext(cert.get(), ext, -1);
X509_EXTENSION_free(ext);
Require(result == 1, "Cannot install certificate extension");
}
Require(X509_sign(cert.get(), key.get(), EVP_sha256()) > 0,
"Cannot sign LAN certificate");
std::unique_ptr<BIO, decltype(&BIO_free)> bio(BIO_new(BIO_s_mem()), BIO_free);
Require(PEM_write_bio_PrivateKey(bio.get(), key.get(), nullptr, nullptr, 0,
nullptr, nullptr) == 1,
"Cannot encode private key");
PrivateWrite(key_path, BioText(bio.get()));
(void)BIO_reset(bio.get());
Require(PEM_write_bio_X509(bio.get(), cert.get()) == 1,
"Cannot encode certificate");
PrivateWrite(cert_path, BioText(bio.get()));
}
} // namespace
unsigned ParsePort(const std::string& value) {
unsigned port = 0;
auto [end, error] =
std::from_chars(value.data(), value.data() + value.size(), port);
Require(error == std::errc{} && end == value.data() + value.size() &&
port > 0 && port <= 65535,
"Invalid LAN port");
return port;
}
bool IsIpv4(const std::string& value) {
unsigned octets[4]{};
size_t offset = 0;
for (unsigned i = 0; i < 4; ++i) {
size_t end = value.find('.', offset);
if (i == 3) end = value.size();
if (end == std::string::npos || end <= offset || end - offset > 3 ||
(end - offset > 1 && value[offset] == '0'))
return false;
for (size_t j = offset; j < end; ++j) {
if (value[j] < '0' || value[j] > '9') return false;
octets[i] = octets[i] * 10 + value[j] - '0';
}
if (octets[i] > 255) return false;
offset = end + 1;
}
return offset == value.size() + 1 && octets[0] > 0 && octets[0] != 127 &&
octets[0] < 224;
}
std::pair<std::string, unsigned> ParseEndpoint(const std::string& value,
unsigned default_port) {
std::string address = value;
const auto colon = address.find(':');
unsigned port = default_port;
if (colon != std::string::npos) {
port = ParsePort(address.substr(colon + 1));
address.resize(colon);
}
Require(IsIpv4(address) && port > 0 && port <= 65535,
"Enter a numeric IPv4 address or IPv4:port");
return {address, port};
}
std::vector<Interface> PhysicalInterfaces() {
std::vector<Interface> result;
#ifdef _WIN32
ULONG bytes = 16384;
std::vector<unsigned char> buffer(bytes);
auto* adapters = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buffer.data());
ULONG code =
GetAdaptersAddresses(AF_INET,
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST |
GAA_FLAG_SKIP_DNS_SERVER,
nullptr, adapters, &bytes);
if (code == ERROR_BUFFER_OVERFLOW) {
buffer.resize(bytes);
adapters = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(buffer.data());
code = GetAdaptersAddresses(AF_INET, 0, nullptr, adapters, &bytes);
}
if (code != NO_ERROR) return result;
for (auto* a = adapters; a; a = a->Next) {
if (a->OperStatus != IfOperStatusUp ||
(a->IfType != IF_TYPE_ETHERNET_CSMACD &&
a->IfType != IF_TYPE_IEEE80211) ||
a->PhysicalAddressLength == 0)
continue;
// HardwareInterface rejects software adapters that imitate Ethernet.
MIB_IF_ROW2 row{};
row.InterfaceLuid = a->Luid;
if (GetIfEntry2(&row) != NO_ERROR ||
!row.InterfaceAndOperStatusFlags.HardwareInterface)
continue;
for (auto* u = a->FirstUnicastAddress; u; u = u->Next) {
char address[INET_ADDRSTRLEN]{};
inet_ntop(
AF_INET,
&reinterpret_cast<sockaddr_in*>(u->Address.lpSockaddr)->sin_addr,
address, sizeof(address));
if (IsIpv4(address)) result.push_back({address});
}
}
#else
ifaddrs* list = nullptr;
if (getifaddrs(&list) != 0) return result;
for (auto* a = list; a; a = a->ifa_next) {
if (!a->ifa_addr || a->ifa_addr->sa_family != AF_INET ||
!(a->ifa_flags & IFF_UP) ||
(a->ifa_flags & (IFF_LOOPBACK | IFF_POINTOPOINT)))
continue;
bool physical = false;
#ifdef __APPLE__
for (auto* link = list; link; link = link->ifa_next)
if (link->ifa_addr && link->ifa_addr->sa_family == AF_LINK &&
std::string(link->ifa_name) == a->ifa_name) {
const auto* dl = reinterpret_cast<sockaddr_dl*>(link->ifa_addr);
physical = dl->sdl_type == IFT_ETHER && dl->sdl_alen == 6 &&
std::string(a->ifa_name).rfind("en", 0) == 0;
}
#else
physical = std::filesystem::exists(std::filesystem::path("/sys/class/net") /
a->ifa_name / "device");
#endif
if (!physical) continue;
char address[INET_ADDRSTRLEN]{};
inet_ntop(AF_INET, &reinterpret_cast<sockaddr_in*>(a->ifa_addr)->sin_addr,
address, sizeof(address));
if (IsIpv4(address)) result.push_back({address});
}
freeifaddrs(list);
#endif
return result;
}
std::string RandomHex(size_t bytes) {
std::vector<unsigned char> data(bytes);
Require(RAND_bytes(data.data(), static_cast<int>(bytes)) == 1,
"Random generator failed");
return Hex(data.data(), bytes);
}
bool ValidPassword(const std::string& password) {
return password.size() == 6 &&
password.find_first_not_of(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy"
"z") == std::string::npos;
}
bool VerifyLanCertificate(X509* cert) {
if (!cert || !X509_get0_pubkey(cert)) return false;
return X509_cmp_current_time(X509_get0_notBefore(cert)) < 0 &&
X509_cmp_current_time(X509_get0_notAfter(cert)) > 0 &&
X509_verify(cert, X509_get0_pubkey(cert)) == 1 &&
X509_check_purpose(cert, X509_PURPOSE_SSL_SERVER, 0) == 1 &&
(X509_get_key_usage(cert) & KU_DIGITAL_SIGNATURE) != 0;
}
Identity::Identity(std::filesystem::path directory)
: directory_(std::move(directory)) {}
void Identity::Load() {
DevicePassword::CheckSupport();
std::filesystem::create_directories(directory_.parent_path());
#ifdef _WIN32
CreatePrivateDirectory(directory_);
#else
std::filesystem::create_directories(directory_);
struct stat directory_status{};
Require(lstat(directory_.c_str(), &directory_status) == 0 &&
S_ISDIR(directory_status.st_mode) &&
directory_status.st_uid == getuid(),
"Invalid LAN directory owner or type");
Require(chmod(directory_.c_str(), 0700) == 0, "Cannot protect LAN directory");
#endif
const auto store = directory_ / "identity.json";
if (!std::filesystem::exists(store)) {
Require(!std::filesystem::exists(private_key()) &&
!std::filesystem::exists(certificate()),
"Incomplete LAN identity; manual recovery required");
CreateCertificate(private_key(), certificate());
state_ = {
{"version", 1}, {"device_id", RandomHex(16)}, {"verifier", nullptr}};
Save(state_);
} else
state_ = nlohmann::json::parse(Read(store));
Require(state_.at("version") == 1 &&
IsHex(state_.at("device_id").get<std::string>(), 32),
"Invalid LAN identity store");
// Legacy trust records no longer affect connections. Drop them from memory
// and from the next ordinary settings/credential save, without requiring a
// migration write before an outgoing connection can authenticate.
state_.erase("trust");
// Migrate the single endpoint in memory; the next ordinary save persists it.
// Ports belong to exact IP addresses, including addresses currently offline.
auto& settings = state_["settings"];
if (settings.is_null()) settings = nlohmann::json::object();
Require(settings.is_object(), "Invalid LAN settings");
const auto address = settings.value("address", std::string{});
Require(address.empty() || IsIpv4(address), "Invalid LAN listener address");
if (!settings.contains("ports")) {
settings["ports"] = nlohmann::json::object();
if (!address.empty())
settings["ports"][address] = settings.contains("port")
? settings["port"]
: nlohmann::json(52888u);
}
Require(settings["ports"].is_object(), "Invalid LAN port settings");
for (const auto& [ip, port] : settings["ports"].items())
Require(IsIpv4(ip) && port.is_number_integer() && port > 0 && port <= 65535,
"Invalid LAN port settings");
settings["address"] = address;
settings["version"] = 2;
settings.erase("enabled");
settings.erase("interface");
settings.erase("port");
id_ = state_.at("device_id");
auto pem = Read(certificate());
std::unique_ptr<BIO, decltype(&BIO_free)> bio(
BIO_new_mem_buf(pem.data(), static_cast<int>(pem.size())), BIO_free);
std::unique_ptr<X509, decltype(&X509_free)> cert(
PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), X509_free);
Require(VerifyLanCertificate(cert.get()),
"LAN certificate invalid or expired");
auto secret = Read(private_key());
std::unique_ptr<BIO, decltype(&BIO_free)> key_bio(
BIO_new_mem_buf(secret.data(), static_cast<int>(secret.size())),
BIO_free);
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
PEM_read_bio_PrivateKey(key_bio.get(), nullptr, nullptr, nullptr),
EVP_PKEY_free);
Require(key && X509_check_private_key(cert.get(), key.get()) == 1,
"LAN private key does not match certificate");
OPENSSL_cleanse(secret.data(), secret.size());
if (!state_["verifier"].is_null()) {
const auto& v = state_["verifier"];
if (v.at("version") == 1) {
Require(v.at("algorithm") == "scrypt-32768-8-1" &&
IsHex(v.at("salt"), 32) && IsHex(v.at("hash"), 64),
"Invalid LAN password verifier");
} else if (v.at("version") == 2) {
// Legacy PAKE records are read only to recover the locally encrypted
// password for migration. They are never used by the network protocol.
Require(v.at("algorithm") == "spake2p-p256-scrypt-32768-8-1" &&
IsHex(v.at("salt"), 32) && IsHex(v.at("w0"), 64) &&
IsHex(v.at("L"), 130),
"Invalid LAN password verifier");
} else {
Require(CurrentVerifier(v), "Invalid LAN password verifier");
}
}
// Lifetime lockouts are obsolete; retain only expiring in-memory cooldowns.
state_.erase("password_attempts");
}
void Identity::Save(const nlohmann::json& state) {
PrivateWrite(directory_ / "identity.json", state.dump(2));
}
std::pair<std::string, nlohmann::json> Identity::GeneratePassword() {
constexpr std::string_view alphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
constexpr auto unbiased_limit = 256 - 256 % alphabet.size();
std::string password;
// Match cloud password generation: unbiased ASCII alphanumeric characters,
// regenerating all-numeric results. Existing numeric passwords remain valid.
do {
password.clear();
while (password.size() < 6) {
unsigned char byte = 0;
Require(RAND_bytes(&byte, 1) == 1, "Cannot generate LAN password");
if (byte < unbiased_limit) password += alphabet[byte % alphabet.size()];
}
} while (password.find_first_not_of("0123456789") == std::string::npos);
return {password, RegisterPassword(password)};
}
void Identity::SetPassword(const std::string& password,
const nlohmann::json& verifier) {
Require(ValidPassword(password),
"LAN password must contain exactly 6 ASCII letters or digits");
Require(CurrentVerifier(verifier), "Invalid LAN password verifier");
Secret key{PasswordKey(directory_, !state_.contains("password"))};
auto next = state_;
next["verifier"] = verifier;
next["password"] = EncryptPassword(
password, key.value,
"crossdesk.lan.password.v1:" + id_ + ":" + verifier.dump());
Save(next);
state_ = std::move(next);
}
std::string Identity::ReadPassword() const {
// Verifier-only legacy records require a local password entry to migrate.
if (!state_.contains("password")) return {};
Secret key{PasswordKey(directory_, false)};
return DecryptPassword(
state_.at("password"), key.value,
"crossdesk.lan.password.v1:" + id_ + ":" + verifier().dump());
}
nlohmann::json Identity::RegisterPassword(const std::string& password) {
Require(ValidPassword(password),
"LAN password must contain exactly 6 ASCII letters or digits");
const auto record = DevicePassword::Hash(password);
Require(record.has_value(), "Cannot derive LAN password verifier");
return {{"version", 3}, {"salt", record->salt}, {"hash", record->hash}};
}
bool Identity::CurrentVerifier(const nlohmann::json& verifier) {
return verifier.is_object() && verifier.value("version", 0) == 3 &&
verifier.contains("salt") && verifier["salt"].is_string() &&
verifier.contains("hash") && verifier["hash"].is_string() &&
DevicePassword::IsCurrent({verifier["salt"], verifier["hash"]});
}
bool Identity::VerifyPassword(const nlohmann::json& verifier,
const std::string& password) {
if (!ValidPassword(password) || !CurrentVerifier(verifier)) return false;
return DevicePassword::Verify(
DevicePasswordRecord{verifier["salt"], verifier["hash"]}, password);
}
void Identity::SaveSettings(const std::string& address, unsigned port) {
Require(IsIpv4(address) && port > 0 && port <= 65535,
"Invalid LAN listener address");
auto next = state_;
const auto interfaces = PhysicalInterfaces();
const auto selected = std::find_if(
interfaces.begin(), interfaces.end(),
[&](const auto& i) { return i.address == address; });
Require(selected != interfaces.end(), "Selected LAN interface is unavailable");
next["settings"]["ports"][address] = port;
Save(next);
state_ = std::move(next);
}
unsigned Identity::PortFor(const std::string& address) const {
return state_.at("settings").at("ports").value(address, 52888u);
}
bool Identity::HasPortFor(const std::string& address) const {
return state_.at("settings").at("ports").contains(address);
}
void Identity::SelectAddress(const std::string& address) {
const auto interfaces = PhysicalInterfaces();
Require(std::any_of(interfaces.begin(), interfaces.end(),
[&](const auto& i) { return i.address == address; }),
"Selected LAN interface is unavailable");
if (state_["settings"].value("address", "") == address) return;
auto next = state_;
next["settings"]["address"] = address;
Save(next);
state_ = std::move(next);
}
} // namespace crossdesk::lan
@@ -0,0 +1,73 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_IDENTITY_H_
#define _LAN_IDENTITY_H_
#include <openssl/x509.h>
#include <filesystem>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
namespace crossdesk::lan {
struct Interface {
std::string address;
};
struct Target {
std::string address;
// Learned from the current connection, never pinned.
std::string device_id;
unsigned port = 52888;
};
unsigned ParsePort(const std::string& value);
std::pair<std::string, unsigned> ParseEndpoint(const std::string& value,
unsigned default_port);
bool IsIpv4(const std::string& value);
std::vector<Interface> PhysicalInterfaces();
std::string RandomHex(size_t bytes);
bool ValidPassword(const std::string& password);
bool VerifyLanCertificate(X509* cert);
// Files are separate from cloud settings and thumbnail credentials. A corrupt
// store is an error; it must never be replaced with a newly generated identity.
class Identity {
public:
explicit Identity(std::filesystem::path directory);
void Load();
static std::pair<std::string, nlohmann::json> GeneratePassword();
void SetPassword(const std::string& password, const nlohmann::json& verifier);
std::string ReadPassword() const;
static nlohmann::json RegisterPassword(const std::string& password);
static bool CurrentVerifier(const nlohmann::json& verifier);
static bool VerifyPassword(const nlohmann::json& verifier,
const std::string& password);
void SaveSettings(const std::string& address, unsigned port);
// An unconfigured address uses the default port; offline addresses keep
// their saved port. Selection only controls the address shown in the UI.
unsigned PortFor(const std::string& address) const;
bool HasPortFor(const std::string& address) const;
void SelectAddress(const std::string& address);
const std::string& id() const { return id_; }
std::filesystem::path certificate() const {
return directory_ / "certificate.pem";
}
std::filesystem::path private_key() const {
return directory_ / "private-key.pem";
}
const nlohmann::json& verifier() const { return state_.at("verifier"); }
const nlohmann::json& settings() const { return state_.at("settings"); }
private:
void Save(const nlohmann::json& state);
std::filesystem::path directory_;
std::string id_;
nlohmann::json state_;
};
} // namespace crossdesk::lan
#endif
@@ -0,0 +1,122 @@
#include "lan_password.h"
#include <openssl/core_names.h>
#include <openssl/crypto.h>
#include <openssl/kdf.h>
#include <openssl/params.h>
#include <openssl/rand.h>
#include <array>
#include <cstdint>
#include <memory>
#include <stdexcept>
namespace crossdesk::lan {
namespace {
// An application format, with a hex salt in password_salt and hex digest here.
// Only known work factors are accepted, bounding CPU/memory for corrupt data.
constexpr char kPrefix[] = "argon2id$v=19$m=19456,t=2,p=1$";
constexpr size_t kSaltBytes = 16, kDigestBytes = 32;
bool ValidInput(const std::string& password) {
return password.size() <= 256 && password.find('\0') == std::string::npos;
}
std::string Hex(const unsigned char* data, size_t size) {
constexpr char digits[] = "0123456789abcdef";
std::string result;
result.reserve(size * 2);
for (size_t i = 0; i < size; ++i) {
result += digits[data[i] >> 4];
result += digits[data[i] & 15];
}
return result;
}
bool Unhex(const std::string& text, unsigned char* output, size_t size) {
if (text.size() != size * 2) return false;
auto digit = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
for (size_t i = 0; i < size; ++i) {
const int hi = digit(text[i * 2]), lo = digit(text[i * 2 + 1]);
if (hi < 0 || lo < 0) return false;
output[i] = static_cast<unsigned char>((hi << 4) | lo);
}
return true;
}
using Kdf = std::unique_ptr<EVP_KDF, decltype(&EVP_KDF_free)>;
using Context = std::unique_ptr<EVP_KDF_CTX, decltype(&EVP_KDF_CTX_free)>;
std::optional<DevicePasswordRecord> Derive(
const std::string& password,
const std::array<unsigned char, kSaltBytes>& salt) {
Kdf kdf(EVP_KDF_fetch(nullptr, "ARGON2ID", nullptr), EVP_KDF_free);
if (!kdf) return std::nullopt;
Context context(EVP_KDF_CTX_new(kdf.get()), EVP_KDF_CTX_free);
if (!context) return std::nullopt;
uint32_t memory = 19456, iterations = 2, lanes = 1, threads = 1,
version = 0x13;
OSSL_PARAM params[] = {
OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_PASSWORD,
const_cast<char*>(password.data()),
password.size()),
OSSL_PARAM_construct_octet_string(OSSL_KDF_PARAM_SALT,
const_cast<unsigned char*>(salt.data()),
salt.size()),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_MEMCOST, &memory),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ITER, &iterations),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_LANES, &lanes),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_THREADS, &threads),
OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_VERSION, &version),
OSSL_PARAM_construct_end()};
std::array<unsigned char, kDigestBytes> digest{};
if (EVP_KDF_derive(context.get(), digest.data(), digest.size(), params) != 1)
return std::nullopt;
DevicePasswordRecord result{Hex(salt.data(), salt.size()),
kPrefix + Hex(digest.data(), digest.size())};
OPENSSL_cleanse(digest.data(), digest.size());
return result;
}
} // namespace
namespace DevicePassword {
void CheckSupport() {
// Exercise the provider and parameters, not just algorithm discovery.
if (!Hash("startup-check"))
throw std::runtime_error(
"Argon2id or the secure random source is unavailable");
}
std::optional<DevicePasswordRecord> Hash(const std::string& password) {
if (!ValidInput(password)) return std::nullopt;
std::array<unsigned char, kSaltBytes> salt{};
if (RAND_bytes(salt.data(), salt.size()) != 1) return std::nullopt;
return Derive(password, salt);
}
bool IsCurrent(const DevicePasswordRecord& record) {
std::array<unsigned char, kSaltBytes> salt{};
std::array<unsigned char, kDigestBytes> digest{};
return record.hash.rfind(kPrefix, 0) == 0 &&
Unhex(record.salt, salt.data(), salt.size()) &&
Unhex(record.hash.substr(sizeof(kPrefix) - 1), digest.data(),
digest.size());
}
bool Verify(const DevicePasswordRecord& record, const std::string& password) {
if (!ValidInput(password) || !IsCurrent(record)) return false;
std::array<unsigned char, kSaltBytes> salt{};
Unhex(record.salt, salt.data(), salt.size());
const auto computed = Derive(password, salt);
return computed && computed->hash.size() == record.hash.size() &&
CRYPTO_memcmp(computed->hash.data(), record.hash.data(),
computed->hash.size()) == 0;
}
} // namespace DevicePassword
} // namespace crossdesk::lan
@@ -0,0 +1,33 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_PASSWORD_H_
#define _LAN_PASSWORD_H_
#include <optional>
#include <string>
namespace crossdesk::lan {
// Keep the password record format aligned with crossdesk-server.
// Stored in identity.json. The hash identifies the algorithm,
// version and work factors; unrecognized formats never fall back to SHA-256.
struct DevicePasswordRecord {
std::string salt;
std::string hash;
};
namespace DevicePassword {
void CheckSupport();
std::optional<DevicePasswordRecord> Hash(const std::string& password);
// LAN accepts only current Argon2id records. Older LAN records are migrated
// locally from the encrypted password before connections are accepted.
bool Verify(const DevicePasswordRecord& record, const std::string& password);
bool IsCurrent(const DevicePasswordRecord& record);
} // namespace DevicePassword
} // namespace crossdesk::lan
#endif
@@ -0,0 +1,806 @@
#include "lan_session_manager.h"
#include <openssl/crypto.h>
#include <algorithm>
#include <thread>
namespace crossdesk::lan {
namespace {
using Clock = std::chrono::steady_clock;
using Json = nlohmann::json;
template <class T>
bool Ready(std::future<T>& future) {
return future.valid() &&
future.wait_for(std::chrono::seconds(0)) == std::future_status::ready;
}
void Check(bool condition) {
if (!condition) throw std::runtime_error("Invalid LAN protocol message");
}
bool Id(const Json& value) {
if (!value.is_string()) return false;
const auto& s = value.get_ref<const std::string&>();
return s.size() == 32 && std::all_of(s.begin(), s.end(), [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
});
}
Json Capabilities() {
return Json::array({"host-only-ipv4", "dtls-srtp-all", "password-auth-v1"});
}
void Capable(const Json& j) {
const auto& caps = j.at("capabilities");
Check(caps.is_array() && caps.size() <= 16);
for (const auto& required : Capabilities())
Check(std::find(caps.begin(), caps.end(), required) != caps.end());
}
void Wipe(std::string& text) {
if (!text.empty()) OPENSSL_cleanse(text.data(), text.size());
text.clear();
}
const char* FailureReason(const Session& s, const char* fallback) {
switch (s.failure_code.load()) {
case MiniRtcDirectInvalidSignal:
return "InvalidSignal";
case MiniRtcDirectSignalSendFailed:
return "SignalSendFailed";
case MiniRtcDirectDtlsHandshakeFailed:
return "DtlsHandshakeFailed";
case MiniRtcDirectRequiredCryptoUnsupported:
return "RequiredCryptoUnsupported";
case MiniRtcDirectSrtpInstallFailed:
return "SrtpInstallFailed";
case MiniRtcDirectNetworkPolicyFailed:
return "NetworkPolicyFailed";
case MiniRtcDirectFingerprintMissing:
return "FingerprintMissing";
case MiniRtcDirectFingerprintMismatch:
return "FingerprintMismatch";
case MiniRtcDirectSrtpKeyExportFailed:
return "SrtpKeyExportFailed";
default:
return fallback;
}
}
} // namespace
int Session::SendSignal(const char* data, size_t size, void* context) {
auto* s = static_cast<Session*>(context);
if (!s || s->closing || !data || !size || size > 256 * 1024) return -1;
std::lock_guard lock(s->signal_mutex);
if (s->closing || s->signal_count >= 256 ||
s->signal_bytes + size > 1024 * 1024)
return -1;
++s->signal_count;
s->signal_bytes += size;
s->signals.emplace_back(data, size);
return 0;
}
void Session::Error(MiniRtcDirectError error, void* context) {
if (auto* s = static_cast<Session*>(context)) {
int empty = -1;
s->failure_code.compare_exchange_strong(empty, error);
s->failed = true;
}
}
void Session::Status(ConnectionStatus status) {
if (closing) return;
if (status == ConnectionStatus::Connected)
ready = true;
else if (status == ConnectionStatus::Closed ||
status == ConnectionStatus::Failed ||
status == ConnectionStatus::Disconnected)
failed = true;
}
SessionManager::SessionManager(std::filesystem::path path, Hooks hooks)
: identity_(std::move(path)), hooks_(std::move(hooks)) {
identity_.Load();
access_password_ = identity_.ReadPassword();
new_password_ = access_password_;
if (!Identity::CurrentVerifier(identity_.verifier())) {
if (!access_password_.empty()) {
// Migrate the saved password, retaining its value, identity and settings.
SetPassword(access_password_);
} else if (identity_.verifier().is_null()) {
// Generate the initial password before automatically accepting connections.
ResetPassword();
} else {
hooks_.error("LAN password is not ready");
}
}
}
SessionManager::~SessionManager() {
Stop();
while (!Quiescent()) {
Tick();
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
transport_.Shutdown();
Wipe(new_password_);
Wipe(access_password_);
}
void SessionManager::Configure(const std::string& address, unsigned port) {
if (stopping_) return;
if (!port || port > 65535) throw std::runtime_error("Invalid LAN port");
if (!Identity::CurrentVerifier(identity_.verifier()))
throw std::runtime_error("LAN password is not ready");
ReplaceListener(address, port, true);
}
void SessionManager::ReplaceListener(const std::string& address, unsigned port,
bool save, bool allow_fallback) {
const auto previous = listeners_.find(address);
if (previous != listeners_.end() && previous->second.port == port) return;
const auto next = [&] {
try {
return transport_.Listen(identity_, address, port);
} catch (const std::exception& e) {
const std::string error = e.what();
if (!allow_fallback ||
(error != "LanPortInUse" && error != "LanPortPermissionDenied" &&
error != "LanPortCheckFailed"))
throw;
// Keep the OS-assigned socket bound through persistence/publication;
// probing then releasing a free port would introduce an allocation race.
return transport_.Listen(identity_, address, 0);
}
}();
try {
if (save) identity_.SaveSettings(address, next.port);
} catch (...) {
transport_.StopAccept(next.id);
throw;
}
const auto old = previous == listeners_.end() ? 0 : previous->second.id;
listeners_[address] = {next.id, next.port};
SetListenerError(address, next.port, "");
if (old) RetireListener(old);
}
void SessionManager::RetireListener(uint64_t id) {
transport_.StopAccept(id);
// Only cancel unfinished authentication on this retired endpoint. Other
// addresses and sessions that already authenticated keep their channels.
for (auto& [_, s] : sessions_)
if (s->incoming && s->channel->listener == id &&
s->phase < Session::AwaitOffer)
Close(s, "ListenerChanged");
}
void SessionManager::ResetPassword() {
if (stopping_ || reset_job_.valid()) return;
reset_job_ =
std::async(std::launch::async, [current = access_password_]() mutable {
try {
for (;;) {
auto result = Identity::GeneratePassword();
if (result.first != current) {
Wipe(current);
return result;
}
Wipe(result.first);
}
} catch (...) {
Wipe(current);
throw;
}
});
}
void SessionManager::SetPassword(std::string password) {
if (!ValidPassword(password)) {
Wipe(password);
throw std::runtime_error(
"LAN password must contain exactly 6 ASCII letters or digits");
}
if (stopping_ || reset_job_.valid() ||
(password == access_password_ &&
Identity::CurrentVerifier(identity_.verifier()))) {
Wipe(password);
return;
}
reset_job_ = std::async(
std::launch::async, [password = std::move(password)]() mutable {
try {
auto record = Identity::RegisterPassword(password);
return std::make_pair(std::move(password), std::move(record));
} catch (...) {
Wipe(password);
throw;
}
});
}
void SessionManager::SetListenerError(const std::string& address, unsigned port,
const std::string& error) {
if (error.empty()) {
listener_errors_.erase(address);
listener_warnings_.erase(address);
return;
}
const auto previous = listener_errors_.find(address);
if (previous != listener_errors_.end() && previous->second.port == port &&
previous->second.error == error)
return;
ListenerIssue issue{address, port, error};
listener_errors_[address] = issue;
listener_warnings_[address] = std::move(issue);
}
std::string SessionManager::ListenerError(const std::string& address) const {
const auto it = listener_errors_.find(address);
return it == listener_errors_.end() ? "" : it->second.error;
}
std::optional<SessionManager::ListenerIssue> SessionManager::TakeListenerWarning() {
if (listener_warnings_.empty()) return std::nullopt;
auto it = listener_warnings_.begin();
auto issue = std::move(it->second);
listener_warnings_.erase(it);
return issue;
}
void SessionManager::RefreshListeners() {
if (stopping_ || !Identity::CurrentVerifier(identity_.verifier())) return;
const auto available = [&](const auto& address) {
return std::any_of(interfaces_.begin(), interfaces_.end(),
[&](const auto& i) { return i.address == address; });
};
for (auto it = listeners_.begin(); it != listeners_.end();) {
if (available(it->first)) {
++it;
continue;
}
RetireListener(it->second.id);
it = listeners_.erase(it);
}
for (auto it = listener_errors_.begin(); it != listener_errors_.end();) {
if (available(it->first)) {
++it;
continue;
}
listener_warnings_.erase(it->first);
it = listener_errors_.erase(it);
}
for (const auto& interface : interfaces_) {
const auto port = identity_.PortFor(interface.address);
const bool initialize = !identity_.HasPortFor(interface.address);
try {
// Persist the first choice even when the default works. Later conflicts
// must not silently change an endpoint users may already have shared.
ReplaceListener(interface.address, port, initialize, initialize);
} catch (const std::exception& e) {
SetListenerError(interface.address, port, e.what());
}
}
}
std::string SessionManager::TakeNewPassword() {
return std::exchange(new_password_, {});
}
SessionManager::SessionPtr SessionManager::Connect(const std::string& address,
unsigned port,
std::string password) {
if (stopping_ || sessions_.size() >= 24)
throw std::runtime_error("LAN connection limit reached");
if (!ValidPassword(password))
throw std::runtime_error(
"LAN password must contain exactly 6 ASCII letters or digits");
if (RetryAfter(address, port) > 0) {
Wipe(password);
throw std::runtime_error("AuthenticationRateLimited");
}
auto s = std::make_shared<Session>();
const auto [ip, endpoint_port] = ParseEndpoint(address, port);
s->target = Target{ip, {}, endpoint_port};
s->channel = transport_.Connect(s->target);
s->password = std::move(password);
s->epoch = epoch_;
s->key = "lan/" + RandomHex(16);
s->deadline = Clock::now() + std::chrono::seconds(8);
sessions_[s->channel->id] = s;
return s;
}
bool SessionManager::Send(const SessionPtr& s, Json j, bool sequenced) {
j["version"] = 2;
if (sequenced) {
j["session_id"] = s->session_id;
j["seq"] = s->send_seq + 1;
}
if (s->closing || !transport_.Send(s->channel, j.dump())) {
Close(s, "SignalSendFailed");
return false;
}
if (sequenced) ++s->send_seq;
return true;
}
int SessionManager::RetryAfter(const std::string& address,
unsigned port) const {
const auto [ip, endpoint_port] = ParseEndpoint(address, port);
const auto it = retry_at_.find(ip + ":" + std::to_string(endpoint_port));
const auto now = Clock::now();
return it == retry_at_.end() || it->second <= now
? 0
: static_cast<int>(
std::chrono::ceil<std::chrono::seconds>(it->second - now)
.count());
}
void SessionManager::Fail(const SessionPtr& s, const std::string& error,
int retry_after) {
Json reply = {{"type", "error"}, {"error", error}};
// Use the same failure codes and retry_after semantics as cloud Join.
if (error == "InvalidCredentials")
reply["error_code"] = "AUTHENTICATION_FAILED";
if (error == "AuthenticationRateLimited")
reply["error_code"] = "AUTHENTICATION_THROTTLED";
if (error == "Busy") reply["error_code"] = "CREDENTIAL_SERVICE_BUSY";
if (retry_after > 0) reply["retry_after"] = retry_after;
if (s->session_id.empty() ||
(s->incoming && s->phase < Session::AwaitOffer)) {
reply["request_id"] = s->auth_request;
Send(s, std::move(reply));
} else {
Send(s, std::move(reply), true);
}
Close(s, error);
}
void SessionManager::Prepare(const SessionPtr& s) {
size_t admitted = 0;
for (auto& [_, other] : sessions_)
if (!other->closing && (other->phase == Session::Preparing ||
other->phase >= Session::AwaitOffer))
++admitted;
if (admitted >= 8 || prepare_jobs_.size() >= 2) {
Fail(s, "Busy");
return;
}
s->phase = Session::Preparing;
if (s->incoming) {
s->session_id = RandomHex(16);
s->local_id = identity_.id();
s->remote_id = RandomHex(16);
}
auto work = hooks_.prepare(s);
prepare_jobs_.push_back(
{s, std::async(std::launch::async, [s, work = std::move(work)] {
if (s->closing) return Prepared{};
Prepared prepared = work();
if (!prepared.peer || s->closing) return prepared;
MiniRtcDirectOptions options{};
options.struct_size = sizeof(options);
options.version = 1;
options.local_id = s->local_id.c_str();
options.remote_id = s->remote_id.c_str();
options.session_id = s->session_id.c_str();
options.local_address = s->channel->local_address.c_str();
options.remote_address = s->channel->remote_address.c_str();
options.offerer = !s->incoming;
options.send_signal = Session::SendSignal;
options.on_error = Session::Error;
options.context = s.get();
if (InitDirectPeer(prepared.peer, &options) != 0 ||
(!s->closing &&
StartDirectSession(prepared.peer, !s->incoming) != 0))
s->failed = true;
return prepared;
})});
}
void SessionManager::Message(const SessionPtr& s, const std::string& text) {
auto j = Json::parse(text);
Check(j.is_object() && j.at("version").is_number_integer() &&
j.at("version") == 2 && j.at("type").is_string() &&
!j.contains("user_id") && !j.contains("remote_user_id"));
const auto type = j.at("type").get<std::string>();
if (type == "error" && (s->session_id.empty() ||
(s->incoming && s->phase < Session::AwaitOffer))) {
Check(!j.contains("session_id") && !j.contains("seq") &&
j.at("request_id") == s->auth_request);
const auto error = j.at("error").get<std::string>();
Check(error.size() <= 80);
if (!s->incoming &&
(error == "AuthenticationRateLimited" || error == "Busy") &&
j.contains("retry_after")) {
Check(j["retry_after"].is_number_integer() && j["retry_after"] > 0 &&
j["retry_after"] <= 900);
const auto now = Clock::now();
for (auto it = retry_at_.begin(); it != retry_at_.end();)
if (it->second <= now)
it = retry_at_.erase(it);
else
++it;
if (retry_at_.size() < 64)
retry_at_[s->target.address + ":" + std::to_string(s->target.port)] =
now + std::chrono::seconds(j["retry_after"].get<int>());
}
Close(s, error);
return;
}
if (s->phase == Session::Preparing && !s->incoming) {
Check(j.at("session_id") == s->session_id &&
j.at("seq").is_number_unsigned() &&
j.at("seq").get<uint64_t>() ==
s->receive_seq + s->pending_receive.size() + 1 &&
!j.contains("request_id"));
// Identity is already bound by auth_result. Process cancellation without
// waiting for Peer preparation, before the following WSS close can erase
// its reason; earlier queued SDP/candidates never grant authorization.
if (type == "bye" || type == "error") {
const auto reason = type == "bye" ? std::string("RemoteClosed")
: j.at("error").get<std::string>();
Check(!reason.empty() && reason.size() <= 80);
Close(s, reason);
return;
}
Check(type == "answer" || type == "candidate");
Check(s->pending_receive.size() < 128);
s->pending_receive.push_back(text);
return;
}
if (s->phase == Session::AwaitAuth) {
Check(type == "auth" && !j.contains("session_id") && !j.contains("seq") &&
Id(j.at("request_id")) && j.at("password").is_string() &&
!j.contains("share") && !j.contains("proof"));
Capable(j);
s->auth_request = j.at("request_id");
auto& submitted = j["password"].get_ref<std::string&>();
if (!ValidPassword(submitted)) {
Wipe(submitted);
Fail(s, "InvalidCredentials");
return;
}
if (auth_jobs_.size() >= 2) {
Wipe(submitted);
Fail(s, "Busy", 1);
return;
}
const auto delay =
credential_work_limiter_.Admit(s->channel->remote_address);
if (delay > 0) {
Wipe(submitted);
Fail(s, "AuthenticationRateLimited", delay);
return;
}
s->phase = Session::Authenticating;
auth_jobs_.push_back(
{s,
std::async(std::launch::async,
[this, password = std::exchange(submitted, {}),
record = identity_.verifier(),
source = s->channel->remote_address,
target = identity_.id()]() mutable {
try {
auto result = auth_limiter_.Verify(source, target, [&] {
return Identity::VerifyPassword(record, password);
});
Wipe(password);
return result;
} catch (...) {
Wipe(password);
throw;
}
})});
return;
}
if (s->phase == Session::AwaitAuthResult) {
Check(type == "auth_result" && j.at("request_id") == s->auth_request &&
!j.contains("seq"));
if (!j.value("success", false)) {
Close(s, "InvalidCredentials");
return;
}
Check(Id(j.at("session_id")) && Id(j.at("host_peer_id")) &&
Id(j.at("controller_peer_id")) &&
j.at("controller_peer_id") != j.at("host_peer_id"));
s->target.device_id = j.at("host_peer_id");
s->session_id = j.at("session_id");
s->local_id = j.at("controller_peer_id");
s->remote_id = j.at("host_peer_id");
Prepare(s);
return;
}
Check(s->peer && j.at("session_id") == s->session_id &&
j.at("seq").is_number_unsigned() &&
j.at("seq").get<uint64_t>() == s->receive_seq + 1 &&
!j.contains("request_id"));
++s->receive_seq;
if (type == "bye" || type == "error") {
const auto reason = type == "bye" ? std::string("RemoteClosed")
: j.at("error").get<std::string>();
Check(!reason.empty() && reason.size() <= 80);
Close(s, reason);
return;
}
if (type == "offer") {
Check(s->incoming && s->phase == Session::AwaitOffer);
s->phase = Session::Negotiating;
s->deadline = Clock::now() + std::chrono::seconds(15);
} else if (type == "answer") {
Check(!s->incoming && s->phase == Session::AwaitAnswer);
s->phase = Session::Negotiating;
} else
Check(type == "candidate" &&
(s->phase == Session::AwaitAnswer ||
s->phase == Session::Negotiating || s->phase == Session::Connected));
if (ReceiveDirectSignal(s->peer, text.data(), text.size()) != 0)
Fail(s, "InvalidSignal");
}
void SessionManager::Dispose(const SessionPtr& s) {
if (!s->peer) return;
auto* peer = std::exchange(s->peer, nullptr);
auto owner = s->callback_owner;
auto drain = s->drain_callbacks;
cleanup_jobs_.push_back(
std::async(std::launch::async, [s, peer, owner, drain]() mutable {
if (drain) drain();
CloseDirectSession(peer);
DestroyPeer(&peer);
}));
}
void SessionManager::Close(const SessionPtr& s, const std::string& error) {
if (!s || s->closing.exchange(true)) return;
s->authorized = false;
if (error.empty() && !s->session_id.empty() &&
s->phase >= Session::AwaitOffer)
transport_.Send(s->channel, Json{{"version", 2},
{"type", "bye"},
{"session_id", s->session_id},
{"seq", ++s->send_seq}}
.dump());
s->phase = Session::Closing;
s->error = error;
Wipe(s->password);
transport_.Close(s->channel);
hooks_.closed(s);
Dispose(s);
if (!error.empty() && !s->incoming && !stopping_) hooks_.error(error);
}
void SessionManager::Stop() {
if (stopping_) return;
stopping_ = true;
for (const auto& [_, listener] : listeners_) transport_.StopAccept(listener.id);
listeners_.clear();
listener_errors_.clear();
listener_warnings_.clear();
for (auto& [_, s] : sessions_) Close(s);
}
bool SessionManager::Quiescent() const {
return (!stopping_ || network_drained_) && sessions_.empty() &&
auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() &&
!reset_job_.valid();
}
std::string SessionManager::PendingWork() const {
return "sessions=" + std::to_string(sessions_.size()) +
", auth=" + std::to_string(auth_jobs_.size()) +
", preparing=" + std::to_string(prepare_jobs_.size()) +
", cleanup=" + std::to_string(cleanup_jobs_.size()) +
", reset=" + std::to_string(reset_job_.valid()) +
", network_drained=" + std::to_string(network_drained_);
}
void SessionManager::Tick() {
if (Ready(reset_job_)) {
try {
auto [password, verifier] = reset_job_.get();
if (!stopping_) {
const bool changed = verifier != identity_.verifier();
try {
identity_.SetPassword(password, verifier);
} catch (...) {
Wipe(password);
throw;
}
if (changed) ++epoch_;
Wipe(new_password_);
Wipe(access_password_);
access_password_ = password;
new_password_ = std::move(password);
for (auto& [_, s] : sessions_)
if (s->incoming && !s->authorized && s->epoch != epoch_)
Fail(s, "CredentialsChanged");
interface_check_ = {}; // Start listening as soon as credentials are ready.
} else
Wipe(password);
} catch (const std::exception& e) {
hooks_.error(e.what());
}
}
for (auto it = auth_jobs_.begin(); it != auth_jobs_.end();) {
if (!Ready(it->result)) {
++it;
continue;
}
auto s = it->session;
DeviceAuthLimiter::Result result;
bool failed = false;
try {
result = it->result.get();
} catch (...) {
failed = true;
}
it = auth_jobs_.erase(it);
if (s->closing || stopping_ || s->epoch != epoch_) continue;
if (failed) {
Fail(s, "Busy", 1);
} else if (result.retry_after > 0) {
Fail(s, "AuthenticationRateLimited", result.retry_after);
} else if (!result.authenticated) {
Fail(s, "InvalidCredentials");
} else {
try {
Prepare(s);
} catch (...) {
Fail(s, "PeerPreparationFailed");
}
}
}
for (auto it = prepare_jobs_.begin(); it != prepare_jobs_.end();) {
if (!Ready(it->result)) {
++it;
continue;
}
auto s = it->session;
try {
auto p = it->result.get();
s->peer = p.peer;
s->callback_owner = std::move(p.callback_owner);
s->drain_callbacks = std::move(p.drain_callbacks);
} catch (...) {
s->failed = true;
}
it = prepare_jobs_.erase(it);
if (s->closing || stopping_ || (s->incoming && s->epoch != epoch_)) {
Dispose(s);
continue;
}
if (!s->peer || s->failed) {
Fail(s, FailureReason(*s, "PeerPreparationFailed"));
continue;
}
hooks_.attached(s);
transport_.Authenticated(s->channel);
if (s->incoming) {
s->phase = Session::AwaitOffer;
s->deadline = Clock::now() + std::chrono::seconds(5);
Send(s, {{"type", "auth_result"},
{"request_id", s->auth_request},
{"success", true},
{"session_id", s->session_id},
{"host_peer_id", s->local_id},
{"controller_peer_id", s->remote_id}});
} else {
s->phase = Session::AwaitAnswer;
s->deadline = Clock::now() + std::chrono::seconds(15);
while (!s->pending_receive.empty() && !s->closing) {
auto text = std::move(s->pending_receive.front());
s->pending_receive.pop_front();
try {
Message(s, text);
} catch (...) {
Fail(s, "InvalidSignal");
}
}
}
}
for (auto& event : transport_.Poll()) {
auto it = sessions_.find(event.channel->id);
if (it == sessions_.end()) {
const auto listener = listeners_.find(event.channel->local_address);
if (event.kind != Transport::Event::Open || !event.channel->incoming ||
stopping_ || listener == listeners_.end() ||
event.channel->listener != listener->second.id) {
transport_.Close(event.channel);
continue;
}
auto s = std::make_shared<Session>();
s->channel = event.channel;
s->incoming = true;
s->key = "lan/" + RandomHex(16);
s->epoch = epoch_;
sessions_[event.channel->id] = s;
it = sessions_.find(event.channel->id);
}
auto s = it->second;
if (s->closing) continue;
if (event.kind == Transport::Event::Open) {
s->deadline = Clock::now() + std::chrono::seconds(10);
s->phase = s->incoming ? Session::AwaitAuth : Session::AwaitAuthResult;
if (!s->incoming) {
s->auth_request = RandomHex(16);
Json auth = {{"type", "auth"},
{"request_id", s->auth_request},
{"password", std::exchange(s->password, {})},
{"capabilities", Capabilities()}};
Send(s, auth);
Wipe(auth["password"].get_ref<std::string&>());
}
} else if (event.kind == Transport::Event::Closed)
Close(s, event.payload.empty() ? "ConnectionClosed" : event.payload);
else
try {
Message(s, event.payload);
} catch (...) {
Fail(s, "InvalidSignal");
}
Wipe(event.payload);
}
const auto now = Clock::now();
for (auto& [_, s] : sessions_) {
if (s->closing) continue;
if (s->failed) {
Fail(s, FailureReason(*s, "SecureTransportFailed"));
continue;
}
if (s->phase != Session::Connected && now >= s->deadline) {
Fail(s, s->phase == Session::Negotiating ? "IceConnectionTimeout"
: "Timeout");
continue;
}
std::deque<std::string> signals;
{
std::lock_guard lock(s->signal_mutex);
signals.swap(s->signals);
}
for (const auto& raw : signals) try {
auto j = Json::parse(raw);
Check(j.at("transmission_id") == s->session_id &&
j.at("user_id") == s->local_id &&
j.at("remote_user_id") == s->remote_id);
const auto type = j.at("type").get<std::string>();
Json out;
if (type == "offer" || type == "answer")
out = {{"type", type}, {"sdp", j.at("sdp")}};
else {
Check(type == "new_candidate");
out = {{"type", "candidate"}, {"ufrag", j.at("ufrag")}};
if (j.at("sdp") == "a=end-of-candidates\r\n")
out["end_of_candidates"] = true;
else
out["candidate"] = j.at("sdp");
}
if (!Send(s, std::move(out), true)) break;
} catch (...) {
Fail(s, "InvalidLocalSignal");
break;
}
if (s->ready.exchange(false) && !s->closing && !s->failed) {
if (s->phase != Session::Negotiating || stopping_ ||
(s->incoming && s->epoch != epoch_)) {
Fail(s, "CredentialsChanged");
continue;
}
s->phase = Session::Connected;
s->authorized = true;
hooks_.connected(s);
}
}
if (!stopping_ && now - interface_check_ >= std::chrono::seconds(2)) {
interface_check_ = now;
interfaces_ = PhysicalInterfaces();
RefreshListeners();
for (auto& [_, s] : sessions_)
if (s->phase != Session::Connecting &&
!s->channel->local_address.empty() &&
std::none_of(interfaces_.begin(), interfaces_.end(),
[&](const auto& i) {
return i.address == s->channel->local_address;
}))
Close(s, "InterfaceRemoved");
}
for (auto it = cleanup_jobs_.begin(); it != cleanup_jobs_.end();)
if (Ready(*it)) {
try {
it->get();
} catch (...) {
}
it = cleanup_jobs_.erase(it);
} else
++it;
for (auto it = sessions_.begin(); it != sessions_.end();) {
const auto& s = it->second;
const bool pending =
std::any_of(auth_jobs_.begin(), auth_jobs_.end(),
[&](const auto& j) { return j.session == s; }) ||
std::any_of(prepare_jobs_.begin(), prepare_jobs_.end(),
[&](const auto& j) { return j.session == s; });
if (s->closing && !pending) {
s->phase = Session::Closed;
it = sessions_.erase(it);
} else
++it;
}
if (stopping_ && !network_drained_ && sessions_.empty() &&
auth_jobs_.empty() && prepare_jobs_.empty() && cleanup_jobs_.empty() &&
!reset_job_.valid()) {
transport_.Shutdown();
network_drained_ = true;
}
}
} // namespace crossdesk::lan
@@ -0,0 +1,151 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_SESSION_MANAGER_H_
#define _LAN_SESSION_MANAGER_H_
#include <atomic>
#include <chrono>
#include <deque>
#include <functional>
#include <future>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include "lan_auth_limiter.h"
#include "lan_transport.h"
#include "minirtc.h"
namespace crossdesk::lan {
struct Session {
enum Phase {
Connecting,
AwaitAuth,
Authenticating,
Preparing,
AwaitAuthResult,
AwaitOffer,
AwaitAnswer,
Negotiating,
Connected,
Closing,
Closed
} phase = Connecting;
Transport::ChannelPtr channel;
Target target;
std::string key, session_id, local_id, remote_id, auth_request;
std::string password, error;
bool incoming = false;
uint64_t epoch = 0, receive_seq = 0, send_seq = 0;
std::chrono::steady_clock::time_point deadline;
PeerPtr* peer = nullptr; // Only published/cleared by the manager.
std::atomic<bool> closing{false}, authorized{false}, ready{false},
failed{false};
std::atomic<int> failure_code{-1};
std::mutex signal_mutex;
size_t signal_count = 0, signal_bytes = 0;
std::deque<std::string> signals, pending_receive;
std::shared_ptr<void> callback_owner;
std::function<void()> drain_callbacks;
static int SendSignal(const char* data, size_t size, void* context);
static void Error(MiniRtcDirectError error, void* context);
void Status(ConnectionStatus status);
};
// All public methods run on the GUI manager thread. Only Session's bounded
// callback entry points are callable by RTC workers.
class SessionManager {
public:
using SessionPtr = std::shared_ptr<Session>;
struct Prepared {
PeerPtr* peer = nullptr;
std::shared_ptr<void> callback_owner;
std::function<void()> drain_callbacks;
};
struct Hooks {
// Called on the manager to take device snapshots; returned work runs off
// UI.
std::function<std::function<Prepared()>(const SessionPtr&)> prepare;
std::function<void(const SessionPtr&)> attached, connected, closed;
std::function<void(const std::string&)> error;
};
SessionManager(std::filesystem::path directory, Hooks hooks);
~SessionManager();
Identity& identity() { return identity_; }
// Refreshed by Tick; UI and listener maintenance share the same snapshot.
const std::vector<Interface>& interfaces() const { return interfaces_; }
void Configure(const std::string& address, unsigned port);
bool password_pending() const { return reset_job_.valid(); }
void ResetPassword();
void SetPassword(std::string password);
bool listening() const { return !listeners_.empty(); }
struct ListenerIssue {
std::string address;
unsigned port;
std::string error;
};
std::string ListenerError(const std::string& address) const;
std::optional<ListenerIssue> TakeListenerWarning();
std::string TakeNewPassword();
int RetryAfter(const std::string& address, unsigned port) const;
SessionPtr Connect(const std::string& address, unsigned port,
std::string password);
void Close(const SessionPtr& session, const std::string& error = {});
void Tick();
void Stop();
bool Quiescent() const;
std::string PendingWork() const;
private:
void Prepare(const SessionPtr& session);
void Message(const SessionPtr& session, const std::string& text);
bool Send(const SessionPtr& session, nlohmann::json message,
bool sequenced = false);
void Dispose(const SessionPtr& session);
void Fail(const SessionPtr& session, const std::string& error,
int retry_after = 0);
void RefreshListeners();
void ReplaceListener(const std::string& address, unsigned port, bool save,
bool allow_fallback = false);
void RetireListener(uint64_t id);
void SetListenerError(const std::string& address, unsigned port,
const std::string& error);
Identity identity_;
Hooks hooks_;
Transport transport_;
std::vector<Interface> interfaces_;
std::map<uint64_t, SessionPtr> sessions_;
struct AuthJob {
SessionPtr session;
std::future<DeviceAuthLimiter::Result> result;
};
struct PrepareJob {
SessionPtr session;
std::future<Prepared> result;
};
std::vector<AuthJob> auth_jobs_;
std::vector<PrepareJob> prepare_jobs_;
std::vector<std::future<void>> cleanup_jobs_;
DeviceAuthLimiter auth_limiter_;
CredentialWorkLimiter credential_work_limiter_;
std::map<std::string, std::chrono::steady_clock::time_point> retry_at_;
struct Listener {
uint64_t id;
unsigned port;
};
std::map<std::string, Listener> listeners_;
std::map<std::string, ListenerIssue> listener_errors_, listener_warnings_;
uint64_t epoch_ = 1;
bool stopping_ = false, network_drained_ = false;
std::chrono::steady_clock::time_point interface_check_{};
std::future<std::pair<std::string, nlohmann::json>> reset_job_;
std::string new_password_, access_password_;
};
} // namespace crossdesk::lan
#endif
@@ -0,0 +1,550 @@
#include "lan_transport.h"
#include <atomic>
#include <chrono>
#include <deque>
#include <future>
#include <map>
#include <mutex>
#include <set>
#include <thread>
#include <websocketpp/client.hpp>
#include <websocketpp/config/asio.hpp>
#include <websocketpp/config/asio_client.hpp>
#include <websocketpp/server.hpp>
namespace crossdesk::lan {
namespace {
using Clock = std::chrono::steady_clock;
using Server = websocketpp::server<websocketpp::config::asio_tls>;
using Client = websocketpp::client<websocketpp::config::asio_tls_client>;
using Hdl = websocketpp::connection_hdl;
using Error = websocketpp::lib::error_code;
using Context = websocketpp::lib::asio::ssl::context;
constexpr const char* kPath = "/crossdesk/lan/v2";
constexpr const char* kProtocol = "crossdesk.lan.v2";
void ValidateListener(const std::string& address, unsigned port) {
if (port > 65535) throw std::runtime_error("Invalid LAN port");
const auto interfaces = PhysicalInterfaces();
if (!IsIpv4(address) ||
std::none_of(interfaces.begin(), interfaces.end(),
[&](const auto& i) { return i.address == address; }))
throw std::runtime_error("Selected LAN interface is unavailable");
}
Error ConfigureAcceptor(asio::ip::tcp::acceptor& acceptor, bool automatic_port) {
Error error;
#ifdef _WIN32
(void)automatic_port;
acceptor.set_option(asio::socket_base::reuse_address(false), error);
if (error) return error;
const BOOL exclusive = TRUE;
if (::setsockopt(acceptor.native_handle(), SOL_SOCKET, SO_EXCLUSIVEADDRUSE,
reinterpret_cast<const char*>(&exclusive),
sizeof(exclusive)) == SOCKET_ERROR)
return Error(WSAGetLastError(), asio::error::get_system_category());
#else
// Fixed ports may rebind after TIME_WAIT. OS-assigned ports need no reuse;
// exclusive allocation also avoids wildcard listener overlap on BSD.
acceptor.set_option(asio::socket_base::reuse_address(!automatic_port), error);
#endif
return error;
}
void CheckListenError(const Error& error) {
if (!error) return;
if (error == asio::error::address_in_use)
throw std::runtime_error("LanPortInUse");
if (error == asio::error::access_denied)
throw std::runtime_error("LanPortPermissionDenied");
throw std::runtime_error("Cannot listen on LAN port: " + error.message());
}
void CheckExistingListener(const std::string& address, unsigned port) {
#ifdef __APPLE__
// BSD permits a specific-address listener alongside a wildcard listener with
// SO_REUSEADDR. Detect that live listener without treating TIME_WAIT or our
// established sessions as occupied ports. No application data is sent.
asio::io_context io;
asio::ip::tcp::socket socket(io);
asio::steady_timer deadline(io, std::chrono::milliseconds(200));
Error result;
bool timed_out = false;
socket.async_connect(
{asio::ip::make_address_v4(address), static_cast<unsigned short>(port)},
[&](const Error& error) {
result = error;
deadline.cancel();
});
deadline.async_wait([&](const Error& error) {
if (error) return;
timed_out = true;
Error ignored;
socket.close(ignored);
});
io.run();
if (timed_out) throw std::runtime_error("LanPortCheckFailed");
if (!result) throw std::runtime_error("LanPortInUse");
if (result != asio::error::connection_refused) CheckListenError(result);
#else
(void)address;
(void)port;
#endif
}
std::shared_ptr<Context> TlsContext() {
auto context = std::make_shared<Context>(Context::tls);
if (SSL_CTX_set_min_proto_version(context->native_handle(), TLS1_3_VERSION) !=
1 ||
SSL_CTX_set_max_early_data(context->native_handle(), 0) != 1)
throw std::runtime_error("TLS 1.3 is required for LAN connections");
SSL_CTX_set_options(context->native_handle(), SSL_OP_NO_COMPRESSION);
SSL_CTX_set_session_cache_mode(context->native_handle(), SSL_SESS_CACHE_OFF);
return context;
}
} // namespace
struct Transport::Channel::State {
std::atomic<bool> closing{false};
std::mutex budget_mutex;
size_t sent_count = 0, sent_bytes = 0, received_count = 0, received_bytes = 0;
bool authenticated = false, opened = false, terminal = false;
Clock::time_point created = Clock::now(), last_ping = Clock::now(),
ping_sent{}, ping_window = Clock::now();
unsigned inbound_pings = 0;
std::string pending_ping, error;
std::function<void(const std::string&)> send, ping;
std::function<void()> close, graceful_close;
};
struct Transport::Impl {
asio::io_context io;
asio::executor_work_guard<asio::io_context::executor_type> work{
io.get_executor()};
asio::steady_timer timer{io};
std::thread thread;
std::atomic<bool> stopping{false};
std::atomic<uint64_t> next_id{1};
std::map<uint64_t, std::shared_ptr<Server>> servers;
std::map<uint64_t, std::shared_ptr<Client>> clients;
std::map<Hdl, ChannelPtr, std::owner_less<Hdl>> channels;
size_t preauth = 0;
std::set<uint64_t> retired_listeners;
std::mutex event_mutex;
std::vector<Event> events;
Impl() {
Tick();
thread = std::thread([this] { io.run(); });
}
bool AdmissionBlocked() {
std::lock_guard lock(event_mutex);
// Keep closed-but-not-yet-consumed channel events in the admission budget.
return events.size() >= 64;
}
void Emit(Event::Kind kind, const ChannelPtr& c, std::string payload = {}) {
std::lock_guard lock(event_mutex);
events.push_back({kind, c, std::move(payload)});
}
void Finish(const ChannelPtr& c) {
if (c->state->terminal) return;
c->state->closing = true;
c->state->terminal = true;
if (c->incoming && !c->state->authenticated) --preauth;
Emit(Event::Closed, c, c->state->error);
}
void Abort(const ChannelPtr& c) {
if (c->state->terminal) return;
c->state->closing = true;
if (c->state->close) c->state->close();
}
void Tick() {
timer.expires_after(std::chrono::seconds(1));
timer.async_wait([this](const Error& error) {
if (error || stopping) return;
const auto now = Clock::now();
std::vector<ChannelPtr> expired;
for (const auto& [_, c] : channels) {
auto& s = *c->state;
if (s.terminal || s.closing) continue;
if ((!s.opened && now - s.created >= std::chrono::seconds(8)) ||
(!s.pending_ping.empty() &&
now - s.ping_sent >= std::chrono::seconds(15))) {
s.error = s.opened ? "HeartbeatTimeout" : "ConnectionTimeout";
expired.push_back(c);
continue;
}
if (s.opened && s.pending_ping.empty() &&
now - s.last_ping >= std::chrono::seconds(5)) {
s.pending_ping = RandomHex(8);
s.last_ping = s.ping_sent = now;
s.ping(s.pending_ping);
}
}
for (auto& c : expired) Abort(c);
for (auto it = retired_listeners.begin();
it != retired_listeners.end();) {
const auto id = *it;
if (std::none_of(channels.begin(), channels.end(),
[id](const auto& item) {
return item.second->listener == id;
})) {
servers.erase(id);
it = retired_listeners.erase(it);
} else
++it;
}
Tick();
});
}
template <class Endpoint>
void Attach(Endpoint* endpoint, Hdl hdl, const ChannelPtr& c) {
channels[hdl] = c;
c->state->send = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)](
const std::string& text) {
Error e;
endpoint->send(hdl, text, websocketpp::frame::opcode::text, e);
if (e)
if (auto c = cweak.lock()) Abort(c);
};
c->state->ping = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)](
const std::string& nonce) {
Error e;
endpoint->ping(hdl, nonce, e);
if (e)
if (auto c = cweak.lock()) Abort(c);
};
c->state->graceful_close = [this, endpoint, hdl,
cweak = std::weak_ptr<Channel>(c)] {
Error error;
endpoint->close(hdl, websocketpp::close::status::going_away, "", error);
if (error)
if (auto c = cweak.lock()) Abort(c);
};
c->state->close = [this, endpoint, hdl, cweak = std::weak_ptr<Channel>(c)] {
Error e;
auto connection = endpoint->get_con_from_hdl(hdl, e);
if (!e && connection) {
connection->get_raw_socket().cancel(e);
connection->get_raw_socket().shutdown(
asio::ip::tcp::socket::shutdown_both, e);
connection->get_raw_socket().close(e);
}
if (auto c = cweak.lock()) Finish(c);
};
}
template <class Endpoint>
void Configure(Endpoint* endpoint) {
endpoint->clear_access_channels(websocketpp::log::alevel::all);
endpoint->clear_error_channels(websocketpp::log::elevel::all);
endpoint->init_asio(&io);
endpoint->set_max_message_size(256 * 1024);
endpoint->set_max_http_body_size(0);
endpoint->set_open_handshake_timeout(8000);
endpoint->set_close_handshake_timeout(1000);
endpoint->set_open_handler([this, endpoint](Hdl hdl) {
auto it = channels.find(hdl);
if (it == channels.end()) return;
auto c = it->second;
if (c->state->closing) {
Abort(c);
return;
}
Error e;
auto connection = endpoint->get_con_from_hdl(hdl, e);
if (e) {
Abort(c);
return;
}
const auto local = connection->get_raw_socket().local_endpoint(e);
if (e) {
Abort(c);
return;
}
const auto remote = connection->get_raw_socket().remote_endpoint(e);
if (e) {
Abort(c);
return;
}
if (!local.address().is_v4() || !remote.address().is_v4()) {
Abort(c);
return;
}
c->local_address = local.address().to_string();
c->remote_address = remote.address().to_string();
const auto interfaces = PhysicalInterfaces();
if (!IsIpv4(c->remote_address) ||
std::none_of(
interfaces.begin(), interfaces.end(),
[&](const auto& i) { return i.address == c->local_address; })) {
c->state->error = "UnsupportedNetworkPath";
Abort(c);
return;
}
const auto protocol =
c->incoming
? connection->get_subprotocol()
: connection->get_response_header("Sec-WebSocket-Protocol");
if (protocol != kProtocol) {
c->state->error = "IncompatibleProtocol";
Abort(c);
return;
}
c->state->opened = true;
Emit(Event::Open, c);
});
auto closed = [this, endpoint](Hdl hdl) {
auto it = channels.find(hdl);
if (it != channels.end()) {
auto& state = *it->second->state;
if (state.error.empty() && !state.opened && !state.closing) {
Error error;
auto connection = endpoint->get_con_from_hdl(hdl, error);
state.error =
!error && connection
? "ConnectionFailed: " + connection->get_ec().message()
: "ConnectionFailed";
}
const auto id = it->second->id;
const bool incoming = it->second->incoming;
Finish(it->second);
channels.erase(it);
if (!incoming) asio::post(io, [this, id] { clients.erase(id); });
}
};
endpoint->set_close_handler(closed);
endpoint->set_fail_handler(closed);
endpoint->set_message_handler(
[this](Hdl hdl, typename Endpoint::message_ptr message) {
auto it = channels.find(hdl);
if (it == channels.end()) return;
auto c = it->second;
auto& s = *c->state;
if (s.closing) return;
const auto& text = message->get_payload();
if (message->get_opcode() != websocketpp::frame::opcode::text ||
text.empty() || text.size() > 256 * 1024 ||
++s.received_count > 256 ||
(s.received_bytes += text.size()) > 1024 * 1024) {
Abort(c);
return;
}
Emit(Event::Message, c, text);
});
endpoint->set_ping_handler([this](Hdl hdl, std::string nonce) {
auto it = channels.find(hdl);
if (it == channels.end()) return false;
auto c = it->second;
auto& s = *c->state;
auto now = Clock::now();
if (now - s.ping_window >= std::chrono::seconds(5)) {
s.ping_window = now;
s.inbound_pings = 0;
}
if (s.closing || nonce.size() != 16 || ++s.inbound_pings > 2) {
Abort(c);
return false;
}
return true;
});
endpoint->set_pong_handler([this](Hdl hdl, std::string nonce) {
auto it = channels.find(hdl);
if (it == channels.end()) return;
auto& s = *it->second->state;
if (!s.pending_ping.empty() && nonce == s.pending_ping)
s.pending_ping.clear();
});
}
};
Transport::Transport() : impl_(std::make_unique<Impl>()) {}
Transport::~Transport() { Shutdown(); }
Transport::Listener Transport::Listen(const Identity& identity,
const std::string& address,
unsigned port) {
ValidateListener(address, port);
auto context = TlsContext();
context->use_certificate_chain_file(identity.certificate().string());
context->use_private_key_file(identity.private_key().string(), Context::pem);
auto promise = std::make_shared<std::promise<Listener>>();
auto future = promise->get_future();
asio::post(impl_->io, [this, address, port, context, promise] {
try {
if (port) CheckExistingListener(address, port);
auto server = std::make_shared<Server>();
impl_->Configure(server.get());
const uint64_t id = impl_->next_id++;
server->set_tls_init_handler([context](Hdl) { return context; });
server->set_tcp_pre_init_handler(
[this, endpoint = server.get(), id](Hdl hdl) {
Error error;
auto connection = endpoint->get_con_from_hdl(hdl, error);
if (error) return;
if (impl_->stopping || impl_->retired_listeners.count(id) ||
impl_->preauth >= 16 || impl_->AdmissionBlocked() ||
impl_->channels.size() >= 24) {
connection->get_raw_socket().close(error);
return;
}
auto c = std::make_shared<Channel>();
c->id = impl_->next_id++;
c->listener = id;
c->incoming = true;
c->state = std::make_shared<Channel::State>();
++impl_->preauth;
impl_->Attach(endpoint, hdl, c);
});
server->set_validate_handler([endpoint = server.get()](Hdl hdl) {
auto c = endpoint->get_con_from_hdl(hdl);
if (c->get_resource() != kPath || !c->get_request_body().empty() ||
!c->get_request_header("Transfer-Encoding").empty() ||
(!c->get_request_header("Content-Length").empty() &&
c->get_request_header("Content-Length") != "0"))
return false;
const auto& protocols = c->get_requested_subprotocols();
if (protocols.size() != 1 || protocols[0] != kProtocol) return false;
c->select_subprotocol(kProtocol);
return true;
});
auto bound_acceptor = std::make_shared<Server::acceptor_ptr>();
server->set_tcp_pre_bind_handler(
[port, bound_acceptor](Server::acceptor_ptr acceptor) {
*bound_acceptor = acceptor;
return ConfigureAcceptor(*acceptor, port == 0);
});
Error listen_error;
server->listen(
asio::ip::tcp::endpoint(asio::ip::make_address_v4(address),
static_cast<unsigned short>(port)),
listen_error);
CheckListenError(listen_error);
const auto actual_port = server->get_local_endpoint(listen_error).port();
CheckListenError(listen_error);
#ifndef _WIN32
if (!port) {
// Allocation is already exclusive. Enable TIME_WAIT reuse before any
// accepted socket inherits options so the saved port can restart too.
(*bound_acceptor)->set_option(asio::socket_base::reuse_address(true),
listen_error);
CheckListenError(listen_error);
}
#endif
server->set_tcp_pre_bind_handler({});
server->start_accept();
impl_->servers[id] = server;
promise->set_value({id, actual_port});
} catch (...) {
promise->set_exception(std::current_exception());
}
});
return future.get();
}
void Transport::StopAccept(uint64_t listener) {
asio::post(impl_->io, [this, listener] {
auto it = impl_->servers.find(listener);
if (it != impl_->servers.end()) {
Error e;
it->second->stop_listening(e);
impl_->retired_listeners.insert(listener);
for (auto& [_, c] : impl_->channels)
if (c->listener == listener && !c->state->authenticated) {
c->state->error = "ListenerChanged";
impl_->Abort(c);
}
}
});
}
Transport::ChannelPtr Transport::Connect(const Target& target) {
if (!IsIpv4(target.address) || target.port == 0 || target.port > 65535)
throw std::runtime_error("Invalid LAN endpoint");
auto c = std::make_shared<Channel>();
c->id = impl_->next_id++;
c->state = std::make_shared<Channel::State>();
asio::post(impl_->io, [this, c, target] {
try {
if (impl_->stopping || impl_->channels.size() >= 24 ||
impl_->AdmissionBlocked()) {
impl_->Finish(c);
return;
}
auto client = std::make_shared<Client>();
impl_->Configure(client.get());
auto context = TlsContext();
context->set_verify_mode(asio::ssl::verify_peer);
context->set_verify_callback(
[c](bool, asio::ssl::verify_context& verification) {
X509_STORE_CTX* store = verification.native_handle();
auto* certificate = X509_STORE_CTX_get_current_cert(store);
// Self-signed LAN certificates establish the encrypted channel.
// Password verification authorizes access at the receiving host;
// it does not authenticate this certificate's device identity.
const bool valid = X509_STORE_CTX_get_error_depth(store) == 0 &&
VerifyLanCertificate(certificate);
if (!valid) c->state->error = "InvalidCertificate";
return valid;
});
client->set_tls_init_handler([context](Hdl) { return context; });
Error error;
auto connection = client->get_connection(
"wss://" + target.address + ":" + std::to_string(target.port) + kPath,
error);
if (error) {
impl_->Finish(c);
return;
}
connection->add_subprotocol(kProtocol);
impl_->Attach(client.get(), connection->get_handle(), c);
impl_->clients[c->id] = client;
client->connect(connection);
} catch (...) {
impl_->Abort(c);
impl_->Finish(c);
}
});
return c;
}
bool Transport::Send(const ChannelPtr& c, std::string message) {
if (!c || impl_->stopping || c->state->closing || message.empty() ||
message.size() > 256 * 1024)
return false;
{
std::lock_guard lock(c->state->budget_mutex);
if (c->state->sent_count >= 256 ||
c->state->sent_bytes + message.size() > 1024 * 1024)
return false;
++c->state->sent_count;
c->state->sent_bytes += message.size();
}
asio::post(impl_->io, [c, message = std::move(message)] {
if (!c->state->terminal && c->state->send) c->state->send(message);
});
return true;
}
void Transport::Authenticated(const ChannelPtr& c) {
asio::post(impl_->io, [this, c] {
if (c->incoming && !c->state->terminal && !c->state->authenticated) {
c->state->authenticated = true;
--impl_->preauth;
}
});
}
void Transport::Close(const ChannelPtr& c) {
if (!c) return;
c->state->closing = true;
asio::post(impl_->io, [this, c] {
if (c->state->opened && !c->state->terminal && c->state->graceful_close)
c->state->graceful_close();
else
impl_->Abort(c);
});
}
std::vector<Transport::Event> Transport::Poll() {
std::lock_guard lock(impl_->event_mutex);
std::vector<Event> result;
result.swap(impl_->events);
return result;
}
void Transport::Shutdown() {
if (!impl_ || impl_->stopping.exchange(true)) return;
asio::post(impl_->io, [this] {
Error e;
impl_->timer.cancel();
for (auto& [_, server] : impl_->servers) server->stop_listening(e);
for (auto& [_, c] : impl_->channels) impl_->Abort(c);
impl_->work.reset();
});
if (impl_->thread.joinable()) impl_->thread.join();
}
} // namespace crossdesk::lan
@@ -0,0 +1,65 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LAN_TRANSPORT_H_
#define _LAN_TRANSPORT_H_
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "lan_identity.h"
namespace crossdesk::lan {
// One network thread; callbacks enqueue bounded value events for the UI-owned
// session manager. No network callback calls MiniRTC or destroys a Peer.
class Transport {
public:
struct Channel;
using ChannelPtr = std::shared_ptr<Channel>;
struct Event {
enum Kind { Open, Message, Closed } kind;
ChannelPtr channel;
std::string payload;
};
Transport();
~Transport();
Transport(const Transport&) = delete;
Transport& operator=(const Transport&) = delete;
struct Listener {
uint64_t id;
unsigned port;
};
// Port zero lets the OS allocate and hold a free port on this address.
Listener Listen(const Identity& identity, const std::string& address,
unsigned port);
void StopAccept(uint64_t listener);
ChannelPtr Connect(const Target& target);
bool Send(const ChannelPtr& channel, std::string message);
void Authenticated(const ChannelPtr& channel);
void Close(const ChannelPtr& channel);
std::vector<Event> Poll();
void Shutdown();
struct Channel {
uint64_t id = 0, listener = 0;
bool incoming = false;
// Published by Open, immutable thereafter.
std::string local_address, remote_address;
private:
friend class Transport;
struct State;
std::shared_ptr<State> state;
};
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace crossdesk::lan
#endif

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