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
178 changed files with 10113 additions and 2486 deletions
+12 -104
View File
@@ -7,124 +7,32 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | 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 - type: textarea
id: problem id: problem
attributes: attributes:
label: 问题与预期 / Problem and expected behavior label: 问题描述 / Problem description
description: 描述实际发生的情况、错误提示,以及期望的行为。Describe what happened, any error messages, and what you expected. description: 遇到了什么问题?如方便,也请说明触发操作或错误提示。What went wrong? If possible, include what you were doing or any error message.
placeholder: |
实际结果 / Actual behavior:
预期结果 / Expected behavior:
validations: validations:
required: true required: true
- type: textarea - type: input
id: reproduction id: environment
attributes: attributes:
label: 复现步骤 / Steps to reproduce label: 使用环境(选填) / Environment (optional)
description: 说明操作步骤和出现频率;偶发问题可描述发生前的操作。Include the steps and frequency. For intermittent issues, describe what you were doing. description: 操作系统和 CrossDesk 版本;连接问题可填写两端信息。OS and CrossDesk version; include both devices for connection issues.
placeholder: | placeholder: Windows 11 → macOS 15; CrossDesk x.y.z
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.
validations: validations:
required: false required: false
- type: textarea - type: textarea
id: attachments id: attachments
attributes: attributes:
label: 日志与截图 / Logs and screenshots label: 截图或日志(选填) / Screenshots or logs (optional)
description: | 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: validations:
required: false required: false
+6 -51
View File
@@ -7,66 +7,21 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | 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. Only a description of your idea is required. Chinese or English is welcome.
- 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
- type: textarea - type: textarea
id: proposal id: proposal
attributes: attributes:
label: 功能建议与预期效果 / Proposal and expected outcome label: 你的建议 / Your idea
description: 描述希望新增或改进的行为,以及理想的使用方式。Describe the behavior you would like to add or improve and how you expect to use it. description: 希望新增或改进什么?如方便,也请说明使用场景。What would you like to add or improve? If possible, describe when you would use it.
validations: validations:
required: true 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 - type: textarea
id: references id: references
attributes: attributes:
label: 参考资料与补充说明 / References and additional context label: 补充说明(选填) / Additional context (optional)
description: 可提供参考链接、截图、示意图或其他要求。Include links, screenshots, mockups, or any additional requirements. description: 可附截图、参考链接或其他说明。Add screenshots, links, or any other details.
validations: validations:
required: false required: false
+66 -14
View File
@@ -50,7 +50,7 @@ def sign_update(payload, private_key, expected_public_key):
return {"payload": payload, "signature": signature.hex()} return {"payload": payload, "signature": signature.hex()}
def version_info(version, fallback_date=None): def version_fields(version):
version = version.removeprefix("v") version = version.removeprefix("v")
base = r"[0-9]+(?:\.[0-9]+){1,3}" base = r"[0-9]+(?:\.[0-9]+){1,3}"
for pattern in ( for pattern in (
@@ -60,18 +60,41 @@ def version_info(version, fallback_date=None):
): ):
match = re.fullmatch(pattern, version) match = re.fullmatch(pattern, version)
if match: if match:
fields = match.groupdict() return 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)}
raise ValueError(f"Invalid release version: {version}") 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): def artifact_downloads(directory):
downloads = {} downloads = {}
private_key = os.environ.get("CROSSDESK_UPDATE_PRIVATE_KEY", "") private_key = os.environ.get("CROSSDESK_UPDATE_PRIVATE_KEY", "")
@@ -129,10 +152,34 @@ def merge_release(current, tag, name, notes, downloads, published_date=None):
if not tag.startswith("v"): if not tag.startswith("v"):
raise ValueError(f"Expected a version tag, got {tag!r}") raise ValueError(f"Expected a version tag, got {tag!r}")
metadata = version_info(tag, published_date) 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 = dict(current)
result.pop("platforms", None) result.pop("platforms", None)
result["downloads"] = {**current["downloads"], **downloads} result["downloads"] = {**current["downloads"], **accepted_downloads}
if not mobile: if not mobile:
result.update({**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag}) result.update({**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag})
return result return result
@@ -159,11 +206,16 @@ def main():
parser.error("Supply --release-json or --tag, --name and --notes") parser.error("Supply --release-json or --tag, --name and --notes")
tag, name, notes = args.tag, args.name, args.notes.read_text() tag, name, notes = args.tag, args.name, args.notes.read_text()
published_date = None 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) downloads = artifact_downloads(args.artifacts)
result = merge_release(current, tag, name, notes, downloads, published_date) result = merge_release(current, tag, name, notes, downloads, published_date)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n") if result == current:
print(f"Updated {tag}: {', '.join(downloads)}") 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__": if __name__ == "__main__":
-4
View File
@@ -36,7 +36,6 @@ on:
- "apps/android/**" - "apps/android/**"
- "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json" - "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"
- "apps/ios/licenses/**" - "apps/ios/licenses/**"
- "apps/ios/scripts/generate_third_party_licenses.py"
- "deps/submodules/minirtc" - "deps/submodules/minirtc"
- "libs/wire/**" - "libs/wire/**"
@@ -155,9 +154,6 @@ jobs:
cache-provider: basic cache-provider: basic
cache-read-only: ${{ github.event_name == 'pull_request' }} 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 - name: Resolve Android version
shell: python shell: python
env: env:
+1 -4
View File
@@ -169,9 +169,6 @@ jobs:
with: with:
python-version: "3.13" python-version: "3.13"
- name: Verify iOS license catalog
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Install xmake - name: Install xmake
uses: xmake-io/github-action-setup-xmake@v1 uses: xmake-io/github-action-setup-xmake@v1
with: with:
@@ -181,7 +178,7 @@ jobs:
shell: bash shell: bash
run: | run: |
xmake --version | tee "${RUNNER_TEMP}/xmake-version.txt" 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" --xmake-version "${RUNNER_TEMP}/xmake-version.txt"
- name: Pin xmake package repository - name: Pin xmake package repository
+73 -219
View File
@@ -36,9 +36,68 @@ env:
CROSSDESK_DESKTOP_VERSION: ${{ inputs.desktop_version }} CROSSDESK_DESKTOP_VERSION: ${{ inputs.desktop_version }}
jobs: 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: build-linux:
name: Build Linux (${{ matrix.arch }}) name: Build Linux (${{ matrix.arch }})
needs: prepare-version
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
env:
LEGAL_VERSION: ${{ needs.prepare-version.outputs.version }}
strategy: strategy:
matrix: matrix:
include: include:
@@ -58,69 +117,6 @@ jobs:
options: --user root options: --user root
steps: steps:
- name: Extract version number
shell: bash
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}"
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 - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
@@ -218,8 +214,10 @@ jobs:
# macOS # macOS
build-macos: build-macos:
name: Build on macOS name: Build on macOS
needs: prepare-version
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
env: env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
EXPECTED_MACOS_SDK: "26.5" EXPECTED_MACOS_SDK: "26.5"
strategy: strategy:
@@ -239,56 +237,6 @@ jobs:
package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh
steps: steps:
- name: Extract version number
id: version
shell: bash
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)
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 - name: Select Xcode 26.6
shell: bash shell: bash
run: | run: |
@@ -380,62 +328,12 @@ jobs:
# Windows # Windows
build-windows-x64: build-windows-x64:
name: Build on Windows x64 name: Build on Windows x64
needs: prepare-version
runs-on: windows-2022 runs-on: windows-2022
env: env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
XMAKE_GLOBALDIR: D:\xmake_global XMAKE_GLOBALDIR: D:\xmake_global
steps: steps:
- name: Extract version number
shell: pwsh
run: |
$ref = $env:GITHUB_REF
$version = $ref -replace '^refs/(tags|heads)/', ''
if ($ref.StartsWith("refs/heads/")) {
$version = if ([string]::IsNullOrEmpty($env:CROSSDESK_DESKTOP_VERSION)) {
"0.0.0"
} else {
$env:CROSSDESK_DESKTOP_VERSION
}
if ($version -notmatch '^v?[0-9]+(\.[0-9]+){1,3}\z') {
throw "desktop_version must be a numeric version such as 1.7.2"
}
} elseif (![string]::IsNullOrEmpty($env:CROSSDESK_DESKTOP_VERSION)) {
throw "desktop_version is only supported for branch builds; tags determine release versions"
}
$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 - name: Cache xmake dependencies
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
@@ -636,7 +534,7 @@ jobs:
release: release:
name: Publish Release name: Publish Release
if: startsWith(github.ref, 'refs/tags/v') 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 runs-on: ubuntu-latest
concurrency: concurrency:
group: publish-release-metadata group: publish-release-metadata
@@ -651,50 +549,6 @@ jobs:
with: with:
path: artifacts 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 - name: Prepare release notes
run: | run: |
cat > "$RUNNER_TEMP/release-notes.md" << EOF cat > "$RUNNER_TEMP/release-notes.md" << EOF
@@ -724,18 +578,18 @@ jobs:
- name: Rename artifacts - name: Rename artifacts
run: | run: |
mkdir -p release 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-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}.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-arm64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}.pkg
for ARCH in amd64 arm64; do for ARCH in amd64 arm64; do
for PACKAGE in crossdesk crossdesk-virtual-desktop-xfce; do for PACKAGE in crossdesk crossdesk-virtual-desktop-xfce; do
FILE="${PACKAGE}-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}.deb" FILE="${PACKAGE}-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}.deb"
cp "artifacts/crossdesk-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}/${FILE}" "release/${FILE}" cp "artifacts/crossdesk-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}/${FILE}" "release/${FILE}"
done done
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-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-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-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}.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-portable-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}.zip
- name: List release files - name: List release files
run: ls -lh release/ run: ls -lh release/
@@ -743,10 +597,10 @@ jobs:
- name: Upload to Versioned GitHub Release - name: Upload to Versioned GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: 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. # The pushed tag already determines the release source commit.
target_commitish: ${{ github.event.repository.default_branch }} target_commitish: ${{ github.event.repository.default_branch }}
name: Release ${{ steps.version.outputs.VERSION_WITH_V }} name: Release ${{ needs.prepare-version.outputs.version }}
draft: false draft: false
prerelease: false prerelease: false
files: release/* files: release/*
@@ -789,7 +643,7 @@ jobs:
- name: Generate version.json for all platforms - name: Generate version.json for all platforms
env: 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 }} CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }}
run: | run: |
python3 .github/scripts/update_version_manifest.py \ python3 .github/scripts/update_version_manifest.py \
+3
View File
@@ -1,3 +1,6 @@
CrossDesk
SPDX-License-Identifier: GPL-3.0-only
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 29 June 2007
+30 -7
View File
@@ -1,6 +1,5 @@
<div align="center"> <div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" /> <img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk 标志:图标在左,双色文字在右" />
<h1>CrossDesk</h1>
<p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p> <p>轻量、跨平台的远程桌面,支持电脑、浏览器和移动端连接,用桌面墙集中查看与操控多台设备。</p>
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p> <p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
</div> </div>
@@ -22,13 +21,14 @@
CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。 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> <a id="features"></a>
## 主要功能 ## 主要功能
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。 - **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。 - **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。 - **画面与声音:** 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/)。 2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。 3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。 使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](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> <a id="preview"></a>
## 界面预览 ## 界面预览
### 桌面客户端 ### 桌面客户端
本机凭据、远程连接和近期设备集中在主窗口,左上角进入桌面墙。 本机凭据、远程连接和近期设备集中在主窗口,左上角提供连接模式切换和桌面墙入口。
<p align="center"> <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> </p>
<a id="deskhub"></a> <a id="deskhub"></a>
@@ -96,7 +116,7 @@ CrossDesk 让你从电脑、浏览器或手机连接远端电脑,也能通过
## 会话操作与设置 ## 会话操作与设置
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置编码、中继、自托管与桌面墙画面预算。 远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。 - [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。 - [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
@@ -151,9 +171,12 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。 - [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。 - [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。 - [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。 - [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
桌面端的 `CROSSDESK_UPDATE_PUBLIC_KEY` 是可选编译参数,用于验证应用内升级清单的 Ed25519 签名。不配置仍可正常编译、运行和检查版本,但更新只能手动下载;需要应用内自动安装更新时,须配置与发布私钥匹配的公钥。配置方式见 [更新公钥](docs/BUILD.md#update-public-key),密钥生成与发布流程见 [桌面端应用内升级](docs/UPDATER.md)。
<a id="sponsor"></a> <a id="sponsor"></a>
## 支持 CrossDesk ## 支持 CrossDesk
+30 -7
View File
@@ -1,6 +1,5 @@
<div align="center"> <div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" /> <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" />
<h1>CrossDesk</h1>
<p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p> <p>A lightweight, cross-platform remote desktop for computers, browsers, and mobile devices, with DeskHub for viewing and controlling multiple desktops.</p>
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p> <p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
</div> </div>
@@ -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. 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> <a id="features"></a>
## Features ## 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. - **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. - **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. - **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. - **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 ## 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. 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**. 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. 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> <a id="preview"></a>
## Preview ## Preview
### Desktop client ### 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"> <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> </p>
<a id="deskhub"></a> <a id="deskhub"></a>
@@ -96,7 +116,7 @@ Group control sends input to all connected devices; closing DeskHub disconnects
## Session controls and settings ## 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. - [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. - [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. - [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. - [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. - [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. - [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> <a id="sponsor"></a>
## Support CrossDesk ## Support CrossDesk
+2 -1
View File
@@ -9,6 +9,7 @@
- 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。 - 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。 - 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。 - 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
- “设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。手机进入会话后先保持横屏,检测到手机实际横过来后才恢复跟随设备方向;竖持或平放不会立即切回竖屏。关闭后沿用自动旋转,平板与多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向。
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。 - 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。 - 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
@@ -245,4 +246,4 @@ AV1 硬件路径尚无对应实机验证;Retroid 上使用 dav1d 软件解码
设置 → 关于使用与 iOS 相同的分组及入口:版本、软件许可、开源组件、源码与构建说明。软件许可中可分别阅读 GNU GPL 第 3 版及开源软件权利说明;开源组件页单独展示 OpenFEC 告知,其余组件列出名称和许可证,详情包含版本、源码、适用的构建配方和每一份许可文档。内容按 Android 构建生成,不显示未使用的 iOS 依赖,Android 签名说明替换 Apple 签名说明。 设置 → 关于使用与 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; RemoteVideoSettings videoSettings;
int signalState, selectedDisplay, width, height; int signalState, selectedDisplay, width, height;
boolean muted, remoteServiceAvailable, background, ended; boolean muted, remoteServiceAvailable, background, ended;
boolean waitingForLandscape;
File receivedFile; File receivedFile;
BackgroundSession(Context context, NativeSession session, String host, int port, String remote) { 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 int videoWidth,videoHeight;
private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture(); private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture();
private RemoteVideoView video; private RemoteVideoView video;
private RemoteOrientation remoteOrientation;
private SessionControls controls; private SessionControls controls;
private Dialog passwordDialog, progressDialog, privacyDialog; private Dialog passwordDialog, progressDialog, privacyDialog;
private AlertDialog disconnectDialog; private AlertDialog disconnectDialog;
@@ -95,7 +96,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private Runnable backAction; private Runnable backAction;
@Override public void onCreate(Bundle state){ @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); pageHost=new PageHost(this);setContentView(pageHost);
preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099); 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);}; 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); 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; return item;
} }
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings. private boolean isTablet(){
private void updateHomeOrientation(){
boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600; boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600;
if(android.os.Build.VERSION.SDK_INT>=30){ if(android.os.Build.VERSION.SDK_INT>=30){
android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds(); android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds();
tablet|=Math.min(bounds.width(),bounds.height())>=dp(600); 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); if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation);
} }
private void home(String message){ 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)); 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); 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(){ private void sessionScreen(boolean waitForLandscape){
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)); 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(){ controls=new SessionControls(this,ui,new SessionControls.Actions(){
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();} 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();} 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 confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
private void end(String reason){ private void end(String reason){
remoteOrientation.end();
if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);} if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);}
boolean closeWindow=isInPictureInPictureMode(); boolean closeWindow=isInPictureInPictureMode();
pictureInPicture.disconnect();updatePictureInPicture(); 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())); 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))); 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); 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; 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); 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); 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);
@@ -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. // Transport readiness can be reported again after ICE changes its selected path.
// The first success consumes pendingPassword; a duplicate must not save it again. // 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;} 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(!canUsePictureInPicture())prepareBackgroundSession(true);
if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection); if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection);
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected(); if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected();
@@ -670,6 +679,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onConfigurationChanged(config); super.onConfigurationChanged(config);
releaseKeys(); releaseKeys();
if(!page.equals("session"))updateHomeOrientation(); if(!page.equals("session"))updateHomeOrientation();
else if(!isInPictureInPictureMode()&&(isTablet()||isInMultiWindowMode()))remoteOrientation.followDevice();
sizePasswordDialog(); sizePasswordDialog();
if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize(); if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize();
if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels); 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.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable;
retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile; retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile;
retained.width=videoWidth;retained.height=videoHeight; retained.width=videoWidth;retained.height=videoHeight;
retained.waitingForLandscape=remoteOrientation.waitingForLandscape();
} }
private boolean prepareBackgroundSession(boolean inform){ private boolean prepareBackgroundSession(boolean inform){
if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false; 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; hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay;
remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile; remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile;
videoSettings=retained.videoSettings;ready=true;retained.attach(this); 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(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);} if(controls!=null){controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
retained.setBackground(false);signaling(signalState,localIdentity); retained.setBackground(false);signaling(signalState,localIdentity);
@@ -771,9 +782,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onPictureInPictureUiStateChanged(state); super.onPictureInPictureUiStateChanged(state);
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true); 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. // 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 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(); @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(); 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; boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
if(backgroundSession!=null)backgroundSession.detach(this); if(backgroundSession!=null)backgroundSession.detach(this);
if(!retained){if(session!=null)session.close();stopSignaling();}else{mainHandler.removeCallbacks(presenceTick);session=signaling=null;} 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 #!/usr/bin/env python3
"""Package existing reviewed notices for the Android runtime dependency set.""" """Package existing reviewed notices for the Android runtime dependency set."""
import json import json
import hashlib
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
@@ -70,8 +69,7 @@ def write_pages(output, catalog, additional, revision, minirtc):
target = output / asset target = output / asset
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(document['text'], encoding='utf-8') target.write_text(document['text'], encoding='utf-8')
documents.append({'title': document['title'], 'asset': asset, documents.append({'title': document['title'], 'asset': asset})
'sha256': hashlib.sha256(document['text'].encode('utf-8')).hexdigest()})
component['documents'] = documents component['documents'] = documents
(output / 'ThirdPartyLicenses.json').write_text(json.dumps( (output / 'ThirdPartyLicenses.json').write_text(json.dumps(
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') {'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.""" """Check the shipped native ABI, ELF load alignment and APK zip alignment."""
from pathlib import Path from pathlib import Path
import argparse import argparse
import hashlib
import json import json
import re import re
import struct import struct
@@ -40,33 +39,15 @@ def verify(path):
name, extra = struct.unpack('<HH', raw.read(4)) name, extra = struct.unpack('<HH', raw.read(4))
if (library.header_offset + 30 + name + extra) % 16384: if (library.header_offset + 30 + name + extra) % 16384:
raise ValueError('APK native library entry is not 16 KB aligned') 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(): if archive.read('assets/PRIVACY.md') != (Path(__file__).resolve().parents[3] / 'PRIVACY.md').read_bytes():
raise ValueError('Missing or outdated bundled privacy policy') raise ValueError('Missing or outdated bundled privacy policy')
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.json'))
source = json.loads(archive.read('assets/SourceMetadata.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: if source['schemaVersion'] != 1 or len(source['revision']) != 40:
raise ValueError('Missing source revision') raise ValueError('Missing source revision')
for document in source['buildDocuments']: for document in source['buildDocuments']:
if not archive.read('assets/'+document['asset']): if not archive.read('assets/'+document['asset']):
raise ValueError('Missing source build instructions') 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__': 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 CREATEPROCESS_MANIFEST_RESOURCE_ID 1
#define RT_MANIFEST 24 #define RT_MANIFEST 24
#ifdef CROSSDESK_PORTABLE // The service supplies either the user's token (desktop observer) or a SYSTEM
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest" // token (secure capture/input). Requiring elevation here prevents the observer
#elif defined(CROSSDESK_DEBUG) // from starting with CreateProcessAsUserW in installed release builds (740).
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest" CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_session_helper.manifest"
#else
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
#endif
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper" #define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe" #define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe"
+11 -2
View File
@@ -20,6 +20,7 @@
#include "platform/windows/gui/slint_backend.h" #include "platform/windows/gui/slint_backend.h"
#include "service_host.h" #include "service_host.h"
#include "service_lifetime.h" #include "service_lifetime.h"
#include "shutdown_order.h"
#include "unattended_config.h" #include "unattended_config.h"
#endif #endif
@@ -207,8 +208,6 @@ int HandleServiceCliCommand(const std::string& command) {
destination.SetTurnMode(source.GetTurnMode()) == 0 && destination.SetTurnMode(source.GetTurnMode()) == 0 &&
destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) == destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) ==
0 && 0 &&
destination.SetHardwareVideoCodec(source.IsHardwareVideoCodec()) ==
0 &&
destination.SetScreenCaptureMethod( destination.SetScreenCaptureMethod(
crossdesk::ScreenCaptureMethod::Auto) == 0 && crossdesk::ScreenCaptureMethod::Auto) == 0 &&
destination.SetPrivacyScreen(false) == 0 && destination.SetPrivacyScreen(false) == 0 &&
@@ -406,6 +405,16 @@ static int RunApplication(
} }
int main(int argc, char* argv[]) { 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__ #ifdef __linux__
// Run before GUI, configuration, logging or display initialization. // Run before GUI, configuration, logging or display initialization.
if (argc == 2 && if (argc == 2 &&
+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; 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 // These options now belong only to the active remote connection. Discard
// legacy local defaults so they cannot affect this host or new connections. // legacy local defaults so they cannot affect this host or new connections.
persist_config_migration |= ini_->Delete(section_, "video_quality"); persist_config_migration |= ini_->Delete(section_, "video_quality");
@@ -66,6 +68,13 @@ int ConfigCenter::Load() {
language_ = static_cast<LANGUAGE>(language_value); 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( const long screen_capture_method_value = ini_->GetLongValue(
section_, "screen_capture_method", section_, "screen_capture_method",
static_cast<long>(ScreenCaptureMethod::Auto)); static_cast<long>(ScreenCaptureMethod::Auto));
@@ -78,8 +87,6 @@ int ConfigCenter::Load() {
ini_->GetLongValue(section_, "video_encode_format", ini_->GetLongValue(section_, "video_encode_format",
static_cast<long>(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); const char* turn_mode_value = ini_->GetValue(section_, "turn_mode", nullptr);
if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) { if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) {
const long parsed_turn_mode = ini_->GetLongValue( const long parsed_turn_mode = ini_->GetLongValue(
@@ -130,6 +137,8 @@ int ConfigCenter::Load() {
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_); enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
enable_privacy_screen_.store( enable_privacy_screen_.store(
ini_->GetBoolValue(section_, "enable_privacy_screen", true)); ini_->GetBoolValue(section_, "enable_privacy_screen", true));
lock_on_disconnect_.store(
ini_->GetBoolValue(section_, "lock_on_disconnect", true));
save_remote_previews_.store( save_remote_previews_.store(
ini_->GetBoolValue(section_, "save_remote_previews", true)); ini_->GetBoolValue(section_, "save_remote_previews", true));
portable_service_prompt_suppressed_ = portable_service_prompt_suppressed_ =
@@ -201,11 +210,13 @@ int ConfigCenter::StoreValues(
int ConfigCenter::Save() { int ConfigCenter::Save() {
ini_->SetLongValue(section_, "language", static_cast<long>(language_)); 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", ini_->SetLongValue(section_, "screen_capture_method",
static_cast<long>(screen_capture_method_)); static_cast<long>(screen_capture_method_));
ini_->SetLongValue(section_, "video_encode_format", ini_->SetLongValue(section_, "video_encode_format",
static_cast<long>(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_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
ini_->SetBoolValue(section_, "enable_turn", ini_->SetBoolValue(section_, "enable_turn",
turn_mode_ != TURN_MODE::DISABLED); turn_mode_ != TURN_MODE::DISABLED);
@@ -223,6 +234,7 @@ int ConfigCenter::Save() {
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_); ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
ini_->SetBoolValue(section_, "enable_privacy_screen", ini_->SetBoolValue(section_, "enable_privacy_screen",
enable_privacy_screen_.load()); enable_privacy_screen_.load());
ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
ini_->SetBoolValue(section_, "save_remote_previews", ini_->SetBoolValue(section_, "save_remote_previews",
save_remote_previews_.load()); save_remote_previews_.load());
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
@@ -250,6 +262,34 @@ int ConfigCenter::SetLanguage(LANGUAGE language) {
return 0; 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) { int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) { if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
return -1; return -1;
@@ -271,13 +311,6 @@ int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) {
return 0; 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) { int ConfigCenter::SetTurnMode(TURN_MODE mode) {
if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1; if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1;
if (StoreValues({{"turn_mode", std::to_string(static_cast<int>(mode))}, if (StoreValues({{"turn_mode", std::to_string(static_cast<int>(mode))},
@@ -286,10 +319,7 @@ int ConfigCenter::SetTurnMode(TURN_MODE mode) {
return 0; return 0;
} }
int ConfigCenter::SetTurn(bool enable_turn, bool force_relay) { int ConfigCenter::SetForceRelay(bool force_relay) {
if (!enable_turn) {
return SetTurnMode(TURN_MODE::DISABLED);
}
if (!force_relay) { if (!force_relay) {
return SetTurnMode(TURN_MODE::AUTO_UDP_TCP); return SetTurnMode(TURN_MODE::AUTO_UDP_TCP);
} }
@@ -379,6 +409,17 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
return 0; 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) { int ConfigCenter::SetSaveRemotePreviews(bool enabled) {
if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) { if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) {
return -1; return -1;
@@ -417,6 +458,12 @@ int ConfigCenter::SetSponsorshipPromptDismissedVersion(
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; } 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 { ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
return screen_capture_method_; return screen_capture_method_;
} }
@@ -425,28 +472,10 @@ ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
return video_encode_format_; 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 { ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
return turn_mode_; return turn_mode_;
} }
bool ConfigCenter::IsEnableTurn() const {
return turn_mode_ != TURN_MODE::DISABLED;
}
bool ConfigCenter::IsForceRelay() const { bool ConfigCenter::IsForceRelay() const {
return turn_mode_ == TURN_MODE::FORCE_UDP || turn_mode_ == TURN_MODE::FORCE_TCP; 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 { class ConfigCenter {
public: public:
enum class LANGUAGE { CHINESE = 0, ENGLISH = 1, RUSSIAN = 2 }; 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 VIDEO_ENCODE_FORMAT { H264 = 0, AV1 = 1 };
enum class TURN_MODE { enum class TURN_MODE {
DISABLED = 0, DISABLED = 0,
@@ -35,11 +36,12 @@ class ConfigCenter {
// write config // write config
int SetLanguage(LANGUAGE language); int SetLanguage(LANGUAGE language);
int SetConnectionMode(CONNECTION_MODE mode);
int SetIpDirectEnabled(bool enabled);
int SetScreenCaptureMethod(ScreenCaptureMethod method); int SetScreenCaptureMethod(ScreenCaptureMethod method);
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format); int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
int SetHardwareVideoCodec(bool hardware_video_codec);
int SetTurnMode(TURN_MODE turn_mode); 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 SetServerHost(const std::string& signal_server_host);
int SetServerPort(int signal_server_port); int SetServerPort(int signal_server_port);
int SetServerEndpoint(const std::string& address, int port); int SetServerEndpoint(const std::string& address, int port);
@@ -47,6 +49,7 @@ class ConfigCenter {
int SetAutostart(bool enable_autostart); int SetAutostart(bool enable_autostart);
int SetDaemon(bool enable_daemon); int SetDaemon(bool enable_daemon);
int SetPrivacyScreen(bool enable_privacy_screen); int SetPrivacyScreen(bool enable_privacy_screen);
int SetLockOnDisconnect(bool enabled);
int SetSaveRemotePreviews(bool enabled); int SetSaveRemotePreviews(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed); int SetPortableServicePromptSuppressed(bool suppressed);
int SetSponsorshipPromptDismissedVersion(const std::string& version); int SetSponsorshipPromptDismissedVersion(const std::string& version);
@@ -55,13 +58,11 @@ class ConfigCenter {
// read config // read config
LANGUAGE GetLanguage() const; LANGUAGE GetLanguage() const;
CONNECTION_MODE GetConnectionMode() const;
bool IsIpDirectEnabled() const;
ScreenCaptureMethod GetScreenCaptureMethod() const; ScreenCaptureMethod GetScreenCaptureMethod() const;
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() 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; TURN_MODE GetTurnMode() const;
bool IsEnableTurn() const;
bool IsForceRelay() const; bool IsForceRelay() const;
bool IsEnableSrtp() const; bool IsEnableSrtp() const;
std::string GetSignalServerHost() const; std::string GetSignalServerHost() const;
@@ -72,6 +73,7 @@ class ConfigCenter {
bool IsEnableAutostart() const; bool IsEnableAutostart() const;
bool IsEnableDaemon() const; bool IsEnableDaemon() const;
bool IsEnablePrivacyScreen() const; bool IsEnablePrivacyScreen() const;
bool IsLockOnDisconnect() const;
bool IsSaveRemotePreviews() const; bool IsSaveRemotePreviews() const;
bool IsPortableServicePromptSuppressed() const; bool IsPortableServicePromptSuppressed() const;
std::string GetSponsorshipPromptDismissedVersion() const; std::string GetSponsorshipPromptDismissedVersion() const;
@@ -90,9 +92,10 @@ class ConfigCenter {
const char* section_ = "Settings"; const char* section_ = "Settings";
LANGUAGE language_ = LANGUAGE::CHINESE; LANGUAGE language_ = LANGUAGE::CHINESE;
CONNECTION_MODE connection_mode_ = CONNECTION_MODE::DEVICE_ID;
bool enable_ip_direct_ = true;
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto; ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264; VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
bool hardware_video_codec_ = false;
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP; TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
std::string signal_server_host_ = ""; std::string signal_server_host_ = "";
std::string signal_server_host_default_ = "api.crossdesk.cn"; std::string signal_server_host_default_ = "api.crossdesk.cn";
@@ -102,6 +105,7 @@ class ConfigCenter {
bool enable_autostart_ = false; bool enable_autostart_ = false;
bool enable_daemon_ = false; bool enable_daemon_ = false;
std::atomic<bool> enable_privacy_screen_{true}; std::atomic<bool> enable_privacy_screen_{true};
std::atomic<bool> lock_on_disconnect_{true};
std::atomic<bool> save_remote_previews_{true}; std::atomic<bool> save_remote_previews_{true};
bool portable_service_prompt_suppressed_ = false; bool portable_service_prompt_suppressed_ = false;
std::string sponsorship_prompt_dismissed_version_; std::string sponsorship_prompt_dismissed_version_;
@@ -32,6 +32,9 @@ class MouseController : public DeviceController {
virtual int Destroy() = 0; virtual int Destroy() = 0;
virtual int SendMouseCommand(RemoteAction remote_action, virtual int SendMouseCommand(RemoteAction remote_action,
int display_index) = 0; 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( virtual void UpdateDisplayInfoList(
const std::vector<DisplayInfo>&) {} const std::vector<DisplayInfo>&) {}
}; };
@@ -32,7 +32,7 @@ struct InteractionState {
bool audio_capture_ = false; bool audio_capture_ = false;
int screen_width_ = 1280; int screen_width_ = 1280;
int screen_height_ = 720; int screen_height_ = 720;
int selected_display_ = 0; std::atomic<int> selected_display_{0};
char remote_id_display_[12] = ""; char remote_id_display_[12] = "";
unsigned char audio_buffer_[720]{}; unsigned char audio_buffer_[720]{};
int audio_len_ = 0; int audio_len_ = 0;
@@ -77,7 +77,7 @@ struct UiState {
bool show_connection_status_window_ = false; bool show_connection_status_window_ = false;
bool fullscreen_button_pressed_ = false; bool fullscreen_button_pressed_ = false;
bool is_client_mode_ = false; bool is_client_mode_ = false;
bool is_server_mode_ = false; std::atomic<bool> is_server_mode_{false};
bool reload_recent_connections_ = true; bool reload_recent_connections_ = true;
bool show_offline_warning_window_ = false; bool show_offline_warning_window_ = false;
std::string offline_warning_text_; std::string offline_warning_text_;
@@ -36,11 +36,13 @@
#include "crossdesk_ui.h" #include "crossdesk_ui.h"
#include "fa_solid_900.h" #include "fa_solid_900.h"
#include "features/about/about_catalog.h"
#include "features/file_transfer/transfer_notification_layout.h" #include "features/file_transfer/transfer_notification_layout.h"
#include "features/gallery/gallery_fullscreen_transition.h" #include "features/gallery/gallery_fullscreen_transition.h"
#include "features/gallery/gallery_window_state.h" #include "features/gallery/gallery_window_state.h"
#include "features/gallery/gallery_workspace.h" #include "features/gallery/gallery_workspace.h"
#include "features/gallery/preview_bandwidth.h" #include "features/gallery/preview_bandwidth.h"
#include "features/gallery/preview_resolution.h"
#include "filesystem_utf8.h" #include "filesystem_utf8.h"
#include "localization.h" #include "localization.h"
#include "platform.h" #include "platform.h"
@@ -55,6 +57,7 @@
#include <windows.h> #include <windows.h>
#include "platform/windows/gui/slint_backend.h" #include "platform/windows/gui/slint_backend.h"
#include "platform/windows/gui/shutdown_session_guard.h"
#include "platform/windows/gui/tray/win_tray.h" #include "platform/windows/gui/tray/win_tray.h"
#include "platform/windows/gui/window_icons.h" #include "platform/windows/gui/window_icons.h"
#elif defined(__APPLE__) #elif defined(__APPLE__)
@@ -464,6 +467,10 @@ std::string ConnectionStatusText(ConnectionStatus status, int language) {
} }
} }
std::string FormatResolution(int width, int height) {
return std::to_string(width) + " × " + std::to_string(height);
}
std::string FormatBitrate(uint32_t bits_per_second) { std::string FormatBitrate(uint32_t bits_per_second) {
char text[48]; char text[48];
if (bits_per_second < 1000) { if (bits_per_second < 1000) {
@@ -471,7 +478,7 @@ std::string FormatBitrate(uint32_t bits_per_second) {
} else if (bits_per_second < 1000000) { } else if (bits_per_second < 1000000) {
std::snprintf(text, sizeof(text), "%u kbps", bits_per_second / 1000); std::snprintf(text, sizeof(text), "%u kbps", bits_per_second / 1000);
} else { } else {
std::snprintf(text, sizeof(text), "%.1f mbps", bits_per_second / 1000000.0); std::snprintf(text, sizeof(text), "%.1f Mbps", bits_per_second / 1000000.0);
} }
return text; return text;
} }
@@ -922,6 +929,10 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
} // namespace } // namespace
struct GuiApplication::SlintUi { struct GuiApplication::SlintUi {
#if _WIN32
std::unique_ptr<ShutdownSessionGuard> shutdown_guard;
std::wstring_view shutdown_reason;
#endif
gallery::Workspace gallery; gallery::Workspace gallery;
std::filesystem::path gallery_path; std::filesystem::path gallery_path;
std::vector<std::string> gallery_ids; std::vector<std::string> gallery_ids;
@@ -1024,6 +1035,9 @@ struct GuiApplication::SlintUi {
bool portable_service_dialog_initialized = false; bool portable_service_dialog_initialized = false;
bool sponsorship_prompt_pending = false; bool sponsorship_prompt_pending = false;
std::string recent_model_signature; std::string recent_model_signature;
gallery::PreviewResolutionDebouncer recent_preview_resolution;
gallery::PreviewResolution recent_preview_size;
std::unordered_map<std::string, slint::Image> recent_preview_images;
std::optional<std::string> recent_group_filter; std::optional<std::string> recent_group_filter;
std::vector<std::string> recent_filter_groups; std::vector<std::string> recent_filter_groups;
slint::Timer timer; slint::Timer timer;
@@ -1152,6 +1166,7 @@ int GuiApplication::Run() {
updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath()); updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath());
#endif #endif
InitializeUi(); InitializeUi();
InitializeLan();
#ifndef __OHOS__ #ifndef __OHOS__
ui_->activation_receiver = std::move(activation_receiver); ui_->activation_receiver = std::move(activation_receiver);
#endif #endif
@@ -1195,7 +1210,7 @@ void GuiApplication::InitializeSettings() {
bool GuiApplication::InitializeSDL() { bool GuiApplication::InitializeSDL() {
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
ConfigureLinuxCudaGraphics(config_center_->IsHardwareVideoCodec()); ConfigureLinuxCudaGraphics();
// Standard Wayland top-level windows cannot choose an absolute screen // Standard Wayland top-level windows cannot choose an absolute screen
// position. Use the session's XWayland compatibility server for the GUI so // position. Use the session's XWayland compatibility server for the GUI so
// auxiliary windows such as the controlled-side status window can be // auxiliary windows such as the controlled-side status window can be
@@ -1215,6 +1230,9 @@ bool GuiApplication::InitializeSDL() {
"windows while retaining Wayland portal capture and input"); "windows while retaining Wayland portal capture and input");
} }
#endif #endif
// SDL disables the screensaver by default. A background remote desktop app
// should leave automatic display sleep under the user's system settings.
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) { if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) {
LOG_ERROR("SDL initialization failed: {}", SDL_GetError()); LOG_ERROR("SDL initialization failed: {}", SDL_GetError());
return false; return false;
@@ -1283,6 +1301,14 @@ void GuiApplication::InitializeUi() {
slint::set_xdg_app_id("crossdesk"); slint::set_xdg_app_id("crossdesk");
#endif #endif
ui_ = std::make_unique<SlintUi>(); ui_ = std::make_unique<SlintUi>();
for (const auto& window : {ui_->main, ui_->wall})
window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled());
ui_->main->set_lan_mode(config_center_->GetConnectionMode() ==
ConfigCenter::CONNECTION_MODE::DIRECT_IP);
#if _WIN32
ui_->shutdown_guard =
std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_);
#endif
ui_->sponsorship_prompt_pending = ui_->sponsorship_prompt_pending =
config_center_->GetSponsorshipPromptDismissedVersion() != CROSSDESK_VERSION; config_center_->GetSponsorshipPromptDismissedVersion() != CROSSDESK_VERSION;
ui_->video_presenter = ui_->video_presenter =
@@ -1419,7 +1445,6 @@ void GuiApplication::InitializeSystemTray() {
}; };
auto exit_app = [this] { auto exit_app = [this] {
exit_ = true; exit_ = true;
slint::quit_event_loop();
}; };
#if _WIN32 #if _WIN32
ui_->tray = std::make_unique<WinTray>( ui_->tray = std::make_unique<WinTray>(
@@ -1467,12 +1492,8 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
auto& main = ui_->Window(gallery_window); auto& main = ui_->Window(gallery_window);
main->set_language_index(language_button_value_); main->set_language_index(language_button_value_);
main->set_codec_index(video_encode_format_button_value_); main->set_codec_index(video_encode_format_button_value_);
const bool hardware_codec_available = main->set_ip_direct_enabled_draft(config_center_->IsIpDirectEnabled());
ConfigCenter::IsHardwareVideoCodecAvailable(); main->set_settings_save_error("");
main->set_hardware_codec_available(hardware_codec_available);
main->set_hardware_codec_enabled(hardware_codec_available &&
enable_hardware_video_codec_);
main->set_turn_enabled(enable_turn_);
main->set_force_relay_enabled(config_center_->IsForceRelay()); main->set_force_relay_enabled(config_center_->IsForceRelay());
main->set_self_hosted_enabled(enable_self_hosted_); main->set_self_hosted_enabled(enable_self_hosted_);
main->set_autostart_enabled(enable_autostart_); main->set_autostart_enabled(enable_autostart_);
@@ -1483,6 +1504,7 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
main->set_save_remote_previews(config_center_->IsSaveRemotePreviews()); main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
main->set_privacy_on_connect_enabled( main->set_privacy_on_connect_enabled(
config_center_->IsEnablePrivacyScreen()); config_center_->IsEnablePrivacyScreen());
main->set_lock_on_disconnect_enabled(config_center_->IsLockOnDisconnect());
#ifdef _WIN32 #ifdef _WIN32
main->set_capture_method_visible(true); main->set_capture_method_visible(true);
main->set_capture_method_index( main->set_capture_method_index(
@@ -1490,8 +1512,10 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
#endif #endif
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__)) #if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
main->set_privacy_setting_available(true); main->set_privacy_setting_available(true);
main->set_lock_on_disconnect_available(true);
#else #else
main->set_privacy_setting_available(false); main->set_privacy_setting_available(false);
main->set_lock_on_disconnect_available(false);
#endif #endif
#if defined(__OHOS__) #if defined(__OHOS__)
// The sandbox default is not the system desktop. Show the actual receive // The sandbox default is not the system desktop. Show the actual receive
@@ -1532,6 +1556,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta); SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta);
}); });
main->on_open_gallery([this] { ShowGalleryWindow(); }); main->on_open_gallery([this] { ShowGalleryWindow(); });
main->on_connection_mode_changed([this, main](bool lan_mode) {
const auto mode = lan_mode ? ConfigCenter::CONNECTION_MODE::DIRECT_IP
: ConfigCenter::CONNECTION_MODE::DEVICE_ID;
if (config_center_->SetConnectionMode(mode) != 0) {
LOG_WARN("Could not save connection mode preference");
main->set_offline_warning(UiText(
localization_language_index_ == 0
? "无法保存连接模式,下次启动可能不会恢复当前模式。"
: localization_language_index_ == 2
? "Не удалось сохранить режим подключения. При следующем запуске он может не восстановиться."
: "Could not save the connection mode. It may not be restored on the next launch."));
}
});
main->on_toggle_gallery_fullscreen( main->on_toggle_gallery_fullscreen(
[this] { ToggleGalleryFullscreen(); }); [this] { ToggleGalleryFullscreen(); });
main->on_gallery_action( main->on_gallery_action(
@@ -1666,8 +1703,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
show_password_ = !show_password_; show_password_ = !show_password_;
main->set_password_visible(show_password_); main->set_password_visible(show_password_);
}); });
main->on_reset_password([this](slint::SharedString value) { main->on_reset_password([this, main](slint::SharedString value) {
return RequestPasswordChange(std::string(value)); const std::string password(value);
if (!main->get_lan_mode()) return RequestPasswordChange(password);
if (!lan::ValidPassword(password)) return false;
if (!LanAvailable()) return true; // The initialization error is shown on the main view.
try {
lan_error_.clear();
lan_->SetPassword(password);
main->set_lan_password_pending(lan_->password_pending());
} catch (const std::exception& error) {
lan_error_ = error.what();
}
return true;
}); });
main->on_refresh_password([this] { main->on_refresh_password([this] {
const auto password = GenerateRandomPassword(password_saved_); const auto password = GenerateRandomPassword(password_saved_);
@@ -1679,6 +1727,106 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
} }
RequestPasswordChange(*password); RequestPasswordChange(*password);
}); });
main->on_lan_select_address([this, main](slint::SharedString address) {
if (!LanAvailable()) {
main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_));
return false;
}
try {
lan_->identity().SelectAddress(std::string(address));
const auto port =
UiText(std::to_string(lan_->identity().PortFor(std::string(address))));
main->set_lan_listener_ip(address);
main->set_lan_listener_port(port);
main->set_lan_local_port(port);
main->set_lan_port_dirty(false);
main->set_lan_endpoint_error("");
return true;
} catch (const std::exception& error) {
main->set_lan_endpoint_error(UiText(error.what()));
return false;
}
});
main->on_lan_configure([this, main](slint::SharedString address,
slint::SharedString port) {
if (!LanAvailable()) {
main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_));
return false;
}
try {
const auto port_number = lan::ParsePort(std::string(port));
lan_->Configure(std::string(address), port_number);
const auto normalized_port = UiText(std::to_string(port_number));
main->set_lan_listener_ip(address);
main->set_lan_listener_port(normalized_port);
main->set_lan_local_port(normalized_port);
main->set_lan_port_dirty(false);
lan_error_.clear();
main->set_lan_endpoint_error("");
return true;
} catch (const std::exception& error) {
main->set_lan_endpoint_error(UiText(error.what()));
return false;
}
});
main->on_lan_reset_password([this] {
if (LanAvailable()) {
lan_error_.clear();
lan_->ResetPassword();
}
});
main->on_lan_copy_text([this](slint::SharedString value) {
if (!SDL_SetClipboardText(std::string(value).c_str())) {
lan_error_ = "Cannot copy text";
return false;
}
return true;
});
main->on_lan_prepare_connect([this, main](slint::SharedString endpoint) {
try {
const auto [address, port] = lan::ParseEndpoint(Trim(std::string(endpoint)), 52888);
if (!LanAvailable()) return UiText(lan_error_.empty() ? "Busy" : lan_error_);
main->set_lan_remote_address(UiText(address));
main->set_lan_remote_port(UiText(std::to_string(port)));
main->set_lan_retry_after(lan_->RetryAfter(address, port));
// History is newest first. Match both address and port, and only use
// the latest matching record when an endpoint has been reused.
for (const auto& [_, recent] : recent_connections_) {
if (recent.lan.device_id.empty() || recent.lan.address != address ||
recent.lan.port != port)
continue;
if (recent.remember_password && lan::ValidPassword(recent.password)) {
main->set_lan_access_password(UiText(recent.password));
main->set_lan_remember_password(true);
}
break;
}
lan_error_.clear();
main->set_lan_message("");
return slint::SharedString{};
} catch (const std::exception& error) {
return UiText(error.what());
}
});
main->on_lan_cancel_connect([this, main] {
if (lan_ && lan_connecting_) lan_->Close(lan_connecting_);
lan_connecting_.reset();
main->set_lan_connecting(false);
lan_error_.clear();
main->set_lan_message("");
});
main->on_lan_connect([this, main](slint::SharedString address,
slint::SharedString port,
slint::SharedString password, bool remember) {
try {
ConnectLan(std::string(address), lan::ParsePort(std::string(port)),
std::string(password), remember);
} catch (const std::exception& error) {
lan_error_ = error.what();
}
main->set_lan_connecting(lan_connecting_ != nullptr);
main->set_lan_message(UiText(lan_error_));
});
main->on_connect_requested( main->on_connect_requested(
[this](slint::SharedString id) { ConnectFromUi(std::string(id)); }); [this](slint::SharedString id) { ConnectFromUi(std::string(id)); });
main->on_format_remote_id([](slint::SharedString value) { main->on_format_remote_id([](slint::SharedString value) {
@@ -1691,7 +1839,7 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
if (auto props = FindRemoteSession(remote_id); if (auto props = FindRemoteSession(remote_id);
props && props->connection_status_ == ConnectionStatus::Connected && props && props->connection_status_ == ConnectionStatus::Connected &&
!props->closing_) { !props->closing_) {
CloseStreamTab(remote_id); CloseStreamTab(props->remote_id_);
SyncMainWindow(); SyncMainWindow();
} }
}); });
@@ -1785,6 +1933,11 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
window->set_update_install_busy(updater_->busy()); window->set_update_install_busy(updater_->busy());
#endif #endif
}); });
about::Populate(ui_->Window(gallery_window));
main->on_open_about_link([this](slint::SharedString url) {
const std::string link(url);
if (link.starts_with("https://")) OpenUrl(link);
});
main->on_open_website([this] { OpenUrl("https://crossdesk.cn"); }); main->on_open_website([this] { OpenUrl("https://crossdesk.cn"); });
main->on_open_afdian_sponsor([this] { main->on_open_afdian_sponsor([this] {
OpenUrl("https://afdian.com/a/kunkundi"); OpenUrl("https://afdian.com/a/kunkundi");
@@ -2249,9 +2402,106 @@ void GuiApplication::BindServerCallbacks() {
void GuiApplication::Tick() { void GuiApplication::Tick() {
if (exit_) { if (exit_) {
slint::quit_event_loop(); BeginShutdown();
if (ShutdownDrained()) slint::quit_event_loop();
return; return;
} }
TickLan();
if (LanAvailable()) {
const auto address = lan_->identity().settings().value("address", "");
ui_->main->set_lan_password_pending(lan_->password_pending());
ui_->main->set_lan_listening(lan_->listening());
if (lan_connecting_ && lan_connecting_->authorized) {
ui_->main->set_lan_open(false);
lan_connecting_.reset();
} else if (lan_connecting_ && lan_connecting_->closing) {
lan_connecting_.reset();
}
ui_->main->set_lan_connecting(lan_connecting_ != nullptr);
int retry_after = 0;
if (ui_->main->get_lan_open()) {
try {
retry_after = lan_->RetryAfter(
std::string(ui_->main->get_lan_remote_address()),
lan::ParsePort(std::string(ui_->main->get_lan_remote_port())));
} catch (const std::exception&) {
// An incomplete address has no cooldown; input validation handles it.
}
}
ui_->main->set_lan_retry_after(retry_after);
if (auto password = lan_->TakeNewPassword(); !password.empty())
ui_->main->set_lan_new_password(UiText(password));
const auto& available_interfaces = lan_->interfaces();
const auto previous = ui_->main->get_lan_interfaces();
bool changed = !previous || previous->row_count() != available_interfaces.size();
for (size_t i = 0; !changed && i < available_interfaces.size(); ++i)
changed = std::string_view(previous->row_data(i).value()) !=
available_interfaces[i].address;
if (changed) {
std::vector<slint::SharedString> addresses;
addresses.reserve(available_interfaces.size());
for (const auto& network_interface : available_interfaces)
addresses.push_back(UiText(network_interface.address));
ui_->main->set_lan_interfaces(
std::make_shared<slint::VectorModel<slint::SharedString>>(std::move(addresses)));
}
// Keep the current displayed address while it remains available, including
// when a previously missing preferred address returns during a port edit.
const auto previous_address = std::string(ui_->main->get_lan_listener_ip());
auto display_address = previous_address.empty() ? address : previous_address;
int interface_index = -1;
for (size_t i = 0; i < available_interfaces.size(); ++i)
if (available_interfaces[i].address == display_address) {
interface_index = static_cast<int>(i);
break;
}
if (interface_index < 0) {
interface_index = available_interfaces.empty() ? -1 : 0;
display_address = interface_index == 0
? available_interfaces.front().address : "";
}
const auto port =
UiText(std::to_string(lan_->identity().PortFor(display_address)));
if (previous_address != display_address) {
// A disappearing address must not transfer its unfinished port edit to
// another interface. Explicit selection commits the old edit first.
if (!previous_address.empty() && ui_->main->get_lan_port_dirty()) {
ui_->main->set_lan_access_warning_endpoint(UiText(previous_address));
ui_->main->set_lan_access_warning("Selected LAN interface is unavailable");
}
ui_->main->set_lan_port_dirty(false);
ui_->main->set_lan_endpoint_error("");
ui_->main->set_lan_local_port(port);
}
ui_->main->set_lan_listener_ip(UiText(display_address));
ui_->main->set_lan_listener_port(port);
ui_->main->set_lan_interface_index(interface_index);
ui_->main->set_lan_interface_draft_index(interface_index);
if (!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty())
ui_->main->set_lan_local_port(port);
ui_->main->set_lan_listener_error(UiText(
display_address.empty() ? "Selected LAN interface is unavailable"
: lan_->ListenerError(display_address)));
// Show failures one address at a time, without interrupting edits or another
// dialog. A working listener must not clear another address's error.
if (ui_->main->get_lan_mode() && ui_->main->get_can_show_sponsorship() &&
!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty()) {
if (auto issue = lan_->TakeListenerWarning()) {
ui_->main->set_lan_access_warning_endpoint(
UiText(issue->address + ":" + std::to_string(issue->port)));
ui_->main->set_lan_access_warning(UiText(issue->error));
}
}
}
const auto lan_message = lan_error_.rfind("ConnectionFailed", 0) == 0
? std::string("ConnectionFailed")
: lan_error_;
ui_->main->set_lan_message(UiText(lan_message));
#if _WIN32
if (ui_->shutdown_guard) {
ui_->shutdown_guard->Update(ui_->shutdown_reason);
}
#endif
PollUpdateCheck(); PollUpdateCheck();
HandleSessionCleanup(); HandleSessionCleanup();
HandlePasswordChangeResult(); HandlePasswordChangeResult();
@@ -2334,11 +2584,13 @@ void GuiApplication::Tick() {
HandleRecentConnections(); HandleRecentConnections();
if (was_reloading_recent_connections && !reload_recent_connections_) { if (was_reloading_recent_connections && !reload_recent_connections_) {
ui_->recent_model_signature.clear(); ui_->recent_model_signature.clear();
ui_->recent_preview_images.clear();
} }
HandleConnectionStatusChange(); HandleConnectionStatusChange();
HandlePendingPresenceProbe(); HandlePendingPresenceProbe();
HandlePresenceProbeTimeout(); HandlePresenceProbeTimeout();
HandlePrivacy(); HandlePrivacy();
HandleScreenLock();
HandleWindowsServiceIntegration(); HandleWindowsServiceIntegration();
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
SyncXWaylandWindowActivation(); SyncXWaylandWindowActivation();
@@ -2439,7 +2691,7 @@ void GuiApplication::ShareLocalCursorState() {
} }
} }
} }
if (!is_server_mode_ || !peer_ || connected_controllers.empty()) { if (!is_server_mode_ || connected_controllers.empty()) {
cursor_delivery_states_.clear(); cursor_delivery_states_.clear();
return; return;
} }
@@ -2565,9 +2817,8 @@ void GuiApplication::ShareLocalCursorState() {
action.type = ControlType::cursor_state; action.type = ControlType::cursor_state;
action.cs = outgoing; action.cs = outgoing;
const std::string message = action.to_json(); const std::string message = action.to_json();
const int result = SendReliableDataFrameToPeer( const int result = SendControllerData(recipient.remote_id, message.c_str(),
peer_, message.c_str(), message.size(), mouse_label_.c_str(), message.size(), mouse_label_.c_str());
recipient.remote_id.c_str(), recipient.remote_id.size());
if (result != 0) { if (result != 0) {
LOG_WARN("Send cursor state to [{}] failed, ret={}", LOG_WARN("Send cursor state to [{}] failed, ret={}",
recipient.remote_id, result); recipient.remote_id, result);
@@ -2809,6 +3060,15 @@ void GuiApplication::UpdateLocalization() {
if (!ui_ || ui_->localized_language == language) { if (!ui_ || ui_->localized_language == language) {
return; return;
} }
#if _WIN32
// All views refer to static literals; selecting the text needs no allocation
// and only runs when the language changes. Tick still checks connection state.
ui_->shutdown_reason = language == 0
? L"远程控制连接中,保留连接以确认或取消关机。"
: language == 2
? L"Удалённый сеанс активен. Соединение сохранено для подтверждения или отмены выключения."
: L"Remote session active. Keeping the connection available to confirm or cancel shutdown.";
#endif
ui_localization::ApplyMainWindowStrings(ui_->main, language); ui_localization::ApplyMainWindowStrings(ui_->main, language);
ui_localization::ApplyStreamWindowStrings(ui_->main, language); ui_localization::ApplyStreamWindowStrings(ui_->main, language);
ui_localization::ApplyMainWindowStrings(ui_->wall, language); ui_localization::ApplyMainWindowStrings(ui_->wall, language);
@@ -2977,8 +3237,9 @@ void GuiApplication::SyncMainWindow() {
std::vector<slint::SharedString> recent_groups{ std::vector<slint::SharedString> recent_groups{
tr_group("全部设备", "All devices", "Все устройства"), tr_group("全部设备", "All devices", "Все устройства"),
tr_group("未分组", "Ungrouped", "Без группы")}; tr_group("未分组", "Ungrouped", "Без группы")};
const bool lan_mode = ui_->main->get_lan_mode();
int recent_group_index = 0; int recent_group_index = 0;
if (ui_->recent_group_filter) { if (!lan_mode && ui_->recent_group_filter) {
if (ui_->recent_group_filter->empty()) recent_group_index = 1; if (ui_->recent_group_filter->empty()) recent_group_index = 1;
else { else {
const auto found = std::find(ui_->gallery.groups.begin(), const auto found = std::find(ui_->gallery.groups.begin(),
@@ -3015,12 +3276,26 @@ void GuiApplication::SyncMainWindow() {
return props && props->connection_status_ == ConnectionStatus::Connected && return props && props->connection_status_ == ConnectionStatus::Connected &&
!props->closing_; !props->closing_;
}; };
const auto preview_size = ui_->recent_preview_resolution.Update(
gallery::PreviewResolution::Viewport(
ui_->main->get_recent_preview_width(),
ui_->main->get_recent_preview_height(),
ui_->main->window().scale_factor()),
gallery::PreviewResolutionDebouncer::Clock::now());
const bool save_previews = config_center_->IsSaveRemotePreviews();
if (!(preview_size == ui_->recent_preview_size) || !save_previews) {
ui_->recent_preview_size = preview_size;
ui_->recent_preview_images.clear();
}
std::ostringstream signature_builder; std::ostringstream signature_builder;
signature_builder << config_center_->IsSaveRemotePreviews() << '\n' signature_builder << save_previews << '\n'
<< preview_size.width << ':' << preview_size.height << '\n'
<< lan_mode << '\n'
<< bool(ui_->recent_group_filter) << '\0' << bool(ui_->recent_group_filter) << '\0'
<< ui_->recent_group_filter.value_or("") << '\n'; << ui_->recent_group_filter.value_or("") << '\n';
for (const auto& [key, connection] : recent_connections_) { for (const auto& [key, connection] : recent_connections_) {
signature_builder << key << '\0' << connection.remote_id << '\0' signature_builder << key << '\0' << connection.remote_id << '\0'
<< connection.lan.address << ':' << connection.lan.port << '\0'
<< connection.remote_host_name << '\0' << connection.remote_host_name << '\0'
<< static_cast<int>(connection.platform) << '\0' << static_cast<int>(connection.platform) << '\0'
<< settings_.RecentConnectionDisplayName(connection) << settings_.RecentConnectionDisplayName(connection)
@@ -3041,7 +3316,9 @@ void GuiApplication::SyncMainWindow() {
std::vector<ui::RecentConnection> model; std::vector<ui::RecentConnection> model;
model.reserve(recent_connections_.size()); model.reserve(recent_connections_.size());
for (const auto& [_, connection] : recent_connections_) { for (const auto& [_, connection] : recent_connections_) {
if (!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter)) if (lan_mode != !connection.lan.device_id.empty()) continue;
if (!lan_mode &&
!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter))
continue; continue;
// Slint-generated scalar fields have no defaults. With no image, // Slint-generated scalar fields have no defaults. With no image,
// has_thumbnail must remain false so the remote platform is rendered. // has_thumbnail must remain false so the remote platform is rendered.
@@ -3051,21 +3328,34 @@ void GuiApplication::SyncMainWindow() {
item.group_names = UiText(group_labels[connection.remote_id]); item.group_names = UiText(group_labels[connection.remote_id]);
item.display_name = item.display_name =
UiText(settings_.RecentConnectionDisplayName(connection)); UiText(settings_.RecentConnectionDisplayName(connection));
item.online = device_presence_cache_.IsOnline(connection.remote_id); item.lan = !connection.lan.device_id.empty();
item.endpoint = UiText(item.lan ? connection.lan.address + ":" +
std::to_string(connection.lan.port)
: "");
item.online =
!item.lan && device_presence_cache_.IsOnline(connection.remote_id);
item.connected = is_connected(connection.remote_id); item.connected = is_connected(connection.remote_id);
item.platform = UiText(HostPlatformName(connection.platform)); item.platform = UiText(HostPlatformName(connection.platform));
std::vector<unsigned char> rgba; if (save_previews && !connection.image_path.empty()) {
int image_width = 0; const auto path = connection.image_path.string();
int image_height = 0; auto cached = ui_->recent_preview_images.find(path);
if (config_center_->IsSaveRemotePreviews() && if (cached == ui_->recent_preview_images.end()) {
!connection.image_path.empty() && std::vector<unsigned char> rgba;
thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width, int image_width = 0, image_height = 0;
&image_height)) { if (thumbnail_->DecodeImage(connection.image_path, preview_size.width,
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width, preview_size.height, &rgba, &image_width,
image_height); &image_height)) {
std::memcpy(pixels.begin(), rgba.data(), rgba.size()); slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
item.thumbnail = slint::Image(std::move(pixels)); image_height);
item.has_thumbnail = true; std::memcpy(pixels.begin(), rgba.data(), rgba.size());
cached = ui_->recent_preview_images.emplace(
path, slint::Image(std::move(pixels))).first;
}
}
if (cached != ui_->recent_preview_images.end()) {
item.thumbnail = cached->second;
item.has_thumbnail = true;
}
} }
model.push_back(std::move(item)); model.push_back(std::move(item));
} }
@@ -3132,7 +3422,9 @@ void GuiApplication::SyncConnectionDialog() {
} else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) { } else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) {
text = localization::connection_not_authenticated[localization_language_index_]; text = localization::connection_not_authenticated[localization_language_index_];
} else { } else {
text = ConnectionStatusText(status, localization_language_index_); text = props->lan_session_ && !props->lan_session_->error.empty()
? props->lan_session_->error
: ConnectionStatusText(status, localization_language_index_);
} }
if (auth.retry_after > 0) { if (auth.retry_after > 0) {
char retry_text[256]; char retry_text[256];
@@ -3612,6 +3904,13 @@ void GuiApplication::SyncStreamWindow() {
(*ui_->stream)->set_selected_display(props->selected_display_); (*ui_->stream)->set_selected_display(props->selected_display_);
const auto net = props->net_traffic_stats_.Load(); const auto net = props->net_traffic_stats_.Load();
const auto codecs = props->net_traffic_stats_.LoadVideoCodecs();
(*ui_->stream)->set_stats_encoder(UiText(
status == ConnectionStatus::Connected && !codecs.remote_encoder.empty()
? codecs.remote_encoder : "—"));
(*ui_->stream)->set_stats_decoder(UiText(
status == ConnectionStatus::Connected && !codecs.local_decoder.empty()
? codecs.local_decoder : "—"));
(*ui_->stream)->set_stats_encryption_enabled( (*ui_->stream)->set_stats_encryption_enabled(
status == ConnectionStatus::Connected && net.srtp_active); status == ConnectionStatus::Connected && net.srtp_active);
std::vector<ui::NetworkStatsRow> stats_rows; std::vector<ui::NetworkStatsRow> stats_rows;
@@ -3635,8 +3934,8 @@ void GuiApplication::SyncStreamWindow() {
net.total_inbound_stats, net.total_outbound_stats); net.total_inbound_stats, net.total_outbound_stats);
ui::UpdateModelRows(*ui_->stats_model, std::move(stats_rows)); ui::UpdateModelRows(*ui_->stats_model, std::move(stats_rows));
(*ui_->stream) (*ui_->stream)
->set_stats_resolution(UiText(std::to_string(props->video_width_) + "x" + ->set_stats_resolution(UiText(FormatResolution(props->video_width_,
std::to_string(props->video_height_))); props->video_height_)));
(*ui_->stream) (*ui_->stream)
->set_stats_connection_mode( ->set_stats_connection_mode(
UiText(props->traversal_mode_ == TraversalMode::P2P UiText(props->traversal_mode_ == TraversalMode::P2P
@@ -3724,8 +4023,7 @@ void GuiApplication::SyncStreamVideoFrame() {
const auto result = ui_->video_presenter->Present(frame); const auto result = ui_->video_presenter->Present(frame);
if (result.width > 0 && result.height > 0) { if (result.width > 0 && result.height > 0) {
(*ui_->stream) (*ui_->stream)
->set_stats_resolution(UiText(std::to_string(result.width) + "x" + ->set_stats_resolution(UiText(FormatResolution(result.width, result.height)));
std::to_string(result.height)));
} }
if (result.new_frame) { if (result.new_frame) {
++props->frame_count_; ++props->frame_count_;
@@ -4223,6 +4521,7 @@ bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) {
bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
const auto previous_server = GetSignalServerSettings(*config_center_); const auto previous_server = GetSignalServerSettings(*config_center_);
auto& main = ui_->Window(gallery_window); auto& main = ui_->Window(gallery_window);
main->set_settings_save_error("");
const bool has_endpoint = main->get_self_hosted_enabled() || const bool has_endpoint = main->get_self_hosted_enabled() ||
!std::string(main->get_server_host()).empty() || !std::string(main->get_server_host()).empty() ||
!std::string(main->get_server_port()).empty(); !std::string(main->get_server_port()).empty();
@@ -4245,7 +4544,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
if (!wall_settings || (wall_changed && !updated_wall.Save(ui_->gallery_path))) { if (!wall_settings || (wall_changed && !updated_wall.Save(ui_->gallery_path))) {
main->set_gallery_settings_error(UiText( main->set_gallery_settings_error(UiText(
localization_language_index_ == 0 ? "保存失败,请重试" localization_language_index_ == 0 ? "保存失败,请重试"
: localization_language_index_ == 2 ? "Не удалось сохранить" : localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
: "Could not save; try again")); : "Could not save; try again"));
main->set_settings_tab(1); main->set_settings_tab(1);
return false; return false;
@@ -4261,12 +4560,29 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
main->set_self_host_settings_open(true); main->set_self_host_settings_open(true);
return false; return false;
} }
if (config_center_->SetIpDirectEnabled(main->get_ip_direct_enabled_draft()) != 0) {
if (wall_changed && !ui_->gallery.Save(ui_->gallery_path))
LOG_ERROR("Failed to restore DeskHub settings after IP direct save failure");
main->set_settings_save_error(UiText(
localization_language_index_ == 0 ? "无法保存 IP 连接设置,请重试。"
: localization_language_index_ == 2 ? "Не удалось сохранить настройки подключения по IP. Повторите попытку."
: "Could not save IP connection settings. Try again."));
return false;
}
#if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__))
if (config_center_->SetLockOnDisconnect(main->get_lock_on_disconnect_enabled()) != 0) {
if (wall_changed && !ui_->gallery.Save(ui_->gallery_path))
LOG_ERROR("Failed to restore DeskHub settings after lock preference save failure");
main->set_settings_save_error(UiText(
localization_language_index_ == 0 ? "无法保存断开后锁屏设置,请重试。"
: localization_language_index_ == 2 ? "Не удалось сохранить настройку блокировки при отключении. Повторите попытку."
: "Could not save lock-on-disconnect settings. Try again."));
return false;
}
#endif
language_button_value_ = language_button_value_ =
localization::detail::ClampLanguageIndex(main->get_language_index()); localization::detail::ClampLanguageIndex(main->get_language_index());
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1); video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
enable_hardware_video_codec_ = ConfigCenter::IsHardwareVideoCodecAvailable() &&
main->get_hardware_codec_enabled();
enable_turn_ = main->get_turn_enabled();
enable_self_hosted_ = main->get_self_hosted_enabled(); enable_self_hosted_ = main->get_self_hosted_enabled();
#if !defined(__OHOS__) #if !defined(__OHOS__)
enable_autostart_ = main->get_autostart_enabled(); enable_autostart_ = main->get_autostart_enabled();
@@ -4282,8 +4598,7 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
config_center_->SetVideoEncodeFormat( config_center_->SetVideoEncodeFormat(
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>( static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
video_encode_format_button_value_)); video_encode_format_button_value_));
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_); config_center_->SetForceRelay(main->get_force_relay_enabled());
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
main->set_force_relay_enabled(config_center_->IsForceRelay()); main->set_force_relay_enabled(config_center_->IsForceRelay());
#if !defined(__OHOS__) #if !defined(__OHOS__)
config_center_->SetAutostart(enable_autostart_); config_center_->SetAutostart(enable_autostart_);
@@ -4349,8 +4664,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_); localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
localization_language_ = config_center_->GetLanguage(); localization_language_ = config_center_->GetLanguage();
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat()); video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
enable_hardware_video_codec_ = config_center_->IsHardwareVideoCodec();
enable_turn_ = config_center_->IsEnableTurn();
enable_self_hosted_ = config_center_->IsSelfHosted(); enable_self_hosted_ = config_center_->IsSelfHosted();
enable_autostart_ = config_center_->IsEnableAutostart(); enable_autostart_ = config_center_->IsEnableAutostart();
enable_daemon_ = config_center_->IsEnableDaemon(); enable_daemon_ = config_center_->IsEnableDaemon();
@@ -4364,8 +4677,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str()); std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
language_button_value_last_ = language_button_value_; language_button_value_last_ = language_button_value_;
video_encode_format_button_value_last_ = video_encode_format_button_value_; video_encode_format_button_value_last_ = video_encode_format_button_value_;
enable_hardware_video_codec_last_ = enable_hardware_video_codec_;
enable_turn_last_ = enable_turn_;
enable_self_hosted_last_ = enable_self_hosted_; enable_self_hosted_last_ = enable_self_hosted_;
enable_autostart_last_ = enable_autostart_; enable_autostart_last_ = enable_autostart_;
enable_daemon_last_ = enable_daemon_; enable_daemon_last_ = enable_daemon_;
@@ -4375,25 +4686,25 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
#endif #endif
if (ui_) { if (ui_) {
ui_->localized_language = -1; ui_->localized_language = -1;
for (const auto& window : {ui_->main, ui_->wall})
window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled());
if (!ui_->main->get_settings_open()) ResetSettingsUi(); if (!ui_->main->get_settings_open()) ResetSettingsUi();
if (!ui_->wall->get_settings_open()) ResetSettingsUi(true); if (!ui_->wall->get_settings_open()) ResetSettingsUi(true);
} }
ApplyLanSettings();
if (reconnect && !HasActiveSession()) { if (reconnect && !HasActiveSession()) {
// Tick recreates the peer after asynchronous cleanup, using the committed // Tick recreates the peer after asynchronous cleanup, using the committed
// configuration. Keep the application and its console running throughout. // configuration. Keep the application and its console running throughout.
CloseAllRemoteSessions(); CloseAllRemoteSessions();
} else { } else {
const bool hardware = config_center_->IsHardwareVideoCodec();
const bool av1 = config_center_->GetVideoEncodeFormat() == const bool av1 = config_center_->GetVideoEncodeFormat() ==
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1; ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
const auto turn = static_cast<TurnMode>(config_center_->GetTurnMode()); const auto turn = static_cast<TurnMode>(config_center_->GetTurnMode());
if (hardware != params_.hardware_acceleration || av1 != params_.av1_encoding || if (av1 != params_.av1_encoding || turn != params_.turn_mode) {
turn != params_.turn_mode) {
// Update the listening peer for incoming connections and the template // Update the listening peer for incoming connections and the template
// copied by ConnectTo for outgoing connections. Existing sessions retain // copied by ConnectTo for outgoing connections. Existing sessions retain
// the codec/ICE snapshot with which they negotiated. // the codec/ICE snapshot with which they negotiated.
if (!peer_ || UpdateConnectionSettings(peer_, hardware, av1, turn) == 0) { if (!peer_ || UpdateConnectionSettings(peer_, true, av1, turn) == 0) {
params_.hardware_acceleration = hardware;
params_.av1_encoding = av1; params_.av1_encoding = av1;
params_.turn_mode = turn; params_.turn_mode = turn;
} else { } else {
@@ -4405,6 +4716,26 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
void GuiApplication::ConnectFromUi(const std::string& input) { void GuiApplication::ConnectFromUi(const std::string& input) {
ui_->gallery_auth_id.clear(); ui_->gallery_auth_id.clear();
if (input.rfind("lan-", 0) == 0) {
if (!LanAvailable()) return;
if (auto existing = FindRemoteSession(input);
existing && existing->connection_established_) {
existing->tab_opened_ = true;
focused_remote_id_ = existing->remote_id_;
need_to_create_stream_window_ = true;
return;
}
for (const auto& [_, recent] : recent_connections_)
if (recent.remote_id == input && !recent.lan.device_id.empty()) {
ui_->main->invoke_request_lan_connection(
UiText(recent.lan.address +
(recent.lan.port == 52888
? ""
: ":" + std::to_string(recent.lan.port))));
return;
}
return;
}
const std::string remote_id = CompactPeerId(input); const std::string remote_id = CompactPeerId(input);
if (!gallery::Workspace::ValidId(remote_id)) { if (!gallery::Workspace::ValidId(remote_id)) {
ui_->main->set_connection_input_error( ui_->main->set_connection_input_error(
@@ -4787,6 +5118,7 @@ void GuiApplication::ReleasePressedInput() {
SendRemoteInput(action); SendRemoteInput(action);
} }
ui_->pressed_mouse_buttons.clear(); ui_->pressed_mouse_buttons.clear();
if (ui_->stream) (*ui_->stream)->invoke_reset_input_state();
} }
void GuiApplication::ReleaseControl() { void GuiApplication::ReleaseControl() {
@@ -4979,8 +5311,8 @@ bool GuiApplication::AddGalleryDevice(const std::string& input,
password.find_first_not_of( password.find_first_not_of(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") != "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") !=
std::string::npos) std::string::npos)
return error("请输入不超过 6 位的字母或数字密码", return error("请输入 1–6 位英文字母或数字密码",
"Use a password of 1–6 letters or digits", "Use 1–6 letters (A–Z, a–z) or digits (0–9)",
"Пароль: 1–6 латинских букв или цифр"); "Пароль: 1–6 латинских букв или цифр");
if (!signal_connected_) if (!signal_connected_)
return error("尚未连接服务器,请稍后重试", return error("尚未连接服务器,请稍后重试",
@@ -4998,7 +5330,7 @@ bool GuiApplication::AddGalleryDevice(const std::string& input,
updated.Add(id, Trim(group)); updated.Add(id, Trim(group));
if (!updated.Save(ui_->gallery_path)) if (!updated.Save(ui_->gallery_path))
return error("保存失败,请重试", "Could not save; try again", return error("保存失败,请重试", "Could not save; try again",
"Не удалось сохранить"); "Не удалось сохранить. Повторите попытку");
ui_->gallery = std::move(updated); ui_->gallery = std::move(updated);
ui_->gallery_hidden_active_ids.erase(id); ui_->gallery_hidden_active_ids.erase(id);
if (auto props = FindRemoteSession(id)) { if (auto props = FindRemoteSession(id)) {
@@ -5041,7 +5373,7 @@ bool GuiApplication::ImportGalleryRecent(const std::string& input_group) {
if (!updated.Save(ui_->gallery_path)) { if (!updated.Save(ui_->gallery_path)) {
ui_->wall->set_gallery_notice(UiText( ui_->wall->set_gallery_notice(UiText(
localization_language_index_ == 0 ? "保存失败,请重试" localization_language_index_ == 0 ? "保存失败,请重试"
: localization_language_index_ == 2 ? "Не удалось сохранить" : localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
: "Could not save; try again")); : "Could not save; try again"));
return false; return false;
} }
@@ -5123,7 +5455,7 @@ bool GuiApplication::SaveDeviceGroups(const std::string& new_group,
if (!updated.SetGroups(id, names) || !updated.Save(ui_->gallery_path)) { if (!updated.SetGroups(id, names) || !updated.Save(ui_->gallery_path)) {
main->set_device_group_error(UiText( main->set_device_group_error(UiText(
localization_language_index_ == 0 ? "保存失败,请重试" localization_language_index_ == 0 ? "保存失败,请重试"
: localization_language_index_ == 2 ? "Не удалось сохранить" : localization_language_index_ == 2 ? "Не удалось сохранить. Повторите попытку"
: "Could not save; try again")); : "Could not save; try again"));
return false; return false;
} }
@@ -5179,6 +5511,7 @@ void GuiApplication::GalleryAction(const std::string& action,
std::set<std::string> seen; std::set<std::string> seen;
int available = 0; int available = 0;
for (const auto& [_, recent] : recent_connections_) { for (const auto& [_, recent] : recent_connections_) {
if (!recent.lan.device_id.empty()) continue;
if (!gallery::Workspace::ValidId(recent.remote_id) || if (!gallery::Workspace::ValidId(recent.remote_id) ||
!seen.insert(recent.remote_id).second) !seen.insert(recent.remote_id).second)
continue; continue;
@@ -5382,7 +5715,7 @@ void GuiApplication::GalleryAction(const std::string& action,
SyncGallery(); SyncGallery();
}; };
if (action == "file") { if (action == "file") {
OpenFileDialog(std::string(tr("选择分发文件", "Select a file", "Выберите файл")), OpenFileDialog(std::string(tr("选择分发文件", "Select a file to send", "Выберите файл для отправки")),
[enqueue](std::string file) { [enqueue](std::string file) {
if (!file.empty()) enqueue(file); if (!file.empty()) enqueue(file);
}); });
@@ -5667,8 +6000,9 @@ void GuiApplication::SyncGallery() {
}; };
const auto name = [this](const std::string& id) { const auto name = [this](const std::string& id) {
for (const auto& [_, recent] : recent_connections_) for (const auto& [_, recent] : recent_connections_)
if (recent.remote_id == id) if (recent.lan.device_id.empty())
return settings_.RecentConnectionDisplayName(recent); if (recent.remote_id == id)
return settings_.RecentConnectionDisplayName(recent);
Thumbnail::RecentConnection device{}; Thumbnail::RecentConnection device{};
device.remote_id = id; device.remote_id = id;
if (auto props = FindRemoteSession(id)) { if (auto props = FindRemoteSession(id)) {
@@ -5686,7 +6020,7 @@ void GuiApplication::SyncGallery() {
{ {
std::shared_lock lock(remote_sessions_mutex_); std::shared_lock lock(remote_sessions_mutex_);
for (const auto& [id, props] : remote_sessions_) for (const auto& [id, props] : remote_sessions_)
if (props && !props->closing_ && if (props && !props->lan_session_ && !props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected) props->connection_status_ == ConnectionStatus::Connected)
connected_ids.push_back(id); connected_ids.push_back(id);
} }
@@ -5702,9 +6036,9 @@ void GuiApplication::SyncGallery() {
device.id = UiText(FormatPeerId(id.c_str())); device.id = UiText(FormatPeerId(id.c_str()));
const auto mode = props->traversal_mode_.load(); const auto mode = props->traversal_mode_.load();
device.mode = UiText(mode == TraversalMode::P2P device.mode = UiText(mode == TraversalMode::P2P
? tr("直连", "Direct", "Напрямую") ? tr("直连", "Direct", "Прямой")
: mode == TraversalMode::UnknownMode : mode == TraversalMode::UnknownMode
? tr("等待统计", "Awaiting stats", "Ждём данные") ? tr("等待统计", "Awaiting stats", "Ожидание статистики")
: tr("中继", "Relay", "Ретрансляция")); : tr("中继", "Relay", "Ретрансляция"));
device.inbound = device.outbound = device.loss = "—"; device.inbound = device.outbound = device.loss = "—";
device.video_latency = "—"; device.video_latency = "—";
@@ -5719,9 +6053,12 @@ void GuiApplication::SyncGallery() {
} }
const auto video = props->received_video_stats_.Get(now); const auto video = props->received_video_stats_.Get(now);
device.resolution = video.width > 0 && video.height > 0 device.resolution = video.width > 0 && video.height > 0
? UiText(std::to_string(video.width) + " × " + std::to_string(video.height)) ? UiText(FormatResolution(video.width, video.height))
: UiText("—"); : UiText("—");
device.fps = video.fps ? UiText(std::to_string(*video.fps) + " FPS") : UiText("—"); device.fps = video.fps ? UiText(std::to_string(*video.fps) + " FPS") : UiText("—");
const auto codecs = props->net_traffic_stats_.LoadVideoCodecs();
device.encoder = UiText(codecs.remote_encoder.empty() ? "—" : codecs.remote_encoder);
device.decoder = UiText(codecs.local_decoder.empty() ? "—" : codecs.local_decoder);
const bool has_report = props->net_traffic_stats_.HasRecentReport(now); const bool has_report = props->net_traffic_stats_.HasRecentReport(now);
const auto stats = props->net_traffic_stats_.Load(); const auto stats = props->net_traffic_stats_.Load();
// Use negotiated transport state, never the local encryption preference. // Use negotiated transport state, never the local encryption preference.
@@ -5783,11 +6120,12 @@ void GuiApplication::SyncGallery() {
bool remember = operation.remember_password; bool remember = operation.remember_password;
if (!operation.password) if (!operation.password)
for (const auto& [_, recent] : recent_connections_) for (const auto& [_, recent] : recent_connections_)
if (recent.remote_id == operation.id) { if (recent.lan.device_id.empty())
password = recent.password; if (recent.remote_id == operation.id) {
remember = recent.remember_password; password = recent.password;
break; remember = recent.remember_password;
} break;
}
// Join itself determines availability. Background probes must never // Join itself determines availability. Background probes must never
// overwrite the interactive page's single pending presence probe. // overwrite the interactive page's single pending presence probe.
ConnectTo(operation.id, password.c_str(), remember, true, true); ConnectTo(operation.id, password.c_str(), remember, true, true);
@@ -5937,6 +6275,7 @@ void GuiApplication::SyncGallery() {
tile.selected = wall.selected.count(id); tile.selected = wall.selected.count(id);
auto props = FindRemoteSession(id); auto props = FindRemoteSession(id);
for (const auto& [_, recent] : recent_connections_) { for (const auto& [_, recent] : recent_connections_) {
if (!recent.lan.device_id.empty()) continue;
if (recent.remote_id != id) continue; if (recent.remote_id != id) continue;
tile.host_name = UiText(recent.remote_host_name); tile.host_name = UiText(recent.remote_host_name);
break; break;
@@ -5962,7 +6301,7 @@ void GuiApplication::SyncGallery() {
}); });
// A queued retry takes precedence over errors from the previous session. // A queued retry takes precedence over errors from the previous session.
if (queued) if (queued)
tile.status = UiText(tr("排队中", "Queued", "В очереди")); tile.status = UiText(tr("队列中", "Queued", "В очереди"));
else if (props && props->connection_auth_.Get().password_required) else if (props && props->connection_auth_.Get().password_required)
tile.status = UiText(tr("需要密码", "Password required", "Нужен пароль")); tile.status = UiText(tr("需要密码", "Password required", "Нужен пароль"));
else if (props && !tile.connected) else if (props && !tile.connected)
@@ -6172,6 +6511,10 @@ void GuiApplication::SyncGallery() {
} }
void GuiApplication::Cleanup() { void GuiApplication::Cleanup() {
BeginShutdown();
while (!ShutdownDrained())
std::this_thread::sleep_for(std::chrono::milliseconds(2));
remote_input_.Stop();
#if _WIN32 #if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed); windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed); windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
@@ -6185,6 +6528,9 @@ void GuiApplication::Cleanup() {
return; return;
} }
ui_->file_dialog_lifetime.reset(); ui_->file_dialog_lifetime.reset();
#if _WIN32
ui_->shutdown_guard.reset();
#endif
#if defined(__APPLE__) #if defined(__APPLE__)
CancelNativeFileDialog(); CancelNativeFileDialog();
#endif #endif
@@ -6208,6 +6554,7 @@ void GuiApplication::Cleanup() {
keyboard_.ReleaseAllRemotePressedKeys("application_exit"); keyboard_.ReleaseAllRemotePressedKeys("application_exit");
devices_.DestroyDevices(); devices_.DestroyDevices();
privacy_.Shutdown(); privacy_.Shutdown();
HandleScreenLock();
devices_.DestroyFactories(); devices_.DestroyFactories();
CloseAllRemoteSessions(); CloseAllRemoteSessions();
WaitForSessionCleanup(); WaitForSessionCleanup();
@@ -21,23 +21,30 @@ struct TranslationRow {
// Single source of truth for all UI strings. // Single source of truth for all UI strings.
#define CROSSDESK_LOCALIZATION_ALL(X) \ #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(messages, u8"消息", "Messages", u8"Сообщения") \
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \ X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
X(message_read, u8"已读", "Read", u8"Прочитано") \ X(message_read, u8"已读", "Read", u8"Прочитано") \
X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \ X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \
X(message_empty, u8"暂无公告", "No announcements", 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_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_close, u8"关闭", "Close", u8"Закрыть") \
X(message_delete, u8"删除", "Delete", u8"Удалить") \ X(message_delete, u8"删除", "Delete", u8"Удалить") \
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \ X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
X(message_delete_confirmation, u8"删除这条公告?", "Delete this 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(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_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_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_status, u8"状态", "Status", u8"Состояние") \
X(console_online, u8"在线", "Online", u8"В сети") \ X(console_online, u8"在线", "Online", u8"В сети") \
X(console_offline, u8"未连接服务器", "Disconnected from server", 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_connection, u8"已保存,将用于后续连接。", "Saved; applies to subsequent connections.", u8"Сохранено. Будет применено к следующим подключениям.") \
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", 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_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_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_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", 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_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_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_language, u8"语言", "Language", u8"Язык") \
X(console_setting_codec, u8"视频编码", "Video codec", 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_turn, u8"中继模式", "TURN relay mode", u8"Режим ретрансляции TURN") \
X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \ X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \
X(console_setting_host, u8"服务器地址", "Server host", 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_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \ X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", 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(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \ X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
X(privacy_enable, u8"开启隐私屏", "Enable 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_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \ X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \
X(local_desktop, u8"本桌面", "Local Desktop", 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", \ X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
u8"Скопировано в буфер обмена") \ u8"Скопировано в буфер обмена") \
X(password, u8"密码", "Password", u8"Пароль") \ X(password, u8"密码", "Password", u8"Пароль") \
X(refresh_password, u8"刷新随机密码", "Refresh random password", \ X(refresh_password, u8"刷新随机密码", "Refresh random password", \
u8"Обновить случайный пароль") \ 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", \ X(remote_desktop, u8"远程桌面", "Remote Desktop", \
u8"Удаленный рабочий стол") \ u8"Удаленный рабочий стол") \
X(device_name, u8"设备名称", "Device Name", 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(connect, u8"连接", "Connect", u8"Подключиться") \
X(recent_connections, u8"近期连接", "Recent Connections", \ X(recent_connections, u8"近期连接", "Recent Connections", \
u8"Недавние подключения") \ u8"Недавние подключения") \
X(disconnect, u8"断开连接", "Disconnect", 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(select_display, u8"选择显示器", "Select Display", u8"Выбрать дисплей") \
X(display_screen, u8"显示屏", "Display", u8"Экран") \ X(display_screen, u8"显示屏", "Display", u8"Экран") \
X(expand_control_bar, u8"展开控制栏", "Expand Control Bar", \ X(expand_control_bar, u8"展开控制栏", "Expand Control Bar", \
u8"Развернуть панель управления") \ u8"Развернуть панель управления") \
X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \ X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \
u8"Свернуть панель управления") \ u8"Свернуть панель управления") \
X(fullscreen, u8"全屏", " Fullscreen", u8"Полный экран") \ X(fullscreen, u8"全屏", "Fullscreen", u8"На весь экран") \
X(show_net_traffic_stats, u8"显示网络状态", "Show Net Traffic Stats", \ X(show_net_traffic_stats, u8"显示网络状态", "Show network status", u8"Показать состояние сети") \
u8"Показать статистику трафика") \ X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide network status", u8"Скрыть состояние сети") \
X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide Net Traffic Stats", \
u8"Скрыть статистику трафика") \
X(video, u8"视频", "Video", u8"Видео") \ X(video, u8"视频", "Video", u8"Видео") \
X(audio, u8"音频", "Audio", u8"Аудио") \ X(audio, u8"音频", "Audio", u8"Аудио") \
X(data, u8"数据", "Data", u8"Данные") \ X(data, u8"数据", "Data", u8"Данные") \
@@ -139,19 +147,15 @@ struct TranslationRow {
u8"Выйти из полноэкранного режима") \ u8"Выйти из полноэкранного режима") \
X(control_mouse, u8"控制鼠标", "Control Mouse", u8"Управление мышью") \ X(control_mouse, u8"控制鼠标", "Control Mouse", u8"Управление мышью") \
X(release_mouse, u8"释放鼠标", "Release Mouse", u8"Освободить мышь") \ X(release_mouse, u8"释放鼠标", "Release Mouse", u8"Освободить мышь") \
X(audio_capture, u8"播放声音", "Audio Capture", u8"Воспроизведение звука") \ X(audio_capture, u8"播放声音", "Play audio", u8"Воспроизведение звука") \
X(mute, u8" 静音", " Mute", u8"Без звука") \ X(mute, u8"静音", "Mute", u8"Без звука") \
X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Сочетания клавиш") \ X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Отправить сочетание клавиш") \
X(send_sas, u8"发送SAS", "Send SAS", u8"Отправить SAS") \ X(send_sas, u8"发送 SAS", "Send SAS", u8"Отправить SAS") \
X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать") \ X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать удалённое устройство") \
X(remote_password_box_visible, u8"远端密码框已出现", \ X(remote_password_box_visible, u8"远端密码框已出现", "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_lock_screen_hint, u8"远端处于锁屏封面,可发送SAS", \ X(remote_secure_desktop_active, u8"远端已进入安全桌面", "Remote secure desktop active", u8"На удалённом устройстве активен защищённый рабочий стол") \
"Remote lock screen visible, send SAS", \ X(remote_service_unavailable, u8"远端 Windows 服务不可用", \
u8"Видна блокировка, отправьте SAS") \
X(remote_secure_desktop_active, u8"远端已进入安全桌面", \
"Remote secure desktop active", u8"Активен защищенный рабочий стол") \
X(remote_service_unavailable, u8"远端Windows服务不可用", \
"Remote Windows service unavailable", \ "Remote Windows service unavailable", \
u8"Служба Windows на удаленной стороне недоступна") \ u8"Служба Windows на удаленной стороне недоступна") \
X(windows_service_setup_title, u8"安装 CrossDesk Service", \ X(windows_service_setup_title, u8"安装 CrossDesk Service", \
@@ -170,8 +174,7 @@ struct TranslationRow {
u8"нормально; будет недоступно только управление этим компьютером на " \ u8"нормально; будет недоступно только управление этим компьютером на " \
u8"экране блокировки.") \ u8"экране блокировки.") \
X(install_windows_service, u8"安装", "Install", u8"Установить") \ X(install_windows_service, u8"安装", "Install", u8"Установить") \
X(windows_service_settings_label, u8"锁屏控制服务:", \ X(windows_service_settings_label, u8"锁屏控制服务:", "Lock-screen control service:", u8"Служба управления при блокировке:") \
"Lock Screen Service:", u8"Служба блокировки экрана:") \
X(windows_service_installed, u8"已安装", "Installed", u8"Установлена") \ X(windows_service_installed, u8"已安装", "Installed", u8"Установлена") \
X(do_not_remind_again, u8"不再提醒", "Do not remind again", \ X(do_not_remind_again, u8"不再提醒", "Do not remind again", \
u8"Больше не напоминать") \ u8"Больше не напоминать") \
@@ -207,13 +210,8 @@ struct TranslationRow {
u8"Способ захвата:") \ u8"Способ захвата:") \
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \ X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \ X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
X(remote_update_title, u8"建议升级被控端", "Remote update recommended", \ X(remote_update_title, u8"建议升级被控端", "Remote update recommended", u8"Рекомендуем обновить удалённое устройство") \
u8"Рекомендуется обновление") \ X(remote_update, u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", "Update the remote device to the latest version for improved features and performance.", u8"В новой версии улучшены функции и производительность. Рекомендуем обновить CrossDesk на удалённом устройстве до последней версии.") \
X(remote_update, \
u8"新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", \
"Update the remote device to the latest version for improved features and performance.", \
u8"В новой версии улучшены функции и производительность. " \
u8"Рекомендуем обновить CrossDesk на удалённом устройстве.") \
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \ X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \ X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", 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_high, u8"高", "High", u8"Высокое") \
X(video_quality_medium, u8"中", "Medium", u8"Среднее") \ X(video_quality_medium, u8"中", "Medium", u8"Среднее") \
X(video_quality_low, u8"低", "Low", u8"Низкое") \ X(video_quality_low, u8"低", "Low", u8"Низкое") \
X(video_encode_format, u8"视频编码格式:", \ X(video_encode_format, u8"视频编码格式:", "Video codec:", u8"Видеокодек:") \
"Video Encode Format:", u8"Формат кодека видео:") \
X(av1, u8"AV1", "AV1", "AV1") \ X(av1, u8"AV1", "AV1", "AV1") \
X(h264, u8"H.264", "H.264", "H.264") \ X(h264, u8"H.264", "H.264", "H.264") \
X(enable_hardware_video_codec, u8"启用硬件编解码器:", \ X(force_relay, u8"强制中继连接:", "Force relay connection:", u8"Только через ретранслятор:") \
"Enable Hardware Video Codec:", u8"Использовать аппаратный кодек:") \ X(self_hosted_server_config, u8"自托管配置", "Self-hosted server", u8"Собственный сервер") \
X(enable_turn, u8"启用中继服务:", \ X(self_hosted_server_settings, u8"自托管设置", "Self-hosted server settings", 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(self_hosted_server_address, u8"服务器地址:", \ X(self_hosted_server_address, u8"服务器地址:", \
"Server Address:", u8"Адрес сервера:") \ "Server Address:", u8"Адрес сервера:") \
X(self_hosted_server_port, u8"信令服务端口:", \ X(self_hosted_server_port, u8"信令服务端口:", "Signaling port:", u8"Порт сигнализации:") \
"Signal Service 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_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_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(ok, u8"确认", "OK", u8"ОК") \
X(cancel, u8"取消", "Cancel", u8"Отмена") \ X(cancel, u8"取消", "Cancel", u8"Отмена") \
X(new_password, u8"请输入六位密码:", \ X(new_password, u8"请输入六位密码:", "Enter a six-character password:", u8"Введите пароль из 6 символов:") \
"Please input a six-char password:", u8"Введите шестизначный пароль:") \
X(input_password, u8"请输入密码:", \ X(input_password, u8"请输入密码:", \
"Please input password:", u8"Введите пароль:") \ "Please input password:", u8"Введите пароль:") \
X(validate_password, u8"验证密码中...", "Validate password ...", \ X(validate_password, u8"验证密码中...", "Validating password...", u8"Проверка пароля...") \
u8"Проверка пароля...") \
X(reinput_password, u8"请重新输入密码", "Please input password again", \ X(reinput_password, u8"请重新输入密码", "Please input password again", \
u8"Повторно введите пароль") \ u8"Повторно введите пароль") \
X(remember_password, u8"记住密码", "Remember password", \ X(remember_password, u8"记住密码", "Remember password", \
u8"Запомнить пароль") \ u8"Запомнить пароль") \
X(signal_connected, u8"已连接服务器", "Connected", u8"Подключено к серверу") \ X(signal_connected, u8"已连接服务器", "Connected to server", u8"Подключено к серверу") \
X(signal_disconnected, u8"未连接服务器", "Disconnected", \ X(signal_disconnected, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
u8"Нет подключения к серверу") \
X(signal_reconnect, u8"重新连接", "Reconnect", u8"Переподключиться") \ X(signal_reconnect, u8"重新连接", "Reconnect", u8"Переподключиться") \
X(signal_reconnecting, u8"正在重新连接...", "Reconnecting...", \ X(signal_reconnecting, u8"正在重新连接...", "Reconnecting...", \
u8"Переподключение...") \ u8"Переподключение...") \
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", \ X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", "Certificate verification failed. Reinstall the self-hosted root certificate.", u8"Ошибка проверки сертификата. Переустановите корневой сертификат собственного сервера.") \
"Certificate verification failed. Reinstall the self-hosted root " \
"certificate.", \
u8"Ошибка проверки сертификата. Переустановите корневой сертификат.") \
X(p2p_connected, u8"对等连接已建立", "P2P Connected", u8"P2P подключено") \ X(p2p_connected, u8"对等连接已建立", "P2P Connected", u8"P2P подключено") \
X(p2p_disconnected, u8"对等连接已断开", "P2P Disconnected", \ X(p2p_disconnected, u8"对等连接已断开", "P2P Disconnected", \
u8"P2P отключено") \ u8"P2P отключено") \
@@ -282,12 +266,12 @@ struct TranslationRow {
X(receiving_screen, u8"画面接收中...", "Receiving screen...", \ X(receiving_screen, u8"画面接收中...", "Receiving screen...", \
u8"Получение изображения...") \ u8"Получение изображения...") \
X(p2p_failed, u8"对等连接失败", "P2P Failed", u8"Сбой P2P") \ 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_service_busy, u8"认证服务繁忙,请稍后重试。", "Authentication service busy. Try again later.", u8"Сервис проверки занят. Повторите позже.") \
X(connection_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", 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(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \ 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(about, u8"关于", "About", u8"О программе") \
X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \ X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \
X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \ X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
@@ -318,26 +302,24 @@ struct TranslationRow {
"Access Website: ", u8"Официальный сайт: ") \ "Access Website: ", u8"Официальный сайт: ") \
X(update, u8"更新", "Update", u8"Обновить") \ X(update, u8"更新", "Update", u8"Обновить") \
X(install_update, u8"安装更新", "Install update", u8"Установить обновление") \ X(install_update, u8"安装更新", "Install update", u8"Установить обновление") \
X(manual_download, u8"手动下载", "Download", u8"Скачать") \ X(manual_download, u8"手动下载", "Download manually", u8"Скачать вручную") \
X(install_update_confirm, u8"下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?", \ 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?", \ "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 закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \ u8"После загрузки CrossDesk закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \
X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \ X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \
X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \ X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \
X(connection_alias, u8"修改名称", "Edit Alias", \ X(connection_alias, u8"修改名称", "Rename", u8"Переименовать") \
u8"Изменить имя подключения") \
X(delete_connection, u8"删除连接", "Delete Connection", \ X(delete_connection, u8"删除连接", "Delete Connection", \
u8"Удалить подключение") \ u8"Удалить подключение") \
X(connect_to_this_connection, u8"发起连接", "Connect to this connection", \ X(connect_to_this_connection, u8"发起连接", "Connect", u8"Подключиться") \
u8"Подключиться") \
X(input_connection_alias, u8"请输入连接名称:", \ X(input_connection_alias, u8"请输入连接名称:", \
"Please input connection name:", u8"Введите имя подключения:") \ "Please input connection name:", u8"Введите имя подключения:") \
X(confirm_delete_connection, u8"确认删除此连接", \ X(confirm_delete_connection, u8"确认删除此连接", "Delete this connection?", u8"Удалить это подключение?") \
"Confirm to delete this connection", u8"Удалить это подключение?") \
X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \ X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \
X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \ X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on connect:", \ X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on incoming connection:", u8"Приватный экран при подключении:") \
u8"Приватность при подключении:") \ X(lock_on_disconnect, u8"连接断开后锁屏:", "Lock screen on disconnect:", \
u8"Блокировка при отключении:") \
X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \ X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \
u8"Вступит в силу после перезапуска") \ u8"Вступит в силу после перезапуска") \
X(select_file, u8"选择文件发送", "Select File to Send", \ X(select_file, u8"选择文件发送", "Select File to Send", \
@@ -370,17 +352,19 @@ struct TranslationRow {
"File Transfer Save Path:", u8"Путь сохранения файлов:") \ "File Transfer Save Path:", u8"Путь сохранения файлов:") \
X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \ X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \
X(resolution, u8"分辨率", "Res", u8"Разрешение") \ X(resolution, u8"分辨率", "Res", u8"Разрешение") \
X(video_latency, u8"视频延时", "Video delay", u8"Задержка") \ X(video_latency, u8"视频延时", "Video latency", u8"Задержка видео") \
X(connection_latency, u8"连接延时", "Connection RTT", u8"Задержка соединения") \ X(connection_latency, u8"连接延时", "Connection latency", u8"Задержка соединения") \
X(connection_mode, u8"连接模式", "Mode", u8"Режим") \ X(connection_mode, u8"连接模式", "Connection mode", u8"Режим подключения") \
X(connection_mode_direct, u8"直连", "Direct", 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(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_enabled, u8"已开启", "Enabled", u8"Включено") \
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \ X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
X(online, u8"在线", "Online", u8"Онлайн") \ X(online, u8"在线", "Online", u8"В сети") \
X(offline, u8"离线", "Offline", u8"Офлайн") \ X(offline, u8"离线", "Offline", u8"Не в сети") \
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство офлайн") \ X(device_offline, u8"设备离线", "Device Offline", u8"Устройство не в сети") \
X(request_permissions, u8"权限请求", "Request Permissions", \ X(request_permissions, u8"权限请求", "Request Permissions", \
u8"Запрос разрешений") \ u8"Запрос разрешений") \
X(screen_recording_permission, 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
@@ -226,15 +226,11 @@ int ClipboardController::SendToPeers(const std::string &text) {
} }
} }
if (owner_.peer_) { lock.unlock();
const int ret = for (const auto& id : owner_.ConnectedControllers()) {
SendReliableDataFrame(owner_.peer_, text.data(), text.size(), const int ret = owner_.SendControllerData(id, text.data(), text.size(),
owner_.clipboard_label_.c_str()); owner_.clipboard_label_.c_str());
if (ret != 0) { if (ret != 0) return ret;
LOG_WARN("Failed to send clipboard data to peer [{}], ret={}",
owner_.remote_id_display_, ret);
return ret;
}
} }
return 0; return 0;
@@ -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 #ifdef _WIN32
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "platform/windows/screen_capturer/screen_capturer_win.h" #include "platform/windows/screen_capturer/screen_capturer_win.h"
#include "platform/windows/input/mouse/mouse_controller.h"
#include "service_host.h" #include "service_host.h"
#endif #endif
@@ -44,18 +43,17 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
SpeakerCapturerFactory().Create()); SpeakerCapturerFactory().Create());
}, },
[this](unsigned char *data, size_t size, const char *) { [this](unsigned char *data, size_t size, const char *) {
if (!owner_.peer_) return;
MiniRtcAudioFrame frame{}; MiniRtcAudioFrame frame{};
frame.data = reinterpret_cast<const char *>(data); frame.data = reinterpret_cast<const char *>(data);
frame.size = size; frame.size = size;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_); for (const auto& id : owner_.ConnectedControllers())
std::shared_lock lock(owner_.connection_status_mutex_); owner_.WithController(
for (const auto& [id, status] : owner_.connection_status_) { id, [&](PeerPtr* peer, const std::string& wire) {
if (status == ConnectionStatus::Connected) frame.captured_timestamp = GetSystemTimeMicros(peer);
SendAudioFrameToPeer(owner_.peer_, &frame, return SendAudioFrameToPeer(peer, &frame,
owner_.audio_label_.c_str(), id.data(), owner_.audio_label_.c_str(),
id.size()); wire.data(), wire.size());
} });
}) {} }) {}
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); } SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
@@ -133,6 +131,10 @@ void SessionDeviceManager::Initialize() {
DeviceControllerFactory::Device::Keyboard)); DeviceControllerFactory::Device::Keyboard));
} }
int SessionDeviceManager::EnsureCaptureReady() {
return screen_capturer_ ? 0 : InitializeScreenCapturer();
}
int SessionDeviceManager::InitializeScreenCapturer() { int SessionDeviceManager::InitializeScreenCapturer() {
#ifdef __APPLE__ #ifdef __APPLE__
if (!owner_.EnsureMacScreenRecordingPermission()) { if (!owner_.EnsureMacScreenRecordingPermission()) {
@@ -194,16 +196,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
.count() .count()
: 0; : 0;
std::vector<std::string> connected_remote_ids; const auto connected_remote_ids = owner_.ConnectedControllers();
{
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);
}
}
}
// Capture can still deliver frames while ICE is gathering or after // Capture can still deliver frames while ICE is gathering or after
// the final controller disconnects. Do not broadcast those frames to // the final controller disconnects. Do not broadcast those frames to
@@ -233,28 +226,27 @@ int SessionDeviceManager::InitializeScreenCapturer() {
has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs; has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs;
const bool stream_changed = !last_video_frame_stream_id_.empty() && const bool stream_changed = !last_video_frame_stream_id_.empty() &&
last_video_frame_stream_id_ != stream_id; 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{}; MiniRtcVideoFrame frame{};
frame.data = reinterpret_cast<const char *>(data); frame.data = reinterpret_cast<const char *>(data);
frame.size = size; frame.size = size;
frame.width = width; frame.width = width;
frame.height = height; frame.height = height;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
frame.native_frame = native_frame; frame.native_frame = native_frame;
const auto send_started = std::chrono::steady_clock::now(); const auto send_started = std::chrono::steady_clock::now();
uint64_t send_errors = 0; uint64_t send_errors = 0;
for (const std::string &remote_id : connected_remote_ids) { for (const std::string &remote_id : connected_remote_ids) {
if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time, if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time,
stream_id)) continue; stream_id)) continue;
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(), if (owner_.WithController(
remote_id.data(), remote_id.size()) != 0) 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; ++send_errors;
} }
const auto send_us = 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() { int SessionDeviceManager::StartScreenCapturer() {
#ifdef __APPLE__ #ifdef __APPLE__
if (!owner_.EnsureMacScreenRecordingPermission()) { if (!owner_.EnsureMacScreenRecordingPermission()) {
@@ -318,8 +325,10 @@ int SessionDeviceManager::StartScreenCapturer() {
cursor_capture_dirty_.store(false, std::memory_order_relaxed); cursor_capture_dirty_.store(false, std::memory_order_relaxed);
const bool show_cursor = ShouldCaptureCursor(); const bool show_cursor = ShouldCaptureCursor();
LOG_INFO("Start screen capturer, show cursor: {}", show_cursor); LOG_INFO("Start screen capturer, show cursor: {}", show_cursor);
screen_sharing_power_.SetSharing(HasConnectedController());
const int ret = screen_capturer_->Start(show_cursor); const int ret = screen_capturer_->Start(show_cursor);
if (ret != 0) { if (ret != 0) {
screen_sharing_power_.SetSharing(false);
LOG_ERROR("Start screen capturer failed: {}", ret); LOG_ERROR("Start screen capturer failed: {}", ret);
} }
return ret; return ret;
@@ -330,29 +339,49 @@ int SessionDeviceManager::StopScreenCapturer() {
LOG_INFO("Stop screen capturer"); LOG_INFO("Stop screen capturer");
screen_capturer_->Stop(); screen_capturer_->Stop();
} }
owner_.screen_capturer_is_started_ = false;
screen_sharing_power_.SetSharing(false);
return 0; 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 #ifdef _WIN32
void SessionDeviceManager::ReleaseRemoteMouseButtons() {
if (auto mouse = dynamic_cast<PlatformMouseController*>(mouse_controller_))
mouse->ReleasePressedButtons();
std::lock_guard lock(service_mouse_mutex_); std::lock_guard lock(service_mouse_mutex_);
for (int button = 0; button < 3; ++button) { for (int button = 0; button < 3; ++button) {
const unsigned bit = 1u << button; const unsigned bit = 1u << button;
if (!(service_mouse_buttons_ & bit)) continue; if (!(service_mouse_buttons_ & mask & bit)) continue;
const auto response = SendCrossDeskSecureDesktopMouseInput( const auto response = SendCrossDeskSecureDesktopMouseInput(
service_mouse_x_, service_mouse_y_, 0, (button + 1) * 2, 1000); service_mouse_x_, service_mouse_y_, 0, (button + 1) * 2, 1000);
const auto json = nlohmann::json::parse(response, nullptr, false); const auto json = nlohmann::json::parse(response, nullptr, false);
if (json.is_object() && json.value("ok", false)) { if (json.is_object() && json.value("ok", false)) {
service_mouse_buttons_ &= ~bit; service_mouse_buttons_ &= ~bit;
} else { } else {
released &= ~bit;
LOG_WARN("Service mouse button release failed: button={}, response={}", LOG_WARN("Service mouse button release failed: button={}, response={}",
button, response); button, response);
} }
} }
#endif
return released;
} }
#ifdef _WIN32
std::string SessionDeviceManager::SendServiceMouseCommand(int x, int y, std::string SessionDeviceManager::SendServiceMouseCommand(int x, int y,
int wheel, int flag) { int wheel, int flag) {
std::lock_guard lock(service_mouse_mutex_); std::lock_guard lock(service_mouse_mutex_);
@@ -398,7 +427,10 @@ bool SessionDeviceManager::SyncDisplayInfo() {
}); });
if (changed) display_info_list_ = displays; 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; return true;
} }
@@ -434,6 +466,7 @@ int SessionDeviceManager::StartMouseController() {
#endif #endif
SyncDisplayInfo(); SyncDisplayInfo();
std::lock_guard lock(mouse_controller_mutex_);
mouse_controller_ = mouse_controller_ =
static_cast<MouseController *>(device_controller_factory_->Create( static_cast<MouseController *>(device_controller_factory_->Create(
DeviceControllerFactory::Device::Mouse)); DeviceControllerFactory::Device::Mouse));
@@ -452,10 +485,16 @@ int SessionDeviceManager::StartMouseController() {
return init_ret; return init_ret;
} }
int SessionDeviceManager::StopMouseController() { int SessionDeviceManager::StopMouseController(bool force) {
#ifdef _WIN32 // 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(); ReleaseRemoteMouseButtons();
#endif std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) { if (mouse_controller_) {
mouse_controller_->Destroy(); mouse_controller_->Destroy();
delete mouse_controller_; delete mouse_controller_;
@@ -620,6 +659,8 @@ void SessionDeviceManager::PushAudio(const std::string& remote_id,
} }
void SessionDeviceManager::UpdateInteractions() { void SessionDeviceManager::UpdateInteractions() {
// Release even when pending remote-input cleanup makes this tick return early.
UpdateScreenSharingPower();
#ifdef _WIN32 #ifdef _WIN32
if (owner_.screen_capturer_is_started_ && if (owner_.screen_capturer_is_started_ &&
cursor_capture_dirty_.load(std::memory_order_relaxed)) { 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_.screen_capturer_is_started_ && !owner_.start_mouse_controller_ &&
owner_.mouse_controller_is_started_; owner_.mouse_controller_is_started_;
if (stop_wayland_mouse_before_screen) { 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"); "cleanly release the shared portal session");
StopMouseController();
owner_.mouse_controller_is_started_ = false; owner_.mouse_controller_is_started_ = false;
} }
#endif #endif
@@ -653,14 +694,15 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.screen_capturer_is_started_ = false; owner_.screen_capturer_is_started_ = false;
} }
UpdateScreenSharingPower();
if (owner_.start_mouse_controller_ && !owner_.mouse_controller_is_started_) { if (owner_.start_mouse_controller_ && !owner_.mouse_controller_is_started_) {
if (StartMouseController() == 0) { if (StartMouseController() == 0) {
owner_.mouse_controller_is_started_ = true; owner_.mouse_controller_is_started_ = true;
} }
} else if (!owner_.start_mouse_controller_ && } else if (!owner_.start_mouse_controller_ &&
owner_.mouse_controller_is_started_) { owner_.mouse_controller_is_started_) {
StopMouseController(); if (StopMouseController() == 0) owner_.mouse_controller_is_started_ = false;
owner_.mouse_controller_is_started_ = false;
} }
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) { 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, bool SessionDeviceManager::SendMouseCommand(const RemoteAction &action,
int selected_display) { int selected_display) {
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) { if (mouse_controller_) {
return mouse_controller_->SendMouseCommand(action, selected_display) == 0; return mouse_controller_->SendMouseCommand(action, selected_display) == 0;
} }
@@ -763,19 +806,20 @@ int SessionDeviceManager::SwitchDisplay(int display_id) {
return ret; return ret;
} }
void SessionDeviceManager::ResetToInitialDisplay() { InputOutcome SessionDeviceManager::ResetToInitialDisplay() {
if (!screen_capturer_) { if (!screen_capturer_) {
return; return InputOutcome::ignored;
} }
const int ret = screen_capturer_->ResetToInitialMonitor(); const int ret = screen_capturer_->ResetToInitialMonitor();
if (ret != 0) { if (ret != 0) {
LOG_WARN("Reset to initial display failed, ret={}", ret); 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 // Capture backends start at display 0. Reset the input mapping as well so
// the next connection cannot show that display while controlling another. // the next connection cannot show that display while controlling another.
owner_.selected_display_ = 0; owner_.selected_display_ = 0;
SyncDisplayInfo(); SyncDisplayInfo();
return InputOutcome::applied;
} }
std::vector<DisplayInfo> SessionDeviceManager::display_info_list() const { std::vector<DisplayInfo> SessionDeviceManager::display_info_list() const {
@@ -795,8 +839,10 @@ std::vector<HostDisplay> SessionDeviceManager::host_display_list() const {
} }
void SessionDeviceManager::DestroyDevices() { void SessionDeviceManager::DestroyDevices() {
owner_.screen_capturer_is_started_ = false;
screen_sharing_power_.SetSharing(false);
speaker_capture_.Shutdown(); speaker_capture_.Shutdown();
StopMouseController(); StopMouseController(true);
if (screen_capturer_) { if (screen_capturer_) {
screen_capturer_->Destroy(); screen_capturer_->Destroy();
delete screen_capturer_; delete screen_capturer_;
@@ -14,6 +14,7 @@
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <utility>
#include <vector> #include <vector>
#include <remote_action.h> #include <remote_action.h>
@@ -21,6 +22,8 @@
#include "device_controller.h" #include "device_controller.h"
#include "display_info.h" #include "display_info.h"
#include "features/input/captured_keyboard_queue.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" #include "speaker_capture_controller.h"
struct SDL_AudioStream; struct SDL_AudioStream;
@@ -51,10 +54,18 @@ public:
void DestroyFactories(); void DestroyFactories();
int InitializeScreenCapturer(); int InitializeScreenCapturer();
int EnsureCaptureReady();
int StartScreenCapturer(); int StartScreenCapturer();
int StopScreenCapturer(); 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 #ifdef _WIN32
void ReleaseRemoteMouseButtons();
std::string SendServiceMouseCommand(int x, int y, int wheel, int flag); std::string SendServiceMouseCommand(int x, int y, int wheel, int flag);
#endif #endif
// Requests are asynchronous; Stop drains/revokes audio delivery before // Requests are asynchronous; Stop drains/revokes audio delivery before
@@ -62,7 +73,9 @@ public:
void StartSpeakerCapturer(); void StartSpeakerCapturer();
void StopSpeakerCapturer(); void StopSpeakerCapturer();
int StartMouseController(); 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 StartKeyboardCapturer();
int StopKeyboardCapturer(); int StopKeyboardCapturer();
@@ -79,7 +92,7 @@ public:
bool extended); bool extended);
bool SendMouseCommand(const RemoteAction &action, int selected_display); bool SendMouseCommand(const RemoteAction &action, int selected_display);
int SwitchDisplay(int display_id); int SwitchDisplay(int display_id);
void ResetToInitialDisplay(); InputOutcome ResetToInitialDisplay();
// Snapshot of the capture displays. The UI thread refreshes the list every // Snapshot of the capture displays. The UI thread refreshes the list every
// tick while transport threads build host information and map mouse input // tick while transport threads build host information and map mouse input
@@ -89,10 +102,13 @@ public:
std::vector<HostDisplay> host_display_list() const; std::vector<HostDisplay> host_display_list() const;
private: private:
unsigned ReleaseMouseButtonMask(unsigned mask);
void DrainCapturedKeyboardInput(); void DrainCapturedKeyboardInput();
bool ShouldSendCapturedFrame(std::chrono::steady_clock::time_point now, bool ShouldSendCapturedFrame(std::chrono::steady_clock::time_point now,
int fps); int fps);
bool ShouldCaptureCursor() const; bool ShouldCaptureCursor() const;
bool HasConnectedController() const;
void UpdateScreenSharingPower();
bool SyncDisplayInfo(); bool SyncDisplayInfo();
void SetDisplayInfoList(std::vector<DisplayInfo> displays); void SetDisplayInfoList(std::vector<DisplayInfo> displays);
void RecordCaptureCadence(std::chrono::steady_clock::time_point now, int fps, 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_; std::unordered_map<std::string, SDL_AudioStream*> output_streams_;
ScreenCapturerFactory *screen_capturer_factory_ = nullptr; ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
ScreenCapturer *screen_capturer_ = nullptr; ScreenCapturer *screen_capturer_ = nullptr;
ScreenSharingPower screen_sharing_power_;
std::atomic<bool> cursor_capture_dirty_{true}; std::atomic<bool> cursor_capture_dirty_{true};
SpeakerCaptureController speaker_capture_; SpeakerCaptureController speaker_capture_;
DeviceControllerFactory *device_controller_factory_ = nullptr; DeviceControllerFactory *device_controller_factory_ = nullptr;
MouseController *mouse_controller_ = 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; KeyboardCapturer *keyboard_capturer_ = nullptr;
// Guards display_info_list_ (UI thread writes, transport threads read). // Guards display_info_list_ (UI thread writes, transport threads read).
mutable std::mutex display_info_mutex_; mutable std::mutex display_info_mutex_;
@@ -94,13 +94,19 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
peer = props ? props->peer_ : owner_.peer_; peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_; 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>( auto session = std::make_shared<file_transfer::Session>(
[peer, events, file, props](const char*, const char* data, size_t size) { [peer, events, file, props](const char*, const char* data, size_t size) {
if (!peer || !events || (props && props->closing_)) return -1; if (!peer || !events || (props && props->closing_)) return -1;
auto callback = events->EnterCallback(); auto callback = events->EnterCallback();
if (!callback) return -1; if (!callback || !events->DataAllowed()) return -1;
return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(), const auto wire_id = events->WireId(file.remote_id);
file.remote_id.c_str(), file.remote_id.size()); return SendReliableDataFrameToPeer(
peer, data, size, file.file_label.c_str(),
wire_id.data(), wire_id.size());
}, file_transfer::Session::Progress{}); }, file_transfer::Session::Progress{});
state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0; state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0;
state.current_file_id_ = id; state.current_file_id_ = id;
@@ -313,6 +319,10 @@ void FileTransferManager::Receive(const char* data, size_t size,
peer = props ? props->peer_ : owner_.peer_; peer = props ? props->peer_ : owner_.peer_;
events = props ? props->peer_events_ : owner_.peer_events_; 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; if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
std::shared_ptr<FileReceiver> receiver; std::shared_ptr<FileReceiver> receiver;
std::filesystem::path default_directory; 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) { entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) {
auto callback = events->EnterCallback(); auto callback = events->EnterCallback();
if (!callback) return -1; if (!callback || !events->DataAllowed()) return -1;
const auto bytes = EncodeFileTransferAck(ack); const auto bytes = EncodeFileTransferAck(ack);
return SendReliableDataFrameToPeer(peer, bytes.data(), bytes.size(), kFileFeedbackStream, const auto wire_id = events->WireId(remote);
remote.c_str(), remote.size()); return SendReliableDataFrameToPeer(
peer, bytes.data(), bytes.size(), kFileFeedbackStream,
wire_id.data(), wire_id.size());
}); });
} }
receiver = entry.session; 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 <SDL3/SDL.h>
#include <algorithm>
#include <cstdint> #include <cstdint>
#include <mutex> #include <mutex>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
@@ -249,14 +250,14 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
extended); extended);
} }
void KeyboardController::ApplyRemoteEvent(const std::string& remote_id, InputOutcome KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
const RemoteAction& action) { const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_); std::lock_guard input_lock(remote_input_mutex_);
const int key_code = static_cast<int>(action.k.key_value); const int key_code = static_cast<int>(action.k.key_value);
const bool is_down = action.k.flag == KeyFlag::key_down; const bool is_down = action.k.flag == KeyFlag::key_down;
const bool injected = const bool injected =
InjectRemoteKey(key_code, is_down, action.k.scan_code, action.k.extended); 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]; auto& state = remote_states_[remote_id];
state.last_seen_tick = static_cast<uint32_t>(SDL_GetTicks()); 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) { } else if (!is_down && injected) {
state.pressed_keys.erase(key_code); 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) { const std::string& text) {
std::lock_guard input_lock(remote_input_mutex_); std::lock_guard input_lock(remote_input_mutex_);
if (!owner_.IsAuthorizedController(remote_id) || if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
!owner_.devices_.SupportsTextInput()) return; if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
if (!owner_.devices_.SendTextCommand(text)) if (!owner_.devices_.SendTextCommand(text)) {
LOG_WARN("Failed to inject committed remote 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) { const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_); std::lock_guard input_lock(remote_input_mutex_);
auto& state = remote_states_[remote_id]; auto& state = remote_states_[remote_id];
if (action.ks.seq != 0 && state.last_seq != 0 && if (action.ks.seq != 0 && state.last_seq != 0 &&
static_cast<int32_t>(action.ks.seq - 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; 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); const auto changes = ReconcileKeyboardState(state.pressed_keys, desired_keys);
bool can_press = true; bool can_press = true;
bool applied = true;
for (const auto& key : changes) { for (const auto& key : changes) {
if (key.is_down && !can_press) continue; if (key.is_down && !can_press) continue;
if (!InjectRemoteKey(key.key_code, key.is_down, key.scan_code, key.extended)) { 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 // Do not replay a shortcut with a stale/missing modifier. The next
// heartbeat will retry from the state that was actually injected. // heartbeat will retry from the state that was actually injected.
can_press = false; can_press = false;
applied = false;
continue; continue;
} }
pending_key_releases_.erase(key.key_code); pending_key_releases_.Supersede(key.key_code);
if (key.is_down) if (key.is_down)
state.pressed_keys[key.key_code] = state.pressed_keys[key.key_code] =
{key.key_code, key.scan_code, key.extended}; {key.key_code, key.scan_code, key.extended};
else else
state.pressed_keys.erase(key.key_code); 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) { const char* reason) {
std::lock_guard input_lock(remote_input_mutex_); 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) { std::string remote_id, const char* reason) {
const auto state_it = remote_states_.find(remote_id); const auto state_it = remote_states_.find(remote_id);
if (state_it == remote_states_.end()) return; if (state_it != remote_states_.end()) {
auto keys_to_release = std::move(state_it->second.pressed_keys); const auto& keys = state_it->second.pressed_keys;
remote_states_.erase(state_it); if (!keys.empty()) {
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
if (!keys_to_release.empty()) { keys.size(), remote_id, reason ? reason : "unknown");
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);
} }
// 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) { void KeyboardController::ReleaseAllRemotePressedKeys(const char* reason) {
{ std::lock_guard input_lock(remote_input_mutex_);
std::lock_guard input_lock(remote_input_mutex_); auto ids = pending_key_releases_.ControllerIds();
while (!remote_states_.empty()) for (const auto& [id, _] : remote_states_) ids.push_back(id);
ReleaseRemotePressedKeysLocked(remote_states_.begin()->first, reason); std::sort(ids.begin(), ids.end());
} ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
CheckRemoteTimeouts(); // A failure remains pending; shutdown must never spin until it succeeds.
for (const auto& id : ids) ReleaseRemotePressedKeysLocked(id, reason);
} }
void KeyboardController::CheckRemoteTimeouts() { void KeyboardController::CheckRemoteTimeouts() {
@@ -359,7 +368,7 @@ void KeyboardController::CheckRemoteTimeouts() {
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks()); const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
// Retain failed releases in ordinary mode as well as privacy mode. Retry // Retain failed releases in ordinary mode as well as privacy mode. Retry
// only on the ordinary desktop, never through the security UI helper. // 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 || (last_release_retry_tick_ == 0 ||
now - last_release_retry_tick_ >= kReleaseRetryIntervalMs) now - last_release_retry_tick_ >= kReleaseRetryIntervalMs)
#ifdef _WIN32 #ifdef _WIN32
@@ -367,15 +376,10 @@ void KeyboardController::CheckRemoteTimeouts() {
#endif #endif
) { ) {
last_release_retry_tick_ = now; last_release_retry_tick_ = now;
for (auto it = pending_key_releases_.begin(); pending_key_releases_.RetryAll([this](const KeyboardKey& key) {
it != pending_key_releases_.end();) { return owner_.devices_.SendKeyboardCommand(
const auto& key = it->second; key.key_code, false, key.scan_code, key.extended);
if (owner_.devices_.SendKeyboardCommand( });
key.key_code, false, key.scan_code, key.extended))
it = pending_key_releases_.erase(it);
else
++it;
}
} }
// Keep the timeout decision and release under the same lock, so a fresh // Keep the timeout decision and release under the same lock, so a fresh
@@ -9,6 +9,8 @@
#include <remote_action.h> #include <remote_action.h>
#include "keyboard_state.h" #include "keyboard_state.h"
#include "input_outcome.h"
#include "pending_key_releases.h"
namespace crossdesk { namespace crossdesk {
@@ -25,12 +27,12 @@ public:
void ForceReleasePressedKeys(); void ForceReleasePressedKeys();
void RecoverCapturedState(const PressedKeys& pressed); void RecoverCapturedState(const PressedKeys& pressed);
void SendHeartbeat(bool force); void SendHeartbeat(bool force);
void ApplyRemoteEvent(const std::string &remote_id, InputOutcome ApplyRemoteEvent(const std::string &remote_id,
const RemoteAction &remote_action); const RemoteAction &remote_action);
void ApplyRemoteText(const std::string& remote_id, const std::string& text); InputOutcome ApplyRemoteText(const std::string& remote_id, const std::string& text);
void ApplyRemoteState(const std::string &remote_id, InputOutcome ApplyRemoteState(const std::string &remote_id,
const RemoteAction &remote_action); const RemoteAction &remote_action);
void ReleaseRemotePressedKeys(const std::string &remote_id, bool ReleaseRemotePressedKeys(const std::string &remote_id,
const char *reason); const char *reason);
void ReleaseAllRemotePressedKeys(const char* reason); void ReleaseAllRemotePressedKeys(const char* reason);
void CheckRemoteTimeouts(); void CheckRemoteTimeouts();
@@ -48,7 +50,7 @@ private:
bool InjectRemoteKey(int key_code, bool is_down, uint32_t scan_code, bool InjectRemoteKey(int key_code, bool is_down, uint32_t scan_code,
bool extended); bool extended);
// Caller holds remote_input_mutex_. Copy the id because this erases state. // 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_; GuiRuntime &owner_;
PressedKeys pressed_keys_; PressedKeys pressed_keys_;
@@ -59,7 +61,7 @@ private:
// Guards all remote key state and serializes injection, cleanup and retries. // Guards all remote key state and serializes injection, cleanup and retries.
std::mutex remote_input_mutex_; std::mutex remote_input_mutex_;
// Successful new input supersedes cleanup of an older press of that key. // 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; 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
@@ -465,9 +465,6 @@ int SettingsManager::Load() {
static_cast<int>(owner_.config_center_->GetLanguage())); static_cast<int>(owner_.config_center_->GetLanguage()));
owner_.video_encode_format_button_value_ = owner_.video_encode_format_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat()); 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_self_hosted_ = owner_.config_center_->IsSelfHosted();
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart(); owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon(); owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
@@ -486,9 +483,6 @@ int SettingsManager::Load() {
owner_.language_button_value_last_ = owner_.language_button_value_; owner_.language_button_value_last_ = owner_.language_button_value_;
owner_.video_encode_format_button_value_last_ = owner_.video_encode_format_button_value_last_ =
owner_.video_encode_format_button_value_; 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_self_hosted_last_ = owner_.enable_self_hosted_;
owner_.enable_autostart_last_ = owner_.enable_autostart_; owner_.enable_autostart_last_ = owner_.enable_autostart_;
@@ -748,6 +742,8 @@ std::string SettingsManager::RecentConnectionDisplayName(
connection.remote_host_name != "unknown") { connection.remote_host_name != "unknown") {
return connection.remote_host_name; 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; return connection.remote_id;
} }
@@ -63,7 +63,7 @@ private:
int legacy_video_quality; int legacy_video_quality;
int legacy_video_frame_rate; int legacy_video_frame_rate;
int video_encode_format; int video_encode_format;
bool enable_hardware_video_codec; bool legacy_enable_hardware_video_codec;
bool enable_turn; bool enable_turn;
bool enable_srtp; bool enable_srtp;
unsigned char key[16]; unsigned char key[16];
@@ -77,7 +77,7 @@ private:
int legacy_video_quality; int legacy_video_quality;
int legacy_video_frame_rate; int legacy_video_frame_rate;
int video_encode_format; int video_encode_format;
bool enable_hardware_video_codec; bool legacy_enable_hardware_video_codec;
bool enable_turn; bool enable_turn;
bool enable_srtp; bool enable_srtp;
unsigned char key[16]; unsigned char key[16];
@@ -14,6 +14,7 @@
#include "platform.h" #include "platform.h"
#include "platform/video_renderer.h" #include "platform/video_renderer.h"
#include "rd_log.h" #include "rd_log.h"
#include "screen_lock.h"
#include "runtime/device_presence_request.h" #include "runtime/device_presence_request.h"
#include "runtime/gui_runtime.h" #include "runtime/gui_runtime.h"
@@ -26,6 +27,7 @@ constexpr auto kPresenceRetryInterval = std::chrono::seconds(1);
} // namespace } // namespace
bool GuiRuntime::HasActiveSession() { bool GuiRuntime::HasActiveSession() {
if (lan_ && !lan_->Quiescent()) return true;
if (stream_window_inited_) return true; if (stream_window_inited_) return true;
std::shared_lock lock(connection_status_mutex_); std::shared_lock lock(connection_status_mutex_);
return std::any_of(connection_status_.begin(), connection_status_.end(), return std::any_of(connection_status_.begin(), connection_status_.end(),
@@ -188,24 +190,21 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
void GuiRuntime::HandleServerControllerDisconnected( void GuiRuntime::HandleServerControllerDisconnected(
const std::string& remote_id, const char* reason) { 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); 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; bool has_connected_controller = false;
std::string remaining_controller_id; std::string remaining_controller_id;
{ {
std::unique_lock lock(connection_status_mutex_); std::unique_lock lock(connection_status_mutex_);
connection_status_.erase(remote_id); connection_status_.erase(remote_id);
disconnect_lock_.Disconnected(remote_id,
config_center_->IsLockOnDisconnect());
remote_input_.Disconnect(remote_id);
devices_.OnControllerConnectionsChanged(); devices_.OnControllerConnectionsChanged();
connection_host_names_.erase(remote_id); connection_host_names_.erase(remote_id);
codec_status_controllers_.erase(remote_id);
privacy_sessions_.Disconnected(remote_id); privacy_sessions_.Disconnected(remote_id);
for (const auto& [id, status] : connection_status_) { for (const auto& [id, status] : connection_status_) {
if (status != ConnectionStatus::Connected) { if (status != ConnectionStatus::Connected) {
@@ -249,10 +248,28 @@ void GuiRuntime::HandleServerControllerDisconnected(
#endif #endif
devices_.StopSpeakerCapturer(); devices_.StopSpeakerCapturer();
start_mouse_controller_ = false; 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(); remote_client_id_.clear();
audio_capture_ = false; 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, int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
@@ -301,10 +318,15 @@ int GuiRuntime::ForceSignalReconnect() {
} }
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) { 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); MuteRemoteAudio(props);
if (props->peer_events_) { if (props->peer_events_) {
props->peer_events_->Deactivate(); props->peer_events_->StopAccepting();
transfers_.Cancel(props->peer_events_.get()); transfers_.Cancel(props->peer_events_.get());
} }
props->connection_established_ = false; 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( session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask(
[props, peer, frame_snapshot, video_width, video_height, platform, [props, peer, frame_snapshot, video_width, video_height, platform,
thumbnail = thumbnail_, queued_at]() mutable { 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_, LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
props->remote_id_); props->remote_id_);
LeaveConnection(peer, props->remote_id_.c_str()); LeaveConnection(peer, props->remote_id_.c_str());
@@ -366,11 +389,20 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
if (thumbnail && props->was_connected_) { if (thumbnail && props->was_connected_) {
const char* nv12 = frame_snapshot && !frame_snapshot->empty() const char* nv12 = frame_snapshot && !frame_snapshot->empty()
? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr; ? 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( if (thumbnail->SaveToThumbnail(
nv12, video_width, video_height, props->remote_id_, nv12, video_width, video_height, record_id,
props->remote_host_name_, props->remote_host_name_,
props->remember_password_ ? props->remote_password_ : "", props->remember_password_ ? props->remote_password_ : "",
platform) != 0) { platform, direct ? &target : nullptr) != 0) {
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_); 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) { 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)) if (!peer_ || remote_id.empty() || controller_cleanup_tasks_.count(remote_id))
return; return;
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask( controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
@@ -399,20 +442,17 @@ void GuiRuntime::CloseConnectionPeer() {
announcements_.Reset(); announcements_.Reset();
return; return;
} }
peer_events_->Deactivate(); peer_events_->StopAccepting();
transfers_.Cancel(peer_events_.get()); transfers_.Cancel(peer_events_.get());
announcements_.Reset(); announcements_.Reset();
std::vector<std::string> controllers; std::vector<std::string> controllers;
{ {
std::shared_lock lock(connection_status_mutex_); 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) for (const auto& id : controllers)
HandleServerControllerDisconnected(id, "peer_closed"); HandleServerControllerDisconnected(id, "peer_closed");
devices_.StopMouseController();
devices_.StopScreenCapturer();
devices_.StopSpeakerCapturer();
devices_.StopKeyboardCapturer();
signal_connected_ = false; signal_connected_ = false;
device_presence_cache_.SetSignalConnected(false); device_presence_cache_.SetSignalConnected(false);
signal_status_ = SignalStatus::SignalClosed; signal_status_ = SignalStatus::SignalClosed;
@@ -425,6 +465,7 @@ void GuiRuntime::CloseConnectionPeer() {
// these finish before the shared peer is destroyed. // these finish before the shared peer is destroyed.
connection_peer_cleanup_ = session_cleanup_queue_.PostTask( connection_peer_cleanup_ = session_cleanup_queue_.PostTask(
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable { [peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
handler->Deactivate();
LOG_INFO("[{}] Background connection peer cleanup", id); LOG_INFO("[{}] Background connection peer cleanup", id);
LeaveConnection(peer, id.c_str()); LeaveConnection(peer, id.c_str());
DestroyPeer(&peer); DestroyPeer(&peer);
@@ -558,6 +599,12 @@ std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
std::shared_lock lock(remote_sessions_mutex_); std::shared_lock lock(remote_sessions_mutex_);
auto it = remote_sessions_.find(remote_id); auto it = remote_sessions_.find(remote_id);
if (it == remote_sessions_.end()) { 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 nullptr;
} }
return it->second; return it->second;
@@ -22,6 +22,7 @@ inline nlohmann::json BuildDevicePresenceRequest(
const auto append = [&](const std::string& id) { const auto append = [&](const std::string& id) {
// Recent connection keys contain Y/N plus display settings after the 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")); 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() && if (!pure_id.empty() &&
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) { std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
devices.push_back(pure_id); 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
+65 -12
View File
@@ -17,6 +17,9 @@
#include "localization.h" #include "localization.h"
#include "platform/video_renderer.h" #include "platform/video_renderer.h"
#include "rd_log.h" #include "rd_log.h"
#ifdef _WIN32
#include "platform/windows/input/windows_input_injector.h"
#endif
#if defined(__linux__) && !defined(__OHOS__) #if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h" #include "platform/linux/headless/headless_console.h"
#endif #endif
@@ -25,20 +28,56 @@ namespace crossdesk {
GuiRuntime::GuiRuntime() GuiRuntime::GuiRuntime()
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this), : 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() { GuiRuntime::~GuiRuntime() {
remote_input_.Stop();
#if _WIN32 #if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed); windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed); windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
if (windows_service_worker_) windows_service_worker_->Reset(); if (windows_service_worker_) windows_service_worker_->Reset();
#endif #endif
lan_.reset();
transfers_.Shutdown(); transfers_.Shutdown();
WaitForSessionCleanup(); WaitForSessionCleanup();
} }
void GuiRuntime::SendRemoteInput(const RemoteAction& action) { void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
const bool mouse = action.type == ControlType::mouse; const bool mouse = action.type == ControlType::mouse;
const bool mouse_move = mouse && action.m.flag == MouseFlag::move;
if (!mouse && action.type != ControlType::keyboard && if (!mouse && action.type != ControlType::keyboard &&
action.type != ControlType::keyboard_state) action.type != ControlType::keyboard_state)
return; return;
@@ -72,9 +111,15 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
} }
// Reuse exactly the leader's mapped coordinates and key metadata. Neither // Reuse exactly the leader's mapped coordinates and key metadata. Neither
// gallery filters nor the follower's preview dimensions affect input. // gallery filters nor the follower's preview dimensions affect input.
const int result = SendReliableDataFrame( // Move updates supersede old positions and must not wait for KCP retries.
props->peer_, message.data(), message.size(), // Use the negotiated unreliable stream; clicks and wheel events remain
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str()); // 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) if (result != 0)
LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result); LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result);
} }
@@ -193,7 +238,8 @@ int GuiRuntime::CreateConnectionPeer() {
strncpy(params_.log_path, dll_log_path_.c_str(), strncpy(params_.log_path, dll_log_path_.c_str(),
sizeof(params_.log_path) - 1); sizeof(params_.log_path) - 1);
params_.log_path[sizeof(params_.log_path) - 1] = '\0'; 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__) #if defined(_WIN32) || (defined(__linux__) && !defined(__OHOS__)) || defined(__APPLE__)
// Windows and Linux retain pooled CPU NV12 frames or CUDA device frames; // Windows and Linux retain pooled CPU NV12 frames or CUDA device frames;
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All // macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
@@ -247,7 +293,7 @@ int GuiRuntime::CreateConnectionPeer() {
LOG_INFO("Create peer [{}] instance failed", client_id_); LOG_INFO("Create peer [{}] instance failed", client_id_);
} }
if (0 == devices_.InitializeScreenCapturer()) { if (0 == devices_.EnsureCaptureReady()) {
const auto &displays = devices_.display_info_list(); const auto &displays = devices_.display_info_list();
for (size_t index = 0; index < displays.size(); ++index) { for (size_t index = 0; index < displays.size(); ++index) {
const std::string stream_id = MakeDisplayStreamId(index); const std::string stream_id = MakeDisplayStreamId(index);
@@ -289,10 +335,18 @@ void GuiRuntime::HandleRecentConnections() {
// Record a successful connection before a preview or host info arrives. // Record a successful connection before a preview or host info arrives.
// The close path still updates its final thumbnail. Remember password // The close path still updates its final thumbnail. Remember password
// controls only credential storage, not whether this device is listed. // 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( if (thumbnail_->SaveToThumbnail(
nullptr, 0, 0, props->remote_id_, host_name, nullptr, 0, 0, record_id, host_name,
props->remember_password_ ? props->remote_password_ : "", props->remember_password_ ? props->remote_password_ : "",
platform) == 0) platform, direct ? &target : nullptr) == 0)
reload_recent_connections_ = true; reload_recent_connections_ = true;
else else
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_); LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
@@ -301,9 +355,7 @@ void GuiRuntime::HandleRecentConnections() {
if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) { if (session_cleanup_tasks_.empty() && reload_recent_connections_ && thumbnail_) {
uint32_t now_time = SDL_GetTicks(); uint32_t now_time = SDL_GetTicks();
if (now_time - recent_connection_image_save_time_ >= 50) { if (now_time - recent_connection_image_save_time_ >= 50) {
int ret = thumbnail_->LoadThumbnail(recent_connections_, int ret = thumbnail_->LoadThumbnail(recent_connections_);
&recent_connection_image_width_,
&recent_connection_image_height_);
if (!ret) { if (!ret) {
LOG_INFO("Load recent connection thumbnails"); LOG_INFO("Load recent connection thumbnails");
} }
@@ -311,7 +363,8 @@ void GuiRuntime::HandleRecentConnections() {
recent_connection_ids_.clear(); recent_connection_ids_.clear();
for (const auto &conn : recent_connections_) { 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; need_to_send_recent_connections_ = true;
} }
@@ -17,9 +17,12 @@
#include "features/devices/session_device_manager.h" #include "features/devices/session_device_manager.h"
#include "features/file_transfer/file_transfer_manager.h" #include "features/file_transfer/file_transfer_manager.h"
#include "features/input/keyboard_controller.h" #include "features/input/keyboard_controller.h"
#include "features/input/remote_input_dispatcher.h"
#include "features/settings/settings_manager.h" #include "features/settings/settings_manager.h"
#include "network/lan/lan_session_manager.h"
#include "privacy_controller.h" #include "privacy_controller.h"
#include "runtime/gui_state.h" #include "runtime/gui_state.h"
#include "runtime/disconnect_lock_policy.h"
#include "runtime/announcement_inbox.h" #include "runtime/announcement_inbox.h"
#include "runtime/peer_event_handler.h" #include "runtime/peer_event_handler.h"
#include "runtime/privacy_session_policy.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 SdlCaptureAudioIn(void* userdata, uint8_t* stream, int len);
static void SdlCaptureAudioOut(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 CreateConnectionPeer();
int ConnectTo(const std::string& remote_id, const char* password, int ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check = false, bool remember_password, bool bypass_presence_check = false,
@@ -71,6 +103,7 @@ class GuiRuntime : protected gui_detail::GuiState {
const char* reason); const char* reason);
void HandleWindowsServiceIntegration(); void HandleWindowsServiceIntegration();
void HandlePrivacy(); void HandlePrivacy();
void HandleScreenLock();
bool IsAuthorizedController(const std::string& remote_id); bool IsAuthorizedController(const std::string& remote_id);
void SetControllerPrivacySupport(const std::string& remote_id, bool supported); void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
// Caller holds connection_status_mutex_ exclusively. Turns privacy off when // 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, // UI thread: send mapped mouse/keyboard input to the current control target,
// or every connected session while that target leads DeskHub group control. // or every connected session while that target leads DeskHub group control.
void SendRemoteInput(const RemoteAction& action); 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_; std::string gallery_group_control_id_;
void ResetRemoteServiceStatus(RemoteSession& props); void ResetRemoteServiceStatus(RemoteSession& props);
@@ -124,6 +159,7 @@ class GuiRuntime : protected gui_detail::GuiState {
bool unattended_host_ = false; bool unattended_host_ = false;
AnnouncementInbox announcements_; AnnouncementInbox announcements_;
PrivacySessionPolicy privacy_sessions_; PrivacySessionPolicy privacy_sessions_;
DisconnectLockPolicy disconnect_lock_;
std::mutex privacy_commands_mutex_; std::mutex privacy_commands_mutex_;
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_; std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
uint32_t last_privacy_revision_ = 0; uint32_t last_privacy_revision_ = 0;
@@ -133,6 +169,7 @@ class GuiRuntime : protected gui_detail::GuiState {
FileTransferManager transfers_; FileTransferManager transfers_;
SettingsManager settings_; SettingsManager settings_;
KeyboardController keyboard_; KeyboardController keyboard_;
RemoteInputDispatcher remote_input_;
std::shared_ptr<PeerEventHandler> peer_events_; std::shared_ptr<PeerEventHandler> peer_events_;
std::atomic<bool> video_frame_dirty_{false}; std::atomic<bool> video_frame_dirty_{false};
std::unique_ptr<VideoRenderer> video_renderer_; 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_ #define _NET_TRAFFIC_STATS_CACHE_H_
#include <atomic> #include <atomic>
#include <cstring>
#include <mutex>
#include <string>
#include "minirtc.h" #include "minirtc.h"
#include "runtime/connection_latency.h" #include "runtime/connection_latency.h"
@@ -15,7 +18,8 @@
namespace crossdesk::gui_detail { namespace crossdesk::gui_detail {
// Fields are independent observations and may come from different reports. // 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 { class NetTrafficStatsCache {
static_assert(std::atomic<uint32_t>::is_always_lock_free && static_assert(std::atomic<uint32_t>::is_always_lock_free &&
std::atomic<float>::is_always_lock_free && std::atomic<float>::is_always_lock_free &&
@@ -26,7 +30,31 @@ class NetTrafficStatsCache {
public: public:
using Clock = std::chrono::steady_clock; 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) { 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_inbound_stats_.Store(stats.video_inbound_stats);
video_outbound_stats_.Store(stats.video_outbound_stats); video_outbound_stats_.Store(stats.video_outbound_stats);
audio_inbound_stats_.Store(stats.audio_inbound_stats); audio_inbound_stats_.Store(stats.audio_inbound_stats);
@@ -46,6 +74,11 @@ class NetTrafficStatsCache {
MiniRtcNetTrafficStats Load() const { MiniRtcNetTrafficStats Load() const {
MiniRtcNetTrafficStats stats{}; 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_inbound_stats = video_inbound_stats_.Load();
stats.video_outbound_stats = video_outbound_stats_.Load(); stats.video_outbound_stats = video_outbound_stats_.Load();
stats.audio_inbound_stats = audio_inbound_stats_.Load(); stats.audio_inbound_stats = audio_inbound_stats_.Load();
@@ -80,6 +113,10 @@ class NetTrafficStatsCache {
void Reset() { void Reset() {
Store(MiniRtcNetTrafficStats{}); Store(MiniRtcNetTrafficStats{});
{
std::lock_guard lock(codec_mutex_);
codecs_ = {};
}
ResetLatency(); ResetLatency();
last_report_ms_.store(0, std::memory_order_relaxed); last_report_ms_.store(0, std::memory_order_relaxed);
} }
@@ -130,6 +167,9 @@ class NetTrafficStatsCache {
std::atomic<double> rtt_ms_{-1}; std::atomic<double> rtt_ms_{-1};
std::atomic<int64_t> last_report_ms_{0}; std::atomic<int64_t> last_report_ms_{0};
ConnectionLatencyStats connection_latency_; ConnectionLatencyStats connection_latency_;
mutable std::mutex codec_mutex_;
std::string local_encoder_;
VideoCodecNames codecs_;
}; };
} // namespace crossdesk::gui_detail } // namespace crossdesk::gui_detail
@@ -84,17 +84,52 @@ void LogSecureDesktopInputBlocked(uint32_t *last_tick, const char *side,
} // namespace } // 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( void PeerEventHandler::OnReceiveDataBuffer(
const char *data, size_t size, const char *user_id, size_t user_id_size, 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) { const char *src_id, size_t src_id_size, void *user_data) {
auto *handler = static_cast<PeerEventHandler *>(user_data); auto *handler = static_cast<PeerEventHandler *>(user_data);
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback || handler->QueueEarlyData(data, size, src_id, src_id_size) ||
GuiRuntime *runtime = handler ? &handler->owner_ : nullptr; !handler->DataAllowed())
if (!runtime) {
return; return;
} GuiRuntime* runtime = &handler->owner_;
std::string source_id = std::string(src_id, src_id_size); std::string source_id = std::string(src_id, src_id_size);
// The service-owned host runs as SYSTEM. Do not expose filesystem or // The service-owned host runs as SYSTEM. Do not expose filesystem or
@@ -105,11 +140,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
source_id == runtime->clipboard_label_)) source_id == runtime->clipboard_label_))
return; return;
if (source_id == runtime->file_label_) { 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; return;
} else if (source_id == runtime->clipboard_label_) { } else if (source_id == runtime->clipboard_label_) {
if (size > 0) { 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 = auto props =
runtime->FindRemoteSession(remote_user_id); runtime->FindRemoteSession(remote_user_id);
if (props && !props->clipboard_allowed_.load()) { if (props && !props->clipboard_allowed_.load()) {
@@ -121,11 +158,15 @@ void PeerEventHandler::OnReceiveDataBuffer(
} }
return; return;
} else if (source_id == runtime->file_feedback_label_) { } 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; 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); std::string json_str(data, size);
const auto preview_json = const auto preview_json =
json_str.find("\"deskhub_preview") != std::string::npos json_str.find("\"deskhub_preview") != std::string::npos
@@ -145,29 +186,31 @@ void PeerEventHandler::OnReceiveDataBuffer(
if (!previous || subscription->IsNewerThan(*previous)) { if (!previous || subscription->IsNewerThan(*previous)) {
if (subscription->fps > 0) if (subscription->fps > 0)
accepted = UpdateVideoSettings( accepted = UpdateVideoSettings(
runtime->peer_, remote_id.data(), remote_id.size(), bound_peer, wire_id.data(), wire_id.size(),
static_cast<VideoQuality>(subscription->quality), static_cast<VideoQuality>(subscription->quality),
subscription->frame_rate, subscription->frame_rate,
static_cast<VideoDegradationPreference>( static_cast<VideoDegradationPreference>(
subscription->EncoderPreference())) == 0; subscription->EncoderPreference())) == 0;
if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) != if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) !=
subscription->max_bitrate_bps.value_or(0))) subscription->max_bitrate_bps.value_or(0)))
accepted = UpdateVideoBitrateLimit( 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; subscription->max_bitrate_bps.value_or(0)) == 0;
// Rebalancing bandwidth alone must not force every device to send a // Rebalancing bandwidth alone must not force every device to send a
// keyframe. The pause API requests its own keyframes on resume. // keyframe. The pause API requests its own keyframes on resume.
if (accepted && (!previous || previous->width != subscription->width || if (accepted && (!previous || previous->width != subscription->width ||
previous->height != subscription->height)) previous->height != subscription->height))
accepted = UpdateVideoResolution( accepted = UpdateVideoResolution(bound_peer, wire_id.data(),
runtime->peer_, remote_id.data(), remote_id.size(), wire_id.size(), subscription->width,
subscription->width, subscription->height) == 0; subscription->height) == 0;
if (accepted) if (accepted)
accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(), accepted =
remote_id.size(), subscription->fps == 0) == 0; SetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(),
subscription->fps == 0) == 0;
if (accepted) if (accepted)
accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(), accepted =
remote_id.size(), !subscription->audio) == 0; SetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(),
!subscription->audio) == 0;
if (accepted && subscription->audio) if (accepted && subscription->audio)
runtime->devices_.StartSpeakerCapturer(); runtime->devices_.StartSpeakerCapturer();
} }
@@ -178,9 +221,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
{"request_id", subscription->request_id}, {"request_id", subscription->request_id},
{"accepted", {"accepted",
accepted}}.dump(); accepted}}.dump();
SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(), SendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(),
runtime->control_data_label_.c_str(), runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size()); wire_id.data(), wire_id.size());
return; return;
} }
if (preview_json.is_object() && if (preview_json.is_object() &&
@@ -209,6 +252,16 @@ void PeerEventHandler::OnReceiveDataBuffer(
return; 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 (remote_action.type == ControlType::video_settings_status) {
if (auto props = runtime->FindRemoteSession(remote_id)) { if (auto props = runtime->FindRemoteSession(remote_id)) {
if (source_id != props->control_data_label_) return; if (source_id != props->control_data_label_) return;
@@ -243,7 +296,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
const auto& settings = remote_action.vs; const auto& settings = remote_action.vs;
remote_action.vs.accepted = remote_action.vs.accepted =
UpdateVideoSettings( 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<VideoQuality>(settings.quality), settings.frame_rate,
static_cast<VideoDegradationPreference>(settings.preference)) == 0; static_cast<VideoDegradationPreference>(settings.preference)) == 0;
LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} " 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); settings.frame_rate, settings.preference, remote_action.vs.accepted);
remote_action.type = ControlType::video_settings_status; remote_action.type = ControlType::video_settings_status;
const auto message = remote_action.to_json(); 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(), runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size()); wire_id.data(), wire_id.size());
return; return;
} }
if (remote_action.type == ControlType::privacy_status) { if (remote_action.type == ControlType::privacy_status) {
@@ -280,29 +333,22 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->QueuePrivacyCommand(remote_id, remote_action.pc); runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
return; return;
} }
if (remote_action.type == ControlType::text_input) { if (RemoteInputDispatcher::Accepts(remote_action)) {
// Share the ordered keyboard stream with delete/return and shortcuts. if (!runtime->IsAuthorizedController(remote_id)) return;
if (source_id == runtime->keyboard_label_ && if (remote_action.type == ControlType::text_input &&
runtime->IsAuthorizedController(remote_id)) source_id != runtime->keyboard_label_) return;
runtime->keyboard_.ApplyRemoteText(remote_id, remote_action.text); 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; return;
} }
const bool operates_host = remote_action.type == ControlType::mouse || if (remote_action.type == ControlType::audio_capture) {
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 (!runtime->IsAuthorizedController(remote_id)) return; if (!runtime->IsAuthorizedController(remote_id)) return;
#ifdef _WIN32 #ifdef _WIN32
// KeyboardController checks the desktop immediately before each native if (runtime->privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable())
// 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()) {
runtime->privacy_.SuspendForDesktop(); runtime->privacy_.SuspendForDesktop();
}
#endif #endif
} }
if (remote_action.type == ControlType::service_status) { if (remote_action.type == ControlType::service_status) {
@@ -312,28 +358,6 @@ void PeerEventHandler::OnReceiveDataBuffer(
return; 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) { if (remote_action.type == ControlType::cursor_state) {
std::shared_ptr<GuiRuntime::RemoteSession> props; std::shared_ptr<GuiRuntime::RemoteSession> props;
{ {
@@ -426,8 +450,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
props->video_settings_request_tick_ = SDL_GetTicks(); props->video_settings_request_tick_ = SDL_GetTicks();
settings.vs = props->video_settings_; settings.vs = props->video_settings_;
const auto message = settings.to_json(); const auto message = settings.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(), SendReliableDataFrame(
props->control_data_label_.c_str()); handler->IsDirect() ? handler->DirectPeer() : props->peer_,
message.data(), message.size(),
props->control_data_label_.c_str());
} }
} }
// client mode // client mode
@@ -455,6 +481,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
{ {
std::unique_lock lock(runtime->connection_status_mutex_); std::unique_lock lock(runtime->connection_status_mutex_);
runtime->connection_host_names_[remote_id] = host_name; 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); LOG_INFO("Remote hostname: [{}]", host_name);
if (source_id == runtime->control_data_label_) { if (source_id == runtime->control_data_label_) {
@@ -464,100 +496,151 @@ void PeerEventHandler::OnReceiveDataBuffer(
FreeRemoteAction(remote_action); FreeRemoteAction(remote_action);
} }
} else { } else {
// remote if (remote_action.type == ControlType::audio_capture) {
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) {
// A negotiated subscription remains authoritative if a legacy capture // A negotiated subscription remains authoritative if a legacy capture
// command arrives after it (e.g. when unmuting an inactive window). // command arrives after it (e.g. when unmuting an inactive window).
const auto subscription = runtime->preview_subscriptions_.Get(remote_id); const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
const bool paused = subscription ? !subscription->audio : !remote_action.a; const bool paused = subscription ? !subscription->audio : !remote_action.a;
const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(), const int ret = SetAudioSendPaused(bound_peer, wire_id.data(),
remote_id.size(), paused); wire_id.size(), paused);
if (remote_action.a) if (remote_action.a)
runtime->devices_.StartSpeakerCapturer(); runtime->devices_.StartSpeakerCapturer();
else if (ret != 0) else if (ret != 0)
// Legacy/web transports without the per-peer API retain their fallback. // Legacy/web transports without the per-peer API retain their fallback.
runtime->devices_.StopSpeakerCapturer(); 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 <algorithm>
#include <chrono> #include <chrono>
#include <cstdio>
#include <cstring> #include <cstring>
#include <filesystem> #include <filesystem>
#include <memory> #include <memory>
@@ -48,6 +49,22 @@ constexpr HostPlatform kClientPlatform = HostPlatform::Unknown;
} // namespace } // 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, void PeerEventHandler::SendClientInfo(PeerPtr* peer,
const std::string& client_id) { const std::string& client_id) {
if (!peer) { if (!peer) {
@@ -73,8 +90,8 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime || !message || size == 0) { if (!message || size == 0) {
return; return;
} }
std::string s(message, size); std::string s(message, size);
@@ -178,10 +195,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime) {
return;
}
std::string client_id(user_id, user_id_size); std::string client_id(user_id, user_id_size);
if (client_id == runtime->client_id_) { if (client_id == runtime->client_id_) {
@@ -250,17 +264,30 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
} }
void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
const char* user_id, const char* user_id, size_t size,
const size_t user_id_size, void* context) {
void* user_data) { 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); auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime) return;
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); if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id);
else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed || else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed ||
status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id); status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id);
@@ -295,6 +322,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_, /*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform); CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true; 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_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true; remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = 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_); std::unique_lock lock(runtime->connection_status_mutex_);
if (status == ConnectionStatus::Connected) { if (status == ConnectionStatus::Connected) {
runtime->disconnect_lock_.Connected(remote_id);
// Local privacy requires explicit desktop capability. Host info may // Local privacy requires explicit desktop capability. Host info may
// arrive before Connected; consume that saved capability here. // arrive before Connected; consume that saved capability here.
const bool automatic_enable = runtime->privacy_sessions_.Connected( const bool automatic_enable = runtime->privacy_sessions_.Connected(
@@ -437,6 +466,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->ApplyPrivacyAdmission(automatic_enable); runtime->ApplyPrivacyAdmission(automatic_enable);
} }
runtime->connection_status_[remote_id] = status; runtime->connection_status_[remote_id] = status;
if (status == ConnectionStatus::Connected)
runtime->remote_input_.Connect(remote_id);
#if _WIN32 #if _WIN32
runtime->windows_rdp_handoff_connected_->store(std::any_of( runtime->windows_rdp_handoff_connected_->store(std::any_of(
runtime->connection_status_.begin(), runtime->connection_status_.end(), runtime->connection_status_.begin(), runtime->connection_status_.end(),
@@ -458,6 +489,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_, /*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform); CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true; 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_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true; remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true; remote_action.i.supports_deskhub_resolution = true;
@@ -469,8 +501,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
} }
std::string msg = remote_action.to_json(); std::string msg = remote_action.to_json();
const int ret = const int ret =
SendReliableDataFrame(runtime->peer_, msg.data(), msg.size(), runtime->SendControllerData(remote_id, msg.data(), msg.size(),
runtime->control_data_label_.c_str()); runtime->control_data_label_.c_str());
if (ret != 0) { if (ret != 0) {
LOG_WARN("[{}] host information send failed, ret={}", remote_id, LOG_WARN("[{}] host information send failed, ret={}", remote_id,
ret); ret);
@@ -511,21 +543,12 @@ void PeerEventHandler::OnNetStatusReport(
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime) {
return;
}
if (strchr(client_id, '@') != nullptr && strchr(user_id, '-') == nullptr) { if (const char* at_pos = handler->IsDirect() ? nullptr : strchr(client_id, '@');
std::string id, password; at_pos && strchr(user_id, '-') == nullptr) {
const char* at_pos = strchr(client_id, '@'); const std::string id(client_id, at_pos - client_id);
if (at_pos == nullptr) { const std::string password(at_pos + 1);
id = client_id;
password.clear();
} else {
id.assign(client_id, at_pos - client_id);
password = at_pos + 1;
}
bool is_self_hosted = runtime->config_center_->IsSelfHosted(); bool is_self_hosted = runtime->config_center_->IsSelfHosted();
@@ -573,9 +596,30 @@ void PeerEventHandler::OnNetStatusReport(
#endif #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); 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) { if (props->traversal_mode_ != mode) {
props->net_traffic_stats_.ResetLatency(); props->net_traffic_stats_.ResetLatency();
props->traversal_mode_ = mode; props->traversal_mode_ = mode;
@@ -8,7 +8,10 @@
#define _PEER_EVENT_HANDLER_H_ #define _PEER_EVENT_HANDLER_H_
#include <cstddef> #include <cstddef>
#include <memory>
#include <mutex>
#include <string> #include <string>
#include <vector>
#include "minirtc.h" #include "minirtc.h"
#include "runtime/session_lifecycle.h" #include "runtime/session_lifecycle.h"
@@ -16,12 +19,30 @@
namespace crossdesk { namespace crossdesk {
class GuiRuntime; class GuiRuntime;
namespace lan {
struct Session;
}
// Adapts MiniRTC C callbacks to the GUI runtime. // Adapts MiniRTC C callbacks to the GUI runtime.
class PeerEventHandler { class PeerEventHandler {
public: public:
explicit PeerEventHandler(GuiRuntime &owner); 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(); } bool IsActive() const { return callbacks_.IsActive(); }
auto EnterCallback() { return callbacks_.Enter(); } auto EnterCallback() { return callbacks_.Enter(); }
void Deactivate() { callbacks_.Close(); } void Deactivate() { callbacks_.Close(); }
@@ -51,12 +72,22 @@ public:
void *user_data); void *user_data);
private: 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_; gui_detail::SessionCallbackGate callbacks_;
}; };
} // namespace crossdesk } // namespace crossdesk
#endif #endif
@@ -38,20 +38,21 @@ void PeerEventHandler::OnReceiveVideoBuffer(
auto* handler = static_cast<PeerEventHandler*>(user_data); auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback || !handler->DataAllowed()) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime || !video_frame) { if (!video_frame) {
return; 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); auto props = runtime->FindRemoteSession(remote_id);
if (!props) return; if (!props) return;
if (props->connection_established_) { if (props->connection_established_) {
const auto timing_now = VideoLatencyFrame::Clock::now(); const auto timing_now = VideoLatencyFrame::Clock::now();
// Pair the clocks before preview conversion so its cost is included. // 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); const auto* received_native = GetNativeVideoFrame(video_frame);
props->received_video_stats_.Record( props->received_video_stats_.Record(
received_native ? received_native->width : video_frame->width, received_native ? received_native->width : video_frame->width,
@@ -274,15 +275,15 @@ void PeerEventHandler::OnReceiveAudioBuffer(
auto* handler = static_cast<PeerEventHandler*>(user_data); auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return; if (!handler) return;
auto callback = handler->EnterCallback(); auto callback = handler->EnterCallback();
if (!callback) return; if (!callback || !handler->DataAllowed()) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; GuiRuntime* runtime = &handler->owner_;
if (!runtime || runtime->unattended_host_) { if (runtime->unattended_host_) {
return; return;
} }
std::lock_guard delivery_lock(runtime->audio_delivery_mutex_); std::lock_guard delivery_lock(runtime->audio_delivery_mutex_);
const auto session = 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; if (!session || session->closing_ || !session->audio_allowed_.load()) return;
runtime->audio_buffer_fresh_ = true; runtime->audio_buffer_fresh_ = true;
@@ -15,6 +15,10 @@
namespace crossdesk { namespace crossdesk {
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) { 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 // Connections appear here only after the existing password/signalling/RTC
// admission path. Outgoing/viewer sessions live in remote_sessions_ instead. // admission path. Outgoing/viewer sessions live in remote_sessions_ instead.
std::shared_lock lock(connection_status_mutex_); std::shared_lock lock(connection_status_mutex_);
@@ -109,9 +113,9 @@ void GuiRuntime::HandlePrivacy() {
"A connected controller does not support privacy screen"); "A connected controller does not support privacy screen");
} }
} }
if (!peer_ || (last_privacy_status_tick_ != 0 && if (last_privacy_status_tick_ != 0 &&
status.revision == last_privacy_revision_ && status.revision == last_privacy_revision_ &&
now - last_privacy_status_tick_ < 1000)) now - last_privacy_status_tick_ < 1000)
return; return;
RemoteAction action{}; RemoteAction action{};
action.type = ControlType::privacy_status; action.type = ControlType::privacy_status;
@@ -119,9 +123,8 @@ void GuiRuntime::HandlePrivacy() {
const auto message = action.to_json(); const auto message = action.to_json();
bool sent = true; bool sent = true;
for (const auto& id : controllers) { for (const auto& id : controllers) {
const int result = SendReliableDataFrameToPeer( const int result = SendControllerData(id, message.data(), message.size(),
peer_, message.data(), message.size(), control_data_label_.c_str(), control_data_label_.c_str());
id.data(), id.size());
if (result != 0) { if (result != 0) {
sent = false; sent = false;
LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result); LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result);
@@ -31,7 +31,12 @@
#include "runtime/received_video_stats.h" #include "runtime/received_video_stats.h"
#include "runtime/connection_auth_state.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 { namespace crossdesk::gui_detail {
@@ -105,6 +110,7 @@ struct RemoteSession {
// Decoder-thread scratch memory, never shared with the UI. // Decoder-thread scratch memory, never shared with the UI.
std::mutex gallery_conversion_mutex_; std::mutex gallery_conversion_mutex_;
std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_; std::vector<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
std::shared_ptr<lan::Session> lan_session_;
Params params_; Params params_;
PeerPtr* peer_ = nullptr; PeerPtr* peer_ = nullptr;
std::shared_ptr<PeerEventHandler> peer_events_; std::shared_ptr<PeerEventHandler> peer_events_;
+2 -6
View File
@@ -18,6 +18,7 @@
#include <string> #include <string>
#include <thread> #include <thread>
#include <unordered_map> #include <unordered_map>
#include <unordered_set>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -58,8 +59,6 @@ struct RecentConnectionsState {
recent_connections_; recent_connections_;
std::vector<std::string> recent_connection_ids_; std::vector<std::string> recent_connection_ids_;
std::vector<std::string> gallery_presence_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; uint32_t recent_connection_image_save_time_ = 0;
DevicePresenceCache device_presence_cache_; DevicePresenceCache device_presence_cache_;
std::atomic<bool> need_to_send_recent_connections_{true}; std::atomic<bool> need_to_send_recent_connections_{true};
@@ -149,15 +148,11 @@ struct UserSettingsState {
char connection_login_identity_[17] = ""; char connection_login_identity_[17] = "";
int language_button_value_ = 0; int language_button_value_ = 0;
int video_encode_format_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_ip_[256] = "api.crossdesk.cn";
char signal_server_port_[6] = "9099"; char signal_server_port_[6] = "9099";
bool enable_self_hosted_ = false; bool enable_self_hosted_ = false;
int language_button_value_last_ = 0; int language_button_value_last_ = 0;
int video_encode_format_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_self_hosted_last_ = false;
bool enable_autostart_ = false; bool enable_autostart_ = false;
bool enable_autostart_last_ = false; bool enable_autostart_last_ = false;
@@ -208,6 +203,7 @@ struct ConnectionState {
std::shared_mutex connection_status_mutex_; std::shared_mutex connection_status_mutex_;
std::unordered_map<std::string, ConnectionStatus> connection_status_; std::unordered_map<std::string, ConnectionStatus> connection_status_;
std::unordered_map<std::string, std::string> connection_host_names_; 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 // Cursor position is sampled asynchronously after remote mouse input has
// been injected. Remember the input source so the sampled position is not // been injected. Remember the input source so the sampled position is not
// immediately echoed back to that same controller as stale feedback. // 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() { void SessionCallbackGate::Close() {
std::unique_lock lock(mutex_); std::unique_lock lock(mutex_);
accepting_callbacks_ = false; accepting_callbacks_ = false;
@@ -39,6 +39,7 @@ class SessionCallbackGate {
CallbackGuard Enter(); CallbackGuard Enter();
// Called by the session owner, never from within this session's callbacks. // Called by the session owner, never from within this session's callbacks.
void StopAccepting();
void Close(); void Close();
private: 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"; 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 { export global FloatingBarStyle {
out property <length> corner-radius: UiRadius.menu; 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 // ImGui loads a 32px font atlas and applies per-window font scales. These
// logical sizes reproduce those visual tiers on Slint's HiDPI renderer. // logical sizes reproduce those visual tiers on Slint's HiDPI renderer.
export global ImGuiFontStyle { export global ImGuiFontStyle {
@@ -32,9 +57,11 @@ export global FontAwesomeIcons {
out property <string> angle-right: "\u{f105}"; out property <string> angle-right: "\u{f105}";
out property <string> angle-up: "\u{f106}"; out property <string> angle-up: "\u{f106}";
out property <string> arrow-right-long: "\u{f178}"; 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> bars: "\u{f0c9}";
out property <string> check: "\u{f00c}"; out property <string> check: "\u{f00c}";
out property <string> circle-arrow-up: "\u{f0aa}"; out property <string> circle-arrow-up: "\u{f0aa}";
out property <string> code: "\u{f121}";
out property <string> compress: "\u{f066}"; out property <string> compress: "\u{f066}";
out property <string> computer-mouse: "\u{f8cc}"; out property <string> computer-mouse: "\u{f8cc}";
out property <string> copy: "\u{f0c5}"; out property <string> copy: "\u{f0c5}";
@@ -42,8 +69,10 @@ export global FontAwesomeIcons {
out property <string> expand: "\u{f065}"; out property <string> expand: "\u{f065}";
out property <string> eye: "\u{f06e}"; out property <string> eye: "\u{f06e}";
out property <string> eye-slash: "\u{f070}"; out property <string> eye-slash: "\u{f070}";
out property <string> file-lines: "\u{f15c}";
out property <string> folder-open: "\u{f07c}"; out property <string> folder-open: "\u{f07c}";
out property <string> keyboard: "\u{f11c}"; out property <string> keyboard: "\u{f11c}";
out property <string> layer-group: "\u{f5fd}";
out property <string> minus: "\u{f068}"; out property <string> minus: "\u{f068}";
out property <string> pen: "\u{f304}"; out property <string> pen: "\u{f304}";
out property <string> rotate: "\u{f2f1}"; out property <string> rotate: "\u{f2f1}";
@@ -65,10 +94,10 @@ export component IconButton inherits Rectangle {
in property <bool> enabled: true; in property <bool> enabled: true;
in property <bool> danger: false; in property <bool> danger: false;
in property <length> icon-size: 16px; 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> normal-background: transparent;
in property <color> hover-background: #edf0f5; in property <color> hover-background: ControlStyle.hover;
in property <color> pressed-background: #d9dde5; in property <color> pressed-background: ControlStyle.pressed;
in property <bool> slashed: false; in property <bool> slashed: false;
callback clicked; callback clicked;
@@ -134,8 +163,8 @@ export component ImageButton inherits Rectangle {
width: 30px; width: 30px;
height: 30px; height: 30px;
background: touch.pressed ? (danger ? #d92d20 : #d9dde5) background: touch.pressed ? (danger ? #d92d20 : ControlStyle.pressed)
: touch.has-hover ? (danger ? #f04438 : #edf0f5) : touch.has-hover ? (danger ? #f04438 : ControlStyle.hover)
: transparent; : transparent;
border-radius: UiRadius.control; 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 property <[string]> model;
in-out property <int> current-index: 0; in-out property <int> current-index: 0;
in property <bool> enabled: true; in property <bool> enabled: true;
in property <bool> subtle: false; in property <bool> subtle: false;
in property <bool> show-arrow: true; in property <bool> show-arrow: true;
in property <color> normal-border-color: root.subtle ? #dfe6ef : #bfc2c7; in property <color> normal-outline-color: ButtonStyle.border;
in property <color> hover-border-color: root.normal-border-color; in property <color> hover-outline-color: ButtonStyle.hover-border;
property <bool> popup-animation-enabled: false; property <bool> popup-animation-enabled: false;
property <float> popup-progress: 0.0; property <float> popup-progress: 0.0;
callback selected(int); callback selected(int);
@@ -201,43 +230,45 @@ export component CompactComboBox inherits Rectangle {
width: 73px; width: 73px;
height: 24px; height: 24px;
background: root.enabled ? (root.subtle ? #f8fafc : white) : root.subtle ? #f1f3f6 : #f1f1f1; background: !root.enabled ? ControlStyle.disabled
border-width: 1px; : combo-touch.pressed ? ButtonStyle.pressed
border-color: root.enabled && combo-touch.has-hover ? root.hover-border-color : root.normal-border-color; : 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; 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 { Text {
x: root.subtle ? 10px : 5px; x: root.subtle ? 10px : 8px;
width: parent.width - (root.show-arrow ? (root.subtle ? 36px : 24px) : (root.subtle ? 20px : 10px)); width: parent.width - self.x - (root.show-arrow ? 28px : self.x);
height: parent.height;
text: root.model[root.current-index]; text: root.model[root.current-index];
color: root.enabled ? #202124 : #8b8f95; color: root.enabled ? #202124 : #8b8f95;
font-size: root.subtle ? 12px : ImGuiFontStyle.body; font-size: root.subtle ? 12px : ImGuiFontStyle.body;
overflow: elide; overflow: elide;
vertical-alignment: center; vertical-alignment: center;
accessible-role: none;
} }
if root.show-arrow: Rectangle { if root.show-arrow: Text {
x: parent.width - (root.subtle ? 27px : 21px); x: parent.width - 26px;
width: 20px; width: 18px; height: parent.height;
height: parent.height - 2px; // Switch glyphs so the software renderer also shows the open state.
y: 1px; text: combo-popup.is-open ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down;
background: root.subtle ? transparent : root.enabled ? #9bc9f8 : #d7d9dc; color: !root.enabled ? #98a2b3 : combo-popup.is-open ? UiSurface.focus : ControlStyle.icon;
border-top-right-radius: UiRadius.control - 1px; font-family: "Font Awesome 6 Free";
border-bottom-right-radius: UiRadius.control - 1px; font-weight: 900;
Text { font-size: 10px;
text: FontAwesomeIcons.angle-down; horizontal-alignment: center;
color: root.subtle ? #79879a : #202124; vertical-alignment: center;
font-family: "Font Awesome 6 Free"; accessible-role: none;
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;
}
}
} }
combo-touch := TouchArea { combo-touch := TouchArea {
enabled: root.enabled; mouse-cursor: pointer; enabled: root.enabled; mouse-cursor: pointer;
@@ -265,12 +296,12 @@ export component CompactComboBox inherits Rectangle {
combo-popup := PopupWindow { combo-popup := PopupWindow {
x: 0px; x: 0px;
y: root.height; y: root.height + 4px;
width: root.width; width: root.width;
height: root.model.length * 23px; height: root.model.length * 23px + 8px;
close-policy: close-on-click-outside; close-policy: close-on-click-outside;
Rectangle { FlatSurface {
// Keep the reveal anchored to the popup's top edge while the // Keep the reveal anchored to the popup's top edge while the
// content settles downward into its final position. // content settles downward into its final position.
y: (1.0 - root.popup-progress) * -8px; y: (1.0 - root.popup-progress) * -8px;
@@ -278,18 +309,33 @@ export component CompactComboBox inherits Rectangle {
height: parent.height * root.popup-progress; height: parent.height * root.popup-progress;
opacity: root.popup-progress; opacity: root.popup-progress;
background: white; background: white;
border-width: 1px; outline-color: root.normal-outline-color;
border-color: root.normal-border-color;
border-radius: UiRadius.menu; border-radius: UiRadius.menu;
clip: true; clip: true;
for choice[index] in root.model: Rectangle { for choice[index] in root.model: Rectangle {
y: index * 23px; x: 4px; y: 4px + index * 23px;
width: parent.width; width: parent.width - 8px;
height: 23px; height: 23px;
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white; border-radius: UiRadius.control;
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } 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 { option-touch := TouchArea {
enabled: root.enabled; enabled: root.enabled;
mouse-cursor: pointer;
clicked => { clicked => {
popup-open-timer.running = false; popup-open-timer.running = false;
root.popup-animation-enabled = false; root.popup-animation-enabled = false;
@@ -302,12 +348,12 @@ export component CompactComboBox inherits Rectangle {
} }
} }
} }
Rectangle { FlatSurface {
width: parent.width; width: parent.width;
height: parent.height; height: parent.height;
background: transparent; background: transparent;
border-width: 1px; clip: true;
border-color: root.normal-border-color; outline-color: root.normal-outline-color;
border-radius: UiRadius.menu; border-radius: UiRadius.menu;
z: 2; 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-out property <bool> checked;
in property <bool> enabled: true; in property <bool> enabled: true;
width: 21px; width: 21px;
height: 21px; height: 21px;
background: root.enabled ? white : #f1f1f1; background: !root.enabled ? ControlStyle.disabled
border-width: 1px; : root.checked ? (check-touch.pressed ? #254cc0 : check-touch.has-hover ? #4574f0 : #3564e3)
border-color: check-touch.has-hover && root.enabled ? #8cbef2 : #b9bdc2; : 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; border-radius: 4px;
if root.checked: Text { if root.checked: Text {
text: FontAwesomeIcons.check; text: FontAwesomeIcons.check;
color: root.enabled ? #2e86de : #8b8f95; color: root.enabled ? white : #8b8f95;
font-family: "Font Awesome 6 Free"; font-family: "Font Awesome 6 Free";
font-weight: 900; font-weight: 900;
font-size: 16px; 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 <string> text;
in property <length> text-size: ImGuiFontStyle.body; in property <length> text-size: ImGuiFontStyle.body;
in property <bool> primary: false; in property <bool> primary: false;
@@ -374,13 +421,17 @@ export component CompactButton inherits Rectangle {
width: 34px; width: 34px;
height: 24px; height: 24px;
background: !root.enabled ? (root.quiet ? transparent : #eceef0) background: !root.enabled ? (root.quiet ? transparent : ControlStyle.disabled)
: button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd) : button-touch.pressed ? (root.primary ? #82b8f2 : ButtonStyle.pressed)
: button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2) : button-touch.has-hover ? (root.primary ? #b8d9fb : ButtonStyle.hover)
: root.primary ? #a8d0fa : root.quiet ? transparent : #e2e4e7; : root.primary ? #a8d0fa : root.quiet ? transparent : ButtonStyle.surface;
border-width: 1px; outline-enabled: !root.quiet;
border-color: root.primary ? #9bc3ed : root.quiet ? transparent : #c8cbd0; 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; border-radius: UiRadius.control;
animate background, outline-color { duration: 100ms; }
Text { Text {
text: root.text; text: root.text;
color: root.enabled ? #24272d : #8e9298; color: root.enabled ? #24272d : #8e9298;
@@ -389,27 +440,30 @@ export component CompactButton inherits Rectangle {
horizontal-alignment: center; horizontal-alignment: center;
vertical-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 <string> text;
in property <length> text-size: ImGuiFontStyle.body; in property <length> text-size: 12px;
in property <bool> enabled: true; in property <bool> enabled: true;
callback clicked; callback clicked;
min-width: 72px; min-width: 72px;
height: 30px; height: 32px;
background: !root.enabled ? #b8c1d1 background: !root.enabled ? #b8c1d1
: touch.pressed ? #1849a9 : touch.pressed ? #254cc0
: touch.has-hover ? #2970d6 : touch.has-hover ? #4574f0
: #2463c7; : #3564e3;
outline-enabled: false;
border-radius: UiRadius.control; border-radius: UiRadius.control;
animate background { duration: 100ms; }
Text { Text {
text: root.text; text: root.text;
color: white; color: white;
font-size: root.text-size; font-size: root.text-size;
font-weight: 600;
horizontal-alignment: center; horizontal-alignment: center;
vertical-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 <string> text;
in property <length> text-size: ImGuiFontStyle.body; in property <length> text-size: 12px;
in property <bool> enabled: true; in property <bool> enabled: true;
callback clicked; callback clicked;
min-width: 72px; min-width: 72px;
height: 30px; height: 32px;
background: !root.enabled ? #f1f2f4 background: !root.enabled ? ControlStyle.disabled
: touch.pressed ? #dfe3e9 : touch.pressed ? ButtonStyle.pressed
: touch.has-hover ? #f3f4f6 : white; : touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface;
border-width: 1px; outline-color: !root.enabled ? ButtonStyle.disabled-border
border-color: #c9ced7; : touch.pressed ? ButtonStyle.pressed-border
: touch.has-hover ? ButtonStyle.hover-border : ButtonStyle.border;
border-radius: UiRadius.control; border-radius: UiRadius.control;
clip: true; animate background, outline-color { duration: 100ms; }
Text { Text {
text: root.text; text: root.text;
color: root.enabled ? #24272d : #949aa5; color: root.enabled ? #303f55 : #949aa5;
font-size: root.text-size; font-size: root.text-size;
font-weight: 500;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
} }
touch := TouchArea { touch := TouchArea {
enabled: root.enabled; enabled: root.enabled;
mouse-cursor: pointer;
clicked => { root.clicked(); } clicked => { root.clicked(); }
} }
} }
@@ -491,9 +548,8 @@ export component SectionTitle inherits Text {
vertical-alignment: center; vertical-alignment: center;
} }
export component DialogSurface inherits Rectangle { export component DialogSurface inherits FlatSurface {
background: white; background: white;
border-width: 1px; outline-color: UiSurface.edge;
border-color: ImGuiLineStyle.border;
border-radius: UiRadius.surface; border-radius: UiRadius.surface;
} }
@@ -1,6 +1,7 @@
// Single AOT entry point. Generating one header for the entire UI avoids // Single AOT entry point. Generating one header for the entire UI avoids
// duplicate imported-component definitions when C++ uses all three windows. // duplicate imported-component definitions when C++ uses all three windows.
export { AnnouncementItem, MessageStrings } from "message_panel.slint"; 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 { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint";
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint"; export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_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 { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.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"; import { GalleryStrings } from "device_strings.slint";
export struct DeviceGroupOption { export struct DeviceGroupOption {
@@ -20,13 +21,13 @@ export component DeviceGroupDialog inherits Rectangle {
callback cancel(); callback cancel();
background: #00000040; background: #00000040;
TouchArea {} TouchArea {}
dialog := Rectangle { dialog := FlatSurface {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
width: min(360px, parent.width - 40px); width: min(360px, parent.width - 40px);
height: 208px + group-list.height + (root.error == "" ? 0px : 32px); height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
background: white; border-radius: UiRadius.surface; background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #b2b2b2; outline-color: UiSurface.edge;
TouchArea {} TouchArea {}
Text { Text {
x: 18px; y: 16px; height: 24px; 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; x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
text: "ID " + root.device-id; color: #858c97; font-size: 11px; text: "ID " + root.device-id; color: #858c97; font-size: 11px;
} }
group-list := Rectangle { group-list := FlatSurface {
x: 18px; y: 95px; width: parent.width - 36px; x: 18px; y: 95px; width: parent.width - 36px;
height: min(5, max(1, root.options.length)) * 32px + 2px; 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; clip: true;
ScrollView { ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
@@ -52,16 +53,21 @@ export component DeviceGroupDialog inherits Rectangle {
VerticalLayout { VerticalLayout {
spacing: 0px; spacing: 0px;
for option[index] in root.options: Rectangle { for option[index] in root.options: Rectangle {
private property <bool> selected: option.selected && (option.name != "" || root.new-group == "");
height: 32px; 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 { ImGuiCheckBox {
x: 9px; y: 6px; width: 20px; height: 20px; x: 9px; y: 6px; width: 20px; height: 20px;
checked: option.selected && (option.name != "" || root.new-group == ""); checked: parent.selected;
} }
Text { Text {
x: 39px; width: parent.width - 48px; height: parent.height; x: 39px; width: parent.width - 48px; height: parent.height;
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name; 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); } } 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-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white; border-color: white;
} }
Rectangle { FlatSurface {
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface; border-radius: UiRadius.surface;
border-color: #e1e5eb; outline-color: UiSurface.edge;
} }
} }
RoundedLineEdit { RoundedLineEdit {
@@ -3,4 +3,16 @@ export global GalleryStrings {
public pure function tr(zh: string, en: string, ru: string) -> string { public pure function tr(zh: string, en: string, ru: string) -> string {
return self.language == 0 ? zh : self.language == 2 ? ru : en; 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 { FileTransferEntry } from "stream_window.slint";
import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint"; import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint";
import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint"; import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint";
@@ -26,7 +27,7 @@ export component FileTransferNotification inherits Window {
DialogSurface { DialogSurface {
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
background: WorkbenchStyle.surface; background: WorkbenchStyle.surface;
border-color: WorkbenchStyle.separator; outline-color: UiSurface.edge;
Text { Text {
x: 16px; y: 8px; x: 16px; y: 8px;
width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px); width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px);
@@ -50,9 +51,9 @@ export component FileTransferNotification inherits Window {
pressed-background: WorkbenchStyle.pressed-surface; pressed-background: WorkbenchStyle.pressed-surface;
clicked => { root.dismissed(); } clicked => { root.dismissed(); }
} }
Rectangle { SurfaceSeparator {
x: 16px; y: 40px; width: parent.width - 32px; height: 1px; x: 16px; y: 40px; width: parent.width - 32px; height: 1px;
background: WorkbenchStyle.separator; outline-color: UiSurface.separator;
} }
Text { Text {
x: 16px; y: 47px; width: parent.width - 32px; height: 18px; x: 16px; y: 47px; width: parent.width - 32px; height: 18px;
@@ -96,7 +97,7 @@ export component FileTransferNotification inherits Window {
text: root.cancel-text; text: root.cancel-text;
text-size: ImGuiFontStyle.base; text-size: ImGuiFontStyle.base;
text-color: WorkbenchStyle.muted; text-color: WorkbenchStyle.muted;
normal-border-color: WorkbenchStyle.border; normal-outline-color: UiSurface.edge;
clicked => { root.cancel-requested(); } clicked => { root.cancel-requested(); }
} }
} }
@@ -1,5 +1,6 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.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 } export struct GalleryMenuItem { label: string, enabled: bool }
@@ -88,12 +89,11 @@ export component GalleryMenuButton inherits Rectangle {
color: root.enabled ? #62666d : #8e9298; color: root.enabled ? #62666d : #8e9298;
horizontal-alignment: center; vertical-alignment: center; horizontal-alignment: center; vertical-alignment: center;
} }
if root.compact-trigger: Rectangle { if root.compact-trigger: FlatSurface {
background: more-touch.pressed || popup.is-open ? #e5edfc : more-touch.has-hover ? white : #fffffff0; background: more-touch.pressed || popup.is-open ? ControlStyle.selected : more-touch.has-hover ? ControlStyle.hover : white;
border-radius: UiRadius.menu; border-radius: UiRadius.menu;
border-width: 1px; outline-color: trigger-focus.has-focus ? UiSurface.focus : UiSurface.edge;
border-color: trigger-focus.has-focus ? #3564e3 : #ffffff80; animate background, outline-color { duration: 120ms; }
animate background, border-color { duration: 120ms; }
for dot in 3: Rectangle { for dot in 3: Rectangle {
x: parent.width / 2 - 6px + dot * 5px; x: parent.width / 2 - 6px + dot * 5px;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
@@ -117,11 +117,12 @@ export component GalleryMenuButton inherits Rectangle {
forward-focus: popup-focus; forward-focus: popup-focus;
popup-focus := FocusScope { popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); } key-pressed(event) => { return root.handle-key(event); }
Rectangle { FlatSurface {
background: white; border-width: 1px; border-color: #c9cdd2; border-radius: UiRadius.menu; background: white; outline-color: UiSurface.edge; border-radius: UiRadius.menu;
if root.destructive-index > 0: Rectangle { 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; 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 { for item[index] in root.items: Rectangle {
private property <bool> destructive: index == root.destructive-index; 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); + (root.destructive-index > 0 && index >= root.destructive-index ? root.divider-spacing : 0px);
width: parent.width - root.menu-padding * 2; height: root.row-height; width: parent.width - root.menu-padding * 2; height: root.row-height;
border-radius: UiRadius.control; 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-role: button; accessible-label: item.label; accessible-enabled: item.enabled;
accessible-action-default => { root.choose(index); } accessible-action-default => { root.choose(index); }
if self.has-icon: Text { if self.has-icon: Text {
@@ -148,7 +151,7 @@ export component GalleryMenuButton inherits Rectangle {
text: item.label; color: parent.foreground; text: item.label; color: parent.foreground;
font-size: root.compact-menu ? 11px : 12px; vertical-alignment: center; font-size: root.compact-menu ? 11px : 12px; vertical-alignment: center;
} }
TouchArea { item-touch := TouchArea {
enabled: item.enabled; enabled: item.enabled;
mouse-cursor: pointer; mouse-cursor: pointer;
changed has-hover => { if self.has-hover { root.current-index = index; } } 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 { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint"; import { GalleryStrings } from "device_strings.slint";
import { CompactButton } from "common.slint"; import { CompactButton } from "common.slint";
@@ -15,6 +16,8 @@ export struct GalleryNetworkDevice {
loss: string, loss: string,
resolution: string, resolution: string,
fps: string, fps: string,
encoder: string,
decoder: string,
} }
component NetworkRow inherits Rectangle { component NetworkRow inherits Rectangle {
@@ -64,7 +67,7 @@ component NetworkRow inherits Rectangle {
text: !root.device.encryption-known text: !root.device.encryption-known
? GalleryStrings.tr("待确认", "Unknown", "Неизвестно") ? GalleryStrings.tr("待确认", "Unknown", "Неизвестно")
: root.device.encryption-enabled : root.device.encryption-enabled
? GalleryStrings.tr("已加密", "Encrypted", "Защищено") ? GalleryStrings.tr("已加密", "Encrypted", "Зашифровано")
: GalleryStrings.tr("未加密", "Unencrypted", "Без шифр."); : GalleryStrings.tr("未加密", "Unencrypted", "Без шифр.");
color: !root.device.encryption-known ? #7a8490 color: !root.device.encryption-known ? #7a8490
: root.device.encryption-enabled ? #18794e : #8a6428; : root.device.encryption-enabled ? #18794e : #8a6428;
@@ -145,6 +148,26 @@ component NetworkRow inherits Rectangle {
vertical-alignment: center; overflow: elide; 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 { component NetworkEmptyState inherits Rectangle {
@@ -163,18 +186,18 @@ component NetworkEmptyState inherits Rectangle {
export component GalleryNetworkDialog inherits Rectangle { export component GalleryNetworkDialog inherits Rectangle {
in property <[GalleryNetworkDevice]> devices; 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; private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
callback close(); callback close();
background: #00000042; background: #00000042;
TouchArea { clicked => { root.close(); } } TouchArea { clicked => { root.close(); } }
Rectangle { FlatSurface {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
width: min(760px, parent.width - 32px); width: min(760px, parent.width - 32px);
height: min(120px + root.list-height, parent.height - 32px); height: min(120px + root.list-height, parent.height - 32px);
background: white; border-radius: UiRadius.surface; background: white; border-radius: UiRadius.surface;
border-width: 1px; border-color: #c6cdd5; outline-color: UiSurface.edge;
TouchArea {} TouchArea {}
FocusScope { FocusScope {
init => { self.focus(); } init => { self.focus(); }
@@ -185,7 +208,7 @@ export component GalleryNetworkDialog inherits Rectangle {
} }
Text { Text {
x: 20px; y: 17px; width: parent.width - 120px; height: 24px; 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; font-size: 17px; font-weight: 600; color: #293746;
} }
CompactButton { CompactButton {
@@ -215,7 +238,7 @@ export component GalleryNetworkDialog inherits Rectangle {
for device[index] in root.devices: NetworkRow { for device[index] in root.devices: NetworkRow {
y: index * root.row-height; width: list.viewport-width; height: root.row-height; y: index * root.row-height; width: list.viewport-width; height: root.row-height;
device: device; background: mod(index, 2) == 0 ? white : #f8fafc; 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 { if root.devices.length == 0: NetworkEmptyState {
@@ -11,7 +11,7 @@ export component GallerySettings inherits Rectangle {
y: 0px; width: parent.width; y: 0px; width: parent.width;
title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3; title: GalleryStrings.tr("画面传输", "Video transmission", "Передача видео"); rows: 3;
SettingsRow { SettingsRow {
label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Разрешение"); label: GalleryStrings.tr("分辨率上限", "Resolution limit", "Макс. разрешение");
label-reserve: self.width - 112px; label-reserve: self.width - 112px;
SettingsSelect { SettingsSelect {
border-radius: UiRadius.control; border-radius: UiRadius.control;
@@ -33,7 +33,7 @@ export component GallerySettings inherits Rectangle {
} }
} }
SettingsRow { SettingsRow {
label: GalleryStrings.tr("视频总带宽", "Video budget", "Бюджет видео"); label: GalleryStrings.tr("视频总带宽", "Total bandwidth", "Общий битрейт");
label-reserve: self.width - 112px; divider: false; label-reserve: self.width - 112px; divider: false;
SettingsSelect { SettingsSelect {
border-radius: UiRadius.control; 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 { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint"; import { GalleryStrings } from "device_strings.slint";
import { RemoteCursor } from "remote_cursor.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 { GroupFilter } from "group_filter.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
import { RoundedLineEdit } from "rounded_line_edit.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 { component GalleryButton inherits CompactButton {
border-radius: UiRadius.control; border-radius: UiRadius.control;
@@ -289,7 +290,7 @@ export component GalleryView inherits Rectangle {
selected(index) => { root.action("group", index); } selected(index) => { root.action("group", index); }
} }
Text { 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; min-width: 0px; overflow: elide; font-size: 11px; color: #858c96; vertical-alignment: center;
} }
Rectangle { horizontal-stretch: 1; min-width: 0px; } Rectangle { horizontal-stretch: 1; min-width: 0px; }
@@ -335,8 +336,8 @@ export component GalleryView inherits Rectangle {
: GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор"); : GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор");
clicked => { root.action("all-select", ""); } 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; 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; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Disconnect", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
batch-menu := GalleryMenuButton { batch-menu := GalleryMenuButton {
horizontal-padding: root.toolbar-button-padding; horizontal-padding: root.toolbar-button-padding;
width: self.preferred-button-width; enabled: root.selected-count > 0; width: self.preferred-button-width; enabled: root.selected-count > 0;
@@ -473,7 +474,7 @@ export component GalleryView inherits Rectangle {
height: root.card-height; height: root.card-height;
opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1; opacity: root.dragging && root.drag-source == tile.id ? 0.45 : 1;
background: !tile.online ? DeviceCardStyle.offline-background 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 { Rectangle {
x: root.card-inset; y: self.x; x: root.card-inset; y: self.x;
width: parent.width - self.x * 2; 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); 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; 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; x: parent.width - 27px; y: parent.height - 27px;
width: 18px; height: 18px; border-radius: 4px; border-width: 1px; width: 18px; height: 18px; border-radius: 4px;
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white; background: tile.selected ? (tile-touch.pressed ? #254cc0 : tile-touch.has-hover ? #4574f0 : #3564e3)
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8; : tile-touch.pressed ? ControlStyle.pressed : tile-touch.has-hover ? ControlStyle.hover : ControlStyle.surface;
animate background, border-color { duration: 120ms; } outline-color: tile.selected ? UiSurface.focus : tile-touch.has-hover ? UiSurface.hover : UiSurface.edge;
animate background, outline-color { duration: 120ms; }
if tile.selected: Text { if tile.selected: Text {
text: FontAwesomeIcons.check; color: white; text: FontAwesomeIcons.check; color: white;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px; 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. // 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; visible: root.count > 0;
x: min(column * root.card-width, grid.table-width - 1px); x: min(column * root.card-width, grid.table-width - 1px);
y: 0px; y: 0px;
width: 1px; height: grid.table-height; width: 1px; height: grid.table-height;
background: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column) vertical: true; outline-color: root.resizing || (grid.has-hover && grid.edge-x != 0 && grid.column-line == column)
? #76aeea : #c9cdd2; ? UiSurface.focus : UiSurface.separator;
} }
for row in root.rows + 1: Rectangle { for row in root.rows + 1: SurfaceSeparator {
visible: root.count > 0; visible: root.count > 0;
x: 0px; x: 0px;
y: min(row * root.card-height, grid.table-height - 1px); y: min(row * root.card-height, grid.table-height - 1px);
width: grid.table-width; height: 1px; width: grid.table-width; height: 1px;
background: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row) outline-color: root.resizing || (grid.has-hover && grid.edge-y != 0 && grid.row-line == row)
? #76aeea : #c9cdd2; ? 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; x: mod(root.drag-target, root.columns) * root.card-width + 1px;
y: floor(root.drag-target / root.columns) * root.card-height + 1px; y: floor(root.drag-target / root.columns) * root.card-height + 1px;
width: root.card-width - 2px; height: root.card-height - 2px; 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 { if root.count == 0: Text {
text: root.simplified text: root.simplified
@@ -577,7 +579,7 @@ export component GalleryView inherits Rectangle {
background: root.controlling ? #e7f1fd : #f0f2f5; background: root.controlling ? #e7f1fd : #f0f2f5;
focus-state := Text { focus-state := Text {
x: 8px; width: parent.width - 16px; height: parent.height; 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.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
: root.focused.connected && root.focused.has-frame && !root.focused.stale : root.focused.connected && root.focused.has-frame && !root.focused.stale
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status; ? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
@@ -588,8 +590,8 @@ export component GalleryView inherits Rectangle {
horizontal-padding: root.toolbar-button-padding; horizontal-padding: root.toolbar-button-padding;
primary: root.group-controlling; primary: root.group-controlling;
enabled: root.group-controlling || (root.focused.connected && root.focused.has-frame && !root.focused.stale); 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", "Групп. ввод"); 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", "Откл. группу"); alternate-text: root.group-controlling ? GalleryStrings.tr("开启群控", "Group control", "Групп. ввод") : GalleryStrings.tr("关闭群控", "Stop group control", "Откл. групп. ввод");
accessible-role: button; accessible-role: button;
accessible-label: self.text + GalleryStrings.tr(":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах"); accessible-label: self.text + GalleryStrings.tr(":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах");
accessible-action-default => { self.clicked(); } 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); } } 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; x: 0px; y: rail.visible ? focus-divider.y + focus-divider.height : 36px;
width: parent.width; height: parent.height - self.y; width: parent.width; height: parent.height - self.y;
background: root.focused.online ? #eff1f4 : DeviceCardStyle.offline-background; border-radius: UiRadius.surface; 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 { video := Rectangle {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
background: root.focused.online ? #24272d : DeviceCardStyle.offline-preview; 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. // Collapse the focused tile and its gap without changing the page model.
width: tile.id == root.focused.id ? 0px : root.rail-slot-width; width: tile.id == root.focused.id ? 0px : root.rail-slot-width;
height: root.rail-card-height; visible: tile.id != root.focused.id; 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", "В сети") private property <string> presence: tile.online ? GalleryStrings.tr("在线", "Online", "В сети")
: tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); : 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; 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; 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-role: button;
accessible-label: self.details; accessible-label: self.details;
accessible-action-default => { root.action("focus", tile.id); } accessible-action-default => { root.action("focus", tile.id); }
@@ -714,9 +716,9 @@ export component GalleryView inherits Rectangle {
visible: root.focus-rail-visible; visible: root.focus-rail-visible;
x: 0px; y: rail.y + rail.height; width: parent.width; height: 12px; 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; 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; 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 { Rectangle {
x: (parent.width - self.width) / 2; y: 4px; width: 36px; height: 3px; border-radius: 1.5px; 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 { Rectangle {
y: parent.height - 44px; height: 44px; background: #fafbfc; 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. // Connection status and pagination remain available in every selection state.
footer := Rectangle { footer := Rectangle {
@@ -792,8 +794,8 @@ export component GalleryView inherits Rectangle {
} }
network-button := SecondaryButton { network-button := SecondaryButton {
x: connection-count.x + connection-count.width + 12px; x: connection-count.x + connection-count.width + 12px;
min-width: 64px; width: 64px; height: parent.height; width: 64px; height: parent.height;
border-color: #c8cbd0; border-radius: UiRadius.control; outline-color: UiSurface.edge; border-radius: UiRadius.control;
text: GalleryStrings.tr("网络状态", "Network", "Сеть"); text: GalleryStrings.tr("网络状态", "Network", "Сеть");
accessible-role: button; accessible-role: button;
accessible-label: self.text; accessible-label: self.text;
@@ -823,6 +825,7 @@ export component GalleryView inherits Rectangle {
IconButton { IconButton {
width: 24px; height: 24px; width: 24px; height: 24px;
border-radius: UiRadius.control; border-radius: UiRadius.control;
normal-background: ControlStyle.surface;
icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand; icon: root.fullscreen ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран"); tooltip: root.fullscreen ? GalleryStrings.tr("退出全屏", "Exit fullscreen", "Выйти из полноэкранного режима") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
clicked => { root.toggle-fullscreen(); } clicked => { root.toggle-fullscreen(); }
@@ -840,11 +843,11 @@ export component GalleryView inherits Rectangle {
horizontal-alignment: center; vertical-alignment: center; 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); 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); y: clamp(grid.y + root.drag-y + 14px, 8px, footer.y - self.height - 12px);
width: 224px; height: 54px; z: 10; width: 224px; height: max(54px, drag-hint.y + drag-hint.height + 9px); z: 10;
background: #fffffff2; border-width: 1px; border-color: #76aeea; border-radius: UiRadius.surface; background: white; outline-color: UiSurface.focus; border-radius: UiRadius.surface;
Rectangle { Rectangle {
x: 8px; y: 9px; width: 64px; height: 36px; x: 8px; y: 9px; width: 64px; height: 36px;
background: root.dragged-tile.online ? #24272d : DeviceCardStyle.offline-preview; 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: 8px; width: parent.width - 88px; height: 18px; text: root.dragged-tile.name; font-size: 12px; color: #303943; overflow: elide; }
Text { drag-hint := Text {
x: 80px; y: 29px; width: parent.width - 88px; height: 16px; 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", "Удерживайте для перелистывания") text: root.drag-page-direction != 0 ? GalleryStrings.tr("停留翻页,松开取消", "Hold to turn page; release to cancel", "Удерживайте для перелистывания; отпустите для отмены")
: root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена") : root.drag-target >= 0 ? GalleryStrings.tr("松开鼠标交换位置", "Release to swap", "Отпустите для обмена")
: GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "На другое устройство"); : GalleryStrings.tr("拖到另一台设备上", "Drag onto another device", "Перетащите на другое устройство");
font-size: 10px; color: #527ca8; overflow: elide; 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; x: (parent.width - self.width) / 2; y: footer.y - self.height - 14px;
width: min(420px, parent.width - 32px); height: 36px; z: 10; 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 { 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; text: GalleryStrings.tr("换位未保存,请重试", "Couldn't save positions. Please try again.", "Не удалось сохранить порядок. Повторите попытку."); overflow: elide;
} }
} }
// Device details are rendered by MainWindow; resize hints stay local. // 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); 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); 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; 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 { device-tooltip-label := Text {
x: 8px; y: 8px; width: parent.width - 16px; x: 8px; y: 8px; width: parent.width - 16px;
text: root.device-tooltip-text; color: white; font-size: 11px; wrap: word-wrap; 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 { if root.adding: Rectangle {
background: #00000042; background: #00000042;
TouchArea {} TouchArea {}
add-dialog := Rectangle { add-dialog := FlatSurface {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px; 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() { function submit() {
if root.add-device(device-id.text, password.text, group.text, remember.checked) { if root.add-device(device-id.text, password.text, group.text, remember.checked) {
password.text = ""; password.text = "";
@@ -919,10 +922,10 @@ export component GalleryView inherits Rectangle {
TouchArea { clicked => { remember.checked = !remember.checked; } } 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; } 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 { GalleryButton {
x: 254px; y: 260px; width: 128px; height: 28px; primary: true; x: 218px; y: 260px; width: 164px; height: 28px; primary: true;
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить"); text: GalleryStrings.tr("添加并连接", "Add and connect", "Добавить и подключить");
enabled: device-id.text != "" && password.text != ""; enabled: device-id.text != "" && password.text != "";
clicked => { add-dialog.submit(); } clicked => { add-dialog.submit(); }
} }
@@ -931,25 +934,27 @@ export component GalleryView inherits Rectangle {
if root.importing: Rectangle { if root.importing: Rectangle {
background: #00000042; background: #00000042;
TouchArea {} TouchArea {}
Rectangle { FlatSurface {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px; 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; 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: 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; } 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; } 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; } 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", ""); } } 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; } 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; 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 { recent-list := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px; viewport-width: self.width - 12px;
viewport-height: max(self.height, root.recent-devices.length * 44px); viewport-height: max(self.height, root.recent-devices.length * 44px);
for device[index] in root.recent-devices: Rectangle { for device[index] in root.recent-devices: Rectangle {
y: index * 44px; width: recent-list.viewport-width; height: 44px; 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; } 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: 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; } 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); } } 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 { Rectangle {
x: -UiRadius.surface; y: -UiRadius.surface; x: -UiRadius.surface; y: -UiRadius.surface;
width: parent.width + 2 * 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-width: UiRadius.surface; border-radius: 2 * UiRadius.surface;
border-color: white; border-color: white;
} }
Rectangle { FlatSurface {
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
border-width: 1px; border-radius: UiRadius.surface; border-radius: UiRadius.surface;
border-color: #d8dadd; outline-color: UiSurface.edge;
} }
} }
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; } 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)", "Группа (необязательно)"); } 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; } 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: 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 { if root.confirming: Rectangle {
background: #00000042; TouchArea {} background: #00000042; TouchArea {}
Rectangle { FlatSurface {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
width: 360px; height: confirmation-list.height + 118px; 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 { Text {
x: 20px; y: 18px; width: parent.width - 132px; height: 24px; x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name; text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
@@ -997,12 +1002,12 @@ export component GalleryView inherits Rectangle {
Rectangle { Rectangle {
x: parent.width - 108px; y: 20px; width: 88px; height: 22px; x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
background: #eff3f8; border-radius: UiRadius.surface; 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; x: 20px; y: 58px; width: parent.width - 40px;
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px); 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 { confirmation-scroll := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px; 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; width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px;
background: device.connected ? #eaf6ee : #f0f2f5; background: device.connected ? #eaf6ee : #f0f2f5;
Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; } 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 { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.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. // 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 <[string]> model;
in property <int> system-count: 2; in property <int> system-count: 2;
in property <bool> enabled: true; in property <bool> enabled: true;
in property <color> normal-border-color: #c9cdd2; // An embedded picker shares its enclosing field's outline and focus state.
in property <color> hover-border-color: root.normal-border-color; in property <bool> embedded: false;
in property <color> focus-border-color: #8db5df; 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; 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); callback selected(int);
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count; private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px); private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
@@ -25,12 +36,16 @@ export component GroupFilter inherits Rectangle {
} }
width: 155px; height: 28px; width: 155px; height: 28px;
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white; background: root.embedded ? (root.enabled && trigger-touch.has-hover ? ControlStyle.hover : transparent)
border-width: 1px; : !root.enabled ? ControlStyle.disabled
border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color : trigger-touch.pressed || popup.is-open ? ControlStyle.pressed
: trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color; : 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; 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; forward-focus: trigger-focus;
accessible-role: combobox; accessible-role: combobox;
accessible-enabled: root.enabled; accessible-enabled: root.enabled;
@@ -52,16 +67,18 @@ export component GroupFilter inherits Rectangle {
root.reveal-selection(); root.reveal-selection();
root.selected(root.current-index); root.selected(root.current-index);
} }
function open-menu() { public function open-menu() {
if root.enabled && root.model.length > 0 { if root.enabled && root.model.length > 0 {
popup-open-timer.running = false; popup-open-timer.running = false;
root.popup-animation-enabled = false; root.popup-animation-enabled = false;
root.popup-progress = 0; root.popup-progress = 0;
root.reveal-selection(); root.reveal-selection();
trigger-focus.focus();
popup.show(); popup.show();
popup-open-timer.running = true; popup-open-timer.running = true;
} }
} }
public function close-menu() { popup.close(); }
// Defer until the popup has its initial frame; animate its contents only, // Defer until the popup has its initial frame; animate its contents only,
// keeping the native popup bounds and scrolling geometry stable. // keeping the native popup bounds and scrolling geometry stable.
popup-open-timer := Timer { popup-open-timer := Timer {
@@ -87,14 +104,19 @@ export component GroupFilter inherits Rectangle {
return reject; return reject;
} }
Text { 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]; text: root.model[root.current-index];
color: root.enabled ? #30343b : #92979f; color: root.enabled ? root.text-color : #92979f;
font-size: 12px; overflow: elide; vertical-alignment: center; font-size: root.text-size; font-weight: root.text-weight;
overflow: elide; vertical-alignment: center;
accessible-role: none; accessible-role: none;
} }
Text { if root.show-indicator: Text {
x: parent.width - 25px; width: 16px; height: parent.height; 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"; text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #858c96; font-weight: 900; font-size: 10px; color: #858c96;
horizontal-alignment: center; vertical-alignment: center; horizontal-alignment: center; vertical-alignment: center;
@@ -107,22 +129,24 @@ export component GroupFilter inherits Rectangle {
key-pressed(event) => { return root.handle-key(event); } key-pressed(event) => { return root.handle-key(event); }
trigger-touch := TouchArea { trigger-touch := TouchArea {
enabled: root.enabled; enabled: root.enabled;
clicked => { trigger-focus.focus(); root.open-menu(); } mouse-cursor: pointer;
clicked => { root.open-menu(); }
} }
} }
popup := PopupWindow { popup := PopupWindow {
x: 0px; y: root.height + 4px; 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; close-policy: close-on-click-outside;
forward-focus: popup-focus; forward-focus: popup-focus;
popup-focus := FocusScope { popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); } key-pressed(event) => { return root.handle-key(event); }
Rectangle { FlatSurface {
y: (1 - root.popup-progress) * -4px; y: (1 - root.popup-progress) * -4px;
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
opacity: root.popup-progress; opacity: root.popup-progress;
background: white; border-width: 1px; background: white;
border-color: root.normal-border-color; border-radius: UiRadius.menu; clip: true;
outline-color: root.normal-outline-color; border-radius: UiRadius.menu;
ScrollView { ScrollView {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
viewport-width: self.width; viewport-width: self.width;
@@ -130,27 +154,30 @@ export component GroupFilter inherits Rectangle {
viewport-y <=> root.scroll-y; viewport-y <=> root.scroll-y;
Rectangle { Rectangle {
width: parent.width; height: root.list-height; 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; 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 { for choice[index] in root.model: Rectangle {
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px); y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
width: parent.width; height: 26px; border-radius: UiRadius.control; 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; } animate background { duration: 100ms; easing: ease-out; }
accessible-role: list-item; accessible-role: list-item;
accessible-label: choice; accessible-label: choice;
Text { Text {
x: 8px; width: parent.width - 34px; height: parent.height; 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; overflow: elide; vertical-alignment: center;
accessible-role: none; accessible-role: none;
} }
if index == root.current-index: Text { if index == root.current-index: Text {
x: parent.width - 24px; width: 16px; height: parent.height; x: parent.width - 24px; width: 16px; height: parent.height;
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free"; 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; horizontal-alignment: center; vertical-alignment: center;
accessible-role: none; 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 { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.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 { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint"; import { WorkbenchStyle } from "workbench_controls.slint";
import "../assets/fonts/message_spacing.ttf"; 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; 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); x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px);
width: parent.width - 48px; height: parent.height - self.y - 24px; width: parent.width - 48px; height: parent.height - self.y - 24px;
background: WorkbenchStyle.surface; 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 { if root.items.length > 0 || root.selected-id != "": Rectangle {
x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px; x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px;
background: WorkbenchStyle.field-surface; background: WorkbenchStyle.field-surface;
@@ -112,19 +113,19 @@ export component MessagePanel inherits SettingsDialog {
announcement-entries := VerticalLayout { announcement-entries := VerticalLayout {
width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px; width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px;
alignment: start; alignment: start;
for item in root.items: Rectangle { for item in root.items: FlatSurface {
height: 64px; 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.pressed ? WorkbenchStyle.pressed-surface
: entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface; : entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: UiRadius.control; border-radius: UiRadius.control;
border-width: 1px; outline-color: root.selected-id == item.id ? UiSurface.focus : UiSurface.edge;
border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border;
Text { Text {
x: 10px; y: 9px; width: parent.width - 30px; height: 20px; x: 10px; y: 9px; width: parent.width - 30px; height: 20px;
text: item.title; overflow: elide; font-size: 13px; text: item.title; overflow: elide; font-size: 13px;
font-weight: item.read ? 400 : 600; 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; vertical-alignment: center;
} }
if !item.read: Rectangle { if !item.read: Rectangle {
@@ -154,10 +155,10 @@ export component MessagePanel inherits SettingsDialog {
color: WorkbenchStyle.muted; font-size: 13px; color: WorkbenchStyle.muted; font-size: 13px;
horizontal-alignment: center; vertical-alignment: center; horizontal-alignment: center; vertical-alignment: center;
} }
content-divider := Rectangle { content-divider := SurfaceSeparator {
x: announcement-list.x + announcement-list.width + 8px; x: announcement-list.x + announcement-list.width + 8px;
y: 1px; width: 1px; height: parent.height - 2px; 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 != ""; visible: root.items.length > 0 || root.selected-id != "";
accessible-role: none; accessible-role: none;
} }
@@ -199,13 +200,14 @@ export component MessagePanel inherits SettingsDialog {
x: parent.width - self.width; y: 0px; width: 28px; height: 28px; x: parent.width - self.width; y: 0px; width: 28px; height: 28px;
icon: FontAwesomeIcons.trash-can; icon-size: 12px; icon: FontAwesomeIcons.trash-can; icon-size: 12px;
icon-color: WorkbenchStyle.muted; danger: true; icon-color: WorkbenchStyle.muted; danger: true;
normal-background: ControlStyle.surface;
accessible-role: button; accessible-role: button;
accessible-label: MessageStrings.delete-message + " " + root.selected-title; accessible-label: MessageStrings.delete-message + " " + root.selected-title;
accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); } accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); }
clicked => { 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 { StyledText {
vertical-stretch: 0; vertical-stretch: 0;
text: root.selected-body; text: root.selected-body;
@@ -1,15 +1,17 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { LineEdit } from "std-widgets.slint"; import { LineEdit } from "std-widgets.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { ControlStyle } from "common.slint";
// Retain the standard editor's selection, clipboard, clear button and input // 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 { export component RoundedLineEdit inherits Rectangle {
in-out property <string> text; in-out property <string> text;
in property <string> placeholder-text; in property <string> placeholder-text;
in property <InputType> input-type: InputType.text; in property <InputType> input-type: InputType.text;
in property <bool> enabled: true; in property <bool> enabled: true;
in property <length> font-size: 12px; 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; in property <brush> surrounding-background: white;
callback accepted(); callback accepted();
callback edited(); callback edited();
@@ -20,8 +22,8 @@ export component RoundedLineEdit inherits Rectangle {
forward-focus: editor; forward-focus: editor;
editor := LineEdit { editor := LineEdit {
x: -1px; y: -1px; x: -2px; y: -2px;
width: parent.width + 2px; height: parent.height + 2px; width: parent.width + 4px; height: parent.height + 4px;
text <=> root.text; text <=> root.text;
placeholder-text: root.placeholder-text; placeholder-text: root.placeholder-text;
input-type: root.input-type; input-type: root.input-type;
@@ -30,8 +32,15 @@ export component RoundedLineEdit inherits Rectangle {
accepted => { root.accepted(); } accepted => { root.accepted(); }
edited => { root.edited(); } 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 // 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 { Rectangle {
x: -root.border-radius; y: -root.border-radius; x: -root.border-radius; y: -root.border-radius;
width: parent.width + 2 * 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-radius: 2 * root.border-radius;
border-color: root.surrounding-background; border-color: root.surrounding-background;
} }
Rectangle { FlatSurface {
width: parent.width; height: parent.height; width: parent.width; height: parent.height;
border-radius: root.border-radius; border-radius: root.border-radius;
border-width: editor.has-focus ? 2px : 1px; clip: true;
border-color: editor.has-focus ? #3564e3 : root.normal-border-color; outline-color: editor.has-focus ? UiSurface.focus : root.normal-outline-color;
outline-focused: editor.has-focus;
background: transparent; background: transparent;
} }
} }
+17 -13
View File
@@ -1,6 +1,7 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { ScrollView } from "std-widgets.slint"; import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint"; import { FontAwesomeIcons, FloatingBarStyle, ButtonStyle } from "common.slint";
export struct ControllerEntry { export struct ControllerEntry {
remote-id: string, remote-id: string,
@@ -19,7 +20,7 @@ export global ServerStrings {
in property <string> cancel: "取消"; in property <string> cancel: "取消";
} }
component SessionButton inherits Rectangle { component SessionButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <bool> danger: false; in property <bool> danger: false;
in property <bool> enabled: true; in property <bool> enabled: true;
@@ -29,10 +30,13 @@ component SessionButton inherits Rectangle {
width: label.preferred-width + 20px; width: label.preferred-width + 20px;
height: 28px; height: 28px;
border-radius: UiRadius.control; border-radius: UiRadius.control;
border-width: 1px; outline-enabled: !root.danger;
border-color: root.danger ? #d92d20 : #c9ced7; 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) 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; opacity: root.enabled ? 1 : 0.5;
accessible-role: button; accessible-role: button;
accessible-label: root.text; accessible-label: root.text;
@@ -72,9 +76,9 @@ component SessionButton inherits Rectangle {
// Restore hover feedback only after a fresh pointer move or press. // Restore hover feedback only after a fresh pointer move or press.
clicked => { root.pointer-feedback = false; root.keyboard-focus = false; keyboard.focus(); root.clicked(); } 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; 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; return reject;
} }
} }
Rectangle { FlatSurface {
x: 0px; y: 0px; width: parent.width; height: parent.height; x: 0px; y: 0px; width: parent.width; height: parent.height;
background: touch.pressed ? #dfe3e9 : touch.has-hover ? #f3f4f6 : white; 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-radius: FloatingBarStyle.corner-radius;
border-top-right-radius: 0px; border-top-right-radius: 0px;
border-bottom-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; } if event.text == Key.Escape { root.dismiss-panel(); return accept; }
return reject; return reject;
} }
Rectangle { FlatSurface {
width: root.width; height: root.height; 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; border-radius: UiRadius.surface;
Text { Text {
x: 10px; y: 10px; width: parent.width - 20px; height: 24px; 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); width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px);
height: parent.height; border-radius: UiRadius.control; height: parent.height; border-radius: UiRadius.control;
background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent; 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; 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; } if index == root.selected-controller: Rectangle { x: 3px; y: 3px; width: 7px; height: 7px; border-radius: 4px; background: #2463c7; }
} }
name := Text { 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 { 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"; import { WorkbenchStyle } from "workbench_controls.slint";
// Shared visual treatment for the global settings pages. // Shared visual treatment for the global settings pages.
export component SettingsButton inherits Rectangle { export component SettingsButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <image> icon; in property <image> icon;
in property <bool> primary: false; in property <bool> primary: false;
@@ -14,13 +15,17 @@ export component SettingsButton inherits Rectangle {
private property <length> content-padding: root.has-icon ? 8px : 12px; private property <length> content-padding: root.has-icon ? 8px : 12px;
callback clicked(); callback clicked();
width: 88px; height: 32px; border-radius: UiRadius.control; 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) : root.primary ? (touch.pressed ? #1e4fa3 : touch.has-hover ? #2866c9 : #3275dc)
: touch.pressed ? #e9eef5 : touch.has-hover ? #f0f4fa : root.quiet ? transparent : white; : touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover
border-width: root.quiet || root.primary ? 0px : 1px; : root.quiet ? transparent : ButtonStyle.surface;
border-color: !root.enabled ? WorkbenchStyle.border outline-enabled: !root.quiet && !root.primary;
: touch.has-hover ? WorkbenchStyle.hover-border : WorkbenchStyle.control-border; 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-role: button; accessible-label: root.text;
accessible-enabled: root.enabled;
accessible-action-default => { if root.enabled { root.clicked(); } } accessible-action-default => { if root.enabled { root.clicked(); } }
Rectangle { Rectangle {
x: root.align-left ? root.content-padding : (parent.width - self.width) / 2; x: root.align-left ? root.content-padding : (parent.width - self.width) / 2;
@@ -59,10 +64,7 @@ export component SettingsDialog inherits DialogSurface {
callback action(); callback action();
callback close(); callback close();
background: #f8fafc; background: #f8fafc;
border-color: WorkbenchStyle.separator; outline-color: UiSurface.edge;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
TouchArea { } TouchArea { }
HorizontalLayout { HorizontalLayout {
x: 24px; y: 18px; width: parent.width - 48px; height: 28px; x: 24px; y: 18px; width: parent.width - 48px; height: 28px;
@@ -97,6 +99,7 @@ export component SettingsDialog inherits DialogSurface {
width: 28px; height: 28px; width: 28px; height: 28px;
icon: FontAwesomeIcons.xmark; icon-size: 14px; icon: FontAwesomeIcons.xmark; icon-size: 14px;
icon-color: WorkbenchStyle.muted; icon-color: WorkbenchStyle.muted;
normal-background: ControlStyle.surface;
hover-background: WorkbenchStyle.hover-surface; hover-background: WorkbenchStyle.hover-surface;
pressed-background: WorkbenchStyle.pressed-surface; pressed-background: WorkbenchStyle.pressed-surface;
accessible-role: button; accessible-label: root.close-label; accessible-role: button; accessible-label: root.close-label;
@@ -109,9 +112,9 @@ export component SettingsDialog inherits DialogSurface {
export component SettingsSelect inherits CompactComboBox { export component SettingsSelect inherits CompactComboBox {
subtle: true; subtle: true;
show-arrow: false; show-arrow: true;
normal-border-color: WorkbenchStyle.control-border; normal-outline-color: UiSurface.edge;
hover-border-color: WorkbenchStyle.hover-border; hover-outline-color: UiSurface.hover;
height: 28px; height: 28px;
} }
@@ -123,9 +126,9 @@ export component SettingsGroup inherits Rectangle {
x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title; x: 2px; y: 0px; width: parent.width - 4px; height: 18px; text: root.title;
font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center; font-size: 11px; font-weight: 600; color: #718096; vertical-alignment: center;
} }
Rectangle { FlatSurface {
y: 24px; width: parent.width; height: parent.height - self.y; 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 } VerticalLayout { padding: 0px; spacing: 0px; @children }
} }
} }
@@ -141,8 +144,8 @@ export component SettingsRow inherits Rectangle {
text: root.label; color: root.enabled ? #34445a : #9ba5b3; text: root.label; color: root.enabled ? #34445a : #9ba5b3;
font-size: 12px; overflow: elide; vertical-alignment: center; font-size: 12px; overflow: elide; vertical-alignment: center;
} }
if root.divider: Rectangle { if root.divider: SurfaceSeparator {
x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #d9e1ec; x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; outline-color: UiSurface.separator;
} }
@children @children
} }
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -42,6 +42,11 @@ inline int ApplyMainWindowStrings(
localization::detail::ClampLanguageIndex(language_index); localization::detail::ClampLanguageIndex(language_index);
window->global<ui::GalleryStrings>().set_language(language); window->global<ui::GalleryStrings>().set_language(language);
auto& strings = window->global<ui::UiStrings>(); 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>(); auto& messages = window->global<ui::MessageStrings>();
messages.set_messages(Text(localization::messages[language])); messages.set_messages(Text(localization::messages[language]));
messages.set_unread(Text(localization::message_unread[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_ok(Text(localization::ok[language]));
strings.set_cancel(Text(localization::cancel[language])); strings.set_cancel(Text(localization::cancel[language]));
strings.set_new_password(Text(localization::new_password[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( strings.set_edit_alias(
Text(localization::input_connection_alias[language])); Text(localization::input_connection_alias[language]));
strings.set_delete_connection( strings.set_delete_connection(
@@ -93,9 +98,6 @@ inline int ApplyMainWindowStrings(
strings.set_capture_method_auto( strings.set_capture_method_auto(
Text(localization::screen_capture_method_auto[language])); Text(localization::screen_capture_method_auto[language]));
strings.set_codec(Text(localization::video_encode_format[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_force_relay(Text(localization::force_relay[language]));
strings.set_self_hosted( strings.set_self_hosted(
Text(localization::self_hosted_server_config[language])); 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_save_remote_previews(Text(localization::save_remote_previews[language]));
strings.set_unknown_platform(Text(localization::unknown_platform[language])); strings.set_unknown_platform(Text(localization::unknown_platform[language]));
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[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( strings.set_file_save_path(
Text(localization::file_transfer_save_path[language])); Text(localization::file_transfer_save_path[language]));
strings.set_default_desktop(Text(localization::default_desktop[language])); strings.set_default_desktop(Text(localization::default_desktop[language]));
@@ -194,6 +197,10 @@ inline int ApplyStreamWindowStrings(
const int language = const int language =
localization::detail::ClampLanguageIndex(language_index); localization::detail::ClampLanguageIndex(language_index);
auto& strings = window->template global<ui::StreamStrings>(); 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_title(Text(localization::remote_update_title[language]));
strings.set_remote_update(Text(localization::remote_update[language])); strings.set_remote_update(Text(localization::remote_update[language]));
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language])); strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
@@ -230,6 +237,11 @@ inline int ApplyStreamWindowStrings(
strings.set_fullscreen(Text(localization::fullscreen[language])); strings.set_fullscreen(Text(localization::fullscreen[language]));
strings.set_exit_fullscreen(Text(localization::exit_fullscreen[language])); strings.set_exit_fullscreen(Text(localization::exit_fullscreen[language]));
strings.set_disconnect(Text(localization::disconnect[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( strings.set_file_transfer(
Text(localization::file_transfer_progress[language])); Text(localization::file_transfer_progress[language]));
strings.set_expand_control(Text(localization::expand_control_bar[language])); strings.set_expand_control(Text(localization::expand_control_bar[language]));
@@ -243,6 +255,8 @@ inline int ApplyStreamWindowStrings(
strings.set_stats_connection_latency(Text(localization::connection_latency[language])); strings.set_stats_connection_latency(Text(localization::connection_latency[language]));
strings.set_stats_connection_mode( strings.set_stats_connection_mode(
Text(localization::connection_mode[language])); 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( strings.set_stats_encryption(
Text(localization::transport_encryption[language])); Text(localization::transport_encryption[language]));
strings.set_stats_encryption_enabled( 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 { UiRadius } from "ui_radius.slint";
import { ControlStyle, ButtonStyle } from "common.slint";
// Colors and spacing for the connection workbench; shapes are shared app-wide. // Colors and spacing for the connection workbench; shapes are shared app-wide.
export global WorkbenchStyle { export global WorkbenchStyle {
out property <color> canvas: #f2f5f9; out property <color> canvas: #edf2f8;
out property <color> surface: #ffffff; 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> field-surface: #f5f7fb;
out property <color> hover-surface: #eef3fb; out property <color> hover-surface: ControlStyle.hover;
out property <color> pressed-surface: #e4ebf6; out property <color> pressed-surface: ControlStyle.pressed;
out property <color> separator: #e1e7ef; out property <color> separator: #e1e7ef;
out property <color> text: #1c2940; out property <color> text: #1c2940;
out property <color> muted: #69788e; out property <color> muted: #69788e;
@@ -16,19 +15,21 @@ export global WorkbenchStyle {
out property <color> success: #18835a; out property <color> success: #18835a;
} }
export component WorkbenchButton inherits Rectangle { export component WorkbenchButton inherits FlatSurface {
in property <string> text; in property <string> text;
in property <length> text-size: 13px; in property <length> text-size: 13px;
in property <color> text-color: root.primary ? white : WorkbenchStyle.text; in property <color> text-color: root.destructive || root.primary ? white : WorkbenchStyle.text;
in property <color> normal-border-color: WorkbenchStyle.control-border; in property <color> normal-outline-color: ButtonStyle.border;
in property <color> normal-background: ButtonStyle.surface;
in property <string> icon; in property <string> icon;
in property <string> tooltip; in property <string> tooltip;
in property <bool> tooltip-above: false; in property <bool> tooltip-above: false;
in property <bool> tooltip-suppressed: 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) ? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
: WorkbenchStyle.muted; : ControlStyle.icon;
in property <bool> primary: false; in property <bool> primary: false;
in property <bool> destructive: false;
in property <bool> soft: false; in property <bool> soft: false;
in property <bool> quiet: false; in property <bool> quiet: false;
in property <bool> enabled: true; in property <bool> enabled: true;
@@ -44,20 +45,23 @@ export component WorkbenchButton inherits Rectangle {
width: 112px; width: 112px;
height: 36px; height: 36px;
border-radius: UiRadius.control; border-radius: UiRadius.control;
border-width: root.show-focus ? 2px : root.soft ? 0px : 1px; outline-enabled: root.show-focus || (!root.quiet && !root.soft && !root.primary && !root.destructive);
border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent) outline-focused: root.show-focus;
: root.primary ? WorkbenchStyle.accent outline-color: !root.enabled ? ButtonStyle.disabled-border
: touch.has-hover ? WorkbenchStyle.hover-border : root.show-focus ? WorkbenchStyle.accent
: root.quiet ? transparent : root.normal-border-color; : touch.pressed ? ButtonStyle.pressed-border
background: root.soft : 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 ? (root.primary
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #edf2ff) ? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #dce7ff)
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.field-surface)) : (touch.pressed ? ButtonStyle.pressed : touch.has-hover ? ButtonStyle.hover : ButtonStyle.surface))
: root.primary : root.primary
? (touch.pressed ? #254cc0 : touch.has-hover ? #4574f0 : WorkbenchStyle.accent) ? (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; opacity: root.enabled ? 1 : 0.45;
animate background, border-color { duration: 120ms; } animate background, outline-color { duration: 120ms; }
forward-focus: button-focus; forward-focus: button-focus;
accessible-role: button; accessible-role: button;
accessible-label: root.tooltip != "" ? root.tooltip : root.text; accessible-label: root.tooltip != "" ? root.tooltip : root.text;
@@ -87,6 +91,11 @@ export component WorkbenchButton inherits Rectangle {
accessible-role: none; 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 { button-focus := FocusScope {
enabled: root.enabled; enabled: root.enabled;
focus-gained(reason) => { focus-gained(reason) => {
@@ -145,7 +154,7 @@ export component WorkbenchButton inherits Rectangle {
} }
// A raised surface keeps transient action feedback distinct from the workbench. // 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> text;
in property <string> icon; in property <string> icon;
in property <bool> error: false; 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)); width: min(root.width-limit, max(280px, message.preferred-width + 64px));
height: 64px; height: 64px;
background: root.error ? #fff1f0 : #e9f6ef; background: root.error ? #fff1f0 : #e9f6ef;
border-width: 1px; outline-color: root.error ? UiSurface.error : UiSurface.edge;
border-color: root.error ? #f0c4bf : #b9dfc9;
border-radius: UiRadius.surface; border-radius: UiRadius.surface;
drop-shadow-color: #14294424;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 6px;
accessible-role: AccessibleRole.text; accessible-role: AccessibleRole.text;
accessible-label: root.text; accessible-label: root.text;
Text { Text {
@@ -187,9 +192,8 @@ export component WorkbenchNotice inherits Rectangle {
} }
} }
export component WorkbenchCard inherits Rectangle { export component WorkbenchCard inherits FlatSurface {
background: WorkbenchStyle.surface; background: WorkbenchStyle.surface;
border-width: 1px; outline-color: UiSurface.edge;
border-color: WorkbenchStyle.border;
border-radius: UiRadius.surface; 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
@@ -9,12 +9,8 @@
namespace crossdesk { namespace crossdesk {
void ConfigureLinuxCudaGraphics(bool hardware_acceleration) { void ConfigureLinuxCudaGraphics() {
#if USE_CUDA && (defined(__x86_64__) || defined(__amd64__)) #if USE_CUDA && (defined(__x86_64__) || defined(__amd64__))
if (!hardware_acceleration) {
return;
}
// Respect explicit GPU/driver selection, including a request to use Mesa // Respect explicit GPU/driver selection, including a request to use Mesa
// or software rendering. Native frames can still use the CPU fallback. // or software rendering. Native frames can still use the CPU fallback.
for (const char* name : {"__NV_PRIME_RENDER_OFFLOAD", for (const char* name : {"__NV_PRIME_RENDER_OFFLOAD",
@@ -45,8 +41,6 @@ void ConfigureLinuxCudaGraphics(bool hardware_acceleration) {
return; return;
} }
LOG_INFO("Selected NVIDIA PRIME rendering for CUDA/OpenGL video interop"); LOG_INFO("Selected NVIDIA PRIME rendering for CUDA/OpenGL video interop");
#else
(void)hardware_acceleration;
#endif #endif
} }
@@ -11,7 +11,7 @@ namespace crossdesk {
// Call before SDL or Slint initializes EGL/GLX. Hybrid laptops otherwise // Call before SDL or Slint initializes EGL/GLX. Hybrid laptops otherwise
// create the GUI context on the integrated GPU while NVDEC uses NVIDIA. // create the GUI context on the integrated GPU while NVDEC uses NVIDIA.
void ConfigureLinuxCudaGraphics(bool hardware_acceleration); void ConfigureLinuxCudaGraphics();
} // namespace crossdesk } // namespace crossdesk
@@ -35,12 +35,12 @@ std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config) {
return { return {
{"language", "console_setting_language", languages[language], "zh-CN / en-US / ru-RU"}, {"language", "console_setting_language", languages[language], "zh-CN / en-US / ru-RU"},
{"codec", "console_setting_codec", config.GetVideoEncodeFormat() == ConfigCenter::VIDEO_ENCODE_FORMAT::AV1 ? "av1" : "h264", "h264 / av1"}, {"codec", "console_setting_codec", config.GetVideoEncodeFormat() == ConfigCenter::VIDEO_ENCODE_FORMAT::AV1 ? "av1" : "h264", "h264 / av1"},
{"hardware", "console_setting_hardware", OnOff(config.IsHardwareVideoCodec()), "on / off"},
{"turn", "console_setting_turn", turn_modes[turn], "off / auto / udp / tcp"}, {"turn", "console_setting_turn", turn_modes[turn], "off / auto / udp / tcp"},
{"self_hosted", "console_setting_self_hosted", OnOff(config.IsSelfHosted()), "on / off"}, {"self_hosted", "console_setting_self_hosted", OnOff(config.IsSelfHosted()), "on / off"},
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"}, {"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
{"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"}, {"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"},
{"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"}, {"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"},
{"lock_on_disconnect", "lock_on_disconnect", OnOff(config.IsLockOnDisconnect()), "on / off"},
{"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"}, {"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"},
{"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"}, {"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"},
{"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "on / off"}, {"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "on / off"},
@@ -54,7 +54,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
result.message = "console_settings_readonly"; result.message = "console_settings_readonly";
return result; return result;
} }
const bool network = key == "codec" || key == "hardware" || key == "turn" || const bool network = key == "codec" || key == "turn" ||
key == "self_hosted" || key == "server_host" || key == "server_port"; key == "self_hosted" || key == "server_host" || key == "server_port";
if (network && connection_busy) { if (network && connection_busy) {
result.message = "console_settings_busy"; result.message = "console_settings_busy";
@@ -111,13 +111,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
if (!boolean) return result; if (!boolean) return result;
const bool enabled = *boolean; const bool enabled = *boolean;
canonical = OnOff(enabled); canonical = OnOff(enabled);
if (key == "hardware") { if (key == "self_hosted") {
if (enabled && !ConfigCenter::IsHardwareVideoCodecAvailable()) {
result.message = "console_settings_hardware_unavailable";
return result;
}
save = [&config, enabled] { return config.SetHardwareVideoCodec(enabled); };
} else if (key == "self_hosted") {
if (enabled && (config.GetSignalServerHost().empty() || config.GetSignalServerPort() <= 0 || config.GetSignalServerPort() > 65535)) { if (enabled && (config.GetSignalServerHost().empty() || config.GetSignalServerPort() <= 0 || config.GetSignalServerPort() > 65535)) {
result.message = "console_settings_server_required"; result.message = "console_settings_server_required";
return result; return result;
@@ -127,6 +121,8 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
save = [&config, enabled] { return config.SetPrivacyScreen(enabled); }; save = [&config, enabled] { return config.SetPrivacyScreen(enabled); };
effect = SettingEffect::next_connection; effect = SettingEffect::next_connection;
message = "console_settings_next_connection"; message = "console_settings_next_connection";
} else if (key == "lock_on_disconnect") {
save = [&config, enabled] { return config.SetLockOnDisconnect(enabled); };
} else if (key == "autostart") { } else if (key == "autostart") {
save = [&config, enabled] { return config.SetAutostart(enabled); }; save = [&config, enabled] { return config.SetAutostart(enabled); };
effect = SettingEffect::next_login; effect = SettingEffect::next_login;
@@ -138,7 +134,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
} else return result; } else return result;
} }
for (const auto& setting : GetConsoleSettings(config)) { for (const auto& setting : GetConsoleSettings(config)) {
if (key != "autostart" && key != "hardware" && setting.key == key && setting.value == canonical) { if (key != "autostart" && setting.key == key && setting.value == canonical) {
return {true, SettingEffect::none, "console_settings_unchanged"}; return {true, SettingEffect::none, "console_settings_unchanged"};
} }
} }
@@ -148,7 +144,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
if (previous_server != GetSignalServerSettings(config)) { if (previous_server != GetSignalServerSettings(config)) {
effect = SettingEffect::reconnect; effect = SettingEffect::reconnect;
message = "console_settings_reconnect"; message = "console_settings_reconnect";
} else if (key == "codec" || key == "hardware" || key == "turn") { } else if (key == "codec" || key == "turn") {
effect = SettingEffect::next_connection; effect = SettingEffect::next_connection;
message = "console_settings_next_connection"; message = "console_settings_next_connection";
} }
@@ -67,6 +67,7 @@ void PlatformMouseController::UpdateDisplayInfoList(
} }
int PlatformMouseController::Destroy() { int PlatformMouseController::Destroy() {
ReleasePressedButtons();
CleanupWaylandPortal(); CleanupWaylandPortal();
if (display_) { if (display_) {
@@ -77,6 +78,28 @@ int PlatformMouseController::Destroy() {
return 0; return 0;
} }
unsigned PlatformMouseController::ReleasePressedButtons(unsigned mask) {
mask &= 7;
const unsigned pressed = pressed_buttons_ & mask;
for (unsigned i = 0; i < 3; ++i) {
const unsigned bit = 1u << i;
if (!(pressed & bit)) continue;
bool released = false;
if (use_wayland_portal_) {
RemoteAction up{};
up.type = ControlType::mouse;
up.m.flag = static_cast<MouseFlag>(2 + i * 2);
released = SendWaylandMouseCommand(up, 0) == 0;
} else if (display_) {
const unsigned buttons[] = {1, 3, 2};
released = XTestFakeButtonEvent(display_, buttons[i], False, CurrentTime) != 0;
XFlush(display_);
}
if (released) pressed_buttons_ &= ~bit;
}
return mask & ~pressed_buttons_;
}
int PlatformMouseController::SendMouseCommand(RemoteAction remote_action, int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
int display_index) { int display_index) {
if (remote_action.type != ControlType::mouse) { if (remote_action.type != ControlType::mouse) {
@@ -84,7 +107,16 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
} }
if (use_wayland_portal_) { if (use_wayland_portal_) {
return SendWaylandMouseCommand(remote_action, display_index); const int ret = SendWaylandMouseCommand(remote_action, display_index);
if (ret == 0) {
const int flag = static_cast<int>(remote_action.m.flag);
if (flag >= 1 && flag <= 6) {
const unsigned bit = 1u << ((flag - 1) / 2);
if (flag & 1) pressed_buttons_ |= bit;
else pressed_buttons_ &= ~bit;
}
}
return ret;
} }
if (!display_) { if (!display_) {
@@ -112,27 +144,27 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; break;
} }
case MouseFlag::left_down: case MouseFlag::left_down:
XTestFakeButtonEvent(display_, 1, True, CurrentTime); if (!XTestFakeButtonEvent(display_, 1, True, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::left_up: case MouseFlag::left_up:
XTestFakeButtonEvent(display_, 1, False, CurrentTime); if (!XTestFakeButtonEvent(display_, 1, False, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::right_down: case MouseFlag::right_down:
XTestFakeButtonEvent(display_, 3, True, CurrentTime); if (!XTestFakeButtonEvent(display_, 3, True, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::right_up: case MouseFlag::right_up:
XTestFakeButtonEvent(display_, 3, False, CurrentTime); if (!XTestFakeButtonEvent(display_, 3, False, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::middle_down: case MouseFlag::middle_down:
XTestFakeButtonEvent(display_, 2, True, CurrentTime); if (!XTestFakeButtonEvent(display_, 2, True, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::middle_up: case MouseFlag::middle_up:
XTestFakeButtonEvent(display_, 2, False, CurrentTime); if (!XTestFakeButtonEvent(display_, 2, False, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::wheel_vertical: { case MouseFlag::wheel_vertical: {
@@ -157,6 +189,12 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; break;
} }
const int flag = static_cast<int>(remote_action.m.flag);
if (flag >= 1 && flag <= 6) {
const unsigned bit = 1u << ((flag - 1) / 2);
if (flag & 1) pressed_buttons_ |= bit;
else pressed_buttons_ &= ~bit;
}
return 0; return 0;
} }
@@ -32,6 +32,7 @@ class PlatformMouseController final : public MouseController {
virtual int Init(std::vector<DisplayInfo> display_info_list); virtual int Init(std::vector<DisplayInfo> display_info_list);
virtual int Destroy(); virtual int Destroy();
virtual int SendMouseCommand(RemoteAction remote_action, int display_index); virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
unsigned ReleasePressedButtons(unsigned mask = 7) override;
void UpdateDisplayInfoList(const std::vector<DisplayInfo>& display_info_list); void UpdateDisplayInfoList(const std::vector<DisplayInfo>& display_info_list);
private: private:
@@ -58,6 +59,7 @@ class PlatformMouseController final : public MouseController {
int screen_width_ = 0; int screen_width_ = 0;
int screen_height_ = 0; int screen_height_ = 0;
bool use_wayland_portal_ = false; bool use_wayland_portal_ = false;
unsigned pressed_buttons_ = 0;
DBusConnection* dbus_connection_ = nullptr; DBusConnection* dbus_connection_ = nullptr;
std::string wayland_session_handle_; std::string wayland_session_handle_;
@@ -0,0 +1,60 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include "screen_lock.h"
#include <dbus/dbus.h>
#include "rd_log.h"
namespace crossdesk {
bool LockScreen() {
dbus_threads_init_default();
DBusError error;
dbus_error_init(&error);
DBusConnection* connection = dbus_bus_get_private(DBUS_BUS_SESSION, &error);
if (!connection) {
LOG_WARN("Cannot connect to screen lock service: {}",
error.message ? error.message : "session bus unavailable");
dbus_error_free(&error);
return false;
}
dbus_connection_set_exit_on_disconnect(connection, false);
// Address the current desktop's session bus, including headless desktops;
// never lock every logged-in user or an unrelated system login session.
struct Service { const char* name; const char* path; };
constexpr Service services[] = {
{"org.freedesktop.ScreenSaver", "/ScreenSaver"},
{"org.gnome.ScreenSaver", "/org/gnome/ScreenSaver"},
{"org.cinnamon.ScreenSaver", "/org/cinnamon/ScreenSaver"},
{"org.mate.ScreenSaver", "/org/mate/ScreenSaver"},
{"org.xfce.ScreenSaver", "/org/xfce/ScreenSaver"},
};
bool accepted = false;
for (const auto& service : services) {
DBusMessage* message = dbus_message_new_method_call(
service.name, service.path, service.name, "Lock");
if (!message) break;
dbus_message_set_auto_start(message, false);
DBusMessage* reply = dbus_connection_send_with_reply_and_block(
connection, message, 1000, &error);
dbus_message_unref(message);
if (reply) {
accepted = dbus_message_get_type(reply) == DBUS_MESSAGE_TYPE_METHOD_RETURN;
dbus_message_unref(reply);
}
dbus_error_free(&error);
if (accepted) break;
}
dbus_connection_close(connection);
dbus_connection_unref(connection);
if (!accepted) LOG_WARN("No desktop screen lock service accepted the request");
return accepted;
}
} // namespace crossdesk
@@ -46,7 +46,37 @@ int PlatformMouseController::Init(std::vector<DisplayInfo> display_info_list) {
return 0; return 0;
} }
int PlatformMouseController::Destroy() { return 0; } int PlatformMouseController::Destroy() {
return ReleasePressedButtons() == 7 ? 0 : -1;
}
unsigned PlatformMouseController::ReleasePressedButtons(unsigned mask) {
mask &= 7;
const unsigned pressed = pressed_buttons_ & mask;
if (!pressed) return mask;
const auto cursor = CGEventCreate(nullptr);
if (!cursor) return mask & ~pressed;
const auto point = CGEventGetLocation(cursor);
CFRelease(cursor);
const CGEventType types[] = {kCGEventLeftMouseUp, kCGEventRightMouseUp,
kCGEventOtherMouseUp};
const CGMouseButton buttons[] = {kCGMouseButtonLeft, kCGMouseButtonRight,
kCGMouseButtonCenter};
for (unsigned i = 0; i < 3; ++i) {
const unsigned bit = 1u << i;
if (!(pressed & bit)) continue;
const auto event = CGEventCreateMouseEvent(nullptr, types[i], point, buttons[i]);
if (!event) continue;
CGEventSetIntegerValueField(event, kCGEventSourceUserData, kCrossDeskInjectedEvent);
CGEventPost(kCGHIDEventTap, event);
CFRelease(event);
pressed_buttons_ &= ~bit;
if (i == 0) { left_dragging_ = false; left_click_tracker_ = {}; }
if (i == 1) { right_dragging_ = false; right_click_tracker_ = {}; }
if (i == 2) middle_click_tracker_ = {};
}
return mask & ~pressed_buttons_;
}
void PlatformMouseController::UpdateDisplayInfoList( void PlatformMouseController::UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list) { const std::vector<DisplayInfo>& display_info_list) {
@@ -135,7 +165,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
switch (remote_action.m.flag) { switch (remote_action.m.flag) {
case MouseFlag::left_down: case MouseFlag::left_down:
mouse_type = kCGEventLeftMouseDown; mouse_type = kCGEventLeftMouseDown;
left_dragging_ = true;
click_state = BeginClick(left_click_tracker_, tracked_mouse_pos_x, click_state = BeginClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y); tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -144,7 +173,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; break;
case MouseFlag::left_up: case MouseFlag::left_up:
mouse_type = kCGEventLeftMouseUp; mouse_type = kCGEventLeftMouseUp;
left_dragging_ = false;
click_state = EndClick(left_click_tracker_, tracked_mouse_pos_x, click_state = EndClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y); tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -153,7 +181,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; break;
case MouseFlag::right_down: case MouseFlag::right_down:
mouse_type = kCGEventRightMouseDown; mouse_type = kCGEventRightMouseDown;
right_dragging_ = true;
click_state = BeginClick(right_click_tracker_, tracked_mouse_pos_x, click_state = BeginClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y); tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -162,7 +189,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; break;
case MouseFlag::right_up: case MouseFlag::right_up:
mouse_type = kCGEventRightMouseUp; mouse_type = kCGEventRightMouseUp;
right_dragging_ = false;
click_state = EndClick(right_click_tracker_, tracked_mouse_pos_x, click_state = EndClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y); tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -215,6 +241,16 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
kCrossDeskInjectedEvent); kCrossDeskInjectedEvent);
CGEventPost(kCGHIDEventTap, mouse_event); CGEventPost(kCGHIDEventTap, mouse_event);
CFRelease(mouse_event); CFRelease(mouse_event);
const int flag = static_cast<int>(remote_action.m.flag);
if (flag >= 1 && flag <= 6) {
const unsigned bit = 1u << ((flag - 1) / 2);
if (flag & 1) pressed_buttons_ |= bit;
else pressed_buttons_ &= ~bit;
left_dragging_ = (pressed_buttons_ & 1) != 0;
right_dragging_ = (pressed_buttons_ & 2) != 0;
}
} else {
return -1;
} }
return 0; return 0;
@@ -26,6 +26,7 @@ class PlatformMouseController final : public MouseController {
virtual int Init(std::vector<DisplayInfo> display_info_list); virtual int Init(std::vector<DisplayInfo> display_info_list);
virtual int Destroy(); virtual int Destroy();
virtual int SendMouseCommand(RemoteAction remote_action, int display_index); virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
unsigned ReleasePressedButtons(unsigned mask = 7) override;
void UpdateDisplayInfoList( void UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list); const std::vector<DisplayInfo>& display_info_list);
@@ -45,6 +46,7 @@ class PlatformMouseController final : public MouseController {
std::vector<DisplayInfo> display_info_list_; std::vector<DisplayInfo> display_info_list_;
bool left_dragging_ = false; bool left_dragging_ = false;
bool right_dragging_ = false; bool right_dragging_ = false;
unsigned pressed_buttons_ = 0;
ClickTracker left_click_tracker_; ClickTracker left_click_tracker_;
ClickTracker right_click_tracker_; ClickTracker right_click_tracker_;
ClickTracker middle_click_tracker_; ClickTracker middle_click_tracker_;
@@ -0,0 +1,35 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-25
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include "screen_lock.h"
#include <dlfcn.h>
#include "rd_log.h"
namespace crossdesk {
bool LockScreen() {
// macOS has no public immediate-lock API. Resolve login.framework at
// runtime so an OS without this private entry point fails gracefully.
static void* framework = dlopen(
"/System/Library/PrivateFrameworks/login.framework/login",
RTLD_LAZY | RTLD_LOCAL);
using LockScreenFunction = int (*)();
static const auto lock = framework
? reinterpret_cast<LockScreenFunction>(
dlsym(framework, "SACLockScreenImmediate"))
: nullptr;
if (!lock) {
LOG_WARN("macOS screen lock API is unavailable");
return false;
}
const int result = lock();
if (result != 0) LOG_WARN("macOS screen lock request failed: {}", result);
return result == 0;
}
} // namespace crossdesk
@@ -45,6 +45,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
if (CreateConnectionPeer() != 0) CloseConnectionPeer(); if (CreateConnectionPeer() != 0) CloseConnectionPeer();
} }
HandleConnectionStatusChange(); HandleConnectionStatusChange();
HandleScreenLock();
HandleWindowsServiceIntegration(); HandleWindowsServiceIntegration();
devices_.UpdateInteractions(); devices_.UpdateInteractions();
ShareLocalCursorState(); ShareLocalCursorState();
@@ -64,12 +65,14 @@ int GuiApplication::RunUnattended(void* stop_event) {
} }
// Quiesce network callbacks before destroying the controllers they use. // Quiesce network callbacks before destroying the controllers they use.
remote_input_.Stop();
CloseAllRemoteSessions(); CloseAllRemoteSessions();
WaitForSessionCleanup(); WaitForSessionCleanup();
keyboard_.ReleaseAllRemotePressedKeys("unattended_host_exit"); keyboard_.ReleaseAllRemotePressedKeys("unattended_host_exit");
devices_.DestroyDevices(); devices_.DestroyDevices();
devices_.DestroyFactories(); devices_.DestroyFactories();
privacy_.Shutdown(); privacy_.Shutdown();
HandleScreenLock();
WriteUnattendedStatus(settings_.ActiveIdentity(), false, session); WriteUnattendedStatus(settings_.ActiveIdentity(), false, session);
SDL_Quit(); SDL_Quit();
return 0; return 0;

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