Compare commits

..
Author SHA1 Message Date
dijunkun 2c76caa918 [feat] add SignPath release signing and manual Windows tests
[skip ci]
2026-09-09 22:57:49 +08:00
dijunkun 211d4de241 [fix] use WinHTTP and Windows security checks for installer updates 2026-09-09 22:07:42 +08:00
dijunkun c2c74913e3 [feat] add in-app Windows updates with automatic download and setup launch 2026-09-09 18:13:21 +08:00
dijunkun 4f200095f4 [fix] improve OpenGL video clarity when downscaling 2026-09-09 17:28:48 +08:00
dijunkun bd0a7aa2f6 [fix] prevent system audio capture hangs across desktop platforms 2026-09-09 11:34:47 +08:00
dijunkun b87ab025be [feat] use server-issued ICE configuration across clients 2026-09-08 17:07:16 +08:00
dijunkun 5b60695a52 [fix] restore Linux Dock icons in packaged builds 2026-09-08 15:03:32 +08:00
dijunkun 0002bcea74 [fix] hide macOS Dock icon when closing to the tray 2026-09-08 00:22:08 +08:00
dijunkun 58225de7ea [release] prepare v1.5.0 release notes and iOS version 2026-09-07 23:24:53 +08:00
dijunkun 5f37d0e753 [docs] update MiniRTC submodule documentation 2026-09-07 23:13:49 +08:00
dijunkun b518ea82e2 [docs] refresh README screenshots and platform guides 2026-09-07 22:53:30 +08:00
dijunkun abc8f907f3 [fix] restore iOS video after returning to the foreground 2026-09-07 21:31:08 +08:00
dijunkun d498d3f0ef [feat] show video resolution in the iOS floating status bar 2026-09-07 21:17:54 +08:00
dijunkun 55806d9e82 [fix] allow iOS relative mouse control outside the video area 2026-09-07 21:14:53 +08:00
dijunkun 2d3f1bb475 [fix] prevent iOS relative cursor jumps after movement 2026-09-07 21:11:27 +08:00
dijunkun 5d97d5a393 [fix] keep iOS floating controls clear of the Dynamic Island 2026-09-07 21:04:14 +08:00
dijunkun 19218a57f9 [fix] prevent stale device online status on iOS 2026-09-07 20:57:30 +08:00
dijunkun af729144fe [fix] prevent stale device online status 2026-09-07 20:02:19 +08:00
79 changed files with 3988 additions and 1840 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Sign Windows artifact
description: Submit one raw EXE or ZIP to SignPath and download the signed result.
inputs:
api-token:
description: SignPath submitter token.
required: true
organization-id:
description: SignPath organization ID.
required: true
signing-policy:
description: test-signing or release-signing.
required: true
artifact-configuration:
description: Artifact configuration slug in the crossdesk project.
required: true
artifact:
description: Absolute path to one EXE or ZIP file.
required: true
output-directory:
description: Absolute directory for the downloaded signed file.
required: true
version:
description: CrossDesk ProductVersion and FileVersion.
required: true
runs:
using: composite
steps:
- name: Upload unsigned artifact
id: unsigned
uses: actions/upload-artifact@v7
with:
path: ${{ inputs.artifact }}
archive: false
if-no-files-found: error
retention-days: 7
- name: Submit signing request and wait for approval
id: sign
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ inputs.api-token }}
organization-id: ${{ inputs.organization-id }}
project-slug: crossdesk
signing-policy-slug: ${{ inputs.signing-policy }}
artifact-configuration-slug: ${{ inputs.artifact-configuration }}
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: ${{ inputs.output-directory }}
skip-decompress: true
parameters: |
version: ${{ toJSON(inputs.version) }}
- name: Record signing request
if: always() && steps.sign.outputs.signing-request-web-url != ''
shell: pwsh
env:
SIGNING_REQUEST_URL: ${{ steps.sign.outputs.signing-request-web-url }}
SIGNING_CONFIGURATION: ${{ inputs.artifact-configuration }}
SIGNING_POLICY: ${{ inputs.signing-policy }}
run: |
"- $env:SIGNING_CONFIGURATION ($env:SIGNING_POLICY): $env:SIGNING_REQUEST_URL" >> $env:GITHUB_STEP_SUMMARY
+169 -54
View File
@@ -14,7 +14,17 @@ on:
required: false
default: "0"
sign_windows_test:
description: "Submit the Windows installer to SignPath using test-signing."
description: "Test-sign Windows binaries and installer (Actions artifacts only)."
required: false
type: boolean
default: false
sign_windows_release:
description: "Test release-signing with the production certificate (Actions artifacts only)."
required: false
type: boolean
default: false
windows_only:
description: "Build only Windows, for signing tests."
required: false
type: boolean
default: false
@@ -28,6 +38,7 @@ env:
jobs:
build-linux:
if: github.event_name != 'workflow_dispatch' || !inputs.windows_only
name: Build Linux (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
@@ -176,6 +187,7 @@ jobs:
# macOS
build-macos:
if: github.event_name != 'workflow_dispatch' || !inputs.windows_only
name: Build on macOS
runs-on: ${{ matrix.runner }}
env:
@@ -308,9 +320,12 @@ jobs:
# iOS
build-ios:
if: github.event_name != 'workflow_dispatch' || !inputs.windows_only
name: Build on iOS (arm64)
runs-on: macos-26
timeout-minutes: 120
outputs:
version: ${{ steps.ios_version.outputs.version }}
env:
IOS_DEPLOYMENT_TARGET: "16.0"
EXPECTED_IOS_SDK: "26.5"
@@ -369,6 +384,26 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
- name: Read independent iOS version
id: ios_version
shell: bash
run: |
IOS_VERSION_BASE="$(xcodebuild \
-project apps/ios/CrossDeskMobile.xcodeproj \
-scheme CrossDeskMobile \
-configuration Release \
-destination 'generic/platform=iOS' \
-showBuildSettings CODE_SIGNING_ALLOWED=NO | \
awk '$1 == "MARKETING_VERSION" && $2 == "=" { print $3 }')"
if [[ ! "${IOS_VERSION_BASE}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid iOS marketing version: ${IOS_VERSION_BASE}" >&2
exit 1
fi
VERSION_NUM="v${IOS_VERSION_BASE}-${VERSION_NUM##*-}"
echo "IOS_VERSION_BASE=${IOS_VERSION_BASE}" >> "${GITHUB_ENV}"
echo "VERSION_NUM=${VERSION_NUM}" >> "${GITHUB_ENV}"
echo "version=${VERSION_NUM}" >> "${GITHUB_OUTPUT}"
- name: Initialize submodules
run: git submodule update --init --recursive
@@ -439,6 +474,11 @@ jobs:
fi
xcrun lipo "${EXECUTABLE_PATH}" -verify_arch arm64
APP_VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${INFO_PLIST}")
if [[ "${APP_VERSION}" != "${IOS_VERSION_BASE}" ]]; then
echo "Unexpected iOS app version: expected ${IOS_VERSION_BASE}, got ${APP_VERSION}" >&2
exit 1
fi
MINIMUM_OS=$(/usr/libexec/PlistBuddy -c 'Print :MinimumOSVersion' "${INFO_PLIST}")
if [[ "${MINIMUM_OS}" != "${IOS_DEPLOYMENT_TARGET}" ]]; then
echo "Unexpected minimum iOS version: expected ${IOS_DEPLOYMENT_TARGET}, got ${MINIMUM_OS}" >&2
@@ -477,9 +517,37 @@ jobs:
build-windows-x64:
name: Build on Windows x64
runs-on: windows-2022
timeout-minutes: 240
outputs:
signing-policy: ${{ steps.signing.outputs.policy }}
env:
XMAKE_GLOBALDIR: D:\xmake_global
steps:
- name: Select Windows signing policy
id: signing
shell: pwsh
env:
TEST_SIGNING: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows_test }}
RELEASE_SIGNING: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows_release }}
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
run: |
if ($env:TEST_SIGNING -eq 'true' -and $env:RELEASE_SIGNING -eq 'true') {
throw 'Select only one of sign_windows_test and sign_windows_release.'
}
$policy = ''
if ($env:PUBLISH_RELEASE -eq 'true' -or $env:RELEASE_SIGNING -eq 'true') {
$policy = 'release-signing'
} elseif ($env:TEST_SIGNING -eq 'true') {
$policy = 'test-signing'
}
if ($policy -and (!$env:SIGNPATH_API_TOKEN -or !$env:SIGNPATH_ORGANIZATION_ID)) {
throw 'Signing requires SIGNPATH_API_TOKEN and SIGNPATH_ORGANIZATION_ID.'
}
"policy=$policy" >> $env:GITHUB_OUTPUT
"Windows signing policy: $(if ($policy) { $policy } else { 'unsigned development build' })" >> $env:GITHUB_STEP_SUMMARY
- name: Extract version number
shell: pwsh
run: |
@@ -584,67 +652,99 @@ jobs:
$nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
copy "${{ github.workspace }}\apps\desktop\scripts\windows\nsProcess.dll" $nsisPluginDir
- name: Test Windows signing helpers
shell: pwsh
run: ./apps/desktop/tests/windows_signing_test.ps1
- name: Build CrossDesk
run: |
xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true -y
xmake b -vy crossdesk
- name: Prepare installer binaries for signing
if: steps.signing.outputs.policy != ''
shell: pwsh
run: |
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation PrepareBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/unsigned/installer-binaries.zip
- name: Sign installer binaries
if: steps.signing.outputs.policy != ''
uses: ./.github/actions/sign-windows
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
signing-policy: ${{ steps.signing.outputs.policy }}
artifact-configuration: windows-binaries
artifact: ${{ github.workspace }}/signpath/unsigned/installer-binaries.zip
output-directory: ${{ github.workspace }}/signpath/signed/installer-binaries
version: ${{ env.VERSION_NUM }}
- name: Verify and restore signed installer binaries
if: steps.signing.outputs.policy != ''
shell: pwsh
env:
SIGNING_POLICY: ${{ steps.signing.outputs.policy }}
run: |
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation RestoreBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/signed/installer-binaries/installer-binaries.zip -SigningPolicy $env:SIGNING_POLICY
- name: Package
shell: pwsh
run: |
& "${{ github.workspace }}\apps\desktop\scripts\windows\pkg_x64.ps1" $env:VERSION_NUM
- name: Upload unsigned Windows installer for test signing
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
id: upload-unsigned-windows-installer
uses: actions/upload-artifact@v7
with:
path: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
archive: false
if-no-files-found: error
retention-days: 1
- name: Sign Windows installer with SignPath test certificate
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
uses: signpath/github-action-submit-signing-request@v2
- name: Sign Windows installer
if: steps.signing.outputs.policy != ''
uses: ./.github/actions/sign-windows
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: crossdesk
signing-policy-slug: test-signing
github-artifact-id: ${{ steps.upload-unsigned-windows-installer.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: ${{ github.workspace }}\signpath\signed-installer
skip-decompress: true
parameters: |
version: ${{ toJSON(env.VERSION_NUM) }}
signing-policy: ${{ steps.signing.outputs.policy }}
artifact-configuration: initial
artifact: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
output-directory: ${{ github.workspace }}/signpath/signed/installer
version: ${{ env.VERSION_NUM }}
- name: Verify and use test-signed Windows installer
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
- name: Verify and use signed Windows installer
if: steps.signing.outputs.policy != ''
shell: pwsh
env:
SIGNING_POLICY: ${{ steps.signing.outputs.policy }}
run: |
$installerName = "crossdesk-win-x64-${{ env.VERSION_NUM }}.exe"
$signedInstaller = Join-Path "${{ github.workspace }}\signpath\signed-installer" $installerName
$destination = Join-Path "${{ github.workspace }}\apps\desktop\scripts\windows" $installerName
if (!(Test-Path $signedInstaller)) {
throw "SignPath did not return the signed installer: $signedInstaller"
}
$signature = Get-AuthenticodeSignature $signedInstaller
if (!$signature.SignerCertificate -or $signature.Status -eq "NotSigned") {
throw "SignPath returned an installer without an Authenticode signature."
}
Write-Host "Signer: $($signature.SignerCertificate.Subject)"
Write-Host "Signature status: $($signature.Status)"
Copy-Item $signedInstaller $destination -Force
$installerName = "crossdesk-win-x64-$env:VERSION_NUM.exe"
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation VerifyInstaller -Artifact "./signpath/signed/installer/$installerName" -SigningPolicy $env:SIGNING_POLICY
Copy-Item "./signpath/signed/installer/$installerName" ./apps/desktop/scripts/windows/ -Force
- name: Build Portable CrossDesk
run: |
xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true --CROSSDESK_PORTABLE=true -y
xmake b -vy crossdesk
- name: Prepare portable binaries for signing
if: steps.signing.outputs.policy != ''
shell: pwsh
run: |
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation PrepareBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/unsigned/portable-binaries.zip
- name: Sign portable binaries
if: steps.signing.outputs.policy != ''
uses: ./.github/actions/sign-windows
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
signing-policy: ${{ steps.signing.outputs.policy }}
artifact-configuration: windows-binaries
artifact: ${{ github.workspace }}/signpath/unsigned/portable-binaries.zip
output-directory: ${{ github.workspace }}/signpath/signed/portable-binaries
version: ${{ env.VERSION_NUM }}
- name: Verify and restore signed portable binaries
if: steps.signing.outputs.policy != ''
shell: pwsh
env:
SIGNING_POLICY: ${{ steps.signing.outputs.policy }}
run: |
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation RestoreBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/signed/portable-binaries/portable-binaries.zip -SigningPolicy $env:SIGNING_POLICY
- name: Package Portable
shell: pwsh
run: |
@@ -692,7 +792,7 @@ jobs:
release:
name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.build-windows-x64.outputs.signing-policy == 'release-signing'
needs: [build-linux, build-macos, build-ios, build-windows-x64]
runs-on: ubuntu-latest
@@ -749,6 +849,25 @@ jobs:
echo "BUILD_DATE_ISO=${BUILD_DATE_ISO}" >> $GITHUB_OUTPUT
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_OUTPUT
- name: Prepare release notes
env:
RELEASE_TAG: ${{ steps.version.outputs.VERSION_WITH_V }}
VERSION_BASE: ${{ steps.version.outputs.VERSION_BASE }}
run: |
if [[ -f "docs/releases/${RELEASE_TAG}.md" ]]; then
cp "docs/releases/${RELEASE_TAG}.md" release-notes.md
elif [[ -f "docs/releases/v${VERSION_BASE}.md" ]]; then
cp "docs/releases/v${VERSION_BASE}.md" release-notes.md
else
cat > release-notes.md << 'EOF'
Binary release only. Source code is not included.
## Code signing policy
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
EOF
fi
- name: Rename artifacts
run: |
mkdir -p release
@@ -756,7 +875,7 @@ jobs:
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-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}.deb
cp artifacts/crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.deb
cp artifacts/crossdesk-ios-arm64-unsigned-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-ios-arm64-unsigned-${{ steps.version.outputs.VERSION_WITH_V }}.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-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip
@@ -767,17 +886,13 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.VERSION_WITH_V }}
target_commitish: ${{ github.sha }}
name: Release ${{ steps.version.outputs.VERSION_WITH_V }}
draft: false
prerelease: false
files: release/*
generate_release_notes: false
body: |
Binary release only. Source code is not included.
## Code signing policy
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
body_path: release-notes.md
- name: Create or update 'latest' tag
run: |
@@ -795,12 +910,7 @@ jobs:
prerelease: false
files: release/*
generate_release_notes: false
body: |
Binary release only. Source code is not included.
## Code signing policy
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
body_path: release-notes.md
- name: Upload artifacts to server
uses: burnett01/rsync-deployments@5.2
@@ -851,6 +961,11 @@ jobs:
}
}
EOF
jq --arg release_name "Release ${{ steps.version.outputs.VERSION_WITH_V }}" \
--rawfile release_notes release-notes.md \
'.releaseName = $release_name | .releaseNotes = $release_notes' \
version.json > version-with-notes.json
mv version-with-notes.json version.json
cat version.json
- name: Upload version.json to server
+3 -2
View File
@@ -92,8 +92,9 @@ jobs:
- name: Generate or update version.json
run: |
# If version.json exists, try to preserve downloads section
if [ -f version.json ] && jq -e '.downloads' version.json > /dev/null 2>&1; then
# Preserve download URLs only for this release, never an older version.
if [ -f version.json ] && jq -e --arg tag "${{ steps.version.outputs.TAG_NAME }}" \
'.tagName == $tag and (.downloads | type == "object")' version.json > /dev/null 2>&1; then
EXISTING_DOWNLOADS=$(jq -c '.downloads' version.json)
if [ "$EXISTING_DOWNLOADS" != "null" ] && [ "$EXISTING_DOWNLOADS" != "{}" ]; then
DOWNLOADS_JSON="$EXISTING_DOWNLOADS"
+155 -374
View File
@@ -1,429 +1,211 @@
# CrossDesk
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
<h1>CrossDesk</h1>
<p>轻量、跨平台的远程桌面,让电脑、浏览器和 iPhone / iPad 连接到同一张桌面。</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>
<a href="https://hellogithub.com/repository/kunkundi/crossdesk" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=55d41367570345f1838e02fd12be7961&claim_uid=cb0OpZRrBuGVAfL&theme=small" alt="Featured|HelloGitHub" /></a>
<div align="center">
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)](https://www.crossdesk.cn/)
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![GitHub last commit](https://img.shields.io/github/last-commit/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/commits/web-client)
[![Build Status](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions)
[![Docker Pulls](https://img.shields.io/docker/pulls/crossdesk/crossdesk-server)](https://hub.docker.com/r/crossdesk/crossdesk-server/tags)
[![GitHub issues](https://img.shields.io/github/issues/kunkundi/crossdesk.svg)](https://github.com/kunkundi/crossdesk/issues)
[![GitHub stars](https://img.shields.io/github/stars/kunkundi/crossdesk.svg?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/kunkundi/crossdesk.svg?style=social)](https://github.com/kunkundi/crossdesk/forks)
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS-blue)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[ [English](README_EN.md) / 中文 ]
</div>
---
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS 客户端控制电脑。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。
> 本文按当前仓库代码整理。截图展示当前 Slint 桌面界面,使用演示 ID、密码、在线状态和连接记录;缩略图来自公开页面;下载包的功能与系统要求请同时核对对应 Release 说明。原生 iOS 客户端的构建、签名方式见下文。
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md)
<a id="preview"></a>
## 界面预览
### PC 客户端
### 桌面客户端
![sup_example](https://github.com/user-attachments/assets/eeb64fbe-1f07-4626-be1c-b77396beb905)
主窗口集中展示本机 ID、连接密码、远程连接入口和近期连接记录。
<p align="center">
<img src="docs/images/desktop-main-zh.png" width="800" alt="当前桌面客户端示例:已连接服务器,三台在线设备均显示页面缩略图" />
</p>
### Web 客户端
在浏览器中输入远程设备 ID 和密码,无需在控制端安装桌面客户端。
<p align="center">
<img width="850" height="550" alt="6bddcbed47ffd4b9988a4037c7f4f524" src="https://github.com/user-attachments/assets/e44f73f9-24ac-46a3-a189-b7f8b6669881" />
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端当前连接首页" />
</p>
---
图片来源与更新方式见 [截图说明](docs/images/README.md)。
## 简介
## 主要功能
CrossDesk 是一个轻量级的跨平台远程桌面软件,支持 Web 端控制远程设备。
CrossDesk 是 [MiniRTC](https://github.com/kunkundi/minirtc.git) 实时音视频传输库的实验性应用。MiniRTC 是一个轻量级的跨平台实时音视频传输库。它具有网络透传([RFC5245](https://datatracker.ietf.org/doc/html/rfc5245)),视频软硬编解码(H264/AV1),音频编解码([Opus](https://github.com/xiph/opus)),信令交互,网络拥塞控制,传输加密([SRTP](https://tools.ietf.org/html/rfc3711))等基础能力。
---
## 系统要求
| 平台 | 最低版本 |
| 能力 | 当前支持 |
| --- | --- |
| **Windows** | Windows 10 及以上 (64 位) |
| **macOS** | macOS Intel 15.0 及以上 ( 大于 14.0 小于 15.0 的版本可自行编译实现兼容 )<br> macOS Apple Silicon 14.0 及以上 |
| **Linux** | Ubuntu 20.04 及以上 |
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS 作为控制端 |
| 实时画面与声音 | H.264 / AV1、30 / 60 fps 设置、硬件编解码选项、远端声音播放;实际能力取决于设备与构建配置 |
| 多设备与多显示器 | 近期连接、设备别名、会话标签与远端显示器切换 |
| 输入与协作 | 键鼠输入、远端光标同步、组合键、文本剪贴板同步、文件传输 |
| 网络与部署 | P2P 直连、TURN 中继、SRTP 加密选项、自托管信令与中继服务 |
| Windows 锁屏控制 | 通过 CrossDesk Service 支持锁屏、登录界面及安全桌面的输入转发 |
---
各客户端的入口和能力有所不同;桌面会话操作见下文,iOS 的具体功能见 [开发说明](apps/ios/README.md)。
## 使用
<a id="download"></a>
在菜单栏“对端ID”处输入远端桌面的ID,点击“→”即可发起远程连接。
## 下载与安装
![usage1](https://github.com/user-attachments/assets/3a4bb59f-c84c-44d2-9a20-11790aac510e)
前往 [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) 或 [官方网站](https://www.crossdesk.cn/),按系统与 CPU 架构选择安装包。
如果远端桌面设置了连接密码,则本端需填写正确的连接密码才能成功发起远程连接。
| 平台 | 当前源码 / CI 基线 | 安装方式 |
| --- | --- | --- |
| Windows | Windows 10+,x64 | `.exe` 安装包;便携构建也受支持 |
| macOS | macOS 14.0+,Intel / Apple Silicon | 选择 x64 或 arm64 的 `.pkg` |
| Linux | Ubuntu 20.04+,amd64 / arm64,glibc 2.31 基线 | `.deb` 安装包 |
| iOS / iPadOS | iOS 16.0+,arm64 真机 | 原生客户端;使用 Xcode 构建并签名,CI 产物为未签名应用 |
| Web | 支持 WebRTC 的浏览器 | 访问 [web.crossdesk.cn](https://web.crossdesk.cn/) |
![password](https://github.com/user-attachments/assets/1beadcce-640d-4f5c-8e77-51917b5294d5)
Linux 下载后,在下载目录中将文件名替换为实际安装包名称:
发起连接前,可在设置中自定义配置项,如语言、视频编码格式等。
![settings](https://github.com/user-attachments/assets/8bc5468d-7bbb-4e30-95bd-da1f352ac08c)
```bash
sudo apt install "./crossdesk-linux-amd64-<version>.deb"
```
### 使用 Web 客户端
**macOS 首次运行:** 按应用提示,在“系统设置 → 隐私与安全性”中授予 CrossDesk **屏幕录制**(新系统可能显示为“屏幕与系统音频录制”)及 **辅助功能**权限,再按系统提示重新打开应用。前者用于捕获桌面,后者用于远程键鼠输入。
浏览器访问 [CrossDesk Web Client](https://web.crossdesk.cn/)。
输入 **远程设备 ID** 与 **密码**,点击连接即可接入远程设备。如图,**iOS Safari 远程控制 Win11**:
<a id="quick-start"></a>
<img width="645" height="300" alt="_cgi-bin_mmwebwx-bin_webwxgetmsgimg__ MsgID=932911462648581698 skey=@crypt_1f5153b1_b550ca7462b5009ce03c991cca2a92a7 mmweb_appid=wx_webfilehelper" src="https://github.com/user-attachments/assets/a5109e6f-752c-4654-9f4e-7e161bddf43e" />
## 快速连接
### Windows 服务(CrossDesk Service)
### 从另一台电脑连接
CrossDesk 在 Windows 平台提供本地辅助服务 **CrossDesk Service**,服务名为 `CrossDeskService`。该服务用于锁屏、登录界面和安全桌面等受保护场景下的远程控制增强能力,包括:
1. **准备被控电脑。** 安装并运行 CrossDesk,等待底部显示“已连接服务器”。在左侧“本桌面”查看 **本机 ID** 和 **密码**,提供给控制端。
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 ID,点击 **→**。
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连,也可编辑别名或删除记录。
- 上报远端当前是否处于锁屏、登录、凭据输入或安全桌面状态;
- 支持从控制端发送 `Ctrl+Alt+Del`(SAS);
- 在锁屏、登录和安全桌面阶段转发键盘、鼠标输入。
本机密码旁的眼睛按钮用于显示 / 隐藏密码,铅笔按钮用于修改密码。修改时使用 **6 位数字或英文字母**,并保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
Windows 安装包会自动打包 `crossdesk_service.exe` 和 `crossdesk_session_helper.exe`,并在安装时注册为按需启动的 Windows 服务。CrossDesk 客户端启动时会尝试启动已安装的服务;当本机没有 CrossDesk 客户端进程运行时,服务会自动退出。卸载客户端时会同步停止并移除该服务。
### 从浏览器连接
如果是手动编译或手动部署 Windows 版本,请确保 `CrossDesk.exe`、`crossdesk_service.exe` 和 `crossdesk_session_helper.exe` 位于同一目录。安装或卸载服务需要使用管理员权限打开 PowerShell:
1. 保持被控电脑上的 CrossDesk 运行,并确认已连接服务器;在被控端“设置”中保持 **启用 SRTP** 开启。
2. 打开 [Web 客户端](https://web.crossdesk.cn/),输入 **远程设备 ID** 和 **密码**,点击“连接”。
3. 连接后使用页面提供的显示器、鼠标模式和键盘控件操作远端;手机和平板也可通过浏览器访问。
如果使用自建服务器,需要让控制端与被控端接入同一服务;Web 部署配置见 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
<a id="session"></a>
## 会话中的常用操作
远程窗口通过会话标签切换设备,控制栏提供以下操作;控制栏收起时先点击展开按钮。将鼠标悬停在图标上可查看提示。
| 入口 | 用途 |
| --- | --- |
| 显示器 | 切换远端显示器 |
| 键盘 | 发送组合键;Windows 的 `Ctrl+Alt+Del` 依赖远端服务 |
| 鼠标 | 控制 / 释放远端鼠标 |
| 扬声器 | 播放声音 / 静音 |
| 文件夹 | 选择文件发送到远端,并查看传输进度 |
| 网络状态 | 查看流量、丢包率、帧率、分辨率及直连 / 中继状态 |
| 全屏 | 进入 / 退出全屏 |
| 断开连接 | 结束当前远程会话 |
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
<a id="settings"></a>
## 调整画面与连接设置
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。
<p align="center">
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前设置界面:画质、帧率、画面偏好、编解码、中继与 SRTP" />
</p>
| 设置 | 说明 |
| --- | --- |
| 画面质量 / 帧率 | 选择低、中、高画质与 30 / 60 fps;实际帧率取决于网络和设备 |
| 画面偏好 | 在帧率优先、画质优先、平衡之间选择 |
| 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 |
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
| 启用 SRTP | 媒体传输加密选项,两端配置需兼容 |
| 自托管配置 | 设置服务器地址、信令端口与中继端口,并勾选启用 |
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
| 文件保存路径 | 选择接收文件的目录 |
<a id="ios"></a>
## 原生 iOS / iPadOS 客户端
仓库的 [`apps/ios`](apps/ios/README.md) 提供原生控制端,与桌面端使用同一套 MiniRTC 协议。它可以控制远端电脑;当前不提供将 iPhone / iPad 作为被控桌面的功能。
1. 按 [iOS 开发说明](apps/ios/README.md) 使用 Xcode 构建并签名,安装到 iOS 16+ 的真机。
2. 在首页输入对端 ID,点击连接并填写密码;成功连接后可从“最近连接”中重连。
3. 在设置中选择鼠标模式:**相对位置**像触控板一样移动光标,**绝对位置**将触摸位置直接映射到远端画面。
4. 点击悬浮图标展开菜单,可切换显示器、显示键盘、控制声音或发送文件。收到的文件位于应用的 `Documents/Received`,可通过传输状态旁的分享按钮导出。
当前支持接收远端文本到 iOS 剪贴板;界面暂未提供主动发送本机剪贴板的按钮。
当前支持单指点击、双指右键、长按拖动与捏合缩放;缩放后的双指平移用于移动视图。原生端与浏览器端分别维护,使用入口和手势可能不同。
<a id="windows-service"></a>
## Windows 锁屏与登录界面
**CrossDesk Service**(服务名 `CrossDeskService`)提供锁屏、登录、凭据输入及安全桌面的状态上报、`Ctrl+Alt+Del` 发送和键鼠输入转发。
安装版会注册按需启动的服务,客户端启动时尝试启动它;本机没有 CrossDesk 客户端进程后服务会自动退出。便携版可在提示窗口或“设置 → 锁屏控制服务”中安装服务,需要管理员权限。该服务依赖客户端运行。
<details>
<summary>手动部署与服务管理命令</summary>
保留完整安装目录;自行部署构建产物时,将 `CrossDesk.exe`、`crossdesk_service.exe`、`crossdesk_session_helper.exe` 及同次构建输出的全部 `.dll` 放在同一目录。在该目录打开管理员 PowerShell,按需执行:
```powershell
# 安装
.\CrossDesk.exe --service-install
# 启动
.\CrossDesk.exe --service-start
# 查看状态
.\CrossDesk.exe --service-status
# 停止
.\CrossDesk.exe --service-ping
# 停止或卸载服务
.\CrossDesk.exe --service-stop
# 卸载
.\CrossDesk.exe --service-uninstall
```
如果远端 Windows 服务未安装、未启动或暂时不可用,基础远程桌面连接仍可使用,但锁屏、登录界面和安全桌面阶段的控制能力会受限,客户端会提示“远端Windows服务不可用”。
</details>
---
出现“远端 Windows 服务不可用”时,检查被控电脑的安装和服务状态;普通桌面连接仍可使用,受保护界面的控制能力会受限。
## 如何编译
### 构建依赖
- [xmake](https://xmake.io/#/guide/installation)
- [cmake](https://cmake.org/download/) 3.21 及以上(系统版本过低时由 xmake 自动安装)
### Linux
Linux 构建支持 Ubuntu 20.04 及以上版本的 amd64 和 arm64。发布安装包以
Ubuntu 20.04/glibc 2.31 为兼容基线。先安装基础编译依赖:
```bash
sudo apt-get update
sudo apt-get install -y \
git curl unzip build-essential gcc-10 g++-10 python3-pip \
pkg-config binutils dpkg-dev \
libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \
libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \
libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \
libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxcb-shm0-dev \
libasound2-dev libsndio-dev libpulse-dev \
libgl1-mesa-dev
```
Ubuntu 20.04 自带的 CMake 3.16 不满足要求时,xmake 会自动下载并使用
CMake 3.21 或更高版本,无需手动替换系统 CMake。
Ubuntu 20.04 默认的 GCC 9 缺少 Slint C++ API 所需的部分 C++20 标准库,
因此上述命令同时安装并在后续配置中指定 GCC 10。
需要启用 Wayland 捕获或 DRM 捕获时,再安装对应依赖:
```bash
sudo apt-get install -y libdbus-1-dev libdrm-dev
# Ubuntu 22.04 及以上也可以直接安装系统开发包
# sudo apt-get install -y libpipewire-0.3-dev libspa-0.2-dev
```
下载子模块并编译 Release 版本:
```bash
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
# 已经克隆过仓库时执行这一行
git submodule update --init --recursive
# Ubuntu 20.04 没有 PipeWire 0.3 开发包,安装仅用于编译的头文件 SDK。
# 已安装发行版 PipeWire 0.3 开发包时可跳过此步骤。
sudo ./docker/linux-build/install-pipewire-sdk.sh
xmake f -c -m release --USE_CUDA=false --toolchain=gcc-10 -y
xmake b -vy crossdesk
```
这里应使用 `--toolchain=gcc-10`,以确保 CrossDesk 及 xmake 在配置阶段构建的
libyuv 等第三方包都使用 GCC 10。只指定 `--cc`/`--cxx` 时,依赖安装可能仍回退到
系统默认的 `/bin/cc` 和 `/bin/c++`。`-c` 会清除旧配置,切换编译器时必须保留。
amd64 构建结果位于 `build/linux/x86_64/release/crossdesk`,arm64 构建结果位于 `build/linux/arm64/release/crossdesk`。
启用 Wayland 和 DRM 的配置示例:
```bash
xmake f -c -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false \
--toolchain=gcc-10 -y
xmake b -vy crossdesk
```
生成 Debian 安装包(必须在对应架构上先完成 Release 编译):
```bash
# amd64
./apps/desktop/scripts/linux/pkg_amd64.sh 1.0.0
# arm64
./apps/desktop/scripts/linux/pkg_arm64.sh 1.0.0
```
打包脚本会将 Slint 共享运行库安装到软件包私有目录 `/usr/lib/crossdesk`,无需用户另外安装 `libslint_cpp.so`。
PipeWire 不属于强制运行时依赖:程序在运行时检测宿主系统的 PipeWire 0.3,
没有该运行库时仍可使用 X11(以及构建时启用的 DRM)捕获。
### 通用编译选项
```text
--USE_CUDA=true/false: 启用 CUDA 硬件编解码,默认不启用
--USE_WAYLAND=true/false: 在 Linux 上启用 Wayland/PipeWire 捕获,默认不启用
--USE_DRM=true/false: 在 Linux 上启用 DRM 捕获,默认不启用
--CROSSDESK_PORTABLE=true/false: 构建便携版本,默认不启用
--CROSSDESK_VERSION=xxx: 指定 CrossDesk 的版本
# 示例
xmake f --CROSSDESK_VERSION=1.0.0 --USE_CUDA=true
```
运行:
```bash
xmake r crossdesk
```
### 无 CUDA 环境下的开发支持
对于**未安装 CUDA 环境的 Linux 开发者,如果希望编译后的成果物拥有硬件编解码能力**,这里提供了预配置的 [Ubuntu 20.04 兼容构建镜像](https://hub.docker.com/r/crossdesk/ubuntu20.04)。该镜像内置必要的构建依赖,可生成兼容 glibc 2.31 的单一 Linux 安装包。
进入容器,下载工程后执行:
```bash
export CUDA_PATH=/usr/local/cuda
export XMAKE_GLOBALDIR=/data
xmake f --USE_CUDA=true
xmake b --root -vy crossdesk
```
对于**未安装 CUDA 环境的 Windows 开发者**,执行下面的命令安装 CUDA 编译环境:
```powershell
xmake require -vy "cuda 12.6.3"
```
安装完成后执行:
```powershell
xmake require --info "cuda 12.6.3"
```
输出如下:
<img width="860" height="226" alt="Image" src="https://github.com/user-attachments/assets/999ac365-581a-4b9a-806e-05eb3e4cf44d" />
根据上述输出获取到 CUDA 的安装目录,即 installdir 指向的位置。将 CUDA_PATH 加入系统环境变量,或在终端中输入:
```powershell
set CUDA_PATH=path_to_cuda_installdir
```
重新执行:
```powershell
xmake f --USE_CUDA=true
xmake b -vy crossdesk
```
#### 注意
运行时如果客户端状态栏显示 **未连接服务器**,请先在 [CrossDesk 官方网站](https://www.crossdesk.cn/) 安装客户端,以便在环境中安装所需的证书文件。
<img width="256" height="120" alt="image" src="https://github.com/user-attachments/assets/1812f7d6-516b-4b4f-8a3d-98bee505cc5a" />
### 关于 Xmake
#### 安装 Xmake
使用 curl:
```bash
curl -fsSL https://xmake.io/shget.text | bash
```
使用 wget:
```bash
wget https://xmake.io/shget.text -O - | bash
```
使用 powershell:
```powershell
irm https://xmake.io/psget.text | iex
```
#### 编译选项
```text
# 切换编译模式
xmake f -m debug/release
# 可选编译参数
-r :重新构建目标
-v :显示详细的构建日志
-y :自动确认提示
# 示例
xmake b -vy crossdesk
```
#### 运行选项
```bash
# 使用调试模式运行
xmake r -d crossdesk
```
更多使用方法可参考 [Xmake 官方文档](https://xmake.io/guide/quick-start.html)。
---
<a id="self-hosting"></a>
## 自托管服务器
### 服务端
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
推荐使用 Docker 部署 CrossDesk Server。
- [自托管指南](docs/SELF_HOSTING.md):当前 Compose 部署入口、客户端设置、证书信任与排查。
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server):服务端源码与发布配置。
- [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client):浏览器端源码与自托管说明。
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=xxx.xxx.xxx.xxx \
-e INTERNAL_IP=xxx.xxx.xxx.xxx \
-e CROSSDESK_SERVER_PORT=xxxx \
-e COTURN_PORT=xxxx \
-e MIN_PORT=xxxxx \
-e MAX_PORT=xxxxx \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.6
```
## 开发与反馈
上述命令中,用户需注意的参数如下:
- [源码构建](docs/BUILD.md):Windows、macOS、Linux、可选编译参数与打包。
- [iOS 开发说明](apps/ios/README.md):原生端构建和真机验证。
- [GUI 架构](docs/gui-architecture.md):桌面 UI、平台实现与工程结构。
- [常见问题](docs/FAQ.md):连接失败、黑屏、文件位置与编译问题。
- [提交问题](https://github.com/kunkundi/crossdesk/issues):请附上两端系统、客户端版本、连接方式和可复现步骤。
#### 配置参数
## 致谢与许可
- EXTERNAL_IP:服务器公网 IP , 对应 CrossDesk 客户端**自托管服务器配置**中填写的**服务器地址**
- INTERNAL_IP:服务器内网 IP
- CROSSDESK_SERVER_PORT:自托管服务使用的端口,对应 CrossDesk 客户端**自托管服务器配置**中填写的**服务器端口**
- COTURN_PORT: COTURN 服务使用的端口, 对应 CrossDesk 客户端**自托管服务器配置**中填写的**中继服务端口**
- MIN_PORT/MAX_PORT:COTURN 服务使用的端口范围,例如:MIN_PORT=50000, MAX_PORT=60000,范围可根据客户端数量调整。
- `-v /var/lib/crossdesk:/var/lib/crossdesk`:持久化数据库和证书文件到宿主机
- `-v /var/log/crossdesk:/var/log/crossdesk`:持久化日志文件到宿主机
感谢 [HelloGitHub](https://hellogithub.com/)、[阮一峰的科技爱好者周刊](https://github.com/ruanyf/weekly) 与 [LinuxDo](https://linux.do) 社区的推荐、交流与支持。
#### 部署示例
CrossDesk 使用 [GPL-3.0](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=114.114.114.114 \
-e INTERNAL_IP=10.0.0.1 \
-e CROSSDESK_SERVER_PORT=9099 \
-e COTURN_PORT=3478 \
-e MIN_PORT=50000 \
-e MAX_PORT=60000 \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.6
```
#### 注意事项
- **服务器需开放端口:COTURN_PORT/udp,COTURN_PORT/tcp,MIN_PORT-MAX_PORT/udp,CROSSDESK_SERVER_PORT/tcp。**
- 如果不挂载 volume,容器删除后数据会丢失
- 证书文件会在首次启动时自动生成并持久化到宿主机的 `/var/lib/crossdesk/certs` 路径下。由于默认使用的是自签证书,无法保障安全性,建议在云服务商申请正式证书放到该目录下并重启服务。
- 数据库文件会自动创建并持久化到宿主机的 `/var/lib/crossdesk/db/crossdesk-server.db` 路径下
- 日志文件会自动创建并持久化到宿主机的 `/var/log/crossdesk/` 路径下
#### 目录权限
如果 Docker 自动创建的目录权限不足(属于 root),容器内用户无法写入,会导致:
- 证书生成失败,容器启动脚本会报错退出
- 数据库目录创建失败,程序会抛出异常并崩溃
- 日志目录创建失败,日志文件无法写入(但程序可能继续运行)
在启动容器前手动设置权限:
```bash
sudo mkdir -p /var/lib/crossdesk /var/log/crossdesk
sudo chown -R $(id -u):$(id -g) /var/lib/crossdesk /var/log/crossdesk
```
### 客户端
1. 点击右上角设置进入设置页面。<br><br>
<img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/6431131d-b32a-4726-8783-6788f47baa3b" /><br>
2. 点击`自托管服务器配置`按钮。<br><br>
<img width="600" height="160" alt="image" src="https://github.com/user-attachments/assets/24c761a3-1985-4d7e-84be-787383c2afb8" /><br>
3. 输入`服务器地址`(**EXTERNAL_IP**)、`信令服务端口`(**CROSSDESK_SERVER_PORT**)、`中继服务端口`(**COTURN_PORT**),点击确认按钮。
4. 勾选`自托管服务器配置`选项,点击确认按钮保存设置。如果服务端使用的是正式证书,则到此步骤为止,客户端即可显示已连接服务器。
5. 如果使用默认证书(正式证书忽略此步骤),则需要将服务端`/var/lib/crossdesk/certs/`目录下的`api.crossdesk.cn_root.crt`自签根证书下载到运行客户端的机器,并执行下述命令安装证书:
**Windows:** 使用管理员权限打开 PowerShell 执行:
```powershell
certutil -addstore "Root" "C:\path\to\api.crossdesk.cn_root.crt"
```
**Linux:**
```bash
sudo cp /path/to/api.crossdesk.cn_root.crt /usr/local/share/ca-certificates/api.crossdesk.cn_root.crt
sudo update-ca-certificates
```
**macOS:**
```bash
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain path/to/api.crossdesk.cn_root.crt
```
### 自托管 Web 客户端
详情见项目 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
---
## 常见问题
见 [常见问题](https://github.com/kunkundi/crossdesk/blob/self-hosted-server/docs/FAQ.md)。
---
## 致谢
- 感谢 [HelloGitHub](https://hellogithub.com/) 的推荐与关注。
- 感谢 [阮一峰的科技爱好者周刊](https://github.com/ruanyf/weekly) 的收录与推荐。
- 感谢 [LinuxDo](https://linux.do) 社区的关注、交流与支持,为 CrossDesk 项目的完善提供了帮助。
---
## Code signing policy
### Code signing policy
CrossDesk 使用 SignPath.io 为本仓库构建的官方 Windows 发布包提供代码签名。
@@ -431,4 +213,3 @@ CrossDesk 使用 SignPath.io 为本仓库构建的官方 Windows 发布包提供
- **Committers and reviewers(代码提交与审核):** [kunkundi](https://github.com/kunkundi)
- **Approvers(签名审批):** [kunkundi](https://github.com/kunkundi)
- **隐私政策:** [查看 CrossDesk 隐私政策](PRIVACY.md#中文)
+154 -390
View File
@@ -1,451 +1,215 @@
# CrossDesk
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
<h1>CrossDesk</h1>
<p>A lightweight, cross-platform remote desktop for computers, browsers, and iPhone / iPad.</p>
<p><a 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>
<a href="https://hellogithub.com/repository/kunkundi/crossdesk" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=55d41367570345f1838e02fd12be7961&claim_uid=cb0OpZRrBuGVAfL&theme=small" alt="Featured|HelloGitHub" /></a>
<div align="center">
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)](https://www.crossdesk.cn/)
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![GitHub last commit](https://img.shields.io/github/last-commit/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/commits/web-client)
[![Build Status](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions)
[![Docker Pulls](https://img.shields.io/docker/pulls/crossdesk/crossdesk-server)](https://hub.docker.com/r/crossdesk/crossdesk-server/tags)
[![GitHub issues](https://img.shields.io/github/issues/kunkundi/crossdesk.svg)](https://github.com/kunkundi/crossdesk/issues)
[![GitHub stars](https://img.shields.io/github/stars/kunkundi/crossdesk.svg?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/kunkundi/crossdesk.svg?style=social)](https://github.com/kunkundi/crossdesk/forks)
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS-blue)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[ [中文](README.md) / English ]
</div>
---
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS clients for controlling computers. Built on [MiniRTC](https://github.com/kunkundi/minirtc), it supports real-time video and audio, keyboard and mouse control, file transfer, and self-hosting. The project is under active development.
> This guide follows the current repository source. Desktop screenshots render the current Slint UI with sample IDs, passwords, online statuses, and recent devices; thumbnails show public pages. Check each release's notes for the features and requirements of its binaries. Native iOS build and signing instructions are below.
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english)
<a id="preview"></a>
## Preview
### PC Client
### Desktop client
![sup_example](https://github.com/user-attachments/assets/3f17d8f3-7c4a-4b63-bae4-903363628687)
### Web Client
The main window brings together your device ID, connection password, remote connection entry, and recent devices.
<p align="center">
<img width="850" height="550" alt="6bddcbed47ffd4b9988a4037c7f4f524" src="https://github.com/user-attachments/assets/e44f73f9-24ac-46a3-a189-b7f8b6669881" />
<img src="docs/images/desktop-main-en.png" width="800" alt="Current desktop UI example showing a connected server and three online devices with page thumbnails" />
</p>
---
### Web client
## Introduction
Enter a remote device ID and password in your browser without installing the desktop app on the controlling device.
CrossDesk is a lightweight cross-platform remote desktop software.
<p align="center">
<img src="docs/images/web-client.png" width="800" alt="Current CrossDesk Web Client connection page in Chinese" />
</p>
CrossDesk is an experimental application of [MiniRTC](https://github.com/kunkundi/minirtc.git), a lightweight cross-platform real-time audio and video transmission library. MiniRTC provides fundamental capabilities including network traversal ([RFC5245](https://datatracker.ietf.org/doc/html/rfc5245)), video software/hardware encoding and decoding (H264/AV1), audio encoding/decoding ([Opus](https://github.com/xiph/opus)), signaling interaction, network congestion control, and transmission encryption ([SRTP](https://tools.ietf.org/html/rfc3711)).
See the [screenshot notes](docs/images/README.md) for image sources and refresh instructions.
---
## Features
## System Requirements
| Platform | Minimum Version |
| Capability | Current support |
| --- | --- |
| **Windows** | Windows 10 or later (64-bit) |
| **macOS** | macOS Intel 15.0 or later *(versions between 14.0 and 15.0 can be built manually for compatibility)*<br>macOS Apple Silicon 14.0 or later |
| **Linux** | Ubuntu 20.04 or later |
| Cross-platform control | Windows / macOS / Linux desktop control; browser and native iOS controllers |
| Live video and audio | H.264 / AV1, 30 / 60 fps settings, hardware codec options, and remote audio; availability depends on hardware and build configuration |
| Devices and displays | Recent connections, device aliases, session tabs, and remote display switching |
| Input and sharing | Keyboard/mouse input, remote cursor synchronization, shortcuts, text clipboard synchronization, and file transfer |
| Networking and deployment | Direct P2P connections, TURN relay, an SRTP encryption option, and self-hosted signaling/relay services |
| Windows protected desktops | CrossDesk Service forwards input on lock screens, sign-in screens, and secure desktops |
---
Controls and capabilities vary by client. Desktop controls are described below; see the [iOS guide](apps/ios/README.md) for native mobile features.
## Usage
<a id="download"></a>
Enter the remote desktop ID in the menu bar’s “Remote ID” field and click “→” to initiate a remote connection.
## Download and install
![usage1](https://github.com/user-attachments/assets/3a4bb59f-c84c-44d2-9a20-11790aac510e)
Choose a package matching your OS and CPU architecture from [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) or the [website](https://www.crossdesk.cn/).
If the remote desktop requires a connection password, you must enter the correct password on your side to successfully establish the connection.
| Platform | Current source / CI baseline | Installation |
| --- | --- | --- |
| Windows | Windows 10+, x64 | `.exe` installer; portable builds are also supported |
| macOS | macOS 14.0+, Intel / Apple Silicon | x64 or arm64 `.pkg` |
| Linux | Ubuntu 20.04+, amd64 / arm64, glibc 2.31 baseline | `.deb` package |
| iOS / iPadOS | iOS 16.0+, arm64 physical device | Native controller; build and sign with Xcode, or sign the unsigned CI app |
| Web | A WebRTC-capable browser | Open [web.crossdesk.cn](https://web.crossdesk.cn/) |
![password](https://github.com/user-attachments/assets/1beadcce-640d-4f5c-8e77-51917b5294d5)
On Linux, replace the example filename with the downloaded package name:
Before connecting, you can customize configuration options in the settings, such as language and video encoding format.
```bash
sudo apt install "./crossdesk-linux-amd64-<version>.deb"
```
![settings](https://github.com/user-attachments/assets/8bc5468d-7bbb-4e30-95bd-da1f352ac08c)
**First launch on macOS:** follow the app prompts to grant **Screen Recording** (called **Screen & System Audio Recording** on newer systems) and **Accessibility** under **System Settings → Privacy & Security**. Reopen the app if prompted. These permissions allow desktop capture and remote keyboard/mouse input respectively.
### Using the Web Client
<a id="quick-start"></a>
Visit [CrossDesk Web Client](https://web.crossdesk.cn/).
Enter the **Remote Device ID** and **Password**, then click Connect to access the remote device. As shown, **iOS Safari remotely controlling Windows 11**:
## Quick start
<img width="645" height="300" alt="_cgi-bin_mmwebwx-bin_webwxgetmsgimg__ MsgID=932911462648581698 skey=@crypt_1f5153b1_b550ca7462b5009ce03c991cca2a92a7 mmweb_appid=wx_webfilehelper" src="https://github.com/user-attachments/assets/a5109e6f-752c-4654-9f4e-7e161bddf43e" />
### Connect from another computer
### Windows Service (CrossDesk Service)
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the bottom status bar to show that the server is connected. Share the **Local ID** and **Password** shown under **Local Desktop** with the controller.
2. **Enter the remote ID.** On the controlling computer, enter the host's ID in **Remote Desktop → Remote ID**, then click **→**.
3. **Verify the password.** Enter the host's current connection password when prompted and confirm. Select **Remember password** if you want to save it.
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, edit its alias, or remove its record.
CrossDesk provides a local helper service on Windows named **CrossDesk Service**. Its service name is `CrossDeskService`. The service improves remote control in protected Windows states such as the lock screen, sign-in UI, credential UI, and secure desktop. It provides:
The eye button shows or hides the local password; the pencil button changes it. Use **6 ASCII letters or digits** and stay connected to the server while changing it. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
- Remote status reporting for lock screen, sign-in, credential, and secure desktop states.
- Remote `Ctrl+Alt+Del` (SAS) delivery.
- Keyboard and mouse input forwarding while the remote Windows device is on the lock screen, sign-in UI, or secure desktop.
### Connect from a browser
The Windows installer bundles `crossdesk_service.exe` and `crossdesk_session_helper.exe`, then registers the service as an on-demand Windows service during installation. When the CrossDesk client starts, it tries to start the installed service automatically. When no CrossDesk client process is running on the machine, the service exits automatically. Uninstalling the client also stops and removes the service.
1. Keep CrossDesk running and connected to the server on the host computer, with **Enable SRTP** enabled in its Settings.
2. Open the [Web Client](https://web.crossdesk.cn/), enter the **Remote Device ID** and **Password**, and click **Connect**.
3. Use the page's display, mouse-mode, and keyboard controls during the session. Phone and tablet browsers can also connect.
For manual Windows builds or deployments, make sure `CrossDesk.exe`, `crossdesk_service.exe`, and `crossdesk_session_helper.exe` are placed in the same directory. Open PowerShell with administrator privileges to install or uninstall the service:
For self-hosting, connect both sides to the same service. Browser deployment is documented in [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client).
<a id="session"></a>
## Session controls
Switch devices using session tabs. Expand the control bar if it is collapsed, and hover over icons to see their tooltips.
| Control | Action |
| --- | --- |
| Display | Switch the remote monitor |
| Keyboard | Send shortcuts; Windows `Ctrl+Alt+Del` requires the remote service |
| Mouse | Enable / release remote mouse control |
| Speaker | Play / mute remote audio |
| Folder | Select a file to send and view transfer progress |
| Network statistics | Inspect traffic, packet loss, frame rate, resolution, and direct / relay mode |
| Fullscreen | Enter / exit fullscreen |
| Disconnect | End the current remote session |
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
<a id="settings"></a>
## Video and connection settings
Open **☰ → Settings** in the top-right corner and click **OK** to save. Configure the session before connecting; some settings are disabled during an active session. Scroll down for self-hosting, startup behavior, and the file save path.
<p align="center">
<img src="docs/images/desktop-settings-en.png" width="720" alt="Current settings for quality, frame rate, video preference, codecs, relay, and SRTP" />
</p>
| Setting | Purpose |
| --- | --- |
| Video Quality / Video Capture Frame Rate | Low, medium, or high quality and 30 / 60 fps; actual performance depends on the connection and device |
| Video Preference | Choose frame-rate priority, quality priority, or balanced adaptation |
| Video Encode Format | H.264 / AV1; hardware codec availability depends on the platform, device, and build options |
| Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails |
| SRTP | Media encryption option; configurations on both ends must be compatible |
| Self-Hosted Config | Set the host, signaling port, and TURN port, then enable the checkbox |
| Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
| File Save Path | Choose where received files are saved |
<a id="ios"></a>
## Native iOS / iPadOS client
[`apps/ios`](apps/ios/README.md) contains a native controller using the same MiniRTC protocol as the desktop client. It controls remote computers; it does not expose an iPhone or iPad as a remotely controlled desktop.
1. Follow the [iOS guide](apps/ios/README.md) to build and sign the app with Xcode, then install it on an iOS 16+ physical device.
2. Enter a remote ID on the home screen, connect, and enter its password. Successful connections become available in the recent devices list.
3. Choose a mouse mode in Settings: **Relative Position** works like a trackpad; **Absolute Position** maps touches directly to the remote screen.
4. Tap the floating icon to expand the menu, then switch displays, open the keyboard, toggle audio, or send files. Received files are stored in `Documents/Received` and can be exported using the share button beside the transfer status.
The app currently receives remote text into the iOS clipboard. Its UI does not yet expose a button to send the local clipboard.
Gestures include one-finger click, two-finger right-click, long-press drag, and pinch-to-zoom. When zoomed in, two-finger panning moves the viewport. The native and browser clients are maintained separately, so their controls and gestures may differ. The current native UI uses Chinese labels.
<a id="windows-service"></a>
## Windows lock screens and sign-in
**CrossDesk Service** (`CrossDeskService`) reports protected desktop states and forwards `Ctrl+Alt+Del`, keyboard, and mouse input on lock screens, sign-in screens, credential prompts, and secure desktops.
The installer registers an on-demand service. CrossDesk tries to start it on launch, and the service exits when no local CrossDesk client is running. Portable builds offer service installation in the prompt or **Settings → Lock Screen Service**, with administrator privileges. The service depends on a running client.
<details>
<summary>Manual deployment and service commands</summary>
Keep the complete installation directory. For manual deployment, place `CrossDesk.exe`, `crossdesk_service.exe`, `crossdesk_session_helper.exe`, and all `.dll` files from the same build together. Open an administrator PowerShell in that directory and run the commands you need:
```powershell
# install
.\CrossDesk.exe --service-install
# start
.\CrossDesk.exe --service-start
# check status
.\CrossDesk.exe --service-status
.\CrossDesk.exe --service-ping
# stop
# Stop or uninstall the service
.\CrossDesk.exe --service-stop
# uninstall
.\CrossDesk.exe --service-uninstall
```
If the remote Windows service is not installed, not running, or temporarily unavailable, the basic remote desktop connection still works, but remote control on the lock screen, sign-in UI, and secure desktop is limited. The client will show “Remote Windows service unavailable”.
</details>
---
If **Remote Windows service unavailable** appears, check installation and service status on the host. Ordinary desktop connections remain available, while control of protected screens is limited.
## How to build
<a id="self-hosting"></a>
### Build requirements
## Self-hosting
- [xmake](https://xmake.io/#/guide/installation)
- [cmake](https://cmake.org/download/) 3.21 or later (installed automatically by xmake when the system version is too old)
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → Self-Hosted Config**. Enable the same server configuration on both the controller and host.
### Linux
- [Self-hosting guide](docs/SELF_HOSTING_EN.md): current Compose deployment, client settings, certificate trust, and troubleshooting.
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server): server source and release configuration.
- [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client): browser source and deployment instructions.
Linux builds support Ubuntu 20.04 or later on amd64 and arm64. Release packages
use Ubuntu 20.04/glibc 2.31 as their compatibility baseline. Install the base
build dependencies first:
## Development and feedback
```bash
sudo apt-get update
sudo apt-get install -y \
git curl unzip build-essential gcc-10 g++-10 python3-pip \
pkg-config binutils dpkg-dev \
libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \
libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \
libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \
libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxcb-shm0-dev \
libasound2-dev libsndio-dev libpulse-dev \
libgl1-mesa-dev
```
- [Build from source](docs/BUILD_EN.md): Windows, macOS, Linux, optional flags, and packaging.
- [iOS development](apps/ios/README.md): native build and physical-device checks.
- [GUI architecture](docs/gui-architecture.md): desktop UI and platform organization.
- [FAQ](docs/FAQ.md#english): connection failures, blank video, received files, and build issues.
- [Report an issue](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
When Ubuntu 20.04's bundled CMake 3.16 is too old, xmake automatically
downloads and uses CMake 3.21 or later; replacing the system CMake manually is
not required.
## Acknowledgements and license
Ubuntu 20.04's default GCC 9 lacks parts of the C++20 standard library needed
by Slint's C++ API, so the commands above install GCC 10 and the configuration
below selects it explicitly.
Thanks to [HelloGitHub](https://hellogithub.com/), [Ruanyf Weekly](https://github.com/ruanyf/weekly), and the [LinuxDo](https://linux.do) community for featuring CrossDesk and contributing feedback.
Install the optional dependencies when Wayland capture or DRM capture is enabled:
CrossDesk is licensed under [GPL-3.0](LICENSE). See the [Privacy Policy](PRIVACY.md#english).
```bash
sudo apt-get install -y libdbus-1-dev libdrm-dev
### Code signing policy
# Ubuntu 22.04 and later can alternatively use the distribution packages.
# sudo apt-get install -y libpipewire-0.3-dev libspa-0.2-dev
```
Clone the submodules and build a release binary:
```bash
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
# Run this when the repository has already been cloned
git submodule update --init --recursive
# Ubuntu 20.04 has no PipeWire 0.3 development package. Install the
# header-only SDK used during compilation. Skip this when the distribution's
# PipeWire 0.3 development packages are already installed.
sudo ./docker/linux-build/install-pipewire-sdk.sh
xmake f -c -m release --USE_CUDA=false --toolchain=gcc-10 -y
xmake b -vy crossdesk
```
Use `--toolchain=gcc-10` so that both CrossDesk and third-party packages such
as libyuv, which xmake builds during configuration, use GCC 10. Specifying only
`--cc`/`--cxx` may still let dependency builds fall back to the system
`/bin/cc` and `/bin/c++`. Keep `-c` when switching compilers to clear the old
configuration.
The amd64 binary is written to `build/linux/x86_64/release/crossdesk`; the arm64 binary is written to `build/linux/arm64/release/crossdesk`.
Example configuration with Wayland and DRM capture enabled:
```bash
xmake f -c -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false \
--toolchain=gcc-10 -y
xmake b -vy crossdesk
```
Build a Debian package after compiling a release binary on the matching architecture:
```bash
# amd64
./apps/desktop/scripts/linux/pkg_amd64.sh 1.0.0
# arm64
./apps/desktop/scripts/linux/pkg_arm64.sh 1.0.0
```
The package scripts install the shared Slint runtime in the private `/usr/lib/crossdesk` directory, so users do not need to install `libslint_cpp.so` separately.
PipeWire is not a mandatory runtime dependency. CrossDesk detects the host's
PipeWire 0.3 runtime dynamically and can still use X11 capture (and DRM when
enabled at build time) when it is unavailable.
### Common build options
```text
--USE_CUDA=true/false: Enable CUDA hardware codec acceleration, disabled by default
--USE_WAYLAND=true/false: Enable Wayland/PipeWire capture on Linux, disabled by default
--USE_DRM=true/false: Enable DRM capture on Linux, disabled by default
--CROSSDESK_PORTABLE=true/false: Build the portable variant, disabled by default
--CROSSDESK_VERSION=xxx: Set the CrossDesk version
# Example
xmake f --CROSSDESK_VERSION=1.0.0 --USE_CUDA=true
```
Run:
```bash
xmake r crossdesk
```
### Development Without CUDA Environment
For **Linux developers who do not have a CUDA environment installed and want to enable hardware codec feature**, a preconfigured [Ubuntu 20.04 compatibility build image](https://hub.docker.com/r/crossdesk/ubuntu20.04) is provided.
This image contains the required build dependencies and produces a single Linux package compatible with the glibc 2.31 baseline.
After entering the container, download the project and run:
```bash
export CUDA_PATH=/usr/local/cuda
export XMAKE_GLOBALDIR=/data
xmake f --USE_CUDA=true
xmake b --root -vy crossdesk
```
For **Windows developers without a CUDA environment** installed, run the following command to install the CUDA build environment:
```powershell
xmake require -vy "cuda 12.6.3"
```
After the installation is complete, execute:
```powershell
xmake require --info "cuda 12.6.3"
```
The output will look like this:
<img width="860" height="226" alt="Image" src="https://github.com/user-attachments/assets/999ac365-581a-4b9a-806e-05eb3e4cf44d" />
From the output above, locate the CUDA installation directory — this is the path pointed to by installdir.
Add this path to your system environment variable CUDA_PATH, or set it in the terminal using:
```powershell
set CUDA_PATH=path_to_cuda_installdir
```
Then re-run:
```powershell
xmake f --USE_CUDA=true
xmake b -vy crossdesk
```
#### Notice
If the client status bar shows **Disconnected** during runtime, please first install the client from the [CrossDesk official website](https://www.crossdesk.cn/) to ensure the required certificate files are available in the environment.
<img width="256" height="120" alt="image" src="https://github.com/user-attachments/assets/1812f7d6-516b-4b4f-8a3d-98bee505cc5a" />
### About Xmake
#### Installing Xmake
You can install Xmake using one of the following methods:
Using curl:
```bash
curl -fsSL https://xmake.io/shget.text | bash
```
Using wget:
```bash
wget https://xmake.io/shget.text -O - | bash
```
Using powershell:
```powershell
irm https://xmake.io/psget.text | iex
```
#### Build Options
```text
# Switch build mode
xmake f -m debug/release
# Optional build parameters
-r : Rebuild the target
-v : Show detailed build logs
-y : Automatically confirm prompts
# Example
xmake b -vy crossdesk
```
#### Run Options
```bash
# Run in debug mode
xmake r -d crossdesk
```
For more information, please refer to the [official Xmake documentation](https://xmake.io/guide/quick-start.html).
---
## Self-Hosted Server
### Server
It is recommended to deploy CrossDesk Server using Docker.
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=xxx.xxx.xxx.xxx \
-e INTERNAL_IP=xxx.xxx.xxx.xxx \
-e CROSSDESK_SERVER_PORT=xxxx \
-e COTURN_PORT=xxxx \
-e MIN_PORT=xxxxx \
-e MAX_PORT=xxxxx \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.6
```
The parameters you need to pay attention to are as follows:
#### Parameters
- **EXTERNAL_IP**: The server’s public IP. This corresponds to **Server Address** in the CrossDesk client’s **Self-Hosted Server Configuration**.
- **INTERNAL_IP**: The server’s internal IP.
- **CROSSDESK_SERVER_PORT**: The port used by the self-hosted service. This corresponds to **Server Port** in the CrossDesk client’s **Self-Hosted Server Configuration**.
- **COTURN_PORT**: The port used by the COTURN service. This corresponds to **Relay Service Port** in the CrossDesk client’s **Self-Hosted Server Configuration**.
- **MIN_PORT / MAX_PORT**: The port range used by the COTURN service. Example: `MIN_PORT=50000`, `MAX_PORT=60000`. Adjust the range depending on the number of clients.
- `-v /var/lib/crossdesk:/var/lib/crossdesk`: Persists database and certificate files on the host machine.
- `-v /var/log/crossdesk:/var/log/crossdesk`: Persists log files on the host machine.
#### Example
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=114.114.114.114 \
-e INTERNAL_IP=10.0.0.1 \
-e CROSSDESK_SERVER_PORT=9099 \
-e COTURN_PORT=3478 \
-e MIN_PORT=50000 \
-e MAX_PORT=60000 \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.6
```
#### Notes
- **The server must open the following ports: COTURN_PORT/udp, COTURN_PORT/tcp, MIN_PORT–MAX_PORT/udp, and CROSSDESK_SERVER_PORT/tcp.**
- If you don’t mount volumes, all data will be lost when the container is removed.
- Certificate files will be automatically generated on first startup and persisted to the host at `/var/lib/crossdesk/certs`. As the default certificates are self-signed and cannot guarantee security, it is strongly recommended to apply for a trusted certificate from a cloud provider, deploy it to this directory, and restart the service.
- The database file will be automatically created and stored at `/var/lib/crossdesk/db/crossdesk-server.db`.
- Log files will be created and stored at `/var/log/crossdesk/`.
#### Directory permissions
If the directories automatically created by Docker belong to root and have insufficient write permissions, the container user may not be able to write to them. This can cause:
- Certificate generation failure, leading to startup script errors and container exit.
- Database directory creation failure, causing the program to throw exceptions and crash.
- Log directory creation failure, preventing logs from being written (though the program may continue running).
Manually set permissions before starting the container:
```bash
sudo mkdir -p /var/lib/crossdesk /var/log/crossdesk
sudo chown -R $(id -u):$(id -g) /var/lib/crossdesk /var/log/crossdesk
```
#### TLS certificate
Place **crossdesk.cn.key** and **crossdesk.cn_bundle.crt** into the **/path/to/your/certs** directory.
### Client
1. Click the settings icon in the top-right corner to enter the settings page.<br><br>
<img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/6431131d-b32a-4726-8783-6788f47baa3b" /><br>
2. Click `Self-Hosted Server Configuration` button.<br><br>
<img width="600" height="160" alt="image" src="https://github.com/user-attachments/assets/24c761a3-1985-4d7e-84be-787383c2afb8" /><br>
3. Enter the `Server Address` (**EXTERNAL_IP**), `Signaling Service Port` (**CROSSDESK_SERVER_PORT**), and `Relay Service Port` (**COTURN_PORT**) and click OK button.
4. Check the `Self-hosted server configuration` option and click the OK button to save the settings. If the server is using a valid (official) certificate, the process ends here and the client will show that it is connected to the server.
5. If the default certificate is used (skip this step if an official certificate is used), download the self-signed root certificate `api.crossdesk.cn_root.crt` from the server directory /var/lib/crossdesk/certs/ to the machine running the client, and install the certificate by executing the following command:
**Windows:** Open PowerShell with administrator privileges and execute:
```powershell
certutil -addstore "Root" "C:\path\to\api.crossdesk.cn_root.crt"
```
**Linux:**
```bash
sudo cp /path/to/api.crossdesk.cn_root.crt /usr/local/share/ca-certificates/api.crossdesk.cn_root.crt
sudo update-ca-certificates
```
**macOS:**
```bash
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain path/to/api.crossdesk.cn_root.crt
```
### Self-Hosted Web Client
See [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client).
---
## FAQ
See [FAQ](https://github.com/kunkundi/crossdesk/blob/self-hosted-server/docs/FAQ.md).
---
## Acknowledgements
- Thanks to [HelloGitHub](https://hellogithub.com/) for the recommendation and exposure.
- Thanks to [Ruanyf Weekly](https://github.com/ruanyf/weekly) for featuring CrossDesk.
- Thanks to the [LinuxDo](https://linux.do) community for the attention, discussions, and support that helped improve CrossDesk.
---
## Code signing policy
CrossDesk uses SignPath.io to code-sign official Windows releases built from this repository.
CrossDesk uses SignPath.io to sign official Windows releases built from this repository.
**Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).**
- **Committers and reviewers:** [kunkundi](https://github.com/kunkundi)
- **Approvers:** [kunkundi](https://github.com/kunkundi)
- **Privacy policy:** [Read the CrossDesk Privacy Policy](PRIVACY.md#english)
+5 -1
View File
@@ -1,6 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
# Keep readelf and ldd output stable for the runtime dependency checks below.
export LC_ALL=C
if [[ $# -lt 3 || $# -gt 4 ]]; then
echo "Usage: $0 <debian-arch> <xmake-arch> <version> [extra-recommendation]" >&2
exit 2
@@ -169,11 +172,12 @@ EOF
cat > "$DESKTOP_DIR/$PKG_NAME.desktop" <<EOF
[Desktop Entry]
Version=$DEB_VERSION
Version=1.0
Name=$APP_NAME
Comment=$DESCRIPTION
Exec=/usr/bin/$PKG_NAME
Icon=$PKG_NAME
StartupWMClass=$PKG_NAME
Terminal=false
Type=Application
Categories=Utility;
@@ -0,0 +1,65 @@
param(
[Parameter(Mandatory)]
[ValidateSet('PrepareBinaries', 'RestoreBinaries', 'VerifyInstaller')]
[string]$Operation,
[Parameter(Mandatory)] [string]$Artifact,
[string]$Directory,
[ValidateSet('test-signing', 'release-signing')] [string]$SigningPolicy
)
. "$PSScriptRoot/signpath-common.ps1"
if ($Operation -ne 'PrepareBinaries' -and !$SigningPolicy) {
throw 'SigningPolicy is required when verifying signed artifacts.'
}
if ($Operation -eq 'VerifyInstaller') {
Assert-CrossDeskSignature -Path $Artifact -SigningPolicy $SigningPolicy
return
}
if (!$Directory -or !(Test-Path -LiteralPath $Directory -PathType Container)) {
throw "Binaries directory does not exist: $Directory"
}
$files = @(Get-CrossDeskSigningFiles)
$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) "crossdesk-signing-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null
try {
if ($Operation -eq 'PrepareBinaries') {
foreach ($name in $files) {
$source = Join-Path $Directory $name
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
throw "Missing CrossDesk binary: $source"
}
Copy-Item -LiteralPath $source -Destination (Join-Path $temporaryDirectory $name)
}
$artifactPath = [IO.Path]::GetFullPath($Artifact)
New-Item -ItemType Directory -Path (Split-Path -Parent $artifactPath) -Force | Out-Null
Compress-Archive -Path "$temporaryDirectory/*" -DestinationPath $artifactPath -Force
return
}
# Inspect the ZIP before extraction: reject missing, duplicate or unexpected
# entries (including paths) so only the four intended files can be replaced.
$archive = [IO.Compression.ZipFile]::OpenRead([IO.Path]::GetFullPath($Artifact))
try {
$entries = @($archive.Entries | ForEach-Object { $_.FullName })
$uniqueEntries = @($entries | Sort-Object -Unique)
if ($entries.Count -ne $files.Count -or $uniqueEntries.Count -ne $files.Count -or
@(Compare-Object $files $entries).Count -ne 0) {
throw "Signed archive must contain exactly: $($files -join ', ')"
}
} finally {
$archive.Dispose()
}
Expand-Archive -LiteralPath $Artifact -DestinationPath $temporaryDirectory
# Verify every file before replacing any build output.
foreach ($name in $files) {
Assert-CrossDeskSignature -Path (Join-Path $temporaryDirectory $name) -SigningPolicy $SigningPolicy
}
foreach ($name in $files) {
Copy-Item -LiteralPath (Join-Path $temporaryDirectory $name) -Destination (Join-Path $Directory $name) -Force
}
} finally {
Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<parameters>
<parameter name="version" required="true" />
</parameters>
<zip-file>
<pe-file-set product-name="CrossDesk"
product-version="${version}"
file-version="${version}"
company-name="CrossDesk"
copyright="Copyright (C) CrossDesk contributors">
<include path="crossdesk.exe" original-filename="CrossDesk.exe" />
<include path="crossdesk_service.exe" original-filename="crossdesk_service.exe" />
<include path="crossdesk_session_helper.exe" original-filename="crossdesk_session_helper.exe" />
<include path="wgc_plugin.dll" original-filename="wgc_plugin.dll" />
<for-each>
<authenticode-sign />
</for-each>
</pe-file-set>
</zip-file>
</artifact-configuration>
@@ -0,0 +1,57 @@
$ErrorActionPreference = 'Stop'
function Get-CrossDeskSigningFiles {
# Only binaries built from this project's sources. Never include third-party DLLs.
return @('crossdesk.exe', 'crossdesk_service.exe', 'crossdesk_session_helper.exe', 'wgc_plugin.dll')
}
function Find-CrossDeskSignTool {
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
$sdk = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/bin'
$tool = Get-ChildItem "$sdk/*/x64/signtool.exe" |
Sort-Object { [version]$_.Directory.Parent.Name } |
Select-Object -Last 1
if (!$tool) {
throw 'SignTool from the Windows SDK is required to verify release signatures.'
}
return $tool.FullName
}
function Assert-CrossDeskSignature {
param(
[Parameter(Mandatory)] [string]$Path,
[Parameter(Mandatory)] [ValidateSet('test-signing', 'release-signing')] [string]$SigningPolicy
)
if (!(Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Missing signed file: $Path"
}
$signature = Get-AuthenticodeSignature -LiteralPath $Path
if (!$signature.SignerCertificate) {
throw "File has no Authenticode signing certificate: $Path"
}
Write-Host "Signature: $Path | $($signature.Status) | $($signature.SignerCertificate.Subject)"
if ($SigningPolicy -eq 'test-signing') {
# Self-signed test certificates are not trusted by stock GitHub runners.
# HashMismatch, NotSigned, UnknownError, etc. must still stop the build.
if ($signature.Status -notin @('Valid', 'NotTrusted')) {
throw "Invalid test signature on ${Path}: $($signature.Status)"
}
return
}
if ($signature.Status -ne 'Valid') {
throw "Release signature is not trusted on ${Path}: $($signature.Status)"
}
if (!$signature.TimeStamperCertificate) {
throw "Release signature has no timestamp: $Path"
}
$signTool = Find-CrossDeskSignTool
& $signTool verify /pa /all /v $Path
if ($LASTEXITCODE -ne 0) {
throw "SignTool verification failed for $Path (exit $LASTEXITCODE)."
}
}
@@ -116,16 +116,6 @@ int ConfigCenter::Load() {
} else {
signal_server_port_ = 0;
}
const char* coturn_server_port_value =
ini_.GetValue(section_, "coturn_server_port", nullptr);
if (coturn_server_port_value != nullptr &&
strlen(coturn_server_port_value) > 0) {
coturn_server_port_ =
static_cast<int>(ini_.GetLongValue(section_, "coturn_server_port", 0));
} else {
coturn_server_port_ = 0;
}
enable_autostart_ =
ini_.GetBoolValue(section_, "enable_autostart", enable_autostart_);
enable_daemon_ = ini_.GetBoolValue(section_, "enable_daemon", enable_daemon_);
@@ -171,8 +161,6 @@ int ConfigCenter::Save() {
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
ini_.SetLongValue(section_, "signal_server_port",
static_cast<long>(signal_server_port_));
ini_.SetLongValue(section_, "coturn_server_port",
static_cast<long>(coturn_server_port_));
}
ini_.SetBoolValue(section_, "enable_autostart", enable_autostart_);
@@ -316,17 +304,6 @@ int ConfigCenter::SetServerPort(int signal_server_port) {
return 0;
}
int ConfigCenter::SetCoturnServerPort(int coturn_server_port) {
coturn_server_port_ = coturn_server_port;
ini_.SetLongValue(section_, "coturn_server_port",
static_cast<long>(coturn_server_port_));
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
return 0;
}
int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
enable_self_hosted_ = enable_self_hosted;
ini_.SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
@@ -346,19 +323,9 @@ int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
signal_server_port_ = static_cast<int>(
ini_.GetLongValue(section_, "signal_server_port", 0));
}
const char* coturn_server_port_value =
ini_.GetValue(section_, "coturn_server_port", nullptr);
if (coturn_server_port_value != nullptr &&
strlen(coturn_server_port_value) > 0) {
coturn_server_port_ = static_cast<int>(
ini_.GetLongValue(section_, "coturn_server_port", 0));
}
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
ini_.SetLongValue(section_, "signal_server_port",
static_cast<long>(signal_server_port_));
ini_.SetLongValue(section_, "coturn_server_port",
static_cast<long>(coturn_server_port_));
}
SI_Error rc = ini_.SaveFile(config_path_.c_str());
@@ -456,8 +423,6 @@ std::string ConfigCenter::GetSignalServerHost() const {
int ConfigCenter::GetSignalServerPort() const { return signal_server_port_; }
int ConfigCenter::GetCoturnServerPort() const { return coturn_server_port_; }
std::string ConfigCenter::GetDefaultServerHost() const {
return signal_server_host_default_;
}
@@ -466,10 +431,6 @@ int ConfigCenter::GetDefaultSignalServerPort() const {
return server_port_default_;
}
int ConfigCenter::GetDefaultCoturnServerPort() const {
return coturn_server_port_default_;
}
bool ConfigCenter::IsSelfHosted() const { return enable_self_hosted_; }
bool ConfigCenter::IsEnableAutostart() const { return enable_autostart_; }
@@ -47,7 +47,6 @@ class ConfigCenter {
int SetSrtp(bool enable_srtp);
int SetServerHost(const std::string& signal_server_host);
int SetServerPort(int signal_server_port);
int SetCoturnServerPort(int coturn_server_port);
int SetSelfHosted(bool enable_self_hosted);
int SetAutostart(bool enable_autostart);
int SetDaemon(bool enable_daemon);
@@ -67,10 +66,8 @@ class ConfigCenter {
bool IsEnableSrtp() const;
std::string GetSignalServerHost() const;
int GetSignalServerPort() const;
int GetCoturnServerPort() const;
std::string GetDefaultServerHost() const;
int GetDefaultSignalServerPort() const;
int GetDefaultCoturnServerPort() const;
bool IsSelfHosted() const;
bool IsEnableAutostart() const;
bool IsEnableDaemon() const;
@@ -98,8 +95,6 @@ class ConfigCenter {
std::string signal_server_host_default_ = "api.crossdesk.cn";
int signal_server_port_ = 0;
int server_port_default_ = 9099;
int coturn_server_port_ = 0;
int coturn_server_port_default_ = 3478;
bool enable_self_hosted_ = false;
bool enable_autostart_ = false;
bool enable_daemon_ = false;
@@ -66,8 +66,6 @@ struct InteractionState {
bool mouse_controller_is_started_ = false;
bool start_screen_capturer_ = false;
bool screen_capturer_is_started_ = false;
bool start_speaker_capturer_ = false;
bool speaker_capturer_is_started_ = false;
bool start_keyboard_capturer_ = false;
bool show_cursor_ = false;
bool keyboard_capturer_is_started_ = false;
@@ -198,4 +196,4 @@ struct ApplicationState : MainWindowState,
} // namespace crossdesk::gui_detail
#endif
#endif
@@ -1111,6 +1111,9 @@ void GuiApplication::InitializeUi() {
wayland_display_guard.emplace("WAYLAND_DISPLAY");
wayland_socket_guard.emplace("WAYLAND_SOCKET");
}
// Match crossdesk.desktop even when the Debian launcher runs crossdesk-bin.
// Set this before creating any windows so X11 and Wayland share the same id.
slint::set_xdg_app_id("crossdesk");
#endif
ui_ = std::make_unique<SlintUi>();
ui_->video_presenter =
@@ -1151,6 +1154,7 @@ void GuiApplication::InitializeSystemTray() {
ui_->main->show();
ui_->main->window().set_minimized(false);
#if defined(__APPLE__)
MacActivateWindow(ui_->main->window().appkit_view());
// A window restored from the tray may only obtain its NSWindow after the
// callback returns, so let the regular UI tick configure it.
ui_->main_native_titlebar_attempts = 30;
@@ -1161,16 +1165,12 @@ void GuiApplication::InitializeSystemTray() {
ui_->main->hide();
}
};
auto open_settings = [this] {
auto open_settings = [this, show_window] {
if (!ui_) {
return;
}
ui_->main->show();
ui_->main->window().set_minimized(false);
show_window();
ui_->main->set_settings_open(true);
#if defined(__APPLE__)
ui_->main_native_titlebar_attempts = 30;
#endif
};
auto exit_app = [this] {
exit_ = true;
@@ -1230,7 +1230,6 @@ void GuiApplication::ResetSettingsUi() {
ui_->main->set_file_save_path(file_transfer_save_path_buf_);
ui_->main->set_server_host(signal_server_ip_self_);
ui_->main->set_server_port(signal_server_port_self_);
ui_->main->set_coturn_port(coturn_server_port_self_);
}
void GuiApplication::BindMainCallbacks() {
@@ -1388,7 +1387,6 @@ void GuiApplication::BindMainCallbacks() {
main->on_save_self_hosted_settings([this] {
const std::string host(ui_->main->get_server_host());
const auto signal_port = ParsePort(ui_->main->get_server_port());
const auto coturn_port = ParsePort(ui_->main->get_coturn_port());
if (!host.empty()) {
config_center_->SetServerHost(host);
std::memset(signal_server_ip_self_, 0, sizeof(signal_server_ip_self_));
@@ -1405,27 +1403,31 @@ void GuiApplication::BindMainCallbacks() {
std::snprintf(signal_server_port_, sizeof(signal_server_port_), "%d",
*signal_port);
}
if (coturn_port) {
config_center_->SetCoturnServerPort(*coturn_port);
std::snprintf(coturn_server_port_self_, sizeof(coturn_server_port_self_),
"%d", *coturn_port);
std::snprintf(coturn_server_port_, sizeof(coturn_server_port_), "%d",
*coturn_port);
}
});
main->on_cancel_self_hosted_settings([this] {
const std::string host = config_center_->GetSignalServerHost();
ui_->main->set_server_host(UiText(host));
const int signal_port = config_center_->GetSignalServerPort();
const int coturn_port = config_center_->GetCoturnServerPort();
ui_->main->set_server_port(signal_port > 0
? UiText(std::to_string(signal_port))
: slint::SharedString{});
ui_->main->set_coturn_port(coturn_port > 0
? UiText(std::to_string(coturn_port))
: slint::SharedString{});
});
main->on_open_download([this] { OpenUrl("https://crossdesk.cn"); });
main->on_start_update([this] {
#if _WIN32
windows_updater_.Start(latest_version_info_);
SyncWindowsUpdate();
#else
OpenUrl("https://crossdesk.cn");
ui_->main->set_update_open(false);
#endif
});
main->on_cancel_update([this] {
#if _WIN32
windows_updater_.Cancel();
#endif
ui_->main->set_update_open(false);
});
main->on_connection_cancel([this] {
const auto props = FindRemoteSession(ui_->connection_dialog_remote_id);
if (!props) {
@@ -2113,6 +2115,9 @@ void GuiApplication::SyncMainWindow() {
ui_->main->set_signal_tls_error(signal_status_ ==
SignalStatus::SignalTlsCertError);
ui_->main->set_update_available(update_available_);
#if _WIN32
SyncWindowsUpdate();
#endif
ui_->main->set_current_version(CROSSDESK_VERSION);
ui_->main->set_latest_version(UiText(latest_version_));
ui_->main->set_release_name(UiText(release_name_));
@@ -2962,9 +2967,6 @@ void GuiApplication::SaveSettingsFromUi() {
if (const auto port = ParsePort(main->get_server_port())) {
config_center_->SetServerPort(*port);
}
if (const auto port = ParsePort(main->get_coturn_port())) {
config_center_->SetCoturnServerPort(*port);
}
language_button_value_last_ = language_button_value_;
video_quality_button_value_last_ = video_quality_button_value_;
@@ -3273,7 +3275,59 @@ bool GuiApplication::OpenUrl(const std::string& url) {
return SDL_OpenURL(url.c_str());
}
#if _WIN32
void GuiApplication::SyncWindowsUpdate() {
using State = WindowsUpdater::State;
if (windows_updater_.GetState() == State::Ready) {
windows_updater_.Launch();
if (windows_updater_.GetState() == State::Launched) {
ui_->main->set_update_open(false);
}
}
const State state = windows_updater_.GetState();
const bool downloading = state == State::Downloading;
ui_->main->set_update_downloading(downloading || state == State::Checking ||
state == State::Launching);
ui_->main->set_update_security_blocked(state == State::SecurityBlocked);
ui_->main->set_update_failed(state == State::Failed ||
state == State::LaunchFailed ||
state == State::SecurityBlocked);
const int language = localization_language_index_;
std::string status;
if (downloading) {
status = localization::update_downloading[language];
const uint64_t total = windows_updater_.Total();
const uint64_t received = windows_updater_.Downloaded();
if (total > 0) {
const int percent = static_cast<int>(std::min(100.0, 100.0 * received / total));
status += " " + std::to_string(percent) + "%";
ui_->main->set_update_progress(static_cast<float>(percent) / 100.0f);
} else {
status += " " + std::to_string(received / (1024 * 1024)) + " MB";
ui_->main->set_update_progress(-1.0f);
}
} else if (state == State::Failed) {
status = localization::update_download_failed[language];
} else if (state == State::LaunchFailed) {
status = localization::update_launch_failed[language];
} else if (state == State::Checking) {
status = localization::update_checking[language];
ui_->main->set_update_progress(1.0f);
} else if (state == State::SecurityBlocked) {
status = localization::update_security_blocked[language];
} else if (state == State::Launching) {
status = localization::update_launching[language];
} else if (state == State::Launched) {
status = localization::update_installer_opened[language];
}
ui_->main->set_update_status(UiText(status));
}
#endif
void GuiApplication::Cleanup() {
#if _WIN32
windows_updater_.Cancel();
#endif
if (!ui_) {
SDL_Quit();
return;
@@ -16,6 +16,9 @@
#include "runtime/cursor_state_provider.h"
#include "runtime/gui_runtime.h"
#if _WIN32
#include "platform/windows/windows_updater.h"
#endif
namespace crossdesk {
@@ -52,6 +55,9 @@ private:
void HandlePasswordChangeResult();
void HandleCredentialRecovery();
void SyncMainWindow();
#if _WIN32
void SyncWindowsUpdate();
#endif
void SyncConnectionDialog();
void SyncPlatformDialogs();
void SyncStreamWindow();
@@ -87,6 +93,9 @@ private:
bool OpenUrl(const std::string &url);
std::unique_ptr<SlintUi> ui_;
#if _WIN32
WindowsUpdater windows_updater_;
#endif
CursorStateProvider cursor_state_provider_;
std::unordered_map<std::string, CursorDeliveryState>
cursor_delivery_states_;
@@ -100,4 +109,4 @@ private:
} // namespace crossdesk
#endif
#endif
@@ -149,8 +149,6 @@ struct TranslationRow {
"Server Address:", u8"Адрес сервера:") \
X(self_hosted_server_port, u8"信令服务端口:", \
"Signal Service Port:", u8"Порт сигнального сервиса:") \
X(self_hosted_server_coturn_server_port, u8"中继服务端口:", \
"Relay Service Port:", u8"Порт реле-сервиса:") \
X(ok, u8"确认", "OK", u8"ОК") \
X(cancel, u8"取消", "Cancel", u8"Отмена") \
X(new_password, u8"请输入六位密码:", \
@@ -186,6 +184,26 @@ struct TranslationRow {
X(notification, u8"通知", "Notification", u8"Уведомление") \
X(new_version_available, u8"新版本可用", "New Version Available", \
u8"Доступна новая версия") \
X(update_downloading, u8"正在下载安装包…", "Downloading installer…", \
u8"Загрузка установщика…") \
X(update_checking, u8"正在进行 Windows 安全检查…", \
"Running Windows security checks…", u8"Проверка безопасности Windows…") \
X(update_security_blocked, \
u8"Windows 安全检查未通过,请查看安全软件日志并核实发布者。", \
"Windows security checks failed. Review security logs and verify the publisher.", \
u8"Проверка Windows не пройдена. Проверьте журнал защиты и издателя.") \
X(update_launching, u8"正在打开安装包,请确认管理员权限请求…", \
"Opening installer. Please confirm the administrator permission request…", \
u8"Запуск установщика. Подтвердите запрос прав администратора…") \
X(update_download_failed, u8"下载失败,请点击更新重试,或访问官网。", \
"Download failed. Click Update to retry, or visit the website.", \
u8"Ошибка загрузки. Повторите обновление или откройте сайт.") \
X(update_launch_failed, u8"安装包未能启动或授权已取消,请点击更新重试。", \
"Installer launch failed or permission was cancelled. Click Update to retry.", \
u8"Установщик не запущен или доступ отменён. Повторите обновление.") \
X(update_installer_opened, u8"安装包已打开,请在安装向导中完成更新。", \
"Installer opened. Follow the setup wizard to finish updating.", \
u8"Установщик открыт. Следуйте указаниям мастера установки.") \
X(release_notes, u8"更新内容", "Release Notes", u8"Содержание обновления") \
X(version, u8"版本", "Version", u8"Версия") \
X(release_date, u8"发布日期: ", "Release Date: ", u8"Дата релиза: ") \
@@ -9,6 +9,7 @@
#include "platform.h"
#include "rd_log.h"
#include "runtime/gui_runtime.h"
#include "speaker_capturer_factory.h"
namespace crossdesk {
namespace {
@@ -19,7 +20,23 @@ constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1);
} // namespace
SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) : owner_(owner) {}
SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
: owner_(owner),
speaker_capture_(
[] {
return std::unique_ptr<SpeakerCapturer>(
SpeakerCapturerFactory().Create());
},
[this](unsigned char *data, size_t size, const char *) {
if (!owner_.peer_) return;
MiniRtcAudioFrame frame{};
frame.data = reinterpret_cast<const char *>(data);
frame.size = size;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
SendAudioFrame(owner_.peer_, &frame, owner_.audio_label_.c_str());
}) {}
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
bool SessionDeviceManager::ShouldSendCapturedFrame(
std::chrono::steady_clock::time_point now, int fps) {
@@ -41,7 +58,6 @@ bool SessionDeviceManager::ShouldSendCapturedFrame(
void SessionDeviceManager::Initialize() {
InitializeAudioOutput();
screen_capturer_factory_ = new ScreenCapturerFactory();
speaker_capturer_factory_ = new SpeakerCapturerFactory();
device_controller_factory_ = new DeviceControllerFactory();
keyboard_capturer_ =
static_cast<KeyboardCapturer *>(device_controller_factory_->Create(
@@ -200,45 +216,12 @@ int SessionDeviceManager::StopScreenCapturer() {
return 0;
}
int SessionDeviceManager::StartSpeakerCapturer() {
if (!speaker_capturer_) {
speaker_capturer_ =
static_cast<SpeakerCapturer *>(speaker_capturer_factory_->Create());
const int init_ret = speaker_capturer_->Init(
[this](unsigned char *data, size_t size, const char *audio_name) {
MiniRtcAudioFrame frame{};
frame.data = reinterpret_cast<const char *>(data);
frame.size = size;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
SendAudioFrame(owner_.peer_, &frame, owner_.audio_label_.c_str());
});
if (init_ret != 0) {
speaker_capturer_->Destroy();
delete speaker_capturer_;
speaker_capturer_ = nullptr;
}
}
if (!speaker_capturer_) {
return -1;
}
const int ret = speaker_capturer_->Start();
if (ret != 0) {
LOG_ERROR("Start speaker capturer failed: {}", ret);
return ret;
}
owner_.start_speaker_capturer_ = true;
return 0;
void SessionDeviceManager::StartSpeakerCapturer() {
speaker_capture_.SetEnabled(true);
}
int SessionDeviceManager::StopSpeakerCapturer() {
if (speaker_capturer_) {
speaker_capturer_->Stop();
owner_.start_speaker_capturer_ = false;
}
return 0;
void SessionDeviceManager::StopSpeakerCapturer() {
speaker_capture_.SetEnabled(false);
}
int SessionDeviceManager::StartMouseController() {
@@ -448,16 +431,6 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.screen_capturer_is_started_ = false;
}
if (owner_.start_speaker_capturer_ && !owner_.speaker_capturer_is_started_) {
if (StartSpeakerCapturer() == 0) {
owner_.speaker_capturer_is_started_ = true;
}
} else if (!owner_.start_speaker_capturer_ &&
owner_.speaker_capturer_is_started_) {
StopSpeakerCapturer();
owner_.speaker_capturer_is_started_ = false;
}
if (owner_.start_mouse_controller_ && !owner_.mouse_controller_is_started_) {
if (StartMouseController() == 0) {
owner_.mouse_controller_is_started_ = true;
@@ -556,6 +529,7 @@ SessionDeviceManager::display_info_list() const {
}
void SessionDeviceManager::DestroyDevices() {
speaker_capture_.Shutdown();
if (mouse_controller_) {
mouse_controller_->Destroy();
delete mouse_controller_;
@@ -566,11 +540,6 @@ void SessionDeviceManager::DestroyDevices() {
delete screen_capturer_;
screen_capturer_ = nullptr;
}
if (speaker_capturer_) {
speaker_capturer_->Destroy();
delete speaker_capturer_;
speaker_capturer_ = nullptr;
}
if (keyboard_capturer_) {
delete keyboard_capturer_;
keyboard_capturer_ = nullptr;
@@ -580,8 +549,6 @@ void SessionDeviceManager::DestroyDevices() {
void SessionDeviceManager::DestroyFactories() {
delete screen_capturer_factory_;
screen_capturer_factory_ = nullptr;
delete speaker_capturer_factory_;
speaker_capturer_factory_ = nullptr;
delete device_controller_factory_;
device_controller_factory_ = nullptr;
}
@@ -1,5 +1,12 @@
#ifndef CROSSDESK_GUI_SESSION_DEVICE_MANAGER_H_
#define CROSSDESK_GUI_SESSION_DEVICE_MANAGER_H_
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SESSION_DEVICE_MANAGER_H_
#define _SESSION_DEVICE_MANAGER_H_
#include <SDL3/SDL.h>
@@ -17,7 +24,7 @@
#include "device_controller_factory.h"
#include "display_info.h"
#include "screen_capturer_factory.h"
#include "speaker_capturer_factory.h"
#include "speaker_capture_controller.h"
namespace crossdesk {
@@ -29,6 +36,7 @@ class GuiRuntime;
class SessionDeviceManager {
public:
explicit SessionDeviceManager(GuiRuntime &owner);
~SessionDeviceManager();
void Initialize();
void UpdateInteractions();
@@ -38,8 +46,10 @@ public:
int InitializeScreenCapturer();
int StartScreenCapturer();
int StopScreenCapturer();
int StartSpeakerCapturer();
int StopSpeakerCapturer();
// Requests are asynchronous; Stop drains/revokes audio delivery before
// returning so the connection peer can be destroyed safely.
void StartSpeakerCapturer();
void StopSpeakerCapturer();
int StartMouseController();
int StopMouseController();
int StartKeyboardCapturer();
@@ -76,8 +86,7 @@ private:
SDL_AudioStream *output_stream_ = nullptr;
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
ScreenCapturer *screen_capturer_ = nullptr;
SpeakerCapturerFactory *speaker_capturer_factory_ = nullptr;
SpeakerCapturer *speaker_capturer_ = nullptr;
SpeakerCaptureController speaker_capture_;
DeviceControllerFactory *device_controller_factory_ = nullptr;
MouseController *mouse_controller_ = nullptr;
KeyboardCapturer *keyboard_capturer_ = nullptr;
@@ -93,4 +102,4 @@ private:
} // namespace crossdesk
#endif // CROSSDESK_GUI_SESSION_DEVICE_MANAGER_H_
#endif
@@ -14,42 +14,39 @@
#include "platform.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#include "runtime/device_presence_request.h"
#include "runtime/gui_runtime.h"
namespace crossdesk {
namespace {
constexpr auto kPresenceProbeTimeout = std::chrono::seconds(5);
constexpr auto kPresenceRefreshInterval = std::chrono::seconds(30);
constexpr auto kPresenceRetryInterval = std::chrono::seconds(1);
} // namespace
void GuiRuntime::HandleConnectionStatusChange() {
if (signal_connected_ && peer_ && need_to_send_recent_connections_) {
if (!recent_connection_ids_.empty()) {
nlohmann::json j;
j["type"] = "recent_connections_presence";
j["user_id"] = client_id_;
j["devices"] = nlohmann::json::array();
for (const auto& id : recent_connection_ids_) {
std::string pure_id = id;
size_t pos_y = pure_id.find('Y');
size_t pos_n = pure_id.find('N');
size_t pos = std::string::npos;
if (pos_y != std::string::npos &&
(pos_n == std::string::npos || pos_y < pos_n)) {
pos = pos_y;
} else if (pos_n != std::string::npos) {
pos = pos_n;
}
if (pos != std::string::npos) {
pure_id = pure_id.substr(0, pos);
}
j["devices"].push_back(pure_id);
}
auto s = j.dump();
SendSignalMessage(peer_, s.data(), s.size());
}
if (!signal_connected_ || !peer_) {
return;
}
const auto now = std::chrono::steady_clock::now();
if (now - last_presence_request_attempt_at_ < kPresenceRetryInterval) {
return;
}
// Consume the flag before sending so a concurrent reconnect cannot lose its
// refresh request. Keep it pending while disconnected or after a send error.
const bool requested = need_to_send_recent_connections_.exchange(false);
if (!requested && now < next_presence_refresh_at_) {
return;
}
last_presence_request_attempt_at_ = now;
const auto message =
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true).dump();
if (SendSignalMessage(peer_, message.data(), message.size()) == 0) {
next_presence_refresh_at_ = now + kPresenceRefreshInterval;
} else {
need_to_send_recent_connections_ = true;
}
need_to_send_recent_connections_ = false;
}
void GuiRuntime::HandlePendingPresenceProbe() {
@@ -171,14 +168,11 @@ void GuiRuntime::HandleServerControllerDisconnected(
#else
start_screen_capturer_ = false;
#endif
start_speaker_capturer_ = false;
devices_.StopSpeakerCapturer();
start_mouse_controller_ = false;
start_keyboard_capturer_ = false;
remote_client_id_.clear();
if (audio_capture_) {
devices_.StopSpeakerCapturer();
audio_capture_ = false;
}
audio_capture_ = false;
devices_.ResetToInitialDisplay();
}
@@ -200,11 +194,9 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
pending_presence_remember_password_ = remember_password;
}
nlohmann::json j;
j["type"] = "recent_connections_presence";
j["user_id"] = client_id_;
j["devices"] = nlohmann::json::array({remote_id});
auto s = j.dump();
const auto s = BuildDevicePresenceRequest(
client_id_, recent_connection_ids_, false, remote_id)
.dump();
int ret = SendSignalMessage(peer_, s.data(), s.size());
if (ret != 0) {
@@ -308,6 +300,7 @@ void GuiRuntime::CloseConnectionPeer() {
devices_.StopSpeakerCapturer();
devices_.StopKeyboardCapturer();
signal_connected_ = false;
device_presence_cache_.SetSignalConnected(false);
signal_status_ = SignalStatus::SignalClosed;
PeerPtr* peer;
{
@@ -7,6 +7,7 @@
#ifndef _DEVICE_PRESENCE_CACHE_H_
#define _DEVICE_PRESENCE_CACHE_H_
#include <chrono>
#include <mutex>
#include <string>
#include <unordered_map>
@@ -15,15 +16,29 @@ namespace crossdesk {
class DevicePresenceCache {
public:
void SetOnline(const std::string &device_id, bool online) {
using Clock = std::chrono::steady_clock;
static constexpr auto kMaxAge = std::chrono::seconds(60);
void SetSignalConnected(bool connected) {
std::lock_guard<std::mutex> lock(mutex_);
cache_[device_id] = online;
signal_connected_ = connected;
cache_.clear();
}
bool IsOnline(const std::string &device_id) const {
void SetOnline(const std::string &device_id, bool online,
Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal_connected_) {
cache_[device_id] = {online, now};
}
}
bool IsOnline(const std::string &device_id,
Clock::time_point now = Clock::now()) const {
std::lock_guard<std::mutex> lock(mutex_);
const auto it = cache_.find(device_id);
return it != cache_.end() && it->second;
return signal_connected_ && it != cache_.end() && it->second.online &&
now - it->second.updated_at < kMaxAge;
}
void Clear() {
@@ -32,7 +47,12 @@ public:
}
private:
std::unordered_map<std::string, bool> cache_;
struct Entry {
bool online;
Clock::time_point updated_at;
};
bool signal_connected_ = false;
std::unordered_map<std::string, Entry> cache_;
mutable std::mutex mutex_;
};
@@ -0,0 +1,43 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-07
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DEVICE_PRESENCE_REQUEST_H_
#define _DEVICE_PRESENCE_REQUEST_H_
#include <algorithm>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
namespace crossdesk {
inline nlohmann::json BuildDevicePresenceRequest(
const std::string& client_id, const std::vector<std::string>& recent_ids,
bool subscribe, const std::string& probe_id = {}) {
std::vector<std::string> devices;
for (const auto& id : recent_ids) {
// Recent connection keys contain Y/N plus display settings after the ID.
const std::string pure_id = id.substr(0, id.find_first_of("YN"));
if (!pure_id.empty() &&
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
devices.push_back(pure_id);
}
}
if (!probe_id.empty() &&
std::find(devices.begin(), devices.end(), probe_id) == devices.end()) {
devices.push_back(probe_id);
}
// Include recent devices even in a one-shot probe: older servers ignore
// subscribe=false and replace their subscriptions with this device list.
return {{"type", "recent_connections_presence"},
{"user_id", client_id},
{"devices", devices},
{"subscribe", subscribe}};
}
} // namespace crossdesk
#endif
+2 -26
View File
@@ -34,12 +34,10 @@ int GuiRuntime::CreateConnectionPeer() {
std::string signal_server_ip;
int signal_server_port;
int coturn_server_port;
if (config_center_->IsSelfHosted()) {
signal_server_ip = config_center_->GetSignalServerHost();
signal_server_port = config_center_->GetSignalServerPort();
coturn_server_port = config_center_->GetCoturnServerPort();
std::string current_self_hosted_ip = config_center_->GetSignalServerHost();
const bool use_cached_id = settings_.LoadCachedSelfHostedIdentity();
@@ -64,7 +62,6 @@ int GuiRuntime::CreateConnectionPeer() {
} else {
signal_server_ip = config_center_->GetDefaultServerHost();
signal_server_port = config_center_->GetDefaultSignalServerPort();
coturn_server_port = config_center_->GetDefaultCoturnServerPort();
settings_.ActivateCachedPublicIdentity();
params_.user_id = client_id_with_password_;
}
@@ -115,34 +112,11 @@ int GuiRuntime::CreateConnectionPeer() {
} else {
signal_server_port_self_[0] = '\0';
}
int coturn_port = config_center_->GetCoturnServerPort();
if (coturn_port > 0) {
strncpy(coturn_server_port_self_, std::to_string(coturn_port).c_str(),
sizeof(coturn_server_port_self_) - 1);
coturn_server_port_self_[sizeof(coturn_server_port_self_) - 1] = '\0';
} else {
coturn_server_port_self_[0] = '\0';
}
// peer config
strncpy((char *)params_.signal_server_ip, signal_server_ip.c_str(),
sizeof(params_.signal_server_ip) - 1);
params_.signal_server_ip[sizeof(params_.signal_server_ip) - 1] = '\0';
params_.signal_server_port = signal_server_port;
strncpy((char *)params_.stun_server_ip, signal_server_ip.c_str(),
sizeof(params_.stun_server_ip) - 1);
params_.stun_server_ip[sizeof(params_.stun_server_ip) - 1] = '\0';
params_.stun_server_port = coturn_server_port;
strncpy((char *)params_.turn_server_ip, signal_server_ip.c_str(),
sizeof(params_.turn_server_ip) - 1);
params_.turn_server_ip[sizeof(params_.turn_server_ip) - 1] = '\0';
params_.turn_server_port = coturn_server_port;
// TURN credentials are issued by the signaling server after login. Keep the
// initial values empty so a reusable static password is never embedded in
// the client binary.
params_.turn_server_username[0] = '\0';
params_.turn_server_password[0] = '\0';
strncpy(params_.log_path, dll_log_path_.c_str(),
sizeof(params_.log_path) - 1);
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
@@ -200,6 +174,8 @@ int GuiRuntime::CreateConnectionPeer() {
// The previous peer may have left a terminal status behind. Reset it before
// Init() starts emitting callbacks for the newly selected server.
signal_connected_ = false;
device_presence_cache_.SetSignalConnected(false);
need_to_send_recent_connections_ = true;
signal_status_ = SignalStatus::SignalConnecting;
{
@@ -298,9 +298,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->devices_.SendMouseCommand(remote_action,
runtime->selected_display_);
} else if (remote_action.type == ControlType::audio_capture) {
if (remote_action.a && !runtime->start_speaker_capturer_)
if (remote_action.a)
runtime->devices_.StartSpeakerCapturer();
else if (!remote_action.a && runtime->start_speaker_capturer_)
else
runtime->devices_.StopSpeakerCapturer();
} else if (remote_action.type == ControlType::keyboard) {
runtime->keyboard_.ApplyRemoteEvent(remote_id, remote_action);
@@ -167,6 +167,8 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id,
std::string client_id(user_id, user_id_size);
if (client_id == runtime->client_id_) {
runtime->signal_status_ = status;
runtime->device_presence_cache_.SetSignalConnected(
status == SignalStatus::SignalConnected);
if (SignalStatus::SignalConnecting == status) {
runtime->signal_connected_ = false;
} else if (SignalStatus::SignalConnected == status) {
@@ -466,7 +468,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
std::memory_order_release);
runtime->is_server_mode_ = true;
runtime->start_screen_capturer_ = true;
runtime->start_speaker_capturer_ = true;
runtime->devices_.StartSpeakerCapturer();
runtime->remote_client_id_ = remote_id;
runtime->start_mouse_controller_ = true;
{
+5 -5
View File
@@ -55,13 +55,15 @@ struct RecentConnectionsState {
int recent_connection_image_height_ = 90;
uint32_t recent_connection_image_save_time_ = 0;
DevicePresenceCache device_presence_cache_;
bool need_to_send_recent_connections_ = true;
std::atomic<bool> need_to_send_recent_connections_{true};
std::chrono::steady_clock::time_point next_presence_refresh_at_{};
std::chrono::steady_clock::time_point last_presence_request_attempt_at_{};
};
struct PeerState {
SignalStatus signal_status_ = SignalStatus::SignalClosed;
std::string signal_status_str_;
bool signal_connected_ = false;
std::atomic<bool> signal_connected_{false};
PeerPtr *peer_ = nullptr;
PeerPtr *peer_reserved_ = nullptr;
std::string video_primary_label_ = "primary_display";
@@ -137,7 +139,6 @@ struct UserSettingsState {
bool enable_srtp_ = false;
char signal_server_ip_[256] = "api.crossdesk.cn";
char signal_server_port_[6] = "9099";
char coturn_server_port_[6] = "3478";
bool enable_self_hosted_ = false;
int language_button_value_last_ = 0;
int video_quality_button_value_last_ = 0;
@@ -156,7 +157,6 @@ struct UserSettingsState {
std::string file_transfer_save_path_last_;
char signal_server_ip_self_[256] = "";
char signal_server_port_self_[6] = "";
char coturn_server_port_self_[6] = "";
bool settings_window_pos_reset_ = true;
bool self_hosted_server_config_window_pos_reset_ = true;
std::string selected_current_file_path_;
@@ -227,4 +227,4 @@ struct RuntimeState : InfrastructureState,
} // namespace crossdesk::gui_detail
#endif
#endif
+33 -21
View File
@@ -54,7 +54,6 @@ export global UiStrings {
in-out property <string> default-desktop: "Desktop";
in-out property <string> server-host: "Server host";
in-out property <string> server-port: "Signal port";
in-out property <string> coturn-port: "TURN port";
in-out property <string> version: "Version";
in-out property <string> copyright: "© 2026 by JUNKUN DI. All right reserved.";
in-out property <string> license: "Licensed under GNU GPL v3.";
@@ -186,6 +185,7 @@ component ConnectionDialogButton inherits Rectangle {
export component MainWindow inherits Window {
title: "CrossDesk";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
preferred-width: 640px;
preferred-height: 450px + root.titlebar-height;
min-width: 640px;
@@ -210,6 +210,11 @@ export component MainWindow inherits Window {
in property <bool> signal-tls-error: false;
in property <[RecentConnection]> recent-connections;
in property <bool> update-available: false;
in property <bool> update-downloading: false;
in property <bool> update-failed: false;
in property <bool> update-security-blocked: false;
in property <float> update-progress: 0;
in property <string> update-status: "";
in property <string> current-version: "";
in property <string> latest-version: "";
in property <string> release-name: "";
@@ -249,7 +254,6 @@ export component MainWindow inherits Window {
in-out property <string> file-save-path: "";
in-out property <string> server-host: "";
in-out property <string> server-port: "";
in-out property <string> coturn-port: "";
in property <bool> settings-session-active: false;
// Keep the setting visible on every platform. Unsupported builds expose it
// as disabled so the fixed settings layout never turns into a blank row.
@@ -269,6 +273,8 @@ export component MainWindow inherits Window {
callback cancel-self-hosted-settings;
callback browse-save-path;
callback open-download;
callback start-update;
callback cancel-update;
callback connection-cancel;
callback connection-acknowledge;
callback connection-submit-password(string, bool);
@@ -1518,7 +1524,7 @@ export component MainWindow inherits Window {
x: root.compact-language ? 190px : 140px;
y: 105px;
width: root.compact-language ? 260px : 360px;
height: 168px;
height: 138px;
border-radius: 7px;
Rectangle {
@@ -1558,21 +1564,8 @@ export component MainWindow inherits Window {
LineEdit { x: 1px; y: 1.5px; width: parent.width - 2px; height: parent.height - 2px; text <=> root.server-port; input-type: InputType.number; font-size: ImGuiFontStyle.body; }
LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; }
}
Text { x: 8px; y: 91px; width: root.compact-language ? 88px : 165px; height: 24px; text: UiStrings.coturn-port; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
Rectangle {
x: root.compact-language ? 100px : 173px;
y: 90px;
width: root.compact-language ? 152px : 179px;
height: 24px;
background: white;
border-width: 1px;
border-color: ImGuiLineStyle.border;
border-radius: 4px;
LineEdit { x: 1px; y: 1.5px; width: parent.width - 2px; height: parent.height - 2px; text <=> root.coturn-port; input-type: InputType.number; font-size: ImGuiFontStyle.body; }
LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; }
}
CompactButton { x: root.compact-language ? 93px : 135px; y: 130px; primary: true; text: UiStrings.ok; clicked => { root.save-self-hosted-settings(); root.self-host-settings-open = false; } }
CompactButton { x: root.compact-language ? 134px : 176px; y: 130px; width: root.compact-language ? 34px : 56px; text: UiStrings.cancel; clicked => { root.cancel-self-hosted-settings(); root.self-host-settings-open = false; } }
CompactButton { x: root.compact-language ? 93px : 135px; y: 100px; primary: true; text: UiStrings.ok; clicked => { root.save-self-hosted-settings(); root.self-host-settings-open = false; } }
CompactButton { x: root.compact-language ? 134px : 176px; y: 100px; width: root.compact-language ? 34px : 56px; text: UiStrings.cancel; clicked => { root.cancel-self-hosted-settings(); root.self-host-settings-open = false; } }
}
}
@@ -1658,7 +1651,7 @@ export component MainWindow inherits Window {
x: 30px;
y: 105px;
width: 360px;
height: 190px;
height: root.update-status != "" ? 136px : 190px;
background: white;
border-width: 1px;
border-color: #b9bdc3;
@@ -1685,8 +1678,27 @@ export component MainWindow inherits Window {
}
}
}
CompactButton { x: root.compact-language ? 163px : 130px; y: 309px; width: root.compact-language ? 34px : 70px; primary: true; text: UiStrings.download; clicked => { root.open-download(); root.update-open = false; } }
CompactButton { x: root.compact-language ? 204px : 207px; y: 309px; width: root.compact-language ? 34px : 56px; text: UiStrings.later; clicked => { root.update-open = false; } }
if root.update-status != "": Text {
x: 30px; y: 247px; width: 340px; height: 40px;
text: root.update-status;
color: root.update-failed ? #b23b3b : #3d434d;
font-size: ImGuiFontStyle.body;
wrap: word-wrap;
vertical-alignment: center;
}
if root.update-downloading: Rectangle {
x: 30px; y: 291px; width: 340px; height: 4px;
background: #e2e4e7;
clip: true;
Rectangle {
x: root.update-progress < 0 ? (parent.width + self.width) * (mod(animation-tick(), 1500ms) / 1500ms) - self.width : 0px;
width: parent.width * (root.update-progress < 0 ? 0.25 : root.update-progress);
height: parent.height;
background: #334fd1;
}
}
CompactButton { x: root.compact-language ? 163px : 130px; y: 309px; width: root.compact-language ? 34px : 70px; primary: true; text: UiStrings.download; enabled: !root.update-downloading && !root.update-security-blocked; clicked => { root.start-update(); } }
CompactButton { x: root.compact-language ? 204px : 207px; y: 309px; width: root.compact-language ? 34px : 56px; text: UiStrings.later; clicked => { root.cancel-update(); } }
}
}
@@ -7,6 +7,7 @@ export struct ControllerEntry {
export component ServerWindow inherits Window {
title: "CrossDesk";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
preferred-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px;
preferred-height: 150px;
min-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px;
@@ -111,6 +111,7 @@ component ControlBarButton inherits Rectangle {
export component StreamWindow inherits Window {
title: "CrossDesk";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
preferred-width: 1280px;
preferred-height: 720px + (root.custom-titlebar ? 32px : 0px);
min-width: 640px;
@@ -78,8 +78,6 @@ inline int ApplyMainWindowStrings(
Text(localization::self_hosted_server_address[language]));
strings.set_server_port(
Text(localization::self_hosted_server_port[language]));
strings.set_coturn_port(
Text(localization::self_hosted_server_coturn_server_port[language]));
strings.set_version(Text(localization::version[language]));
strings.set_signal_connected(Text(localization::signal_connected[language]));
strings.set_signal_disconnected(
@@ -36,12 +36,12 @@ int GuiApplication::SelfHostedServerWindow() {
ImGui::SetNextWindowPos(
ImVec2(io.DisplaySize.x * 0.298f, io.DisplaySize.y * 0.25f));
ImGui::SetNextWindowSize(
ImVec2(io.DisplaySize.x * 0.407f, io.DisplaySize.y * 0.35f));
ImVec2(io.DisplaySize.x * 0.407f, io.DisplaySize.y * 0.29f));
} else {
ImGui::SetNextWindowPos(
ImVec2(io.DisplaySize.x * 0.27f, io.DisplaySize.y * 0.3f));
ImGui::SetNextWindowSize(
ImVec2(io.DisplaySize.x * 0.465f, io.DisplaySize.y * 0.35f));
ImVec2(io.DisplaySize.x * 0.465f, io.DisplaySize.y * 0.29f));
}
self_hosted_server_config_window_pos_reset_ = false;
@@ -101,25 +101,6 @@ int GuiApplication::SelfHostedServerWindow() {
IM_ARRAYSIZE(signal_server_port_self_));
}
ImGui::Separator();
{
ImGui::AlignTextToFramePadding();
ImGui::Text("%s", localization::self_hosted_server_coturn_server_port
[localization_language_index_]
.c_str());
ImGui::SameLine();
if (ConfigCenter::LANGUAGE::CHINESE == localization_language_) {
ImGui::SetCursorPosX(title_bar_button_width_ * 2.5f);
} else {
ImGui::SetCursorPosX(title_bar_button_width_ * 3.43f);
}
ImGui::SetNextItemWidth(title_bar_button_width_ * 3.8f);
ImGui::InputText("##coturn_server_port_self_", coturn_server_port_self_,
IM_ARRAYSIZE(coturn_server_port_self_));
}
if (stream_window_inited_) {
ImGui::EndDisabled();
}
@@ -141,17 +122,12 @@ int GuiApplication::SelfHostedServerWindow() {
config_center_->SetServerHost(signal_server_ip_self_);
config_center_->SetServerPort(atoi(signal_server_port_self_));
config_center_->SetCoturnServerPort(atoi(coturn_server_port_self_));
strncpy(signal_server_ip_, signal_server_ip_self_,
sizeof(signal_server_ip_) - 1);
signal_server_ip_[sizeof(signal_server_ip_) - 1] = '\0';
strncpy(signal_server_port_, signal_server_port_self_,
sizeof(signal_server_port_) - 1);
signal_server_port_[sizeof(signal_server_port_) - 1] = '\0';
strncpy(coturn_server_port_, coturn_server_port_self_,
sizeof(coturn_server_port_) - 1);
coturn_server_port_[sizeof(coturn_server_port_) - 1] = '\0';
self_hosted_server_config_window_pos_reset_ = true;
}
@@ -173,14 +149,6 @@ int GuiApplication::SelfHostedServerWindow() {
} else {
signal_server_port_self_[0] = '\0';
}
int coturn_port = config_center_->GetCoturnServerPort();
if (coturn_port > 0) {
strncpy(coturn_server_port_self_, std::to_string(coturn_port).c_str(),
sizeof(coturn_server_port_self_) - 1);
coturn_server_port_self_[sizeof(coturn_server_port_self_) - 1] = '\0';
} else {
coturn_server_port_self_[0] = '\0';
}
}
ImGui::SetWindowFontScale(1.0f);
@@ -122,6 +122,11 @@ struct OpenGlFunctions {
PFNGLUNIFORM4FPROC uniform_4f = nullptr;
PFNGLUSEPROGRAMPROC use_program = nullptr;
PFNGLVERTEXATTRIBPOINTERPROC vertex_attrib_pointer = nullptr;
PFNGLGENFRAMEBUFFERSPROC gen_framebuffers = nullptr;
PFNGLDELETEFRAMEBUFFERSPROC delete_framebuffers = nullptr;
PFNGLBINDFRAMEBUFFERPROC bind_framebuffer = nullptr;
PFNGLFRAMEBUFFERTEXTURE2DPROC framebuffer_texture_2d = nullptr;
PFNGLCHECKFRAMEBUFFERSTATUSPROC check_framebuffer_status = nullptr;
#if CROSSDESK_OPENGL_CUDA_INTEROP
PFNGLFENCESYNCPROC fence_sync = nullptr;
PFNGLCLIENTWAITSYNCPROC client_wait_sync = nullptr;
@@ -163,6 +168,15 @@ struct OpenGlFunctions {
LoadOpenGlFunction(&vertex_attrib_pointer, "glVertexAttribPointer");
}
bool LoadFramebufferFunctions() {
return LoadOpenGlFunction(&gen_framebuffers, "glGenFramebuffers") &&
LoadOpenGlFunction(&delete_framebuffers, "glDeleteFramebuffers") &&
LoadOpenGlFunction(&bind_framebuffer, "glBindFramebuffer") &&
LoadOpenGlFunction(&framebuffer_texture_2d, "glFramebufferTexture2D") &&
LoadOpenGlFunction(&check_framebuffer_status,
"glCheckFramebufferStatus");
}
#if CROSSDESK_OPENGL_CUDA_INTEROP
bool LoadSyncFunctions() {
return LoadOpenGlFunction(&fence_sync, "glFenceSync") &&
@@ -287,6 +301,17 @@ struct OpenGlVideoRenderer::Impl {
GLint video_rect_location = -1;
GLint corner_radius_location = -1;
GLint video_enabled_location = -1;
GLint source_size_location = -1;
GLint filter_direction_location = -1;
GLint packed_video_location = -1;
GLuint scale_framebuffer = 0;
std::array<GLuint, 2> scale_textures{};
std::array<std::pair<int, int>, 2> scale_sizes{};
uint64_t scaled_sequence = 0;
bool scaling_available = false;
GLenum framebuffer_target = GL_FRAMEBUFFER;
GLenum framebuffer_binding = GL_FRAMEBUFFER_BINDING;
std::array<int, 4> logged_render_size{};
int texture_width = 0;
int texture_height = 0;
int cpu_texture_width = 0;
@@ -504,6 +529,116 @@ struct OpenGlVideoRenderer::Impl {
}
#endif
// The vertex buffer and program are shared with the final underlay draw.
void DrawVideo(GLuint y, GLuint uv, int source_width, int source_height,
int target_width, int target_height, int video_x, int video_y,
int video_width, int video_height, int corner_radius,
bool enabled, bool packed, float filter_x = 0.0f,
float filter_y = 0.0f) {
gl.active_texture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, y);
gl.active_texture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, uv);
gl.uniform_2f(source_size_location, static_cast<GLfloat>(source_width),
static_cast<GLfloat>(source_height));
gl.uniform_2f(target_size_location, static_cast<GLfloat>(target_width),
static_cast<GLfloat>(target_height));
gl.uniform_4f(video_rect_location, static_cast<GLfloat>(video_x),
static_cast<GLfloat>(video_y), static_cast<GLfloat>(video_width),
static_cast<GLfloat>(video_height));
gl.uniform_1f(corner_radius_location, static_cast<GLfloat>(corner_radius));
gl.uniform_1f(video_enabled_location, enabled ? 1.0f : 0.0f);
gl.uniform_1f(packed_video_location, packed ? 1.0f : 0.0f);
gl.uniform_2f(filter_direction_location, filter_x, filter_y);
glViewport(0, 0, target_width, target_height);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
}
bool PrepareScaleTarget(size_t index, int width, int height) {
gl.active_texture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, scale_textures[index]);
if (scale_sizes[index] != std::pair{width, height}) {
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA,
GL_UNSIGNED_BYTE, nullptr);
scale_sizes[index] = {width, height};
}
gl.framebuffer_texture_2d(framebuffer_target, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D, scale_textures[index], 0);
return gl.check_framebuffer_status(framebuffer_target) ==
GL_FRAMEBUFFER_COMPLETE &&
glGetError() == GL_NO_ERROR;
}
bool ScaleVideo(GLuint y, GLuint uv, int width, int height, int output_width,
int output_height) {
if (!scaling_available) {
return false;
}
if (scaled_sequence == uploaded_sequence &&
scale_sizes[1] == std::pair{output_width, output_height}) {
return true;
}
GLint previous_framebuffer = 0;
glGetIntegerv(framebuffer_binding, &previous_framebuffer);
if (scale_framebuffer == 0) {
gl.gen_framebuffers(1, &scale_framebuffer);
glGenTextures(static_cast<GLsizei>(scale_textures.size()),
scale_textures.data());
gl.active_texture(GL_TEXTURE0);
for (const GLuint texture : scale_textures) {
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
}
// A CUDA upload may have left its PBO bound: nullptr below allocates empty
// render targets and must never be interpreted as a PBO byte offset.
if (pixel_unpack_buffer_available) {
gl.bind_buffer(GL_PIXEL_UNPACK_BUFFER, 0);
}
gl.bind_framebuffer(framebuffer_target, scale_framebuffer);
bool success = scale_framebuffer != 0 && scale_textures[0] != 0 &&
scale_textures[1] != 0;
bool packed = false;
while (success && (width != output_width || height != output_height)) {
// Bound shader work without truncating the filter for very small windows.
const int next_width = std::max(output_width, (width + 3) / 4);
const int next_height = std::max(output_height, (height + 3) / 4);
if (!PrepareScaleTarget(0, next_width, height)) {
success = false;
break;
}
DrawVideo(y, uv, width, height, next_width, height, 0, 0, next_width,
height, 0, true, packed, 1.0f, 0.0f);
if (!PrepareScaleTarget(1, next_width, next_height)) {
success = false;
break;
}
DrawVideo(scale_textures[0], uv, next_width, height, next_width,
next_height, 0, 0, next_width, next_height, 0, true, true,
0.0f, 1.0f);
success = glGetError() == GL_NO_ERROR;
y = scale_textures[1];
width = next_width;
height = next_height;
packed = true;
}
gl.bind_framebuffer(framebuffer_target,
static_cast<GLuint>(previous_framebuffer));
if (!success) {
scaling_available = false;
while (glGetError() != GL_NO_ERROR) {
}
LOG_WARN("OpenGL video downsampling unavailable; using linear sampling");
return false;
}
scaled_sequence = uploaded_sequence;
return true;
}
void ResetGlHandles() {
program = 0;
vertex_buffer = 0;
@@ -517,6 +652,15 @@ struct OpenGlVideoRenderer::Impl {
video_rect_location = -1;
corner_radius_location = -1;
video_enabled_location = -1;
source_size_location = -1;
filter_direction_location = -1;
packed_video_location = -1;
scale_framebuffer = 0;
scale_textures = {};
scale_sizes = {};
scaled_sequence = 0;
scaling_available = false;
logged_render_size = {};
texture_width = 0;
texture_height = 0;
cpu_texture_width = 0;
@@ -559,6 +703,10 @@ bool OpenGlVideoRenderer::Setup() {
HasOpenGlExtension(extensions, "GL_ARB_pixel_buffer_object")));
impl_->unpack_subimage_available = !is_gles || major >= 3 ||
HasOpenGlExtension(extensions, "GL_EXT_unpack_subimage");
impl_->scaling_available = impl_->gl.LoadFramebufferFunctions();
impl_->framebuffer_target = major >= 3 ? GL_DRAW_FRAMEBUFFER : GL_FRAMEBUFFER;
impl_->framebuffer_binding =
major >= 3 ? GL_DRAW_FRAMEBUFFER_BINDING : GL_FRAMEBUFFER_BINDING;
#if CROSSDESK_OPENGL_CUDA_INTEROP
// GLX may return entry points even if the current GLES 2 context cannot
// use them. Gate CUDA interop on context capabilities as well as symbols.
@@ -577,8 +725,8 @@ bool OpenGlVideoRenderer::Setup() {
}
#endif
static constexpr char kGlesVertexShader[] = R"glsl(
#version 100
const std::string vertex_source =
std::string(is_gles ? "#version 100\n" : "#version 110\n") + R"glsl(
attribute vec2 position;
attribute vec2 texcoord;
varying vec2 video_texcoord;
@@ -587,20 +735,56 @@ void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
)glsl";
static constexpr char kGlesFragmentShader[] = R"glsl(
#version 100
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
const std::string fragment_source =
std::string(is_gles ? "#version 100\n"
"#ifdef GL_FRAGMENT_PRECISION_HIGH\n"
"precision highp float;\n"
"#else\nprecision mediump float;\n#endif\n"
: "#version 110\n") + R"glsl(
uniform sampler2D y_texture;
uniform sampler2D uv_texture;
uniform vec2 target_size;
uniform vec2 source_size;
uniform vec2 filter_direction;
uniform float packed_video;
uniform vec4 video_rect;
uniform float corner_radius;
uniform float video_enabled;
varying vec2 video_texcoord;
// A separable Lanczos-2 low-pass filter. Each pass reduces by at most 4x,
// so 16 taps cover its complete support even for non-integer scale ratios.
float lanczos2(float x) {
x = abs(x);
if (x < 0.0001) return 1.0;
if (x >= 2.0) return 0.0;
float p = 3.14159265359 * x;
return sin(p) * sin(p * 0.5) / (p * p * 0.5);
}
vec4 sample_plane(sampler2D plane, vec2 size, vec2 coord) {
// FBO textures have the opposite vertical origin to the decoded NV12 planes.
if (packed_video > 0.5) coord.y = 1.0 - coord.y;
if (filter_direction.x + filter_direction.y < 0.5)
return texture2D(plane, coord);
float scale = max(1.0, dot(size / video_rect.zw, filter_direction));
float center = dot(coord * size - vec2(0.5), filter_direction);
float first = floor(center - 2.0 * scale) + 1.0;
vec4 sum = vec4(0.0);
float weight_sum = 0.0;
for (int tap = 0; tap < 16; ++tap) {
float offset = first + float(tap) - center;
if (offset >= 2.0 * scale) break;
float weight = lanczos2(offset / scale);
sum += weight * texture2D(plane, coord + filter_direction * offset / size);
weight_sum += weight;
}
return sum / weight_sum;
}
vec3 sample_video(vec2 coord) {
vec4 y_sample = sample_plane(y_texture, source_size, coord);
if (packed_video > 0.5) return y_sample.rgb;
return vec3(y_sample.r,
sample_plane(uv_texture, source_size * 0.5, coord).ra);
}
void main() {
vec2 surface_point = video_texcoord * target_size;
vec3 rgb = vec3(0.0);
@@ -611,10 +795,14 @@ void main() {
surface_point.y <= video_rect.y + video_rect.w) {
vec2 frame_texcoord =
(surface_point - video_rect.xy) / video_rect.zw;
float y = 1.16438356 *
(texture2D(y_texture, frame_texcoord).r - 16.0 / 255.0);
vec2 uv =
texture2D(uv_texture, frame_texcoord).ra - vec2(0.5, 0.5);
vec3 yuv = sample_video(frame_texcoord);
if (filter_direction.x + filter_direction.y > 0.5) {
// Intermediate RGBA textures carry Y/U/V until the final conversion.
gl_FragColor = vec4(yuv, 1.0);
return;
}
float y = 1.16438356 * (yuv.x - 16.0 / 255.0);
vec2 uv = yuv.yz - vec2(0.5);
rgb = clamp(vec3(y + 1.59602678 * uv.y,
y - 0.39176229 * uv.x - 0.81296764 * uv.y,
y + 2.01723214 * uv.x),
@@ -633,67 +821,11 @@ void main() {
gl_FragColor = vec4(rgb * coverage, coverage);
}
)glsl";
static constexpr char kDesktopVertexShader[] = R"glsl(
#version 110
attribute vec2 position;
attribute vec2 texcoord;
varying vec2 video_texcoord;
void main() {
video_texcoord = texcoord;
gl_Position = vec4(position, 0.0, 1.0);
}
)glsl";
static constexpr char kDesktopFragmentShader[] = R"glsl(
#version 110
uniform sampler2D y_texture;
uniform sampler2D uv_texture;
uniform vec2 target_size;
uniform vec4 video_rect;
uniform float corner_radius;
uniform float video_enabled;
varying vec2 video_texcoord;
void main() {
vec2 surface_point = video_texcoord * target_size;
vec3 rgb = vec3(0.0);
if (video_enabled > 0.5 &&
surface_point.x >= video_rect.x &&
surface_point.x <= video_rect.x + video_rect.z &&
surface_point.y >= video_rect.y &&
surface_point.y <= video_rect.y + video_rect.w) {
vec2 frame_texcoord =
(surface_point - video_rect.xy) / video_rect.zw;
float y = 1.16438356 *
(texture2D(y_texture, frame_texcoord).r - 16.0 / 255.0);
vec2 uv =
texture2D(uv_texture, frame_texcoord).ra - vec2(0.5, 0.5);
rgb = clamp(vec3(y + 1.59602678 * uv.y,
y - 0.39176229 * uv.x - 0.81296764 * uv.y,
y + 2.01723214 * uv.x),
0.0, 1.0);
}
float coverage = 1.0;
if (corner_radius > 0.0) {
vec2 half_size = target_size * 0.5;
vec2 corner = abs(surface_point - half_size) -
(half_size - vec2(corner_radius));
float distance_to_edge =
length(max(corner, vec2(0.0))) +
min(max(corner.x, corner.y), 0.0) - corner_radius;
coverage = clamp(0.5 - distance_to_edge, 0.0, 1.0);
}
gl_FragColor = vec4(rgb * coverage, coverage);
}
)glsl";
const char *vertex_source =
is_gles ? kGlesVertexShader : kDesktopVertexShader;
const char *fragment_source =
is_gles ? kGlesFragmentShader : kDesktopFragmentShader;
const GLuint vertex_shader =
CompileShader(impl_->gl, GL_VERTEX_SHADER, vertex_source);
CompileShader(impl_->gl, GL_VERTEX_SHADER, vertex_source.c_str());
const GLuint fragment_shader =
CompileShader(impl_->gl, GL_FRAGMENT_SHADER, fragment_source);
CompileShader(impl_->gl, GL_FRAGMENT_SHADER, fragment_source.c_str());
if (vertex_shader == 0 || fragment_shader == 0) {
if (vertex_shader != 0)
impl_->gl.delete_shader(vertex_shader);
@@ -743,11 +875,18 @@ void main() {
impl_->gl.get_uniform_location(impl_->program, "corner_radius");
impl_->video_enabled_location =
impl_->gl.get_uniform_location(impl_->program, "video_enabled");
impl_->source_size_location =
impl_->gl.get_uniform_location(impl_->program, "source_size");
impl_->filter_direction_location =
impl_->gl.get_uniform_location(impl_->program, "filter_direction");
impl_->packed_video_location =
impl_->gl.get_uniform_location(impl_->program, "packed_video");
if (impl_->position_location < 0 || impl_->texcoord_location < 0 ||
impl_->y_texture_location < 0 || impl_->uv_texture_location < 0 ||
impl_->target_size_location < 0 || impl_->video_rect_location < 0 ||
impl_->corner_radius_location < 0 ||
impl_->video_enabled_location < 0) {
impl_->video_enabled_location < 0 || impl_->source_size_location < 0 ||
impl_->filter_direction_location < 0 || impl_->packed_video_location < 0) {
LOG_ERROR("OpenGL NV12 program is missing required shader bindings");
impl_->gl.delete_program(impl_->program);
impl_->ResetGlHandles();
@@ -817,6 +956,11 @@ void main() {
void OpenGlVideoRenderer::Teardown() {
impl_->ready.store(false, std::memory_order_release);
if (impl_->scale_framebuffer != 0) {
impl_->gl.delete_framebuffers(1, &impl_->scale_framebuffer);
}
glDeleteTextures(static_cast<GLsizei>(impl_->scale_textures.size()),
impl_->scale_textures.data());
#if CROSSDESK_OPENGL_CUDA_INTEROP
impl_->ReleaseCudaInterop();
#endif
@@ -1356,24 +1500,8 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
}
#endif
impl_->gl.use_program(impl_->program);
impl_->gl.active_texture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, displayed_y_texture);
impl_->gl.uniform_1i(impl_->y_texture_location, 0);
impl_->gl.active_texture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, displayed_uv_texture);
impl_->gl.uniform_1i(impl_->uv_texture_location, 1);
impl_->gl.uniform_2f(impl_->target_size_location,
static_cast<GLfloat>(target_width),
static_cast<GLfloat>(target_height));
impl_->gl.uniform_4f(
impl_->video_rect_location, static_cast<GLfloat>(video_x),
static_cast<GLfloat>(video_y), static_cast<GLfloat>(video_width),
static_cast<GLfloat>(video_height));
impl_->gl.uniform_1f(
impl_->corner_radius_location,
static_cast<GLfloat>(std::clamp(
corner_radius_pixels, 0, std::min(target_width, target_height) / 2)));
impl_->gl.uniform_1f(impl_->video_enabled_location, has_video ? 1.0f : 0.0f);
impl_->gl.bind_buffer(GL_ARRAY_BUFFER, impl_->vertex_buffer);
impl_->gl.vertex_attrib_pointer(
static_cast<GLuint>(impl_->position_location), 2, GL_FLOAT, GL_FALSE,
@@ -1386,7 +1514,25 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
static_cast<GLuint>(impl_->position_location));
impl_->gl.enable_vertex_attrib_array(
static_cast<GLuint>(impl_->texcoord_location));
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const bool downscaled = has_video &&
(source_width > video_width || source_height > video_height) &&
impl_->ScaleVideo(displayed_y_texture, displayed_uv_texture, source_width,
source_height, video_width, video_height);
impl_->DrawVideo(
downscaled ? impl_->scale_textures[1] : displayed_y_texture,
displayed_uv_texture, downscaled ? video_width : source_width,
downscaled ? video_height : source_height, target_width, target_height,
video_x, video_y, video_width, video_height,
std::clamp(corner_radius_pixels, 0, std::min(target_width, target_height) / 2),
has_video, downscaled);
const std::array<int, 4> render_size{
source_width, source_height, video_width, video_height};
if (has_video && impl_->logged_render_size != render_size) {
LOG_INFO("OpenGL video presentation: source={}x{}, display={}x{} physical "
"pixels, filter={}", source_width, source_height, video_width,
video_height, downscaled ? "Lanczos2" : "linear");
impl_->logged_render_size = render_size;
}
#if CROSSDESK_OPENGL_CUDA_INTEROP
if (impl_->displayed_cuda_upload_slot < impl_->cuda_upload_slots.size()) {
auto &cuda_slot =
@@ -3,269 +3,240 @@
#include <pulse/error.h>
#include <pulse/introspect.h>
#include <condition_variable>
#include <iostream>
#include <thread>
#include <string>
#include <utility>
#include "rd_log.h"
namespace crossdesk {
constexpr int kSampleRate = 48000;
constexpr pa_sample_format_t kFormat = PA_SAMPLE_S16LE;
constexpr int kChannels = 1;
namespace {
constexpr size_t kFrameSizeBytes = 480 * sizeof(int16_t);
constexpr auto kStartupTimeout = std::chrono::seconds(2);
constexpr auto kPollInterval = std::chrono::milliseconds(5);
SpeakerCapturerLinux::SpeakerCapturerLinux()
: inited_(false), paused_(false), stop_flag_(false) {}
SpeakerCapturerLinux::~SpeakerCapturerLinux() {
Stop();
Destroy();
void ReleaseOperation(pa_operation* operation) {
if (!operation) return;
if (pa_operation_get_state(operation) == PA_OPERATION_RUNNING)
pa_operation_cancel(operation);
pa_operation_unref(operation);
}
} // namespace
SpeakerCapturerLinux::~SpeakerCapturerLinux() { Destroy(); }
int SpeakerCapturerLinux::Init(speaker_data_cb cb) {
if (inited_) return 0;
cb_ = cb;
cb_ = std::move(cb);
inited_ = true;
return 0;
}
int SpeakerCapturerLinux::Destroy() {
inited_ = false;
return 0;
// Use nonblocking iterations instead of pa_threaded_mainloop_wait(): a server
// that never replies, disappears, or never reaches STREAM_READY must not leave
// startup/Stop waiting for a signal that will never be delivered.
bool SpeakerCapturerLinux::PumpUntil(
const std::function<bool()>& ready,
std::chrono::steady_clock::time_point deadline) {
while (!stop_flag_) {
if (ready()) return true;
if (std::chrono::steady_clock::now() >= deadline ||
!PA_CONTEXT_IS_GOOD(pa_context_get_state(context_)))
return false;
if (pa_mainloop_iterate(mainloop_, 0, nullptr) < 0) return false;
std::this_thread::sleep_for(kPollInterval);
}
return false;
}
std::string SpeakerCapturerLinux::GetDefaultMonitorSourceName() {
std::string monitor_name;
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
bool SpeakerCapturerLinux::Prepare() {
const auto deadline = std::chrono::steady_clock::now() + kStartupTimeout;
mainloop_ = pa_mainloop_new();
if (!mainloop_) return false;
context_ =
pa_context_new(pa_mainloop_get_api(mainloop_), "CrossDesk system audio");
if (!context_ ||
pa_context_connect(context_, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0)
return false;
if (!PumpUntil(
[&] { return pa_context_get_state(context_) == PA_CONTEXT_READY; },
deadline))
return false;
pa_mainloop* mainloop = pa_mainloop_new();
pa_mainloop_api* api = pa_mainloop_get_api(mainloop);
pa_context* context = pa_context_new(api, "GetMonitor");
struct CallbackState {
std::string* name;
std::mutex* mtx;
std::condition_variable* cv;
bool* ready;
} state{&monitor_name, &mtx, &cv, &ready};
pa_context_set_state_callback(
context,
[](pa_context* c, void* userdata) {
auto* state = static_cast<CallbackState*>(userdata);
if (pa_context_get_state(c) == PA_CONTEXT_READY) {
pa_operation* operation = pa_context_get_server_info(
c,
[](pa_context*, const pa_server_info* info, void* userdata) {
auto* state = static_cast<CallbackState*>(userdata);
if (info && info->default_sink_name) {
*(state->name) =
std::string(info->default_sink_name) + ".monitor";
}
{
std::lock_guard<std::mutex> lock(*(state->mtx));
*(state->ready) = true;
}
state->cv->notify_one();
},
userdata);
if (operation) {
pa_operation_unref(operation);
}
}
struct ServerResult {
bool done = false;
std::string sink;
} server;
pa_operation* operation = pa_context_get_server_info(
context_,
[](pa_context*, const pa_server_info* info, void* data) {
auto& result = *static_cast<ServerResult*>(data);
if (info && info->default_sink_name)
result.sink = info->default_sink_name;
result.done = true;
},
&state);
&server);
const bool got_server =
operation && PumpUntil([&] { return server.done; }, deadline);
ReleaseOperation(operation);
if (!got_server || server.sink.empty()) return false;
pa_context_connect(context, nullptr, PA_CONTEXT_NOFLAGS, nullptr);
// Query the actual monitor name; a changed/default device need not follow
// the "<sink>.monitor" naming convention.
struct SinkResult {
bool done = false;
std::string monitor;
} sink;
operation = pa_context_get_sink_info_by_name(
context_, server.sink.c_str(),
[](pa_context*, const pa_sink_info* info, int eol, void* data) {
auto& result = *static_cast<SinkResult*>(data);
if (info && info->monitor_source_name)
result.monitor = info->monitor_source_name;
if (eol != 0) result.done = true;
},
&sink);
const bool got_sink =
operation && PumpUntil([&] { return sink.done; }, deadline);
ReleaseOperation(operation);
if (!got_sink || sink.monitor.empty()) return false;
std::thread loop_thread([&]() {
int ret = 0;
pa_mainloop_run(mainloop, &ret);
});
const pa_sample_spec spec{PA_SAMPLE_S16LE, 48000, 1};
stream_ = pa_stream_new(context_, "System audio", &spec, nullptr);
if (!stream_) return false;
pa_stream_set_read_callback(
stream_,
[](pa_stream* stream, size_t, void* data) {
static_cast<SpeakerCapturerLinux*>(data)->ReadAudio(stream);
},
this);
pa_buffer_attr attr{};
attr.maxlength = static_cast<uint32_t>(-1);
attr.fragsize = static_cast<uint32_t>(kFrameSizeBytes);
if (pa_stream_connect_record(stream_, sink.monitor.c_str(), &attr,
PA_STREAM_ADJUST_LATENCY) < 0)
return false;
if (!PumpUntil(
[&] {
const auto state = pa_stream_get_state(stream_);
return state == PA_STREAM_READY || !PA_STREAM_IS_GOOD(state);
},
deadline) ||
pa_stream_get_state(stream_) != PA_STREAM_READY)
return false;
LOG_INFO("System audio monitor ready: {}", sink.monitor);
return true;
}
void SpeakerCapturerLinux::Run() {
const bool ready = Prepare();
running_ = ready && !stop_flag_;
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait_for(lock, std::chrono::seconds(2), [&] { return ready; });
std::lock_guard<std::mutex> lock(state_mtx_);
startup_success_ = running_;
startup_done_ = true;
}
pa_context_disconnect(context);
pa_context_unref(context);
pa_mainloop_quit(mainloop, 0);
loop_thread.join();
pa_mainloop_free(mainloop);
return monitor_name;
startup_wake_.notify_one();
if (!ready && !stop_flag_) {
LOG_ERROR(
"PulseAudio capture startup failed or timed out: {}",
context_ ? pa_strerror(pa_context_errno(context_)) : "no context");
}
while (running_ && !stop_flag_) {
if (pa_mainloop_iterate(mainloop_, 0, nullptr) < 0 ||
pa_context_get_state(context_) != PA_CONTEXT_READY ||
pa_stream_get_state(stream_) != PA_STREAM_READY)
break;
std::this_thread::sleep_for(kPollInterval);
}
running_ = false;
Cleanup();
}
int SpeakerCapturerLinux::Start() {
if (!inited_ || mainloop_thread_.joinable()) return -1;
if (!inited_) return -1;
if (IsRunning()) return 0;
Stop();
stop_flag_ = false;
mainloop_thread_ = std::thread([this]() {
std::string monitor_name = GetDefaultMonitorSourceName();
if (monitor_name.empty()) {
LOG_ERROR("Failed to get monitor source");
return;
}
mainloop_ = pa_threaded_mainloop_new();
pa_mainloop_api* api = pa_threaded_mainloop_get_api(mainloop_);
context_ = pa_context_new(api, "SpeakerCapturer");
pa_context_set_state_callback(
context_,
[](pa_context* c, void* userdata) {
auto self = static_cast<SpeakerCapturerLinux*>(userdata);
pa_context_state_t state = pa_context_get_state(c);
if (state == PA_CONTEXT_READY || state == PA_CONTEXT_FAILED ||
state == PA_CONTEXT_TERMINATED) {
pa_threaded_mainloop_signal(self->mainloop_, 0);
}
},
this);
if (pa_threaded_mainloop_start(mainloop_) < 0) {
LOG_ERROR("Failed to start mainloop");
Cleanup();
return;
}
pa_threaded_mainloop_lock(mainloop_);
if (pa_context_connect(context_, nullptr, PA_CONTEXT_NOFLAGS, nullptr) <
0) {
LOG_ERROR("Failed to connect context");
pa_threaded_mainloop_unlock(mainloop_);
Cleanup();
return;
}
while (true) {
pa_context_state_t state = pa_context_get_state(context_);
if (state == PA_CONTEXT_READY) break;
if (!PA_CONTEXT_IS_GOOD(state) || stop_flag_) {
pa_threaded_mainloop_unlock(mainloop_);
Cleanup();
return;
}
pa_threaded_mainloop_wait(mainloop_);
}
pa_sample_spec ss = {kFormat, kSampleRate, kChannels};
stream_ = pa_stream_new(context_, "Capture", &ss, nullptr);
pa_stream_set_read_callback(
stream_,
[](pa_stream* s, size_t len, void* u) {
auto self = static_cast<SpeakerCapturerLinux*>(u);
if (self->paused_ || self->stop_flag_) return;
const void* data = nullptr;
if (pa_stream_peek(s, &data, &len) < 0 || !data) return;
const uint8_t* p = static_cast<const uint8_t*>(data);
self->frame_cache_.insert(self->frame_cache_.end(), p, p + len);
while (self->frame_cache_.size() >= kFrameSizeBytes) {
std::vector<uint8_t> temp_frame(
self->frame_cache_.begin(),
self->frame_cache_.begin() + kFrameSizeBytes);
self->cb_(temp_frame.data(), kFrameSizeBytes, "audio");
self->frame_cache_.erase(
self->frame_cache_.begin(),
self->frame_cache_.begin() + kFrameSizeBytes);
}
pa_stream_drop(s);
},
this);
pa_buffer_attr attr = {.maxlength = (uint32_t)-1,
.tlength = 0,
.prebuf = 0,
.minreq = 0,
.fragsize = (uint32_t)kFrameSizeBytes};
if (pa_stream_connect_record(stream_, monitor_name.c_str(), &attr,
PA_STREAM_ADJUST_LATENCY) < 0) {
LOG_ERROR("Failed to connect stream");
pa_threaded_mainloop_unlock(mainloop_);
Cleanup();
return;
}
while (true) {
pa_stream_state_t s = pa_stream_get_state(stream_);
if (s == PA_STREAM_READY) break;
if (!PA_STREAM_IS_GOOD(s) || stop_flag_) {
pa_threaded_mainloop_unlock(mainloop_);
Cleanup();
return;
}
pa_threaded_mainloop_wait(mainloop_);
}
pa_threaded_mainloop_unlock(mainloop_);
while (!stop_flag_)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
});
paused_ = false;
{
std::lock_guard<std::mutex> lock(state_mtx_);
startup_done_ = false;
startup_success_ = false;
}
mainloop_thread_ = std::thread([this] { Run(); });
std::unique_lock<std::mutex> lock(state_mtx_);
const bool started =
startup_wake_.wait_for(lock, kStartupTimeout + std::chrono::seconds(1),
[&] { return startup_done_; }) &&
startup_success_;
lock.unlock();
if (!started) {
Stop();
return -1;
}
return 0;
}
int SpeakerCapturerLinux::Stop() {
stop_flag_ = true;
if (mainloop_) {
pa_threaded_mainloop_lock(mainloop_);
pa_threaded_mainloop_signal(mainloop_, 0);
pa_threaded_mainloop_unlock(mainloop_);
}
if (mainloop_thread_.joinable()) {
mainloop_thread_.join();
}
Cleanup();
if (mainloop_thread_.joinable()) mainloop_thread_.join();
running_ = false;
return 0;
}
int SpeakerCapturerLinux::Destroy() {
Stop();
cb_ = nullptr;
inited_ = false;
return 0;
}
void SpeakerCapturerLinux::ReadAudio(pa_stream* stream) {
const void* data = nullptr;
size_t size = 0;
if (pa_stream_peek(stream, &data, &size) < 0 || size == 0) return;
if (data && !paused_ && !stop_flag_ && cb_) {
const auto* bytes = static_cast<const uint8_t*>(data);
frame_cache_.insert(frame_cache_.end(), bytes, bytes + size);
size_t consumed = 0;
while (!stop_flag_ && frame_cache_.size() - consumed >= kFrameSizeBytes) {
cb_(frame_cache_.data() + consumed, kFrameSizeBytes, "audio");
consumed += kFrameSizeBytes;
}
frame_cache_.erase(frame_cache_.begin(), frame_cache_.begin() + consumed);
} else {
frame_cache_.clear();
}
// Drop holes and paused data too, otherwise the read queue cannot advance.
pa_stream_drop(stream);
}
void SpeakerCapturerLinux::Cleanup() {
if (stream_) {
pa_stream_set_read_callback(stream_, nullptr, nullptr);
pa_stream_disconnect(stream_);
pa_stream_unref(stream_);
stream_ = nullptr;
}
if (context_) {
pa_context_disconnect(context_);
pa_context_unref(context_);
context_ = nullptr;
}
if (mainloop_) {
pa_threaded_mainloop_stop(mainloop_);
pa_threaded_mainloop_lock(mainloop_);
if (stream_) {
pa_stream_disconnect(stream_);
pa_stream_unref(stream_);
stream_ = nullptr;
}
if (context_) {
pa_context_disconnect(context_);
pa_context_unref(context_);
context_ = nullptr;
}
pa_threaded_mainloop_unlock(mainloop_);
pa_threaded_mainloop_free(mainloop_);
pa_mainloop_free(mainloop_);
mainloop_ = nullptr;
}
frame_cache_.clear();
}
bool SpeakerCapturerLinux::IsRunning() const { return running_; }
int SpeakerCapturerLinux::Pause() {
paused_ = true;
return 0;
}
int SpeakerCapturerLinux::Resume() {
paused_ = false;
return 0;
}
} // namespace crossdesk
} // namespace crossdesk
@@ -4,53 +4,59 @@
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SPEAKER_CAPTURER_LINUX_H_
#define _SPEAKER_CAPTURER_LINUX_H_
#ifndef CROSSDESK_SPEAKER_CAPTURER_LINUX_H_
#define CROSSDESK_SPEAKER_CAPTURER_LINUX_H_
#include <pulse/pulseaudio.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "speaker_capturer.h"
namespace crossdesk {
class SpeakerCapturerLinux : public SpeakerCapturer {
public:
SpeakerCapturerLinux();
~SpeakerCapturerLinux();
SpeakerCapturerLinux() = default;
~SpeakerCapturerLinux() override;
int Init(speaker_data_cb cb) override;
int Destroy() override;
int Start() override;
int Stop() override;
bool IsRunning() const override;
int Pause();
int Resume();
private:
std::string GetDefaultMonitorSourceName();
void Run();
bool Prepare();
bool PumpUntil(const std::function<bool()>& ready,
std::chrono::steady_clock::time_point deadline);
void Cleanup();
void ReadAudio(pa_stream* stream);
private:
speaker_data_cb cb_ = nullptr;
std::atomic<bool> inited_;
std::atomic<bool> paused_;
std::atomic<bool> stop_flag_;
speaker_data_cb cb_;
bool inited_ = false;
std::atomic<bool> paused_{false};
std::atomic<bool> stop_flag_{false};
std::atomic<bool> running_{false};
std::thread mainloop_thread_;
pa_threaded_mainloop* mainloop_ = nullptr;
std::mutex state_mtx_;
std::condition_variable startup_wake_;
bool startup_done_ = false;
bool startup_success_ = false;
// These objects are created, driven and freed exclusively by Run(). Stop
// only changes an atomic flag and joins, never races PulseAudio callbacks.
pa_mainloop* mainloop_ = nullptr;
pa_context* context_ = nullptr;
pa_stream* stream_ = nullptr;
std::mutex state_mtx_;
std::vector<uint8_t> frame_cache_;
};
} // namespace crossdesk
#endif
#endif
@@ -17,6 +17,9 @@ namespace crossdesk {
struct MacTrayImpl;
// Activates the app and focuses the restored Slint window's current NSView.
void MacActivateWindow(void* appkit_view);
class MacTray {
public:
MacTray(::SDL_Window* app_window, const std::string& tooltip,
@@ -57,6 +57,8 @@ struct MacTrayImpl {
} else if (app_window) {
SDL_HideWindow(app_window);
}
// Keep running through the menu-bar tray without a Dock application icon.
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
}
void RemoveTrayIcon() {
@@ -69,6 +71,8 @@ struct MacTrayImpl {
}
void ShowWindow() {
// Restore normal application behavior before showing or focusing a window.
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
if (show_window) {
show_window();
} else if (app_window) {
@@ -142,6 +146,7 @@ struct MacTrayImpl {
}
void OpenSettings() {
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
if (open_settings) {
open_settings();
} else {
@@ -279,6 +284,13 @@ MacTray::MacTray(std::function<void()> show_window,
MacTray::~MacTray() = default;
void MacActivateWindow(void *appkit_view) {
NSView *view = (__bridge NSView *)appkit_view;
NSWindow *window = [view window];
[NSApp activateIgnoringOtherApps:YES];
[window makeKeyAndOrderFront:nil];
}
void MacTray::MinimizeToTray() { impl_->MinimizeToTray(); }
void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); }
@@ -17,21 +17,19 @@ namespace crossdesk {
class SpeakerCapturerMacosx : public SpeakerCapturer {
public:
SpeakerCapturerMacosx();
~SpeakerCapturerMacosx();
~SpeakerCapturerMacosx() override;
public:
virtual int Init(speaker_data_cb cb);
virtual int Destroy();
virtual int Start();
virtual int Stop();
int Init(speaker_data_cb cb) override;
int Destroy() override;
int Start() override;
int Stop() override;
bool IsRunning() const override;
int Pause();
int Resume();
public:
speaker_data_cb cb_ = nullptr;
bool inited_ = false;
private:
class Impl;
Impl* impl_ = nullptr;
};
@@ -2,47 +2,98 @@
#import <Foundation/Foundation.h>
#import <ScreenCaptureKit/ScreenCaptureKit.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <memory>
#include <mutex>
#include <utility>
#include "rd_log.h"
#include "speaker_capturer_macosx.h"
namespace crossdesk {
class SpeakerCapturerMacosx;
namespace {
constexpr auto kCaptureTimeout = std::chrono::seconds(2);
std::string NSErrorToString(NSError* error) {
if (!error) return "";
NSString* message = [NSString
stringWithFormat:@"%@ (%@:%ld), reason=%@", error.localizedDescription, error.domain,
(long)error.code, error.localizedFailureReason ?: @""];
return message.UTF8String ?: "";
}
namespace {
std::string NSErrorToString(NSError* error) {
if (!error) {
return "";
// Late system replies own only their result, never the capturer or stack data.
// Complete() also tells a late successful start to stop its abandoned stream.
template <typename T>
class CaptureReply {
public:
bool Complete(T value) {
std::lock_guard<std::mutex> lock(mutex_);
if (expired_) return false;
value_ = std::move(value);
ready_ = true;
wake_.notify_one();
return true;
}
bool Wait(T& value) {
std::unique_lock<std::mutex> lock(mutex_);
if (!wake_.wait_for(lock, kCaptureTimeout, [&] { return ready_; })) {
expired_ = true;
return false;
}
value = value_;
return true;
}
const char* description = [error.localizedDescription UTF8String];
return description ? description : "";
}
private:
std::mutex mutex_;
std::condition_variable wake_;
bool ready_ = false;
bool expired_ = false;
T value_{};
};
struct ContentReply {
SCShareableContent* content = nil;
NSError* error = nil;
};
struct StreamReply {
NSError* error = nil;
};
struct AudioCallbackState {
std::mutex mutex;
crossdesk::SpeakerCapturer::speaker_data_cb callback;
std::atomic<bool> running{false};
};
} // namespace
@interface SpeakerCaptureDelegate : NSObject <SCStreamDelegate, SCStreamOutput>
@property(nonatomic, assign) crossdesk::SpeakerCapturerMacosx* owner;
- (instancetype)initWithOwner:(crossdesk::SpeakerCapturerMacosx*)owner;
@interface SpeakerCaptureDelegate : NSObject <SCStreamDelegate, SCStreamOutput> {
std::shared_ptr<AudioCallbackState> state_;
}
- (instancetype)initWithState:(std::shared_ptr<AudioCallbackState>)state;
@end
@implementation SpeakerCaptureDelegate
- (instancetype)initWithOwner:(crossdesk::SpeakerCapturerMacosx*)owner {
- (instancetype)initWithState:(std::shared_ptr<AudioCallbackState>)state {
self = [super init];
if (self) {
_owner = owner;
}
if (self) state_ = std::move(state);
return self;
}
- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error {
state_->running = false;
SPDLOG_LOGGER_WARN(crossdesk::get_logger(), "System audio stream stopped: {}",
NSErrorToString(error));
}
- (void)stream:(SCStream*)stream
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
ofType:(SCStreamOutputType)type {
if (type != SCStreamOutputTypeAudio) return;
crossdesk::SpeakerCapturerMacosx* owner = _owner;
if (!owner || !owner->cb_) {
return;
}
const auto state = state_;
std::lock_guard<std::mutex> lock(state->mutex);
if (!state->callback || !state->running) return;
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
if (!blockBuffer) {
@@ -51,8 +102,7 @@ std::string NSErrorToString(NSError* error) {
size_t length = CMBlockBufferGetDataLength(blockBuffer);
char* dataPtr = NULL;
OSStatus dataStatus =
CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, NULL, &dataPtr);
OSStatus dataStatus = CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, NULL, &dataPtr);
if (dataStatus != noErr || dataPtr == nullptr || length == 0) {
return;
}
@@ -68,7 +118,7 @@ std::string NSErrorToString(NSError* error) {
return;
}
if (owner->cb_) {
if (state->callback) {
std::vector<short> out_pcm16;
if (asbd->mFormatFlags & kAudioFormatFlagIsFloat) {
int channels = asbd->mChannelsPerFrame;
@@ -118,191 +168,156 @@ std::string NSErrorToString(NSError* error) {
size_t total_bytes = out_pcm16.size() * sizeof(short);
unsigned char* p = (unsigned char*)out_pcm16.data();
for (size_t offset = 0; offset + frame_bytes <= total_bytes; offset += frame_bytes) {
if (!owner->cb_) {
if (!state->callback) {
return;
}
owner->cb_(p + offset, frame_bytes, "audio");
state->callback(p + offset, frame_bytes, "audio");
}
}
}
@end
namespace crossdesk {
class SpeakerCapturerMacosx::Impl {
public:
SCStreamConfiguration* config = nil;
speaker_data_cb callback;
SCStream* stream = nil;
SpeakerCaptureDelegate* delegate = nil;
dispatch_queue_t queue = nil;
SCShareableContent* content = nil;
SCDisplay* mainDisplay = nil;
~Impl() {
if (stream) {
[stream stopCaptureWithCompletionHandler:^(NSError* _Nullable error){
}];
stream = nil;
}
delegate = nil;
if (queue) {
queue = nil;
}
content = nil;
mainDisplay = nil;
config = nil;
}
std::shared_ptr<AudioCallbackState> state;
};
SpeakerCapturerMacosx::SpeakerCapturerMacosx() {
impl_ = new Impl();
inited_ = false;
cb_ = nullptr;
}
SpeakerCapturerMacosx::SpeakerCapturerMacosx() : impl_(new Impl) {}
SpeakerCapturerMacosx::~SpeakerCapturerMacosx() {
Destroy();
delete impl_;
impl_ = nullptr;
}
int SpeakerCapturerMacosx::Init(speaker_data_cb cb) {
if (inited_) {
return 0;
}
cb_ = cb;
impl_->config = [[SCStreamConfiguration alloc] init];
impl_->config.capturesAudio = YES;
impl_->config.sampleRate = 48000;
impl_->config.channelCount = 1;
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
__block NSError* error = nil;
[SCShareableContent
getShareableContentWithCompletionHandler:^(SCShareableContent* c, NSError* e) {
impl_->content = c;
error = e;
dispatch_semaphore_signal(sema);
}];
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
if (error || !impl_->content) {
LOG_ERROR("Failed to get shareable content: {}",
NSErrorToString(error));
return -1;
}
CGDirectDisplayID mainDisplayId = CGMainDisplayID();
impl_->mainDisplay = nil;
for (SCDisplay* d in impl_->content.displays) {
if (d.displayID == mainDisplayId) {
impl_->mainDisplay = d;
break;
}
}
if (!impl_->mainDisplay) {
LOG_ERROR("Main display not found");
return -1;
}
if (!impl_->queue) {
impl_->callback = std::move(cb);
if (!impl_->queue)
impl_->queue = dispatch_queue_create("SpeakerAudio.Queue", DISPATCH_QUEUE_SERIAL);
}
inited_ = true;
return 0;
}
int SpeakerCapturerMacosx::Start() {
if (!inited_) {
return -1;
}
@autoreleasepool {
if (!impl_->callback) return -1;
if (IsRunning()) return 0;
if (Stop() != 0) return -1;
if (impl_->stream) {
dispatch_semaphore_t semaStop = dispatch_semaphore_create(0);
[impl_->stream stopCaptureWithCompletionHandler:^(NSError* error) {
dispatch_semaphore_signal(semaStop);
}];
dispatch_semaphore_wait(semaStop, DISPATCH_TIME_FOREVER);
impl_->stream = nil;
impl_->delegate = nil;
}
impl_->delegate = [[SpeakerCaptureDelegate alloc] initWithOwner:this];
SCContentFilter* filter = [[SCContentFilter alloc] initWithDisplay:impl_->mainDisplay
excludingWindows:@[]];
impl_->stream = [[SCStream alloc] initWithFilter:filter
configuration:impl_->config
delegate:impl_->delegate];
NSError* addOutputError = nil;
BOOL ok = [impl_->stream addStreamOutput:impl_->delegate
type:SCStreamOutputTypeAudio
sampleHandlerQueue:impl_->queue
error:&addOutputError];
if (!ok || addOutputError) {
LOG_ERROR("addStreamOutput error: {}",
NSErrorToString(addOutputError));
impl_->stream = nil;
impl_->delegate = nil;
return -1;
}
dispatch_semaphore_t semaStart = dispatch_semaphore_create(0);
__block int ret = 0;
[impl_->stream startCaptureWithCompletionHandler:^(NSError* _Nullable error) {
if (error) {
LOG_ERROR("startCaptureWithCompletionHandler error: {}",
NSErrorToString(error));
ret = -1;
// A cached SCDisplay becomes invalid after hotplug, sleep or a display
// reconfiguration. Obtain a fresh snapshot on every start/reconnection.
auto content_reply = std::make_shared<CaptureReply<ContentReply>>();
[SCShareableContent
getShareableContentWithCompletionHandler:^(SCShareableContent* content, NSError* error) {
content_reply->Complete({content, error});
}];
ContentReply content;
if (!content_reply->Wait(content)) {
LOG_ERROR("Timed out refreshing system audio capture displays");
return -1;
}
dispatch_semaphore_signal(semaStart);
}];
dispatch_semaphore_wait(semaStart, DISPATCH_TIME_FOREVER);
if (content.error || !content.content) {
LOG_ERROR("Failed to refresh system audio capture displays: {}",
NSErrorToString(content.error));
return -1;
}
SCDisplay* display = nil;
const auto main_id = CGMainDisplayID();
for (SCDisplay* candidate in content.content.displays) {
if (candidate.displayID == main_id) {
display = candidate;
break;
}
}
if (!display) display = content.content.displays.firstObject;
if (!display) {
LOG_ERROR("No display available for system audio capture");
return -1;
}
LOG_INFO("Starting system audio capture with current display {}", display.displayID);
return ret;
SCStreamConfiguration* config = [[SCStreamConfiguration alloc] init];
config.capturesAudio = YES;
config.sampleRate = 48000;
config.channelCount = 1;
SCContentFilter* filter = [[SCContentFilter alloc] initWithDisplay:display
excludingWindows:@[]];
impl_->state = std::make_shared<AudioCallbackState>();
impl_->state->callback = impl_->callback;
impl_->delegate = [[SpeakerCaptureDelegate alloc] initWithState:impl_->state];
impl_->stream = [[SCStream alloc] initWithFilter:filter
configuration:config
delegate:impl_->delegate];
NSError* output_error = nil;
if (!impl_->stream || ![impl_->stream addStreamOutput:impl_->delegate
type:SCStreamOutputTypeAudio
sampleHandlerQueue:impl_->queue
error:&output_error]) {
LOG_ERROR("Failed to attach system audio output: {}", NSErrorToString(output_error));
Stop();
return -1;
}
auto start_reply = std::make_shared<CaptureReply<StreamReply>>();
SCStream* stream = impl_->stream;
impl_->state->running = true;
[stream startCaptureWithCompletionHandler:^(NSError* error) {
if (!start_reply->Complete({error}) && !error) {
[stream stopCaptureWithCompletionHandler:nil];
}
}];
StreamReply started;
if (!start_reply->Wait(started)) {
LOG_ERROR("Timed out starting system audio capture");
Stop();
return -1;
}
if (started.error || !impl_->state->running) {
LOG_ERROR("Failed to start system audio capture: {}", NSErrorToString(started.error));
Stop();
return -1;
}
return 0;
}
}
int SpeakerCapturerMacosx::Stop() {
if (!inited_) return -1;
if (!impl_->stream) return -1;
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
[impl_->stream stopCaptureWithCompletionHandler:^(NSError* error) {
if (error) {
LOG_ERROR("stopCaptureWithCompletionHandler error: {}",
NSErrorToString(error));
@autoreleasepool {
if (auto state = std::exchange(impl_->state, {})) {
std::lock_guard<std::mutex> lock(state->mutex);
state->running = false;
state->callback = nullptr;
}
dispatch_semaphore_signal(sema);
}];
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
impl_->stream = nil;
impl_->delegate.owner = nullptr;
impl_->delegate = nil;
return 0;
SCStream* stream = impl_->stream;
impl_->stream = nil;
impl_->delegate = nil;
if (!stream) return 0;
auto stop_reply = std::make_shared<CaptureReply<StreamReply>>();
[stream stopCaptureWithCompletionHandler:^(NSError* error) {
stop_reply->Complete({error});
}];
StreamReply stopped;
if (!stop_reply->Wait(stopped)) {
LOG_ERROR("Timed out stopping system audio capture; callbacks revoked");
return -1;
}
// A start failure or daemon restart can leave an already stopped stream.
// Its references have been discarded, so future starts can recover.
if (stopped.error) LOG_WARN("System audio stop reply: {}", NSErrorToString(stopped.error));
return 0;
}
}
bool SpeakerCapturerMacosx::IsRunning() const { return impl_->state && impl_->state->running; }
int SpeakerCapturerMacosx::Destroy() {
Stop();
cb_ = nullptr;
if (impl_) {
impl_->config = nil;
impl_->content = nil;
impl_->mainDisplay = nil;
if (impl_->queue) {
impl_->queue = nil;
}
}
inited_ = false;
return 0;
const int result = Stop();
impl_->callback = nullptr;
impl_->queue = nil;
return result;
}
int SpeakerCapturerMacosx::Pause() { return 0; }
int SpeakerCapturerMacosx::Pause() { return Stop(); }
int SpeakerCapturerMacosx::Resume() { return Start(); }
} // namespace crossdesk
@@ -1,104 +1,95 @@
#include "speaker_capturer_wasapi.h"
#include <utility>
#include "rd_log.h"
#define MINIAUDIO_IMPLEMENTATION
#include "miniaudio.h"
#define SAVE_AUDIO_FILE 0
namespace crossdesk {
static ma_device_config device_config_;
static ma_device device_;
static ma_format format_ = ma_format_s16;
static ma_uint32 sample_rate_ = ma_standard_sample_rate_48000;
static ma_uint32 channels_ = 1;
static FILE* fp_ = nullptr;
void data_callback(ma_device* pDevice, void* pOutput, const void* pInput,
ma_uint32 frameCount) {
SpeakerCapturerWasapi* ptr = (SpeakerCapturerWasapi*)pDevice->pUserData;
if (ptr) {
if (SAVE_AUDIO_FILE) {
fwrite(pInput, frameCount * ma_get_bytes_per_frame(format_, channels_), 1,
fp_);
}
ptr->GetCallback()((unsigned char*)pInput,
frameCount * ma_get_bytes_per_frame(format_, channels_),
"audio");
}
(void)pOutput;
}
SpeakerCapturerWasapi::speaker_data_cb SpeakerCapturerWasapi::GetCallback() {
return cb_;
}
SpeakerCapturerWasapi::SpeakerCapturerWasapi() {}
// Each attempt owns its device. A failed/old session must never uninitialize
// a device belonging to another capturer or reuse already released storage.
class SpeakerCapturerWasapi::Impl {
public:
ma_device device{};
bool initialized = false;
speaker_data_cb callback;
};
SpeakerCapturerWasapi::SpeakerCapturerWasapi() : impl_(new Impl) {}
SpeakerCapturerWasapi::~SpeakerCapturerWasapi() {
if (SAVE_AUDIO_FILE) {
fclose(fp_);
}
Destroy();
delete impl_;
}
int SpeakerCapturerWasapi::Init(speaker_data_cb cb) {
if (inited_) {
return 0;
}
cb_ = cb;
if (SAVE_AUDIO_FILE) {
fopen_s(&fp_, "system_audio.pcm", "wb");
}
ma_result result;
if (impl_->initialized) return 0;
impl_->callback = std::move(cb);
ma_device_config config = ma_device_config_init(ma_device_type_loopback);
config.capture.pDeviceID =
nullptr; // Resolve the current default each attempt.
config.capture.format = ma_format_s16;
config.capture.channels = 1;
config.sampleRate = 48000;
config.pUserData = impl_;
config.dataCallback = [](ma_device* device, void*, const void* input,
ma_uint32 frames) {
auto* state = static_cast<Impl*>(device->pUserData);
if (state && state->callback && input && frames > 0)
state->callback((unsigned char*)input, frames * sizeof(int16_t), "audio");
};
ma_backend backends[] = {ma_backend_wasapi};
device_config_ = ma_device_config_init(ma_device_type_loopback);
device_config_.capture.pDeviceID = NULL;
device_config_.capture.format = format_;
device_config_.capture.channels = channels_;
device_config_.sampleRate = sample_rate_;
device_config_.dataCallback = data_callback;
device_config_.pUserData = this;
result = ma_device_init_ex(backends, sizeof(backends) / sizeof(backends[0]),
NULL, &device_config_, &device_);
const ma_result result =
ma_device_init_ex(backends, 1, nullptr, &config, &impl_->device);
if (result != MA_SUCCESS) {
LOG_ERROR("Failed to initialize loopback device");
impl_->callback = nullptr;
LOG_ERROR("Failed to initialize WASAPI loopback device: {}",
static_cast<int>(result));
return -1;
}
inited_ = true;
impl_->initialized = true;
return 0;
}
int SpeakerCapturerWasapi::Start() {
ma_result result = ma_device_start(&device_);
if (!impl_->initialized) return -1;
if (IsRunning()) return 0;
const ma_result result = ma_device_start(&impl_->device);
if (result != MA_SUCCESS) {
ma_device_uninit(&device_);
LOG_ERROR("Failed to start device");
LOG_ERROR("Failed to start WASAPI loopback device: {}",
static_cast<int>(result));
Destroy();
return -1;
}
return 0;
}
int SpeakerCapturerWasapi::Stop() {
ma_device_stop(&device_);
if (!impl_->initialized || !IsRunning()) return 0;
const ma_result result = ma_device_stop(&impl_->device);
if (result != MA_SUCCESS) {
LOG_WARN("Failed to stop WASAPI loopback device: {}",
static_cast<int>(result));
return -1;
}
return 0;
}
int SpeakerCapturerWasapi::Destroy() {
ma_device_uninit(&device_);
if (impl_->initialized) {
// ma_device_uninit also stops and drains the device callback thread.
ma_device_uninit(&impl_->device);
impl_->initialized = false;
}
impl_->callback = nullptr;
return 0;
}
int SpeakerCapturerWasapi::Pause() { return 0; }
} // namespace crossdesk
bool SpeakerCapturerWasapi::IsRunning() const {
return impl_->initialized && ma_device_is_started(&impl_->device);
}
int SpeakerCapturerWasapi::Pause() { return Stop(); }
int SpeakerCapturerWasapi::Resume() { return Start(); }
} // namespace crossdesk
@@ -14,24 +14,21 @@ namespace crossdesk {
class SpeakerCapturerWasapi : public SpeakerCapturer {
public:
SpeakerCapturerWasapi();
~SpeakerCapturerWasapi();
~SpeakerCapturerWasapi() override;
public:
virtual int Init(speaker_data_cb cb);
virtual int Destroy();
virtual int Start();
virtual int Stop();
int Init(speaker_data_cb cb) override;
int Destroy() override;
int Start() override;
int Stop() override;
bool IsRunning() const override;
int Pause();
int Resume();
speaker_data_cb GetCallback();
private:
speaker_data_cb cb_ = nullptr;
private:
bool inited_ = false;
class Impl;
Impl* impl_ = nullptr;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,224 @@
#include "windows_installer.h"
#include <windows.h>
#include <shobjidl.h>
#include <winhttp.h>
#include <wrl/client.h>
#include <array>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <system_error>
#include "rd_log.h"
namespace crossdesk {
namespace {
constexpr uint64_t kMaxInstallerSize = 1024ull * 1024 * 1024;
// Stable identity used only for Windows Attachment Services policy/UI.
constexpr GUID kAttachmentClient = {
0x72d17a6b,
0xeac1,
0x4816,
{0x91, 0x40, 0x33, 0x1b, 0xa7, 0x76, 0x22, 0xcd}};
void CheckWin32(BOOL success, const char* operation) {
if (!success) {
throw std::system_error(static_cast<int>(GetLastError()),
std::system_category(), operation);
}
}
struct InternetCloser {
void operator()(HINTERNET handle) const { WinHttpCloseHandle(handle); }
};
using InternetHandle = std::unique_ptr<void, InternetCloser>;
struct PartialFile {
std::filesystem::path path;
~PartialFile() {
std::error_code error;
std::filesystem::remove(path, error);
}
};
bool IsWindowsExecutable(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
std::array<unsigned char, 64> header{};
input.read(reinterpret_cast<char*>(header.data()), header.size());
if (!input || header[0] != 'M' || header[1] != 'Z') return false;
const uint32_t pe_offset =
uint32_t(header[60]) | (uint32_t(header[61]) << 8) |
(uint32_t(header[62]) << 16) | (uint32_t(header[63]) << 24);
if (pe_offset < header.size()) return false;
input.seekg(pe_offset);
std::array<char, 4> signature{};
input.read(signature.data(), signature.size());
return input && signature == std::array<char, 4>{'P', 'E', 0, 0};
}
uint64_t ContentLength(HINTERNET request) {
std::array<wchar_t, 32> value{};
DWORD bytes = static_cast<DWORD>(sizeof(value));
if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_LENGTH,
WINHTTP_HEADER_NAME_BY_INDEX, value.data(), &bytes,
WINHTTP_NO_HEADER_INDEX)) {
if (GetLastError() == ERROR_WINHTTP_HEADER_NOT_FOUND) return 0;
CheckWin32(FALSE, "WinHttpQueryHeaders(Content-Length)");
}
const std::wstring length(value.data());
if (length.empty() ||
length.find_first_not_of(L"0123456789") != std::wstring::npos) {
throw std::runtime_error("Invalid installer content length");
}
return std::stoull(length);
}
class ComApartment {
public:
ComApartment() : status_(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED)) {}
~ComApartment() {
if (SUCCEEDED(status_)) CoUninitialize();
}
bool Ready() const {
return SUCCEEDED(status_) || status_ == RPC_E_CHANGED_MODE;
}
private:
HRESULT status_;
};
bool HandleAttachment(const std::filesystem::path& installer,
const std::string& source, bool execute) {
ComApartment apartment;
if (!apartment.Ready()) {
LOG_WARN("Cannot initialize Windows Attachment Services");
return false;
}
Microsoft::WRL::ComPtr<IAttachmentExecute> attachment;
HRESULT result =
CoCreateInstance(CLSID_AttachmentServices, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&attachment));
const std::wstring source_url(source.begin(), source.end());
if (SUCCEEDED(result)) result = attachment->SetClientGuid(kAttachmentClient);
if (SUCCEEDED(result))
result = attachment->SetClientTitle(L"CrossDesk Update");
if (SUCCEEDED(result)) result = attachment->SetLocalPath(installer.c_str());
if (SUCCEEDED(result)) result = attachment->SetSource(source_url.c_str());
if (SUCCEEDED(result)) {
if (execute) {
HANDLE process = nullptr;
// Execute applies attachment policy, scanning, and any required prompts.
// The installer's manifest, rather than this process, requests elevation.
result = attachment->Execute(GetActiveWindow(), L"open", &process);
if (process) CloseHandle(process);
} else {
// Save records the source zone and invokes installed trust/AV services.
result = attachment->Save();
}
}
if (FAILED(result)) {
LOG_WARN("Windows Attachment Services {} failed/blocked, HRESULT=0x{:08X}",
execute ? "execute" : "save", static_cast<uint32_t>(result));
return false;
}
return true;
}
} // namespace
bool DownloadWindowsInstaller(
const InstallerDownload& download, const std::filesystem::path& destination,
const std::function<bool(uint64_t, uint64_t)>& progress) {
auto partial_path = destination;
partial_path += ".part";
PartialFile partial{partial_path};
try {
if (!progress(0, 0)) return false;
const std::string agent = "CrossDesk/" CROSSDESK_VERSION " WindowsUpdater";
const std::wstring user_agent(agent.begin(), agent.end());
InternetHandle session(
WinHttpOpen(user_agent.c_str(), WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0));
CheckWin32(session != nullptr, "WinHttpOpen");
CheckWin32(WinHttpSetTimeouts(session.get(), 5000, 5000, 5000, 5000),
"WinHttpSetTimeouts");
InternetHandle connection(WinHttpConnect(session.get(),
L"downloads.crossdesk.cn",
INTERNET_DEFAULT_HTTPS_PORT, 0));
CheckWin32(connection != nullptr, "WinHttpConnect");
const std::wstring path(download.path.begin(), download.path.end());
InternetHandle request(WinHttpOpenRequest(
connection.get(), L"GET", path.c_str(), nullptr, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE));
CheckWin32(request != nullptr, "WinHttpOpenRequest");
DWORD disabled = WINHTTP_DISABLE_REDIRECTS;
CheckWin32(WinHttpSetOption(request.get(), WINHTTP_OPTION_DISABLE_FEATURE,
&disabled, sizeof(disabled)),
"Disable redirects");
// Use Windows certificate validation and configured proxies as-is. Do not
// ignore TLS errors, spoof a browser, or retry through alternative hosts.
CheckWin32(WinHttpSendRequest(request.get(), WINHTTP_NO_ADDITIONAL_HEADERS,
0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0),
"WinHttpSendRequest");
CheckWin32(WinHttpReceiveResponse(request.get(), nullptr),
"WinHttpReceiveResponse");
DWORD status = 0;
DWORD status_size = sizeof(status);
CheckWin32(WinHttpQueryHeaders(
request.get(),
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size,
WINHTTP_NO_HEADER_INDEX),
"WinHttpQueryHeaders(status)");
if (status != 200) {
LOG_WARN("Windows installer download returned HTTP {}", status);
return false;
}
const uint64_t total = ContentLength(request.get());
if (total > kMaxInstallerSize || !progress(0, total)) return false;
std::ofstream output(partial.path, std::ios::binary | std::ios::trunc);
if (!output)
throw std::runtime_error("Cannot create installer download file");
uint64_t received = 0;
std::array<unsigned char, 64 * 1024> buffer{};
while (progress(received, total)) {
DWORD count = 0;
CheckWin32(WinHttpReadData(request.get(), buffer.data(),
static_cast<DWORD>(buffer.size()), &count),
"WinHttpReadData");
if (count == 0) break;
received += count;
if (received > kMaxInstallerSize || (total > 0 && received > total))
return false;
output.write(reinterpret_cast<const char*>(buffer.data()), count);
if (!output)
throw std::runtime_error("Cannot write installer download file");
}
output.close();
if (output.fail() || received == 0 || (total > 0 && received != total) ||
!progress(received, total) || !IsWindowsExecutable(partial.path))
return false;
LOG_INFO("Windows installer downloaded, bytes={}", received);
std::filesystem::rename(partial.path, destination);
return true;
} catch (const std::exception& error) {
LOG_WARN("Windows installer download failed: {}", error.what());
return false;
}
}
bool CheckWindowsInstaller(const std::filesystem::path& installer,
const std::string& source) {
return HandleAttachment(installer, source, false);
}
bool LaunchWindowsInstaller(const std::filesystem::path& installer,
const std::string& source) {
return HandleAttachment(installer, source, true);
}
} // namespace crossdesk
@@ -0,0 +1,33 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOWS_INSTALLER_H_
#define _WINDOWS_INSTALLER_H_
#include <cstdint>
#include <filesystem>
#include <functional>
#include <string>
#include "installer_download.h"
namespace crossdesk {
bool DownloadWindowsInstaller(
const InstallerDownload& download, const std::filesystem::path& destination,
const std::function<bool(uint64_t, uint64_t)>& progress);
// Attachment Services records the Internet source and applies Windows policy
// and installed antivirus checks. Failure must never fall back to direct
// launch.
bool CheckWindowsInstaller(const std::filesystem::path& installer,
const std::string& source);
bool LaunchWindowsInstaller(const std::filesystem::path& installer,
const std::string& source);
} // namespace crossdesk
#endif
@@ -0,0 +1,123 @@
#include "windows_updater.h"
#include <shlobj.h>
#include <windows.h>
#include <random>
#include <stdexcept>
#include "installer_download.h"
#include "rd_log.h"
#include "windows_installer.h"
namespace crossdesk {
WindowsUpdater::~WindowsUpdater() {
Cancel();
if (worker_.joinable()) worker_.join();
RemoveDownload();
}
void WindowsUpdater::RemoveDownload() {
// A running NSIS installer still needs its source file. Leave that one in
// the user's update cache; clean up cancelled/failed attempts.
if (installer_.empty() || installer_launched_) return;
std::error_code error;
std::filesystem::remove(installer_, error);
std::filesystem::remove(installer_.parent_path(), error);
}
void WindowsUpdater::Start(const nlohmann::json& metadata) {
const State state = GetState();
if (state == State::Downloading || state == State::Checking ||
state == State::Launching || state == State::SecurityBlocked)
return;
if (state == State::Ready || state == State::LaunchFailed ||
state == State::Launched) {
cancelled_ = false;
Launch();
return;
}
if (worker_.joinable()) worker_.join();
RemoveDownload();
installer_.clear();
installer_launched_ = false;
cancelled_ = false;
downloaded_ = 0;
total_ = 0;
const auto download = GetWindowsInstallerDownload(metadata);
if (!download) {
LOG_WARN("No valid Windows installer for the advertised version");
state_ = State::Failed;
return;
}
try {
std::random_device random;
PWSTR local_app_data = nullptr;
const HRESULT path_result = SHGetKnownFolderPath(
FOLDERID_LocalAppData, KF_FLAG_CREATE, nullptr, &local_app_data);
if (FAILED(path_result))
throw std::runtime_error("Cannot locate LocalAppData");
const std::filesystem::path data_path(local_app_data);
CoTaskMemFree(local_app_data);
const auto updates = data_path / L"CrossDesk" / L"Updates";
std::filesystem::create_directories(updates);
for (int attempt = 0; attempt < 10; ++attempt) {
const auto directory = updates / ("download-" + std::to_string(random()) +
"-" + std::to_string(random()));
if (std::filesystem::create_directory(directory)) {
installer_ = directory / download->filename;
break;
}
}
if (installer_.empty())
throw std::runtime_error("Cannot create update directory");
source_url_ = download->origin + download->path;
state_ = State::Downloading;
worker_ = std::thread([this, download = *download] {
const bool success = DownloadWindowsInstaller(
download, installer_, [this](uint64_t received, uint64_t total) {
downloaded_ = received;
total_ = total;
return !cancelled_.load();
});
if (success && !cancelled_) {
state_ = State::Checking;
state_ = CheckWindowsInstaller(installer_, source_url_)
? State::Ready
: State::SecurityBlocked;
} else {
state_ = State::Failed;
}
if (cancelled_) state_ = State::Idle;
});
} catch (const std::exception& error) {
LOG_WARN("Cannot start Windows update: {}", error.what());
state_ = State::Failed;
}
}
void WindowsUpdater::Cancel() {
cancelled_ = true;
if (GetState() == State::Ready) state_ = State::Idle;
}
void WindowsUpdater::Launch() {
if (cancelled_) return;
const State state = GetState();
if (state != State::Ready && state != State::LaunchFailed &&
state != State::Launched)
return;
if (worker_.joinable()) worker_.join();
state_ = State::Launching;
if (!LaunchWindowsInstaller(installer_, source_url_)) {
state_ = State::LaunchFailed;
return;
}
installer_launched_ = true;
state_ = State::Launched;
// NSIS already prompts to close CrossDesk and stops the installed service.
// Keep this process alive if the user subsequently cancels installation.
}
} // namespace crossdesk
@@ -0,0 +1,55 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOWS_UPDATER_H_
#define _WINDOWS_UPDATER_H_
#include <atomic>
#include <filesystem>
#include <nlohmann/json.hpp>
#include <string>
#include <thread>
namespace crossdesk {
class WindowsUpdater {
public:
enum class State {
Idle,
Downloading,
Checking,
Ready,
Launching,
Failed,
SecurityBlocked,
LaunchFailed,
Launched
};
~WindowsUpdater();
void Start(const nlohmann::json& metadata);
void Cancel();
void Launch();
State GetState() const { return state_.load(); }
uint64_t Downloaded() const { return downloaded_.load(); }
uint64_t Total() const { return total_.load(); }
private:
void RemoveDownload();
std::atomic<State> state_{State::Idle};
std::atomic<bool> cancelled_{false};
std::atomic<uint64_t> downloaded_{0};
std::atomic<uint64_t> total_{0};
std::thread worker_;
std::filesystem::path installer_;
std::string source_url_;
bool installer_launched_ = false;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,165 @@
#include "speaker_capture_controller.h"
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <mutex>
#include <thread>
#include <utility>
#include "rd_log.h"
namespace crossdesk {
struct SpeakerCaptureController::State {
Factory factory;
RetryPolicy policy;
std::mutex mutex;
std::condition_variable wake;
bool enabled = false;
bool shutdown = false;
uint64_t generation = 0;
std::atomic<bool> running{false};
// Never hold this mutex across a native capture operation. It only drains
// an in-flight audio delivery when a session is disabled/destroyed.
std::mutex callback_mutex;
SpeakerCapturer::speaker_data_cb callback;
bool deliver = false;
uint64_t callback_attempt = 0;
};
SpeakerCaptureController::SpeakerCaptureController(
Factory factory, SpeakerCapturer::speaker_data_cb cb)
: SpeakerCaptureController(std::move(factory), std::move(cb),
RetryPolicy{}) {}
SpeakerCaptureController::SpeakerCaptureController(
Factory factory, SpeakerCapturer::speaker_data_cb cb, RetryPolicy policy)
: state_(std::make_shared<State>()) {
state_->factory = std::move(factory);
state_->callback = std::move(cb);
policy.max_attempts = std::max(1u, policy.max_attempts);
state_->policy = policy;
// The worker owns only shared state, never this/controller/GUI. Even a
// driver stuck inside a native API cannot block shutdown or outlive an
// unguarded callback target. There is at most one worker per controller.
std::thread([state = state_] { Run(state); }).detach();
}
SpeakerCaptureController::~SpeakerCaptureController() { Shutdown(); }
void SpeakerCaptureController::SetEnabled(bool enabled) {
std::lock_guard<std::mutex> lock(state_->mutex);
if (state_->shutdown || state_->enabled == enabled) return;
state_->enabled = enabled;
++state_->generation;
if (!enabled) {
std::lock_guard<std::mutex> callback_lock(state_->callback_mutex);
state_->deliver = false;
state_->running = false;
}
state_->wake.notify_one();
}
bool SpeakerCaptureController::IsRunning() const { return state_->running; }
void SpeakerCaptureController::Shutdown() {
std::lock_guard<std::mutex> lock(state_->mutex);
state_->shutdown = true;
state_->enabled = false;
state_->running = false;
{
std::lock_guard<std::mutex> callback_lock(state_->callback_mutex);
state_->deliver = false;
state_->callback = nullptr;
}
state_->wake.notify_one();
}
void SpeakerCaptureController::Run(const std::shared_ptr<State>& state) {
uint64_t callback_attempt = 0;
std::unique_lock<std::mutex> lock(state->mutex);
while (!state->shutdown) {
state->wake.wait(lock, [&] { return state->shutdown || state->enabled; });
if (state->shutdown) break;
const uint64_t generation = state->generation;
const auto cancelled = [&] {
return state->shutdown || !state->enabled ||
state->generation != generation;
};
for (unsigned attempt = 1; !cancelled(); ++attempt) {
const uint64_t current_attempt = ++callback_attempt;
lock.unlock();
auto capturer = state->factory();
std::weak_ptr<State> weak = state;
auto callback = [weak, current_attempt](unsigned char* data, size_t size,
const char* name) {
if (auto current = weak.lock()) {
std::lock_guard<std::mutex> callback_lock(current->callback_mutex);
if (current->deliver &&
current->callback_attempt == current_attempt &&
current->callback) {
current->callback(data, size, name);
}
}
};
const bool initialized = capturer && capturer->Init(callback) == 0;
lock.lock();
const bool should_start = initialized && !cancelled();
lock.unlock();
const bool started = should_start && capturer->Start() == 0;
lock.lock();
if (started && !cancelled()) {
{
std::lock_guard<std::mutex> callback_lock(state->callback_mutex);
state->callback_attempt = current_attempt;
state->deliver = true;
state->running = true;
}
LOG_INFO("System audio capture started (attempt {})", attempt);
while (!cancelled()) {
if (state->wake.wait_for(lock, state->policy.health_interval,
cancelled))
break;
lock.unlock();
const bool healthy = capturer->IsRunning();
lock.lock();
if (!healthy) {
LOG_WARN("System audio capture stopped unexpectedly");
break;
}
}
}
{
std::lock_guard<std::mutex> callback_lock(state->callback_mutex);
state->deliver = false;
state->running = false;
}
lock.unlock();
// Recreate on every attempt/reconnection to discard stale displays,
// PulseAudio sources and invalidated WASAPI devices.
if (capturer) capturer->Destroy();
capturer.reset();
lock.lock();
if (cancelled()) break;
if (attempt >= state->policy.max_attempts) {
LOG_ERROR(
"System audio capture disabled after {} attempts; toggle audio "
"or reconnect to retry",
attempt);
state->wake.wait(lock, cancelled);
break;
}
const auto delay =
state->policy.initial_delay * (1u << std::min(attempt - 1, 5u));
LOG_WARN("System audio capture failed (attempt {}); retry in {} ms",
attempt, delay.count());
state->wake.wait_for(lock, delay, cancelled);
}
}
}
} // namespace crossdesk
@@ -0,0 +1,50 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SPEAKER_CAPTURE_CONTROLLER_H_
#define _SPEAKER_CAPTURE_CONTROLLER_H_
#include <chrono>
#include <functional>
#include <memory>
#include "speaker_capturer.h"
namespace crossdesk {
// Serializes platform capture operations away from the UI and transport
// threads. Stop/Shutdown revoke callbacks before returning; native teardown
// can then finish without keeping the GUI or its peer alive.
class SpeakerCaptureController {
public:
using Factory = std::function<std::unique_ptr<SpeakerCapturer>()>;
struct RetryPolicy {
unsigned max_attempts = 3;
std::chrono::milliseconds initial_delay{1000};
std::chrono::milliseconds health_interval{100};
};
SpeakerCaptureController(Factory factory,
SpeakerCapturer::speaker_data_cb cb);
SpeakerCaptureController(Factory factory, SpeakerCapturer::speaker_data_cb cb,
RetryPolicy policy);
~SpeakerCaptureController();
SpeakerCaptureController(const SpeakerCaptureController&) = delete;
SpeakerCaptureController& operator=(const SpeakerCaptureController&) = delete;
void SetEnabled(bool enabled);
bool IsRunning() const;
void Shutdown();
private:
struct State;
static void Run(const std::shared_ptr<State>& state);
std::shared_ptr<State> state_;
};
} // namespace crossdesk
#endif
@@ -24,6 +24,8 @@ class SpeakerCapturer {
virtual int Destroy() = 0;
virtual int Start() = 0;
virtual int Stop() = 0;
// Called on the same control worker as Init/Start/Stop. Must not block.
virtual bool IsRunning() const = 0;
};
} // namespace crossdesk
#endif
#endif
@@ -0,0 +1,41 @@
#include "installer_download.h"
namespace crossdesk {
std::optional<InstallerDownload> GetWindowsInstallerDownload(
const nlohmann::json& metadata) {
if (!metadata.is_object()) return std::nullopt;
const auto version_it = metadata.find(
metadata.contains("latest_version") ? "latest_version" : "version");
if (version_it == metadata.end() || !version_it->is_string()) {
return std::nullopt;
}
std::string version = version_it->get<std::string>();
if (!version.empty() && version.front() == 'v') version.erase(0, 1);
if (version.empty() ||
version.find_first_not_of(
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-") !=
std::string::npos) {
return std::nullopt;
}
const auto downloads = metadata.find("downloads");
if (downloads == metadata.end() || !downloads->is_object())
return std::nullopt;
const auto installer = downloads->find("windows-x64");
if (installer == downloads->end() || !installer->is_object())
return std::nullopt;
const auto url = installer->find("url");
const auto filename = installer->find("filename");
InstallerDownload result{"https://downloads.crossdesk.cn", "",
"crossdesk-win-x64-v" + version + ".exe"};
result.path = "/" + result.filename;
if (url == installer->end() || !url->is_string() ||
url->get<std::string>() != result.origin + result.path ||
(filename != installer->end() &&
(!filename->is_string() ||
filename->get<std::string>() != result.filename))) {
return std::nullopt;
}
return result;
}
} // namespace crossdesk
@@ -0,0 +1,28 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _INSTALLER_DOWNLOAD_H_
#define _INSTALLER_DOWNLOAD_H_
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
namespace crossdesk {
struct InstallerDownload {
std::string origin;
std::string path;
std::string filename;
};
// Only accept the official Windows installer for the advertised version.
std::optional<InstallerDownload> GetWindowsInstallerDownload(
const nlohmann::json& metadata);
} // namespace crossdesk
#endif
@@ -6,6 +6,7 @@
#include <charconv>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
@@ -44,6 +45,7 @@ struct CaptureOptions {
std::string page;
int language = 0;
std::string snapshot_path;
std::string demo_assets_path;
};
// Compatibility entry point for the former application-level debug capture
@@ -98,9 +100,55 @@ CaptureOptions LoadCaptureOptions() {
std::getenv("CROSSDESK_UI_CAPTURE_SNAPSHOT")) {
options.snapshot_path = snapshot;
}
if (const char *assets = std::getenv("CROSSDESK_UI_CAPTURE_DEMO_ASSETS")) {
options.demo_assets_path = assets;
}
return options;
}
// Opt-in documentation data, separate from the smoke test's baseline state.
// The images are public page captures, never a user's cached remote desktops.
bool ConfigureDocumentationDemo(
const slint::ComponentHandle<crossdesk::ui::MainWindow> &window,
const CaptureOptions &options) {
if (options.demo_assets_path.empty()) {
return true;
}
struct DemoDevice {
const char *id;
const char *name;
const char *thumbnail;
};
const DemoDevice devices[] = {
{"987654321", "Dev Workstation", "fixtures/source.png"},
{"246813579", "Office PC", "fixtures/website.png"},
{"135792468", "Home Laptop", "web-client.png"},
};
std::vector<crossdesk::ui::RecentConnection> connections;
for (const auto &device : devices) {
const std::string path = options.demo_assets_path + "/" + device.thumbnail;
const auto thumbnail =
slint::Image::load_from_path(slint::SharedString(path.c_str()));
if (thumbnail.size().width == 0 || thumbnail.size().height == 0) {
std::cerr << "Missing documentation thumbnail: " << path << '\n';
return false;
}
crossdesk::ui::RecentConnection connection;
connection.remote_id = device.id;
connection.display_name = device.name;
connection.host_name = device.name;
connection.online = true;
connection.thumbnail = thumbnail;
connections.emplace_back(std::move(connection));
}
window->set_recent_connections(
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>(
std::move(connections)));
window->set_signal_connected(true);
window->set_hardware_codec_available(true);
return true;
}
bool HasNonEmptyEnvironmentVariable(const char *name) {
const char *value = std::getenv(name);
return value != nullptr && value[0] != '\0';
@@ -306,6 +354,9 @@ int RunCaptureMode(
slint::ComponentHandle<crossdesk::ui::StreamWindow> &stream,
slint::ComponentHandle<crossdesk::ui::ServerWindow> &server) {
ConfigureMainCapturePage(window, options);
if (!ConfigureDocumentationDemo(window, options)) {
return 8;
}
stream->hide();
server->hide();
+123
View File
@@ -0,0 +1,123 @@
# Portable tests for artifact handling and signature validation decisions.
# Get-AuthenticodeSignature and SignTool are simulated; CI validates real files.
$ErrorActionPreference = 'Stop'
$scripts = Join-Path $PSScriptRoot '../scripts/windows'
. "$scripts/signpath-common.ps1"
$artifactScript = Join-Path $scripts 'signpath-artifact.ps1'
$testDirectory = Join-Path ([IO.Path]::GetTempPath()) "crossdesk-signing-test-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $testDirectory | Out-Null
$checks = 0
function Assert-True([bool]$Condition, [string]$Message) {
if (!$Condition) { throw $Message }
$script:checks++
}
function Assert-Throws([scriptblock]$Action, [string]$Expected) {
try { & $Action } catch {
if ($_.Exception.Message -notlike "*$Expected*") { throw }
$script:checks++
return
}
throw "Expected an error containing: $Expected"
}
function New-TestZip([string]$Path, [string[]]$Names) {
$zip = [IO.Compression.ZipFile]::Open($Path, [IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($name in $Names) {
$writer = [IO.StreamWriter]::new($zip.CreateEntry($name).Open())
try { $writer.Write("signed $name") } finally { $writer.Dispose() }
}
} finally { $zip.Dispose() }
}
$mockStatus = 'NotTrusted'
$mockBadFile = ''
$mockCertificate = [pscustomobject]@{ Subject = "CN=Test certificate for 'CrossDesk [OSS]'" }
$mockTimestamp = $null
function Get-AuthenticodeSignature {
param([string]$LiteralPath)
$status = if ([IO.Path]::GetFileName($LiteralPath) -eq $mockBadFile) { 'HashMismatch' } else { $mockStatus }
return [pscustomobject]@{
Status = $status
SignerCertificate = $mockCertificate
TimeStamperCertificate = $mockTimestamp
}
}
try {
$files = @(Get-CrossDeskSigningFiles)
$build = Join-Path $testDirectory 'build'
New-Item -ItemType Directory -Path $build | Out-Null
foreach ($name in $files) { Set-Content -LiteralPath (Join-Path $build $name) -Value "original $name" }
Set-Content -LiteralPath (Join-Path $build 'third-party.dll') -Value 'upstream'
$unsigned = Join-Path $testDirectory 'unsigned.zip'
& $artifactScript -Operation PrepareBinaries -Directory $build -Artifact $unsigned
$zip = [IO.Compression.ZipFile]::OpenRead($unsigned)
try {
$entries = @($zip.Entries | ForEach-Object { $_.FullName })
Assert-True ($entries.Count -eq 4 -and @(Compare-Object $files $entries).Count -eq 0) 'Signing input must include only the four project binaries.'
} finally { $zip.Dispose() }
[xml]$config = Get-Content -Raw (Join-Path $scripts 'signpath-binaries.xml')
$configured = @($config.SelectNodes('//*[local-name()="include"]') | ForEach-Object { $_.path })
Assert-True (@(Compare-Object $files $configured).Count -eq 0) 'SignPath XML must cover exactly the files in the submitted ZIP.'
Remove-Item -LiteralPath (Join-Path $build 'wgc_plugin.dll')
Assert-Throws { & $artifactScript -Operation PrepareBinaries -Directory $build -Artifact (Join-Path $testDirectory 'missing.zip') } 'Missing CrossDesk binary'
Set-Content -LiteralPath (Join-Path $build 'wgc_plugin.dll') -Value 'original wgc_plugin.dll'
$signed = Join-Path $testDirectory 'signed.zip'
New-TestZip $signed $files
$mockBadFile = 'wgc_plugin.dll'
Assert-Throws { & $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $signed -SigningPolicy test-signing } 'HashMismatch'
foreach ($name in $files) {
Assert-True ((Get-Content -Raw (Join-Path $build $name)).Trim() -eq "original $name") 'Failed verification must not replace any output.'
}
$mockBadFile = ''
& $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $signed -SigningPolicy test-signing
foreach ($name in $files) {
Assert-True ((Get-Content -Raw (Join-Path $build $name)) -eq "signed $name") 'Verified signed files must replace the unsigned build output.'
}
Assert-True ((Get-Content -Raw (Join-Path $build 'third-party.dll')).Trim() -eq 'upstream') 'Third-party libraries must remain untouched.'
$badArchives = @(
@($files[0], $files[1], $files[2]),
@($files[0], $files[1], $files[2], '../wgc_plugin.dll'),
@($files[0], $files[1], $files[2], $files[0]),
@($files[0], $files[1], $files[2], $files[3], 'third-party.dll')
)
for ($i = 0; $i -lt $badArchives.Count; $i++) {
$badZip = Join-Path $testDirectory "bad-$i.zip"
New-TestZip $badZip $badArchives[$i]
Assert-Throws { & $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $badZip -SigningPolicy test-signing } 'Signed archive must contain exactly'
}
$file = Join-Path $build 'crossdesk.exe'
foreach ($mockStatus in @('NotSigned', 'HashMismatch', 'UnknownError')) {
Assert-Throws { Assert-CrossDeskSignature $file test-signing } 'Invalid test signature'
}
$mockStatus = 'Valid'
$mockCertificate = $null
Assert-Throws { Assert-CrossDeskSignature $file test-signing } 'no Authenticode signing certificate'
$mockCertificate = [pscustomobject]@{ Subject = 'CN=SignPath Foundation' }
$mockStatus = 'NotTrusted'
Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'Release signature is not trusted'
$mockStatus = 'Valid'
Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'no timestamp'
$mockTimestamp = [pscustomobject]@{ Subject = 'CN=Timestamp authority' }
# Only the real SignTool process is replaced; exercise the actual exit-code check.
$fakeSignTool = Join-Path $testDirectory 'signtool.ps1'
function Find-CrossDeskSignTool { return $fakeSignTool }
Set-Content $fakeSignTool '$global:LASTEXITCODE = 1'
Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'SignTool verification failed'
Set-Content $fakeSignTool '$global:LASTEXITCODE = 0'
Assert-CrossDeskSignature $file release-signing
$checks++
Write-Host "Passed $checks Windows signing checks."
} finally {
Remove-Item -LiteralPath $testDirectory -Recurse -Force
}
+9 -2
View File
@@ -193,13 +193,15 @@ function setup_targets()
set_kind("object")
add_deps("rd_log", "crossdesk_wire")
add_includedirs("apps/desktop/src/speaker_capturer", {public = true})
add_files("apps/desktop/src/speaker_capturer/speaker_capture_controller.cpp")
if is_os("windows") then
add_packages("miniaudio")
add_files("apps/desktop/src/platform/windows/speaker_capturer/*.cpp")
add_includedirs("apps/desktop/src/platform/windows/speaker_capturer")
elseif is_os("macosx") then
add_files("apps/desktop/src/platform/macos/speaker_capturer/*.cpp",
"apps/desktop/src/platform/macos/speaker_capturer/*.mm")
add_files("apps/desktop/src/platform/macos/speaker_capturer/*.cpp")
add_files("apps/desktop/src/platform/macos/speaker_capturer/*.mm",
{mxxflags = "-fobjc-arc"})
add_includedirs("apps/desktop/src/platform/macos/speaker_capturer")
elseif is_os("linux") then
add_files("apps/desktop/src/platform/linux/speaker_capturer/*.cpp")
@@ -283,6 +285,11 @@ function setup_targets()
add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"")
add_deps("rd_log")
add_files("apps/desktop/src/version_checker/*.cpp")
if is_os("windows") then
add_files("apps/desktop/src/platform/windows/windows_updater.cpp",
"apps/desktop/src/platform/windows/windows_installer.cpp")
add_links("winhttp", "ole32", "uuid")
end
add_includedirs("apps/desktop/src/version_checker", {public = true})
if is_os("macosx") then
add_defines("CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN")
@@ -265,7 +265,7 @@
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -277,7 +277,7 @@
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
@@ -314,7 +314,7 @@
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -326,7 +326,7 @@
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 0.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
@@ -61,7 +61,8 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
displayNames:(NSArray<NSString *> *)displayNames
displaySizes:(NSArray<NSValue *> *)displaySizes;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence;
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence
snapshot:(BOOL)snapshot;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveCursorVisible:(BOOL)visible
shape:(NSInteger)shape
@@ -99,15 +100,17 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
- (void)configureWithSignalHost:(NSString *)host
signalPort:(NSInteger)signalPort
turnPort:(NSInteger)turnPort
enableSRTP:(BOOL)enableSRTP;
- (void)setHardwareAccelerationEnabled:(BOOL)enabled;
- (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy;
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
NS_SWIFT_NAME(requestPresence(remoteIDs:));
subscribe:(BOOL)subscribe
NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:));
- (void)invalidatePresence;
- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password;
- (void)disconnect;
- (void)requestKeyFrame;
- (void)sendPointerAtX:(float)x
y:(float)y
@@ -8,6 +8,7 @@
#include <cmath>
#include <cstdio>
#include <cstring>
#include <deque>
#include <limits>
#include <memory>
#include <mutex>
@@ -62,6 +63,11 @@ struct CallbackContext {
uint64_t generation = 0;
};
struct PresenceRequest {
uint64_t generation;
bool subscription;
};
struct RTCState {
PeerPtr *identity_peer = nullptr;
PeerPtr *controller_peer = nullptr;
@@ -69,12 +75,12 @@ struct RTCState {
CallbackContext controller_context;
std::string signal_host;
int signal_port = 0;
int turn_port = 0;
bool enable_srtp = false;
bool hardware_acceleration = true;
VideoDegradationPreference video_adaptation_policy =
VideoDegradationPreference::MaintainResolution;
bool identity_ready = false;
std::deque<PresenceRequest> presence_requests;
bool identity_recovery_attempted = false;
std::string identity_with_password;
std::string identity_base;
@@ -192,11 +198,15 @@ void DispatchMain(dispatch_block_t block) {
} // namespace
@interface CrossDeskRTCBridge ()
- (void)handleSignalState:(CrossDeskSignalState)state role:(PeerRole)role;
- (void)handleSignalState:(CrossDeskSignalState)state
role:(PeerRole)role
generation:(uint64_t)generation;
- (void)handleSignalMessage:(const char *)message
size:(size_t)size
role:(PeerRole)role;
- (void)handleProvisionedIdentity:(const std::string &)identity;
role:(PeerRole)role
generation:(uint64_t)generation;
- (void)handleProvisionedIdentity:(const std::string &)identity
generation:(uint64_t)generation;
- (void)handleConnectionState:(CrossDeskConnectionState)state
remoteID:(const std::string &)remoteID
generation:(uint64_t)generation;
@@ -222,7 +232,6 @@ void DispatchMain(dispatch_block_t block) {
- (void)createControllerPeer;
- (void)destroyIdentityPeer;
- (void)destroyControllerPeer;
- (void)requestKeyFrame;
@end
namespace {
@@ -271,7 +280,8 @@ void OnSignalState(SignalStatus status, const char *, size_t, void *user_data) {
(context->role == PeerRole::Identity ||
[owner isControllerGenerationActive:context->generation])) {
[owner handleSignalState:static_cast<CrossDeskSignalState>(status)
role:context->role];
role:context->role
generation:context->generation];
}
}
@@ -279,7 +289,8 @@ void OnSignalMessage(const char *message, size_t size, void *user_data) {
auto *context = static_cast<CallbackContext *>(user_data);
CrossDeskRTCBridge *owner = context ? context->owner : nil;
if (owner && message && size > 0) {
[owner handleSignalMessage:message size:size role:context->role];
[owner handleSignalMessage:message size:size role:context->role
generation:context->generation];
}
}
@@ -305,7 +316,8 @@ void OnNetworkStats(const char *peer_id, size_t peer_id_size,
if (!owner) return;
if (context->role == PeerRole::Identity && mode == TraversalMode::UnknownMode &&
peer_id && peer_id_size > 0) {
[owner handleProvisionedIdentity:std::string(peer_id, peer_id_size)];
[owner handleProvisionedIdentity:std::string(peer_id, peer_id_size)
generation:context->generation];
} else if (context->role == PeerRole::Controller && stats &&
[owner isControllerGenerationActive:context->generation]) {
[owner handleStats:stats mode:mode];
@@ -319,14 +331,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
CopyCString(params.signal_server_ip, sizeof(params.signal_server_ip),
state.signal_host);
params.signal_server_port = state.signal_port;
CopyCString(params.stun_server_ip, sizeof(params.stun_server_ip),
state.signal_host);
params.stun_server_port = state.turn_port;
CopyCString(params.turn_server_ip, sizeof(params.turn_server_ip),
state.signal_host);
params.turn_server_port = state.turn_port;
params.turn_server_username[0] = '\0';
params.turn_server_password[0] = '\0';
CopyCString(params.log_path, sizeof(params.log_path), state.log_path);
params.hardware_acceleration = state.hardware_acceleration;
params.native_video_output = true;
@@ -370,6 +374,9 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
std::atomic<uint64_t> _receivedVideoFrames;
std::atomic<uint64_t> _controllerGenerationCounter;
std::atomic<uint64_t> _activeControllerGeneration;
std::atomic<uint64_t> _identityGeneration;
std::atomic<uint64_t> _signalGeneration;
std::atomic<uint64_t> _presenceGeneration;
}
- (instancetype)init {
@@ -396,6 +403,9 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
_receivedVideoFrames.store(0);
_controllerGenerationCounter.store(0);
_activeControllerGeneration.store(0);
_identityGeneration.store(0);
_signalGeneration.store(0);
_presenceGeneration.store(0);
}
return self;
}
@@ -423,13 +433,12 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
- (void)configureWithSignalHost:(NSString *)host
signalPort:(NSInteger)signalPort
turnPort:(NSInteger)turnPort
enableSRTP:(BOOL)enableSRTP {
NSString *trimmed = [host stringByTrimmingCharactersInSet:
NSCharacterSet.whitespaceAndNewlineCharacterSet];
const char *host_c_string = trimmed.UTF8String;
const std::string host_value = host_c_string ? host_c_string : "";
if (host_value.empty() || signalPort <= 0 || turnPort <= 0) return;
if (host_value.empty() || signalPort <= 0) return;
NSString *cache = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES)
@@ -446,7 +455,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
dispatch_async(_rtcQueue, ^{
const bool unchanged = self->_state->signal_host == host_value &&
self->_state->signal_port == signalPort &&
self->_state->turn_port == turnPort &&
self->_state->enable_srtp == enableSRTP &&
self->_state->identity_peer != nullptr;
if (unchanged) return;
@@ -455,7 +463,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
[self destroyIdentityPeer];
self->_state->signal_host = host_value;
self->_state->signal_port = static_cast<int>(signalPort);
self->_state->turn_port = static_cast<int>(turnPort);
self->_state->enable_srtp = enableSRTP;
self->_state->log_path = log_path;
self->_state->identity_ready = false;
@@ -517,10 +524,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
});
}
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs {
- (void)invalidatePresence {
_presenceGeneration.fetch_add(1);
}
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
subscribe:(BOOL)subscribe {
NSArray<NSString *> *owned_ids = [remoteIDs copy];
const uint64_t generation = _presenceGeneration.load();
dispatch_async(_rtcQueue, ^{
if (!self->_state->identity_peer || !self->_state->identity_ready ||
if (generation != self->_presenceGeneration.load() ||
!self->_state->identity_peer || !self->_state->identity_ready ||
self->_state->identity_base.empty()) {
return;
}
@@ -540,14 +554,19 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
@"type" : @"recent_connections_presence",
@"user_id" : user_id ?: @"",
@"devices" : devices,
@"subscribe" : @(subscribe),
};
NSData *payload = [NSJSONSerialization dataWithJSONObject:request
options:0
error:nil];
if (!payload) return;
SendSignalMessage(self->_state->identity_peer,
static_cast<const char *>(payload.bytes),
payload.length);
if (SendSignalMessage(self->_state->identity_peer,
static_cast<const char *>(payload.bytes),
payload.length) == 0) {
// Both old and new servers return snapshots in request order. Retain the
// epoch so a pre-foreground response cannot satisfy a fresh query.
self->_state->presence_requests.push_back({generation, bool(subscribe)});
}
});
}
@@ -832,13 +851,15 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
- (void)createIdentityPeer {
if (_state->identity_peer || _state->signal_host.empty()) return;
_state->identity_context.generation = _identityGeneration.fetch_add(1) + 1;
Params params = MakeParams(*_state, _state->identity_with_password,
&_state->identity_context);
_state->identity_peer = CreatePeer(&params);
if (!_state->identity_peer || Init(_state->identity_peer) != 0) {
[self destroyIdentityPeer];
[self handleSignalState:CrossDeskSignalStateFailed
role:PeerRole::Identity];
role:PeerRole::Identity
generation:_identityGeneration.load()];
}
}
@@ -890,6 +911,9 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
}
- (void)destroyIdentityPeer {
_identityGeneration.fetch_add(1);
_presenceGeneration.fetch_add(1);
_state->presence_requests.clear();
if (_state->identity_peer) {
DestroyPeer(&_state->identity_peer);
}
@@ -920,8 +944,19 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
[self resetVideoDelivery];
}
- (void)handleSignalState:(CrossDeskSignalState)state role:(PeerRole)role {
- (void)handleSignalState:(CrossDeskSignalState)state
role:(PeerRole)role
generation:(uint64_t)generation {
// Only the long-lived identity peer drives presence and the home indicator.
if (role != PeerRole::Identity || generation != _identityGeneration.load()) {
return;
}
const uint64_t signal_generation = _signalGeneration.fetch_add(1) + 1;
_presenceGeneration.fetch_add(1);
dispatch_async(_rtcQueue, ^{
if (generation != self->_identityGeneration.load() ||
signal_generation != self->_signalGeneration.load()) return;
self->_state->presence_requests.clear();
if (role == PeerRole::Identity) {
self->_state->identity_ready = state == CrossDeskSignalStateConnected;
if (state == CrossDeskSignalStateFailed &&
@@ -954,6 +989,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
// its shutdown would incorrectly make the server look disconnected.
if (role == PeerRole::Identity) {
DispatchMain(^{
if (generation != self->_identityGeneration.load() ||
signal_generation != self->_signalGeneration.load()) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didChangeSignalState:)]) {
@@ -966,13 +1003,18 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
- (void)handleSignalMessage:(const char *)message
size:(size_t)size
role:(PeerRole)role {
role:(PeerRole)role
generation:(uint64_t)generation {
constexpr size_t kMaxPresenceMessageSize = 256 * 1024;
if (role != PeerRole::Identity || !message || size == 0 ||
if (role != PeerRole::Identity || generation != _identityGeneration.load() ||
!message || size == 0 ||
size > kMaxPresenceMessageSize) {
return;
}
const uint64_t signal_generation = _signalGeneration.load();
const uint64_t presence_generation = _presenceGeneration.load();
NSData *payload = [NSData dataWithBytes:message length:size];
id decoded = [NSJSONSerialization JSONObjectWithData:payload
options:0
@@ -1007,22 +1049,41 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
} else {
return;
}
if (presence.count == 0) return;
NSDictionary<NSString *, NSNumber *> *result = [presence copy];
DispatchMain(^{
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didReceivePresence:)]) {
[delegate rtcBridge:self didReceivePresence:result];
const bool response = [type isEqualToString:@"presence"];
dispatch_async(_rtcQueue, ^{
if (generation != self->_identityGeneration.load() ||
signal_generation != self->_signalGeneration.load() ||
!self->_state->identity_ready) return;
uint64_t delivery_generation = presence_generation;
BOOL snapshot = NO;
if (response) {
if (self->_state->presence_requests.empty()) return;
const auto request = self->_state->presence_requests.front();
self->_state->presence_requests.pop_front();
delivery_generation = request.generation;
snapshot = request.subscription;
}
if (delivery_generation != self->_presenceGeneration.load()) return;
DispatchMain(^{
if (generation != self->_identityGeneration.load() ||
signal_generation != self->_signalGeneration.load() ||
delivery_generation != self->_presenceGeneration.load()) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didReceivePresence:snapshot:)]) {
[delegate rtcBridge:self didReceivePresence:result snapshot:snapshot];
}
});
});
}
- (void)handleProvisionedIdentity:(const std::string &)identity {
- (void)handleProvisionedIdentity:(const std::string &)identity
generation:(uint64_t)generation {
if (identity.empty()) return;
const std::string identity_copy = identity;
dispatch_async(_rtcQueue, ^{
if (generation != self->_identityGeneration.load()) return;
self->_state->identity_with_password = identity_copy;
self->_state->identity_base = BaseIdentity(identity_copy);
NSString *host = [NSString stringWithUTF8String:
@@ -1038,6 +1099,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
}
DispatchMain(^{
if (generation != self->_identityGeneration.load()) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) {
[delegate rtcBridge:self didProvisionIdentity:value];
+5 -2
View File
@@ -23,11 +23,16 @@ struct ContentView: View {
}
if scenePhase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
session.resumeVideoAfterForeground()
}
}
.onChange(of: scenePhase) { phase in
if phase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
session.resumeVideoAfterForeground()
} else if phase == .background {
session.suspendVideoForBackground()
session.suspendPresenceMonitoring()
}
}
}
@@ -711,8 +716,6 @@ private struct ServerSettingsView: View {
.autocorrectionDisabled()
TextField("信令端口", text: $session.signalPort)
.keyboardType(.numberPad)
TextField("STUN/TURN 端口", text: $session.turnPort)
.keyboardType(.numberPad)
}
Section {
Toggle("启用 SRTP", isOn: $session.enableSRTP)
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
@@ -58,6 +58,7 @@ struct RemoteTouchInputView: UIViewRepresentable {
let videoSize: CGSize
let controlMode: MouseControlMode
let remoteCursorPosition: CGPoint?
let remoteCursorPositionRevision: UInt64
let viewportScale: CGFloat
let viewportOffset: CGSize
let viewportChanged: (CGFloat, CGSize) -> Void
@@ -81,7 +82,8 @@ struct RemoteTouchInputView: UIViewRepresentable {
view.onLeftUp = leftUp
view.onRightClick = rightClick
view.onScroll = scroll
view.synchronizeRemoteCursor(remoteCursorPosition)
view.synchronizeRemoteCursor(remoteCursorPosition,
revision: remoteCursorPositionRevision)
}
}
@@ -108,6 +110,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
private var relativeCursorPoint: (Float, Float) = (0.5, 0.5)
private var lastRemoteCursorPoint: (Float, Float)?
private var lastRemoteCursorRevision: UInt64?
private var relativePanActive = false
private var heldDragPoint: (Float, Float)?
private var heldDragLastLocation: CGPoint?
@@ -184,7 +187,12 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
}
func synchronizeRemoteCursor(_ position: CGPoint?) {
func synchronizeRemoteCursor(_ position: CGPoint?, revision: UInt64) {
// SwiftUI refreshes this view for video frames and shape-only feedback
// too. Consume each position update once, even when it arrives during
// local movement, so a later refresh cannot restore an old coordinate.
guard revision != lastRemoteCursorRevision else { return }
lastRemoteCursorRevision = revision
guard let position else {
lastRemoteCursorPoint = nil
return
@@ -194,7 +202,11 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
Float(min(max(position.y, 0), 1))
)
lastRemoteCursorPoint = point
applyRemoteCursorIfIdle()
guard controlMode == .relative,
!relativePanActive,
heldDragPoint == nil,
hoverLastLocation == nil else { return }
relativeCursorPoint = point
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
@@ -359,7 +371,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
if controlMode == .relative {
switch recognizer.state {
case .began:
guard renderRect()?.contains(location) == true else { return }
guard canStartRelativePointerGesture(at: location) else { return }
hoverLastLocation = location
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
case .changed:
@@ -386,7 +398,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
switch recognizer.state {
case .began:
let location = recognizer.location(in: self)
relativePanActive = renderRect()?.contains(location) == true
relativePanActive = canStartRelativePointerGesture(at: location)
recognizer.setTranslation(.zero, in: self)
if relativePanActive {
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
@@ -400,7 +412,6 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
default:
relativePanActive = false
applyRemoteCursorIfIdle()
}
}
@@ -408,7 +419,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
let location = recognizer.location(in: self)
switch recognizer.state {
case .began:
guard renderRect()?.contains(location) == true else { return }
guard canStartRelativePointerGesture(at: location) else { return }
heldDragLastLocation = location
heldDragPoint = relativeCursorPoint
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
@@ -429,7 +440,6 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
heldDragPoint = nil
heldDragLastLocation = nil
applyRemoteCursorIfIdle()
default:
break
}
@@ -437,12 +447,18 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
private func pointerPoint(for location: CGPoint) -> (Float, Float)? {
if controlMode == .relative {
guard renderRect()?.contains(location) == true else { return nil }
guard canStartRelativePointerGesture(at: location) else { return nil }
return relativeCursorPoint
}
return normalized(location)
}
private func canStartRelativePointerGesture(at location: CGPoint) -> Bool {
// The entire touch surface acts as a trackpad, including letterboxing.
// A valid video rect is still required to scale pointer movement.
bounds.contains(location) && renderRect() != nil
}
private func updateRelativeCursor(by translation: CGPoint) -> (Float, Float)? {
guard let renderRect = renderRect(),
renderRect.width > 0, renderRect.height > 0 else { return nil }
@@ -457,14 +473,6 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
return relativeCursorPoint
}
private func applyRemoteCursorIfIdle() {
guard controlMode == .relative,
!relativePanActive,
heldDragPoint == nil,
let point = lastRemoteCursorPoint else { return }
relativeCursorPoint = point
}
private func normalized(_ point: CGPoint) -> (Float, Float)? {
guard let renderRect = renderRect(), renderRect.contains(point) else { return nil }
return (Float((point.x - renderRect.minX) / renderRect.width),
+204 -35
View File
@@ -358,7 +358,6 @@ private final class RemoteAudioPlayer {
final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDelegate {
@Published var signalHost = "api.crossdesk.cn"
@Published var signalPort = "9099"
@Published var turnPort = "3478"
@Published var enableSRTP = false
@Published var mouseControlMode = MouseControlMode.saved {
didSet { mouseControlMode.save() }
@@ -381,6 +380,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@Published private(set) var remoteCursorVisible = true
@Published private(set) var remoteCursorShape = 0
@Published private(set) var remoteCursorPosition: CGPoint?
@Published private(set) var remoteCursorPositionRevision: UInt64 = 0
@Published private(set) var remoteCursorVisualOffset = CGPoint.zero
@Published private(set) var hasRemoteCursorState = false
@Published private(set) var bitrate: UInt = 0
@@ -407,8 +407,26 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
private var shouldCaptureThumbnail = false
private var activeDisplayName = ""
private var remoteCursorSequence: UInt32?
private var videoWasBackgrounded = false
private var videoRecoveryTask: Task<Void, Never>?
private var pendingPresenceRemoteID: String?
private var presenceProbeGeneration: UInt64 = 0
private var signalConnected = false
private var presenceIsForeground = false
private var awaitingPresenceSnapshot = true
private var presenceUpdatedAt: [String: ContinuousClock.Instant] = [:]
private var lastPresenceRefreshAt: ContinuousClock.Instant?
private var presenceTimer: Timer?
private let presenceClock = ContinuousClock()
private static let presenceRefreshInterval: Duration = .seconds(30)
private static let presenceMaxAge: Duration = .seconds(60)
private struct BridgeConfiguration: Equatable {
let host: String
let signalPort: Int
let enableSRTP: Bool
}
private var bridgeConfiguration: BridgeConfiguration?
var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer }
var frameSize: CGSize { videoFrame?.encodedSize ?? .zero }
@@ -426,18 +444,37 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
configureBridge()
}
func configureBridge() {
guard let signal = Int(signalPort), let turn = Int(turnPort),
!signalHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
deinit {
presenceTimer?.invalidate()
}
@discardableResult
func configureBridge() -> Bool {
let host = signalHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard let signal = Int(signalPort),
(1...65535).contains(signal),
!host.isEmpty else {
signalStatus = "服务器配置无效"
return
return false
}
let configuration = BridgeConfiguration(host: host, signalPort: signal,
enableSRTP: enableSRTP)
if bridgeConfiguration != configuration {
bridgeConfiguration = configuration
signalConnected = false
signalStatus = "正在连接信令服务"
stopPresenceMonitoring()
invalidatePresence()
cancelPendingPresenceConnection()
} else if signalConnected {
signalStatus = "已连接服务器"
}
bridge.setHardwareAccelerationEnabled(videoCodecMode == .hardware)
bridge.setVideoAdaptationPolicy(videoAdaptationPolicy.bridgeValue)
bridge.configure(withSignalHost: signalHost,
bridge.configure(withSignalHost: host,
signalPort: signal,
turnPort: turn,
enableSRTP: enableSRTP)
return true
}
func connect(password: String, rememberPassword: Bool) {
@@ -453,8 +490,11 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
return
}
remoteID = identifier
configureBridge()
if recentConnectionPresence[identifier] == true {
guard configureBridge() else {
connectionStatus = signalStatus
return
}
if isDeviceOnline(identifier) {
beginRemoteConnection(identifier)
} else {
beginPresenceProbe(identifier)
@@ -462,6 +502,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func beginRemoteConnection(_ identifier: String) {
cancelVideoRecovery()
cancelPresenceProbe()
activeRemoteID = identifier
activeDisplayName = ""
@@ -477,7 +518,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
private func beginPresenceProbe(_ identifier: String) {
guard signalStatus == "已连接服务器" else {
guard signalConnected, presenceIsForeground else {
showDeviceOffline()
return
}
@@ -489,7 +530,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
isConnected = false
sessionVisible = false
connectionStatus = "正在确认设备状态…"
bridge.requestPresence(remoteIDs: [identifier])
requestPresenceProbe(identifier)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
guard let self,
@@ -508,6 +549,21 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
pendingPresenceRemoteID = nil
}
private func cancelPendingPresenceConnection() {
guard pendingPresenceRemoteID != nil else { return }
cancelPresenceProbe()
isConnecting = false
connectionStatus = "未连接"
}
private func requestPresenceProbe(_ identifier: String) {
guard signalConnected, presenceIsForeground else { return }
// Older servers ignore subscribe=false and replace the watched list.
var identifiers = recentConnections.map(\.remoteID)
if !identifiers.contains(identifier) { identifiers.append(identifier) }
bridge.requestPresence(remoteIDs: identifiers, subscribe: false)
}
private func showDeviceOffline() {
isConnecting = false
isConnected = false
@@ -521,6 +577,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func disconnect() {
cancelVideoRecovery()
let wasCheckingPresence = pendingPresenceRemoteID != nil
cancelPresenceProbe()
if wasCheckingPresence {
@@ -543,6 +600,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func retry() {
cancelVideoRecovery()
bridge.disconnect()
connect()
}
@@ -569,29 +627,120 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool {
recentConnectionPresence[connection.remoteID] == true
isDeviceOnline(connection.remoteID)
}
private func isDeviceOnline(_ identifier: String) -> Bool {
guard signalConnected, presenceIsForeground, !awaitingPresenceSnapshot,
recentConnectionPresence[identifier] == true,
let updatedAt = presenceUpdatedAt[identifier] else { return false }
return updatedAt.duration(to: presenceClock.now) < Self.presenceMaxAge
}
private func refreshRecentConnectionPresence() {
bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID))
guard signalConnected, presenceIsForeground else { return }
lastPresenceRefreshAt = presenceClock.now
bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID),
subscribe: true)
}
private func invalidatePresence() {
bridge.invalidatePresence()
recentConnectionPresence = [:]
presenceUpdatedAt = [:]
awaitingPresenceSnapshot = true
lastPresenceRefreshAt = nil
}
private func startPresenceMonitoring() {
guard signalConnected, presenceIsForeground, presenceTimer == nil else { return }
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
self?.maintainPresence()
}
RunLoop.main.add(timer, forMode: .common)
presenceTimer = timer
}
private func stopPresenceMonitoring() {
presenceTimer?.invalidate()
presenceTimer = nil
}
private func maintainPresence() {
guard signalConnected, presenceIsForeground else { return }
let now = presenceClock.now
let expired = presenceUpdatedAt.compactMap { identifier, updatedAt in
updatedAt.duration(to: now) >= Self.presenceMaxAge ? identifier : nil
}
if !expired.isEmpty {
var updated = recentConnectionPresence
for identifier in expired {
updated.removeValue(forKey: identifier)
presenceUpdatedAt.removeValue(forKey: identifier)
}
recentConnectionPresence = updated
}
if lastPresenceRefreshAt.map({ $0.duration(to: now) >= Self.presenceRefreshInterval }) ?? true {
refreshRecentConnectionPresence()
}
}
func suspendPresenceMonitoring() {
presenceIsForeground = false
stopPresenceMonitoring()
invalidatePresence()
cancelPendingPresenceConnection()
}
func suspendVideoForBackground() {
videoWasBackgrounded = true
cancelVideoRecovery()
}
func resumeVideoAfterForeground() {
guard videoWasBackgrounded else { return }
videoWasBackgrounded = false
guard isConnected, sessionVisible else { return }
cancelVideoRecovery()
NSLog("CrossDesk resuming foreground video, requesting key frame")
bridge.requestKeyFrame()
// The first request can race transport resumption. Retry briefly until
// a decoded frame arrives, and never carry retries into another session.
videoRecoveryTask = Task { @MainActor [weak self] in
for delay in [500_000_000, 1_000_000_000, 2_000_000_000] as [UInt64] {
do {
try await Task.sleep(nanoseconds: delay)
} catch {
return
}
guard !Task.isCancelled, let self,
!self.videoWasBackgrounded,
self.isConnected, self.sessionVisible else { return }
self.bridge.requestKeyFrame()
}
}
}
private func cancelVideoRecovery() {
videoRecoveryTask?.cancel()
videoRecoveryTask = nil
}
func refreshRecentConnectionPresenceAfterForeground() {
// iOS can suspend the process while it is in the background, so presence
// updates received during that time are missed. Treat cached values as
// unknown (and therefore offline, like the desktop client) until the
// signaling server returns a fresh snapshot.
recentConnectionPresence = [:]
presenceIsForeground = true
invalidatePresence()
startPresenceMonitoring()
refreshRecentConnectionPresence()
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
requestPresenceProbe(pendingPresenceRemoteID)
}
}
func removeRecentConnection(_ connection: RecentConnection) {
recentConnections.removeAll { $0.remoteID == connection.remoteID }
recentConnectionPresence.removeValue(forKey: connection.remoteID)
presenceUpdatedAt.removeValue(forKey: connection.remoteID)
RecentConnectionStore.save(recentConnections)
RecentConnectionStore.removeThumbnail(fileName: connection.thumbnailFileName)
ConnectionCredentialStore.removePassword(for: connection.remoteID)
@@ -621,7 +770,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
)
recentConnections.removeAll { $0.remoteID == activeRemoteID }
recentConnections.insert(connection, at: 0)
recentConnectionPresence[activeRemoteID] = true
RecentConnectionStore.save(recentConnections)
refreshRecentConnectionPresence()
}
@@ -665,6 +813,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
remoteCursorVisible = true
remoteCursorShape = 0
remoteCursorPosition = nil
remoteCursorPositionRevision &+= 1
remoteCursorVisualOffset = .zero
hasRemoteCursorState = false
remoteCursorSequence = nil
@@ -706,6 +855,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didChange state: CrossDeskSignalState) {
signalConnected = state.rawValue == 1
invalidatePresence()
switch state.rawValue {
case 0: signalStatus = "正在连接信令服务"
case 1: signalStatus = "已连接服务器"
@@ -716,12 +867,15 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
case 6: signalStatus = "TLS 证书校验失败"
default: signalStatus = "未知信令状态"
}
if state.rawValue == 1 {
if signalConnected {
startPresenceMonitoring()
refreshRecentConnectionPresence()
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
requestPresenceProbe(pendingPresenceRemoteID)
}
} else {
stopPresenceMonitoring()
cancelPendingPresenceConnection()
}
}
@@ -729,14 +883,14 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
didChange state: CrossDeskConnectionState,
remoteID: String) {
isConnected = state.rawValue == 1
if !isConnected {
cancelVideoRecovery()
}
isConnecting = [0, 2].contains(state.rawValue)
switch state.rawValue {
case 0: connectionStatus = "正在建立远程连接"
case 1:
connectionStatus = "已连接"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = true
}
sessionVisible = true
recordSuccessfulConnectionIfNeeded()
audioPlayer.setEnabled(audioEnabled)
@@ -760,6 +914,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
connectionStatus = "远程设备 ID 不存在"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = false
presenceUpdatedAt.removeValue(forKey: remoteID)
}
sessionVisible = false
AppOrientation.update(to: .portrait)
@@ -767,6 +922,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
connectionStatus = "远程设备当前不可用"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = false
presenceUpdatedAt.removeValue(forKey: remoteID)
}
sessionVisible = false
AppOrientation.update(to: .portrait)
@@ -776,18 +932,28 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
refreshRecentConnectionPresence()
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
requestPresenceProbe(pendingPresenceRemoteID)
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceivePresence presence: [String: NSNumber]) {
var updated = recentConnectionPresence
for (remoteID, online) in presence {
didReceivePresence presence: [String: NSNumber],
snapshot: Bool) {
guard signalConnected, presenceIsForeground,
snapshot || !awaitingPresenceSnapshot else { return }
var updated = snapshot ? [:] : recentConnectionPresence
if snapshot {
presenceUpdatedAt = [:]
awaitingPresenceSnapshot = false
}
let now = presenceClock.now
let watched = Set(recentConnections.map(\.remoteID))
for (remoteID, online) in presence
where watched.contains(remoteID) || pendingPresenceRemoteID == remoteID {
updated[remoteID] = online.boolValue
presenceUpdatedAt[remoteID] = now
}
recentConnectionPresence = updated
@@ -808,6 +974,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
didReceive pixelBuffer: CVPixelBuffer,
width: Int,
height: Int) {
guard !videoWasBackgrounded else { return }
cancelVideoRecovery()
// Buffer and encoded dimensions must be one observable value. Adaptive
// resolution changes must never expose a new frame with the previous
// frame's geometry to SwiftUI.
@@ -867,6 +1035,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
} else {
remoteCursorPosition = nil
}
remoteCursorPositionRevision &+= 1
}
hasRemoteCursorState = true
}
@@ -79,6 +79,7 @@ struct RemoteSessionView: View {
videoSize: session.displayGeometrySize,
controlMode: session.mouseControlMode,
remoteCursorPosition: session.remoteCursorPosition,
remoteCursorPositionRevision: session.remoteCursorPositionRevision,
viewportScale: viewport.scale,
viewportOffset: viewport.offset,
viewportChanged: { scale, offset in
@@ -161,8 +162,8 @@ struct RemoteSessionView: View {
.onChange(of: session.mouseControlMode) { _ in
cursorPosition = session.remoteCursorPosition
}
.onChange(of: session.remoteCursorPosition) { position in
guard let position else { return }
.onChange(of: session.remoteCursorPositionRevision) { _ in
guard let position = session.remoteCursorPosition else { return }
cursorPosition = position
}
.fileImporter(isPresented: $showingFileImporter,
@@ -245,6 +246,8 @@ struct RemoteSessionView: View {
}
private var floatingStatusControls: some View {
// Keep the orb and expanded menu inside the safe area so their initial
// positions and drag bounds clear the Dynamic Island in either orientation.
GeometryReader { proxy in
let containerSize = proxy.size
let restingOrbCenter = boundedOrbCenter(in: containerSize)
@@ -332,7 +335,6 @@ struct RemoteSessionView: View {
)
}
}
.ignoresSafeArea()
}
private var draggableKeyboard: some View {
@@ -1167,8 +1169,12 @@ private struct FloatingSessionMenu: View {
}
private var statusDetail: String {
var components = [session.usingTURN ? "TURN" : "P2P",
session.formattedBitrate]
var components = [session.usingTURN ? "TURN" : "P2P"]
let frameSize = session.frameSize
if frameSize.width > 0 && frameSize.height > 0 {
components.append("\(Int(frameSize.width))×\(Int(frameSize.height))")
}
components.append(session.formattedBitrate)
if session.lossRate > 0 {
components.append(String(format: "丢包 %.1f%%", session.lossRate * 100))
}
@@ -10,6 +10,7 @@ import UIKit
/// itself. This avoids the Core Image -> MTKView path, which can remain black
/// on a physical device even though VideoToolbox is producing valid frames.
struct NativeVideoView: UIViewRepresentable {
@Environment(\.scenePhase) private var scenePhase
let pixelBuffer: CVPixelBuffer?
func makeUIView(context: Context) -> SampleBufferVideoView {
@@ -17,7 +18,7 @@ struct NativeVideoView: UIViewRepresentable {
}
func updateUIView(_ uiView: SampleBufferVideoView, context: Context) {
uiView.display(pixelBuffer)
uiView.display(pixelBuffer, isActive: scenePhase == .active)
}
}
@@ -31,6 +32,7 @@ final class SampleBufferVideoView: UIView {
}
private var lastPixelBuffer: CVPixelBuffer?
private var renderingActive = false
private var submittedFrames: UInt64 = 0
private var droppedFrames: UInt64 = 0
@@ -55,7 +57,16 @@ final class SampleBufferVideoView: UIView {
videoLayer.videoGravity = .resize
}
func display(_ pixelBuffer: CVPixelBuffer?) {
func display(_ pixelBuffer: CVPixelBuffer?, isActive: Bool) {
if renderingActive != isActive {
renderingActive = isActive
// Backgrounding can invalidate the display layer without changing
// the retained frame. Clear the queue and allow that frame to be
// submitted again when the scene becomes active.
lastPixelBuffer = nil
videoLayer.flushAndRemoveImage()
}
guard let pixelBuffer else {
lastPixelBuffer = nil
submittedFrames = 0
@@ -63,18 +74,21 @@ final class SampleBufferVideoView: UIView {
videoLayer.flushAndRemoveImage()
return
}
guard renderingActive else { return }
// SwiftUI can refresh for unrelated status fields. Do not enqueue the
// same retained CVPixelBuffer more than once.
guard lastPixelBuffer !== pixelBuffer else { return }
lastPixelBuffer = pixelBuffer
if videoLayer.status == .failed {
// Check recovery before deduplicating frames: a failed layer may need
// to display the same buffer again even if no new decoded frame arrives.
if videoLayer.status == .failed || videoLayer.requiresFlushToResumeDecoding {
NSLog("CrossDesk video layer failed: %@",
videoLayer.error?.localizedDescription ?? "unknown error")
videoLayer.flush()
lastPixelBuffer = nil
}
// SwiftUI can refresh for unrelated status fields. Only deduplicate a
// frame after it was actually submitted to a healthy, active layer.
guard lastPixelBuffer !== pixelBuffer else { return }
// Never let AVSampleBufferDisplayLayer turn temporary rendering
// pressure into seconds of latency. Its queued buffers are already
// stale by the time it stops accepting more data, so discard them and
@@ -132,6 +146,7 @@ final class SampleBufferVideoView: UIView {
}
videoLayer.enqueue(sampleBuffer)
lastPixelBuffer = pixelBuffer
submittedFrames &+= 1
if submittedFrames == 1 || submittedFrames.isMultiple(of: 300) {
NSLog("CrossDesk presented video frame %llu (%zu x %zu)",
+51 -23
View File
@@ -12,12 +12,34 @@ There is no `WKWebView` or browser runtime.
## Build
Open `CrossDeskMobile.xcodeproj`, select the `CrossDeskMobile` scheme and a
physical device, then build. The first build phase compiles MiniRTC and the
CrossDesk wire library, then merges their iPhoneOS static dependencies into
a local archive under `Vendor/`.
Clone the repository with its submodules. Run these commands from the parent
directory where you want to place the checkout:
You can also verify the target without code signing:
```sh
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
```
For an existing checkout, run `git submodule update --init --recursive` from
the repository root. All remaining commands below use that root directory.
1. Open `apps/ios/CrossDeskMobile.xcodeproj` and select the `CrossDeskMobile`
target and scheme.
2. Under **Signing & Capabilities**, select your own development team and set
a bundle identifier available to that team. The checked-in signing team
and `cn.crossdesk.mobile` identifier belong to the project maintainer.
3. Connect and select a physical iPhone or iPad, configure device trust and
Developer Mode as prompted by Xcode, then build and run. See Apple's
[device setup guide](https://developer.apple.com/documentation/xcode/running-your-app-on-simulated-or-physical-devices).
The first build phase builds MiniRTC and the CrossDesk wire library, downloads
and builds their dependencies, then merges the iPhoneOS archives into a local library under
`Vendor/`. This build path targets physical devices; simulator builds are
not supported. The exact tool versions used by CI are recorded in the
[workflow](../../.github/workflows/build.yml).
You can also build the target without code signing. The resulting app, like
the unsigned CI artifact, requires signing before device installation:
```sh
xcodebuild -project apps/ios/CrossDeskMobile.xcodeproj \
@@ -35,8 +57,9 @@ the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
## Video codecs
Every iOS build includes VideoToolbox, OpenH264, dav1d, libaom, and SVT-AV1. H.264 uses
VideoToolbox by default; the in-app **Settings > Video Codec** selector switches
H.264 software processing to OpenH264. AV1 encoding uses SVT-AV1 and AV1 decoding
VideoToolbox by default; choose software processing under **设置 → 视频编解码 →
处理方式** (Settings → Video codec → Processing mode) to use OpenH264 for H.264.
The change takes effect on the next connection. AV1 encoding uses SVT-AV1 and AV1 decoding
uses dav1d; VideoToolbox AV1 hardware decoding is currently unsupported.
## Physical-device test checklist
@@ -45,26 +68,31 @@ Run the app from Xcode on a physical device and connect to a current desktop
build. Test the features in this order so a media problem is not confused with
a data-channel problem:
1. **Video:** after the connection turns green, the waiting panel should be
replaced by the remote desktop. The status bar reports the decoded size and
frame count.
1. **Video:** after the session connects, the waiting panel should be replaced
by the remote desktop. Tap the floating icon to see the connection state,
P2P/TURN mode, decoded resolution, and bitrate. The current menu does not
display a frame counter.
2. **Audio:** play continuous sound on the remote computer, then toggle the
speaker button. Audio is Opus-decoded by MiniRTC and played as 48 kHz mono
16-bit PCM through `AVAudioEngine`.
3. **Clipboard:** copy a short text value on the phone and choose **Send local
clipboard**. Copy a different value on the desktop and confirm that it is
written to the iOS pasteboard. Text is limited to 128 KiB.
3. **Clipboard:** copy a short text value on the remote desktop, then paste it
into a text field on the phone to verify reception. Text is limited to
128 KiB. The bridge supports sending local text, but the current UI has no
**Send local clipboard** action.
4. **Displays:** open the display menu, switch every listed monitor, and check
that the frame counter restarts and the selected monitor appears after a
new key frame.
5. **Files:** use the folder button to pick a small file, then send a file back
that the selected monitor appears and the displayed resolution updates
after a new key frame.
5. **Files:** choose **发送文件** (Send file) from the floating menu to pick a
small file, then send a file back
from the desktop. Progress is ACK-driven. Received files are stored in the
app's `Documents/Received` directory and can be exported with the share
button or Finder's Files tab.
button beside the transfer status or Finder's Files tab.
For black-screen diagnosis, keep Xcode's device console open and filter for
`CrossDesk`. A working pipeline prints both `VideoToolbox decoded frame` from
MiniRTC and `CrossDesk received decoded frame` from the iOS bridge. If only the
first message appears, verify that the selected stream is named `Display1`,
`Display2`, and so on. If neither appears, update and rebuild the desktop side
and request a key frame.
For black-screen diagnosis, keep Xcode's debug console open and look for
`CrossDesk` and `VideoToolbox` messages. With VideoToolbox H.264 decoding selected, look for
`VideoToolbox decoded frame` from MiniRTC and `CrossDesk delivered latest frame`
from the iOS bridge. Other codec paths do not produce the VideoToolbox message.
If decoded frames do not reach the bridge, check that the selected stream is
named `Display1`, `Display2`, and so on. If no frames decode, check the selected
codec and the desktop capture/encoding logs, then reconnect or switch displays
to request a new key frame.
+240
View File
@@ -0,0 +1,240 @@
# 从源码构建
[返回 README](../README.md) · [English](BUILD_EN.md)
克隆仓库后,构建和打包命令均在仓库根目录执行。构建依赖与参数以 [Xmake 配置](../apps/desktop/xmake/options.lua) 和 [CI 工作流](../.github/workflows/build.yml) 为准。`1.4.3` 仅为本地构建示例版本号;调整时请同步修改配置命令与打包参数,避免程序内版本号与安装包不一致。
## 构建依赖
- Git、[Xmake](https://xmake.io/guide/quick-start.html) 及下文对应平台的编译工具链。
- 构建脚本通过 Xmake 管理 **CMake 3.x(≥ 3.21、< 4.0)**、**Rust 1.92.0** 等依赖;首次构建需要下载工具与源码。具体约束见 [Slint 包配置](../deps/thirdparty/slint/xmake.lua)。
## Windows
安装 Visual Studio 2022 的“使用 C++ 的桌面开发”工作负载及 Windows SDK,并安装 Xmake。在开发者 PowerShell 中执行:
```powershell
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
xmake f -c -p windows -a x64 -m release --CROSSDESK_VERSION=1.4.3 --USE_CUDA=false -y
xmake b -vy crossdesk
xmake r crossdesk
```
安装包还使用 NSIS,需将仓库中的 [nsProcess.dll](../apps/desktop/scripts/windows/nsProcess.dll) 放入 NSIS 的 `Plugins/x86-unicode` 目录,并确保 `makensis` 在 `PATH` 中。完成 Release 编译后运行:
```powershell
.\apps\desktop\scripts\windows\pkg_x64.ps1 1.4.3
```
完整流程见 [Windows 脚本](../apps/desktop/scripts/windows/pkg_x64.ps1) 和 [CI 工作流](../.github/workflows/build.yml)。手动部署时需保留运行库及两个服务辅助程序,见 [Windows 服务说明](../README.md#windows-service)。
## macOS
安装 Xcode 及其命令行工具、Xmake,然后执行:
```bash
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
xmake f -c -m release --target_minver=14.0 --CROSSDESK_VERSION=1.4.3 --USE_CUDA=false -y
xmake b -vy crossdesk
xmake r crossdesk
```
当前 CI 对 Intel 与 Apple Silicon 均设置 macOS 14.0 为最低目标版本,使用的 Xcode/SDK 版本见 [CI 工作流](../.github/workflows/build.yml)。打包时按本机架构执行一个脚本:
```bash
# Apple Silicon
./apps/desktop/scripts/macos/pkg_arm64.sh 1.4.3
# Intel
./apps/desktop/scripts/macos/pkg_x64.sh 1.4.3
```
打包脚本会生成包含 Slint 运行库的 `.app` 和 `.pkg`。屏幕录制、辅助功能授权与应用路径及签名有关;测试被控能力时请使用固定路径下的应用包。
## Linux
Linux 构建支持 Ubuntu 20.04 及以上版本的 amd64 和 arm64。发布安装包以
Ubuntu 20.04/glibc 2.31 为兼容基线。以下依赖命令以 Ubuntu 20.04 为例;其他发行版或较新版本需按其软件源调整包名和工具链,复现 CI 可使用下文的构建镜像。先安装基础编译依赖:
```bash
sudo apt-get update
sudo apt-get install -y \
git curl unzip build-essential gcc-10 g++-10 python3-pip \
pkg-config binutils dpkg-dev \
libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \
libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \
libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \
libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxcb-shm0-dev \
libasound2-dev libsndio-dev libpulse-dev \
libgl1-mesa-dev
```
Ubuntu 20.04 自带的 CMake 3.16 不满足要求,Xmake 会下载符合上述范围的
CMake 3.x,无需手动替换系统 CMake。
Ubuntu 20.04 默认的 GCC 9 缺少 Slint C++ API 所需的部分 C++20 标准库,
因此上述命令同时安装并在后续配置中指定 GCC 10。
下载子模块并编译 Release 版本:
```bash
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
# 已经克隆过仓库时执行这一行
git submodule update --init --recursive
xmake f -c -m release --CROSSDESK_VERSION=1.4.3 --USE_CUDA=false --toolchain=gcc-10 -y
xmake b -vy crossdesk
```
这里应使用 `--toolchain=gcc-10`,以确保 CrossDesk 及 xmake 在配置阶段构建的
libyuv 等第三方包都使用 GCC 10。只指定 `--cc`/`--cxx` 时,依赖安装可能仍回退到
系统默认的 `/bin/cc` 和 `/bin/c++`。`-c` 会清除旧配置,切换编译器时必须保留。
amd64 构建结果位于 `build/linux/x86_64/release/crossdesk`,arm64 构建结果位于 `build/linux/arm64/release/crossdesk`。
启用 Wayland 和 DRM 时,再安装 D-Bus、DRM 及 PipeWire 开发依赖。Ubuntu 20.04 使用仓库提供的 PipeWire 头文件 SDK;Ubuntu 22.04 及以上可改用系统的 `libpipewire-0.3-dev` 和 `libspa-0.2-dev` 包:
```bash
sudo apt-get install -y libdbus-1-dev libdrm-dev
# 已安装系统 PipeWire 0.3 开发包时跳过这一行
sudo ./docker/linux-build/install-pipewire-sdk.sh
xmake f -c -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false \
--CROSSDESK_VERSION=1.4.3 --toolchain=gcc-10 -y
xmake b -vy crossdesk
```
生成 Debian 安装包(必须在对应架构上先完成 Release 编译):
```bash
# amd64
./apps/desktop/scripts/linux/pkg_amd64.sh 1.4.3
# arm64
./apps/desktop/scripts/linux/pkg_arm64.sh 1.4.3
```
打包脚本会将 Slint 共享运行库安装到软件包私有目录 `/usr/lib/crossdesk`,无需用户另外安装 `libslint_cpp.so`。
PipeWire 不属于强制运行时依赖:程序在运行时检测宿主系统的 PipeWire 0.3,
没有该运行库时仍可使用 X11(以及构建时启用的 DRM)捕获。
## 通用编译选项
```text
--USE_CUDA=true/false: 启用 CUDA 硬件编解码,默认不启用
--USE_WAYLAND=true/false: 在 Linux 上启用 Wayland/PipeWire 捕获,默认不启用
--USE_DRM=true/false: 在 Linux 上启用 DRM 捕获,默认不启用
--CROSSDESK_PORTABLE=true/false: 构建便携版本,默认不启用
--CROSSDESK_VERSION=xxx: 指定 CrossDesk 的版本
# 示例
xmake f --CROSSDESK_VERSION=1.4.3 --USE_CUDA=true
```
运行:
```bash
xmake r crossdesk
```
## 无 CUDA 环境下的开发支持
对于**未安装 CUDA 环境的 Linux 开发者,如果希望编译后的成果物拥有硬件编解码能力**,这里提供了预配置的 [Ubuntu 20.04 兼容构建镜像](https://hub.docker.com/r/crossdesk/ubuntu20.04)。该镜像内置必要的构建依赖,可生成兼容 glibc 2.31 的单一 Linux 安装包。
镜像版本和 amd64 / arm64 差异见 [构建镜像说明](../docker/linux-build/README.md)。以下 CUDA 示例适用于 amd64 镜像;进入容器、下载工程后执行:
```bash
export CUDA_PATH=/usr/local/cuda
export XMAKE_GLOBALDIR=/data
xmake f --root -c -m release --CROSSDESK_VERSION=1.4.3 --USE_CUDA=true --toolchain=gcc-10 -y
xmake b --root -vy crossdesk
```
对于**未安装 CUDA 环境的 Windows 开发者**,执行下面的命令安装 CUDA 编译环境:
```powershell
xmake require -vy "cuda 12.6.3"
```
安装完成后执行:
```powershell
xmake require --info "cuda 12.6.3"
```
在输出中查找 `installdir` 字段。
使用 `installdir` 指向的 CUDA 安装目录。将 CUDA_PATH 加入系统环境变量,或在终端中输入:
```powershell
$env:CUDA_PATH = "C:\path\to\cuda_installdir"
```
重新执行:
```powershell
xmake f --USE_CUDA=true
xmake b -vy crossdesk
```
### 运行时连接问题
“未连接服务器”可能由网络、服务器配置或证书信任引起,不应只通过安装另一份客户端来排查。请按 [常见问题](FAQ.md) 和 [自托管证书说明](SELF_HOSTING.md) 检查。
## 关于 Xmake
### 安装 Xmake
使用 curl:
```bash
curl -fsSL https://xmake.io/shget.text | bash
```
使用 wget:
```bash
wget https://xmake.io/shget.text -O - | bash
```
使用 powershell:
```powershell
irm https://xmake.io/psget.text | iex
```
### 编译选项
```text
# 切换到 Debug;Release 构建将 debug 替换为 release
xmake f -m debug
# 可选编译参数
-r :重新构建目标
-v :显示详细的构建日志
-y :自动确认提示
# 示例
xmake b -vy crossdesk
```
### 运行选项
```bash
# 使用调试器启动当前构建;不会自动切换为 Debug 编译模式
xmake r -d crossdesk
```
更多使用方法可参考 [Xmake 官方文档](https://xmake.io/guide/quick-start.html)。
## iOS / iPadOS
原生客户端使用独立 Xcode 工程,要求 iOS 16+ 的 arm64 真机。构建与音视频、文件传输验证步骤见 [iOS 开发说明](../apps/ios/README.md)。当前 CI 导出未签名应用,真机安装需要自行签名。
## 更新界面截图
README 的桌面图片直接由当前 Slint UI 渲染,复现方式见 [截图说明](images/README.md)。
+253
View File
@@ -0,0 +1,253 @@
# Build from source
[Back to README](../README_EN.md) · [中文](BUILD.md)
After cloning, run build and packaging commands from the repository root. The [Xmake options](../apps/desktop/xmake/options.lua) and [CI workflow](../.github/workflows/build.yml) define the build configuration. `1.4.3` is an example version for local builds; change both the configuration value and packaging argument together so the app and package versions match.
## Build requirements
- Git, [Xmake](https://xmake.io/guide/quick-start.html), and the platform toolchain listed below.
- The build scripts use Xmake to manage **CMake 3.x (≥ 3.21, < 4.0)**, **Rust 1.92.0**, and other dependencies. The first build downloads tools and source archives. See the [Slint package configuration](../deps/thirdparty/slint/xmake.lua) for the constraints.
## Windows
Install Visual Studio 2022 with the **Desktop development with C++** workload and Windows SDK, plus Xmake. In a developer PowerShell, run:
```powershell
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
xmake f -c -p windows -a x64 -m release --CROSSDESK_VERSION=1.4.3 --USE_CUDA=false -y
xmake b -vy crossdesk
xmake r crossdesk
```
Installer packaging also uses NSIS. Copy the repository's [nsProcess.dll](../apps/desktop/scripts/windows/nsProcess.dll) into NSIS's `Plugins/x86-unicode` directory and ensure `makensis` is on `PATH`. After building Release, run:
```powershell
.\apps\desktop\scripts\windows\pkg_x64.ps1 1.4.3
```
See the [Windows packaging script](../apps/desktop/scripts/windows/pkg_x64.ps1) and [CI workflow](../.github/workflows/build.yml) for the full process. Manual deployment must retain the runtime libraries and both service helpers; see [Windows Service](../README_EN.md#windows-service).
## macOS
Install Xcode and its command-line tools, plus Xmake, then run:
```bash
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
xmake f -c -m release --target_minver=14.0 --CROSSDESK_VERSION=1.4.3 --USE_CUDA=false -y
xmake b -vy crossdesk
xmake r crossdesk
```
The current CI sets macOS 14.0 as the minimum target for both Intel and Apple Silicon. Check the [workflow](../.github/workflows/build.yml) for its Xcode/SDK versions. Package with the script matching your architecture:
```bash
# Apple Silicon
./apps/desktop/scripts/macos/pkg_arm64.sh 1.4.3
# Intel
./apps/desktop/scripts/macos/pkg_x64.sh 1.4.3
```
The scripts create an `.app` and `.pkg` containing the Slint runtime. Screen Recording and Accessibility grants depend on the app path and signature; use a consistent application bundle when testing host functionality.
## Linux
Linux builds support Ubuntu 20.04 or later on amd64 and arm64. Release packages
use Ubuntu 20.04/glibc 2.31 as their compatibility baseline. The dependency
commands below target Ubuntu 20.04; adapt package names and toolchains for
other distributions or newer versions, or use the build image below to
reproduce CI. Install the base build dependencies first:
```bash
sudo apt-get update
sudo apt-get install -y \
git curl unzip build-essential gcc-10 g++-10 python3-pip \
pkg-config binutils dpkg-dev \
libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \
libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \
libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \
libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxcb-shm0-dev \
libasound2-dev libsndio-dev libpulse-dev \
libgl1-mesa-dev
```
Ubuntu 20.04's bundled CMake 3.16 is too old. Xmake downloads a CMake 3.x
version within the range above; replacing the system CMake manually is not required.
Ubuntu 20.04's default GCC 9 lacks parts of the C++20 standard library needed
by Slint's C++ API, so the commands above install GCC 10 and the configuration
below selects it explicitly.
Clone the submodules and build a release binary:
```bash
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
# Run this when the repository has already been cloned
git submodule update --init --recursive
xmake f -c -m release --CROSSDESK_VERSION=1.4.3 --USE_CUDA=false --toolchain=gcc-10 -y
xmake b -vy crossdesk
```
Use `--toolchain=gcc-10` so that both CrossDesk and third-party packages such
as libyuv, which xmake builds during configuration, use GCC 10. Specifying only
`--cc`/`--cxx` may still let dependency builds fall back to the system
`/bin/cc` and `/bin/c++`. Keep `-c` when switching compilers to clear the old
configuration.
The amd64 binary is written to `build/linux/x86_64/release/crossdesk`; the arm64 binary is written to `build/linux/arm64/release/crossdesk`.
For Wayland and DRM, install the D-Bus, DRM, and PipeWire development dependencies.
Ubuntu 20.04 uses the repository's PipeWire header SDK; Ubuntu 22.04 and later
can use the distribution's `libpipewire-0.3-dev` and `libspa-0.2-dev` packages instead:
```bash
sudo apt-get install -y libdbus-1-dev libdrm-dev
# Skip this line if the system PipeWire 0.3 development packages are installed
sudo ./docker/linux-build/install-pipewire-sdk.sh
xmake f -c -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false \
--CROSSDESK_VERSION=1.4.3 --toolchain=gcc-10 -y
xmake b -vy crossdesk
```
Build a Debian package after compiling a release binary on the matching architecture:
```bash
# amd64
./apps/desktop/scripts/linux/pkg_amd64.sh 1.4.3
# arm64
./apps/desktop/scripts/linux/pkg_arm64.sh 1.4.3
```
The package scripts install the shared Slint runtime in the private `/usr/lib/crossdesk` directory, so users do not need to install `libslint_cpp.so` separately.
PipeWire is not a mandatory runtime dependency. CrossDesk detects the host's
PipeWire 0.3 runtime dynamically and can still use X11 capture (and DRM when
enabled at build time) when it is unavailable.
## Common build options
```text
--USE_CUDA=true/false: Enable CUDA hardware codec acceleration, disabled by default
--USE_WAYLAND=true/false: Enable Wayland/PipeWire capture on Linux, disabled by default
--USE_DRM=true/false: Enable DRM capture on Linux, disabled by default
--CROSSDESK_PORTABLE=true/false: Build the portable variant, disabled by default
--CROSSDESK_VERSION=xxx: Set the CrossDesk version
# Example
xmake f --CROSSDESK_VERSION=1.4.3 --USE_CUDA=true
```
Run:
```bash
xmake r crossdesk
```
## Development Without CUDA Environment
For **Linux developers who do not have a CUDA environment installed and want to enable hardware codec feature**, a preconfigured [Ubuntu 20.04 compatibility build image](https://hub.docker.com/r/crossdesk/ubuntu20.04) is provided.
This image contains the required build dependencies and produces a single Linux package compatible with the glibc 2.31 baseline.
See the [build image notes](../docker/linux-build/README.md) for image tags and architecture differences. The CUDA example below targets the amd64 image. Inside the container, download the project and run:
```bash
export CUDA_PATH=/usr/local/cuda
export XMAKE_GLOBALDIR=/data
xmake f --root -c -m release --CROSSDESK_VERSION=1.4.3 --USE_CUDA=true --toolchain=gcc-10 -y
xmake b --root -vy crossdesk
```
For **Windows developers without a CUDA environment** installed, run the following command to install the CUDA build environment:
```powershell
xmake require -vy "cuda 12.6.3"
```
After the installation is complete, execute:
```powershell
xmake require --info "cuda 12.6.3"
```
Find the `installdir` field in the output.
From the output above, locate the CUDA installation directory — this is the path pointed to by installdir.
Add this path to your system environment variable CUDA_PATH, or set it in the terminal using:
```powershell
$env:CUDA_PATH = "C:\path\to\cuda_installdir"
```
Then re-run:
```powershell
xmake f --USE_CUDA=true
xmake b -vy crossdesk
```
### Connection issues at runtime
A disconnected status can result from network, server configuration, or certificate trust problems. See the [FAQ](FAQ.md#english) and [self-hosted TLS guide](SELF_HOSTING_EN.md) before changing the installation.
## About Xmake
### Installing Xmake
You can install Xmake using one of the following methods:
Using curl:
```bash
curl -fsSL https://xmake.io/shget.text | bash
```
Using wget:
```bash
wget https://xmake.io/shget.text -O - | bash
```
Using powershell:
```powershell
irm https://xmake.io/psget.text | iex
```
### Build Options
```text
# Select Debug; replace debug with release for a Release build
xmake f -m debug
# Optional build parameters
-r : Rebuild the target
-v : Show detailed build logs
-y : Automatically confirm prompts
# Example
xmake b -vy crossdesk
```
### Run Options
```bash
# Launch the current build in a debugger; this does not select Debug build mode
xmake r -d crossdesk
```
For more information, please refer to the [official Xmake documentation](https://xmake.io/guide/quick-start.html).
## iOS / iPadOS
The native client has a separate Xcode project and requires an arm64 physical device running iOS 16+. See the [iOS development guide](../apps/ios/README.md) for build and media/file-transfer checks. CI exports an unsigned app; device installation requires your own signing.
## Refreshing screenshots
Desktop README images are rendered directly from the current Slint UI. See the [screenshot notes](images/README.md) for reproduction commands.
+65 -25
View File
@@ -1,33 +1,73 @@
# 常见问题(FAQ)
# 常见问题 / FAQ
欢迎来到 **CrossDesk 常见问题** 页面!
这里整理了用户和开发者最常见的一些疑问。如果你没有找到答案,欢迎在 [Issues](https://github.com/kunkundi/crossdesk/issues) 中反馈。
[返回 README](../README.md) · [English](#english)
## 未连接服务器,或提示 TLS 证书错误
先确认网络可用、两端使用预期的服务器配置。自托管环境检查信令端口、证书有效期、主机名匹配和系统信任;详见 [自托管指南](SELF_HOSTING.md)。这一步解决后,再排查远程设备和 P2P 连接。
## 已连接服务器,但远程设备离线
确认被控端 CrossDesk 仍在运行,双方连接同一个信令服务,并重新复制被控端当前的本机 ID。切换服务器后,原来的 ID 和连接记录可能不再适用。
## 对等连接失败
在 **☰ → 设置** 中勾选 **启用中继服务**,点击“确认”后重新连接。自托管用户还需检查 Coturn 是否正常运行,以及中继端口 TCP/UDP、媒体端口范围 UDP 是否放通。
公共服务的兼容性变更也会影响旧客户端;请核对 [Release 说明](https://github.com/kunkundi/crossdesk/releases),尽量让两端使用兼容的近期版本。
## macOS 有连接但黑屏,或无法操作键鼠
在“系统设置 → 隐私与安全性”中检查 CrossDesk 的屏幕录制与辅助功能权限。更换应用位置或签名、更新开发构建后,可能需要重新授权并重启应用。
## Linux Wayland 无法捕获画面
Wayland 画面捕获依赖启用了 Wayland 支持的构建、宿主系统 PipeWire 0.3 和桌面门户授权;无法捕获时也可在 X11 会话中对比验证。构建选项见 [Linux 构建说明](BUILD.md#linux)。
## Windows 锁屏后无法操作
在被控电脑检查 CrossDesk Service 是否安装、是否运行,并保持 CrossDesk 客户端运行。便携版可在“设置 → 锁屏控制服务”安装。服务状态检查命令见 [Windows 服务说明](../README.md#windows-service)。
## 修改密码后连接失败
修改密码时使用 6 位数字或英文字母,并保持连接服务器。等待修改成功、客户端重新连接后,重新复制当前密码。控制端保存的旧密码会失效,按弹窗输入新密码再连接。
## 收到的文件在哪里
桌面端见 **☰ → 设置 → 文件保存路径**。iOS 原生端收到的文件保存在应用的 `Documents/Received`,可以从分享入口导出;更多说明见 [iOS 开发文档](../apps/ios/README.md)。
## 关闭主窗口后为什么还能连接
关闭主窗口会隐藏窗口,客户端仍在后台运行。需要结束程序时,从系统托盘或 macOS 菜单栏中选择退出。
## Windows 没有安装 CUDA,如何编译
可以先使用 `--USE_CUDA=false` 构建。需要 CUDA 编解码支持时,按 [构建指南](BUILD.md) 安装对应依赖;PowerShell 中设置环境变量应使用 `$env:CUDA_PATH = "实际安装目录"`。
## iOS 构建产物为什么不能直接安装
当前 CI 导出的是未签名应用。需要使用自己的开发签名 / 分发方式安装;也可用 Xcode 打开工程并选择真机运行。见 [iOS 构建要求](../apps/ios/README.md)。
---
### Q1. 对等连接失败
**A:**
打开设置,勾选 **启用中继服务** 选项,尝试重新发起连接。
<a id="english"></a>
<img width="396" height="306" alt="Image" src="https://github.com/user-attachments/assets/fd8db148-c782-4f4d-b874-8f1b2a7ec7d6" />
## English
由于公共中继服务器带宽较小,连接的清晰度流畅度可能会下降,建议自建服务器。 [Issue #8](https://github.com/kunkundi/crossdesk/issues/8)
[Back to README](../README_EN.md)
### Q2. Windows 无 CUDA 环境下编译
**A:**
运行下面的命令安装 CUDA 编译环境。
```
xmake require -vy "cuda 12.6.3"
```
安装完成后执行
```
xmake require --info "cuda 12.6.3"
```
输出如下
| Symptom | What to check |
| --- | --- |
| Server disconnected / TLS error | Network access, signaling endpoint, certificate dates, hostname, and system trust. See [self-hosting](SELF_HOSTING_EN.md). |
| Remote device offline | Keep the host running, connect both sides to the same signaling server, and copy its current ID again. |
| P2P connection failed | Enable TURN relay in Settings and reconnect. For self-hosting, check Coturn and relay/media firewall ports. Review [release compatibility notes](https://github.com/kunkundi/crossdesk/releases). |
| Black screen / no input on macOS | Grant Screen Recording and Accessibility, then reopen the app. A changed app path or signature may require fresh permission grants. |
| No capture on Linux Wayland | Check the Wayland build option, host PipeWire 0.3, and desktop portal permission. Compare with an X11 session. See [Linux build instructions](BUILD_EN.md#linux). |
| Cannot control a Windows lock screen | Install/start CrossDesk Service and keep the host client running. See [service commands](../README_EN.md#windows-service). |
| Saved password no longer works | Use 6 ASCII letters or digits when changing the password, with the host connected to the server. Wait for the change and reconnection to complete, then copy the current password and enter it on the controller. |
| Cannot find received files | Check Settings → File Save Path on desktop. Native iOS stores them in `Documents/Received` and provides a share action. |
| Main window closed but app still running | Closing hides the window. Use the tray/menu-bar exit command to quit. |
| Windows build without CUDA | Build with `--USE_CUDA=false`, or follow the [CUDA build instructions](BUILD_EN.md). Use `$env:CUDA_PATH` in PowerShell. |
| iOS artifact cannot be installed directly | CI exports an unsigned app. Sign it yourself or build and run from Xcode on a physical device. See [iOS requirements](../apps/ios/README.md). |
<img width="860" height="226" alt="Image" src="https://github.com/user-attachments/assets/999ac365-581a-4b9a-806e-05eb3e4cf44d" />
根据上述输出获取到 CUDA 的安装目录,即 installdir 指向的位置。将 CUDA_PATH 加入系统环境变量,或在终端中输入 set CUDA_PATH=path_to_cuda_installdir,重新执行 xmake b -vy crossdesk 即可。
[Issue #6](https://github.com/kunkundi/crossdesk/issues/6)
---
If the issue persists, [open an issue](https://github.com/kunkundi/crossdesk/issues) with both OS versions, client versions, connection mode, and reproduction steps. Remove passwords and private device information from logs or screenshots before sharing them.
+99
View File
@@ -0,0 +1,99 @@
# 自托管服务器
[返回 README](../README.md) · [English](SELF_HOSTING_EN.md)
CrossDesk 的信令服务与 TURN 中继由独立的 [CrossDesk Server](https://github.com/kunkundi/crossdesk-server) 项目维护。本页说明部署入口与当前客户端的配置方法;服务端文件请使用同一 Release 中的配套版本。
## 1. 部署服务端
以下步骤面向已安装 Docker Engine 和 Compose 插件的 Linux 服务器;先确认 `docker compose version` 可用。当前 Compose 使用主机网络,分别运行信令服务和 Coturn。
按 [服务端部署文档](https://github.com/kunkundi/crossdesk-server#运行服务),从其 [Releases](https://github.com/kunkundi/crossdesk-server/releases) 下载 `compose.yaml` 和 `env.example`,放到同一目录。在该目录执行:
```bash
cp env.example .env
```
用文本编辑器修改 `.env`,至少核对以下内容,填写完成后再启动。完整参数以配套的环境文件为准,也可参考服务端仓库的 [环境变量示例](https://github.com/kunkundi/crossdesk-server/blob/admin-dashboard/.env.example)。
| 配置 | 填写内容 |
| --- | --- |
| `CROSSDESK_IMAGE` | 与下载的配置匹配的固定发布 tag |
| `EXTERNAL_IP` / `INTERNAL_IP` | 服务器真实公网 / 内网 IP;不要直接使用示例地址 |
| `CROSSDESK_SERVER_PORT` | 信令端口,例如 `9099` |
| `COTURN_PORT` | 中继端口,例如 `3478` |
| `MIN_PORT` / `MAX_PORT` | TURN 媒体端口范围 |
| `COTURN_AUTH_SECRET` | 信令服务与 Coturn 的共享密钥;不填入客户端 |
| `COTURN_STATELESS_NONCE_SECRET` | 独立生成、持久保存的 nonce 密钥 |
| `CROSSDESK_DATA_DIR` / `CROSSDESK_LOG_DIR` | 数据、证书和日志的持久化位置 |
两个密钥分别用 `openssl rand -hex 32` 生成。防火墙及云安全组需允许信令端口 TCP、中继端口 TCP/UDP,以及所选媒体端口范围 UDP。容器挂载和网络配置见 [官方 Compose 文件](https://github.com/kunkundi/crossdesk-server/blob/admin-dashboard/compose.yaml)。
保存配置后,校验并启动:
```bash
sudo docker compose config -q
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
```
## 2. 配置桌面客户端
使用自签证书时,先按 [证书信任步骤](#3-配置信任证书) 在两端导入根证书,再检查连接状态。
1. 断开现有会话,点击主窗口右上角 **☰ → 设置**。
2. 在设置列表中向下滚动,点击 **自托管配置**。
3. 填写 **服务器地址**、**信令服务端口**、**中继服务端口**,点击“确认”。地址只填主机名或 IP,不加 `https://`、路径或端口。
4. 回到设置窗口,勾选 **自托管配置** 右侧复选框,再点击“确认”保存。
5. 在控制端和被控端执行相同配置。等待两端都显示“已连接服务器”,再使用被控端当前显示的 ID 发起连接。
<p align="center">
<img src="images/desktop-self-hosted-zh.png" width="720" alt="当前 Slint 客户端自托管设置:服务器地址、信令端口、中继端口" />
</p>
切换服务器后请重新核对本机 ID;设备身份与服务端相关,旧服务上的 ID 不应直接套用。
## 3. 配置信任证书
客户端通过 TLS 连接信令服务。证书必须覆盖填写的域名 / IP,且被客户端系统信任。使用受信任 CA 签发的证书时按服务端说明配置证书链;使用自签证书时,需要信任自己部署的根证书。
默认的 [证书生成脚本](https://github.com/kunkundi/crossdesk-server/blob/admin-dashboard/docker/generate_certs.sh) 只将 `EXTERNAL_IP` 写入证书的地址范围;使用这份证书时,客户端应填写该 IP。改用域名需要配置包含该域名的证书。已有证书存在时,[启动脚本](https://github.com/kunkundi/crossdesk-server/blob/admin-dashboard/docker/start.sh) 会复用它们,修改 `.env` 中的 IP 不会自动更新证书。
默认证书文件位于服务端数据目录下的 `certs/`。将自己服务器的 `api.crossdesk.cn_root.crt` 下载到客户端机器,再执行对应平台命令;不要导入来源不明的根证书。
**Windows(管理员 PowerShell):**
```powershell
certutil -addstore "Root" "C:\path\to\api.crossdesk.cn_root.crt"
```
**Ubuntu / Debian:**
```bash
sudo cp /path/to/api.crossdesk.cn_root.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
```
**macOS:**
```bash
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain /path/to/api.crossdesk.cn_root.crt
```
完成后重新打开客户端。自签证书并不等于没有加密,但根证书信任和主机名校验必须正确配置。
## 4. Web 与 iOS
- **Web:** 按 [Web 客户端项目](https://github.com/kunkundi/crossdesk-web-client) 配置自己的信令连接。浏览器必须信任该服务的证书;不要通过关闭 TLS 校验解决证书错误。
- **iOS:** 在“设置 → 服务器”填写信令服务器、信令端口与 STUN/TURN 端口,然后点击“应用”。自签证书需要在设备上安装对应根证书并启用信任。
## 排查顺序
- **未连接服务器 / TLS 错误:** 先检查地址、信令端口、证书有效期、证书主机名及根证书信任。
- **已连服务器但设备离线:** 确认两端连接同一服务、被控端仍在运行,并重新复制当前 ID。
- **P2P 连接失败:** 检查“启用中继服务”、Coturn 状态、TURN 端口和媒体端口范围,并核对客户端与服务端版本兼容性。
- **配置修改未生效:** 在配置目录重新执行 `sudo docker compose up -d`;单纯重启容器不会应用修改过的环境变量。
日志、备份和迁移操作请参考 [服务端维护说明](https://github.com/kunkundi/crossdesk-server)。
+99
View File
@@ -0,0 +1,99 @@
# Self-hosting
[Back to README](../README_EN.md) · [中文](SELF_HOSTING.md)
Signaling and TURN deployment are maintained in [CrossDesk Server](https://github.com/kunkundi/crossdesk-server). Use matching server configuration files from the same release. This guide focuses on deployment entry points and the current client UI.
## 1. Deploy the server
These steps target a Linux server with Docker Engine and the Compose plugin installed. Confirm that `docker compose version` works. The current Compose deployment uses host networking and runs signaling and Coturn separately.
Follow the [server deployment guide](https://github.com/kunkundi/crossdesk-server#运行服务). Download `compose.yaml` and `env.example` from its [Releases](https://github.com/kunkundi/crossdesk-server/releases) into one directory and run there:
```bash
cp env.example .env
```
Edit `.env` in a text editor and complete these values before starting the services. The repository also provides an [environment example](https://github.com/kunkundi/crossdesk-server/blob/admin-dashboard/.env.example).
| Variable | Value |
| --- | --- |
| `CROSSDESK_IMAGE` | A fixed release tag matching the configuration |
| `EXTERNAL_IP` / `INTERNAL_IP` | Your actual public / private server IPs |
| `CROSSDESK_SERVER_PORT` | Signaling port, e.g. `9099` |
| `COTURN_PORT` | Relay port, e.g. `3478` |
| `MIN_PORT` / `MAX_PORT` | TURN media port range |
| `COTURN_AUTH_SECRET` | Secret shared by signaling and Coturn; never enter it in clients |
| `COTURN_STATELESS_NONCE_SECRET` | A separately generated, persistent nonce secret |
| `CROSSDESK_DATA_DIR` / `CROSSDESK_LOG_DIR` | Persistent data, certificate, and log locations |
Generate each secret separately with `openssl rand -hex 32`. Allow signaling TCP, relay TCP/UDP, and the selected media UDP range through the firewall and cloud security group. Mounts and networking are defined in the [official Compose file](https://github.com/kunkundi/crossdesk-server/blob/admin-dashboard/compose.yaml).
After saving the configuration, validate it and start the services:
```bash
sudo docker compose config -q
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
```
## 2. Configure desktop clients
For a self-signed deployment, complete the [certificate trust steps](#3-trust-the-tls-certificate) on both clients before checking their connection status.
1. Disconnect existing sessions and open **☰ → Settings**.
2. Scroll down and click **Self-Hosted Config**.
3. Enter **Server Address**, **Signal Service Port**, and **Relay Service Port**, then confirm. Enter only the hostname or IP in the address field, without a URL scheme, path, or port.
4. Enable the checkbox beside **Self-Hosted Config**, then click **OK** in the parent settings window.
5. Configure both the controller and host this way. Once both report a server connection, connect using the host's currently displayed ID.
<p align="center">
<img src="images/desktop-self-hosted-en.png" width="720" alt="Current Slint self-hosting dialog for server address, signaling port, and relay port" />
</p>
Recheck the device ID after changing servers. Device identities belong to their server; do not assume an old ID carries over.
## 3. Trust the TLS certificate
The signaling certificate must cover the configured hostname/IP and be trusted by the client OS. For a public CA certificate, configure the server's certificate chain as instructed by the server project. For a self-signed deployment, trust the root certificate belonging to your own server.
The default [certificate generator](https://github.com/kunkundi/crossdesk-server/blob/admin-dashboard/docker/generate_certs.sh) includes only `EXTERNAL_IP` in the certificate's subject alternative name. Use that IP in the client, or supply a certificate covering your custom domain. The [startup script](https://github.com/kunkundi/crossdesk-server/blob/admin-dashboard/docker/start.sh) reuses existing certificates; editing the IP in `.env` does not update them automatically.
Default certificates are stored in the data directory's `certs/` folder. Copy your server's `api.crossdesk.cn_root.crt` to the client and run the relevant command. Do not trust a root certificate from an unknown source.
**Windows — administrator PowerShell:**
```powershell
certutil -addstore "Root" "C:\path\to\api.crossdesk.cn_root.crt"
```
**Ubuntu / Debian:**
```bash
sudo cp /path/to/api.crossdesk.cn_root.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
```
**macOS:**
```bash
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain /path/to/api.crossdesk.cn_root.crt
```
Reopen the client afterwards. Self-signed certificates still support encryption; correct root trust and hostname verification are required.
## 4. Web and iOS
- **Web:** configure your signaling endpoint following the [Web Client project](https://github.com/kunkundi/crossdesk-web-client). The browser must trust its certificate; do not disable TLS verification to work around certificate errors.
- **iOS:** enter the signaling host, signaling port, and STUN/TURN port under **Settings → Server**, then apply. A self-signed deployment requires installing and trusting its root certificate on the device.
## Troubleshooting order
- **Server disconnected / TLS error:** check the host, signaling port, certificate dates, certificate hostname, and root trust.
- **Server connected, device offline:** confirm both clients use the same server, keep the host running, and copy its current ID again.
- **P2P failed:** check the relay setting, Coturn status, relay/media ports, and compatibility between client and server versions.
- **Environment changes ignored:** run `sudo docker compose up -d` from the configuration directory. Restarting existing containers alone does not apply changed environment variables.
See the [server maintenance documentation](https://github.com/kunkundi/crossdesk-server) for logs, backups, and migration.
+56
View File
@@ -0,0 +1,56 @@
# README 截图 / Screenshot notes
[中文 README](../../README.md) · [English README](../../README_EN.md)
## 来源 / Sources
- 生成日期:2026-09-07。
- 桌面 UI 源代码:`abc8f907`(`ios-support` 分支),Slint 1.17.1,macOS arm64;截图工具增加了可选的文档演示数据。
- 桌面图片来自 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp) 的 UI 捕获模式,直接渲染仓库中的 Slint 组件;不是旧版本截图或重新绘制的界面。
- 主窗口展示“已连接服务器”和三台在线设备的完整示例状态。ID `123 456 789`、密码 `123456`、连接状态及设备记录均为演示数据,不是真实设备凭据或真实远程会话。示例设置不代表应用运行时默认值。
- 设备缩略图使用可公开的页面截图:`fixtures/source.png` 来自 [CrossDesk 的 Xmake 源码页](https://github.com/kunkundi/crossdesk/blob/ios-support/xmake.lua),`fixtures/website.png` 来自 [CrossDesk 官网](https://www.crossdesk.cn/),第三张复用 `web-client.png`。它们用于展示缩略图效果,不代表这些页面正在对应的远程电脑上运行。
- `web-client.png`:2026-09-07 在 [web.crossdesk.cn](https://web.crossdesk.cn/) 截取的连接首页,视口为 1280 × 720,没有发起远程连接。
Desktop images render the actual Slint components with an illustrative connected state and three populated recent-device cards. The thumbnails are public browser-page captures, not private remote desktops or evidence of live sessions. IDs, device names, statuses, and settings are demonstration data. The Web image captures the public connection page. Native iOS screenshots are not included because this update did not capture a signed app running on a physical device.
## 文件 / Files
| 文件 | 页面 | 语言 |
| --- | --- | --- |
| `desktop-main-zh.png` / `desktop-main-en.png` | Main window / 主窗口 | 中文 / English |
| `desktop-settings-zh.png` / `desktop-settings-en.png` | Settings / 设置 | 中文 / English |
| `desktop-self-hosted-zh.png` / `desktop-self-hosted-en.png` | Self-hosting / 自托管设置 | 中文 / English |
| `web-client.png` | Web connection page / Web 连接首页 | 中文 |
桌面图片为 1280 × 900 PNG,使用 2× 缩放保持文字清晰。Markdown 只设置展示宽度,保持原始比例。设置列表可滚动,截图仅展示首屏。演示配置显示硬件编解码器可用;实际可用性由平台、设备与构建决定。
## 重新生成 / Regenerate
以下命令在仓库根目录、已完成依赖配置的 macOS 开发环境运行。`sips` 仅将 Slint 导出的 PPM 无损转换为 PNG,不修改 UI。其他平台可以使用支持 PPM 的图片格式转换工具。
```bash
set -e
xmake b -y slint_ui_smoke_test
mkdir -p build/readme-capture docs/images
for locale in zh en; do
language=0
if [ "$locale" = en ]; then language=1; fi
for page in main settings self-hosted; do
snapshot="$PWD/build/readme-capture/desktop-$page-$locale.ppm"
SLINT_BACKEND=winit-software SLINT_SCALE_FACTOR=2 \
CROSSDESK_UI_CAPTURE=1 \
CROSSDESK_UI_CAPTURE_DEMO_ASSETS="$PWD/docs/images" \
CROSSDESK_UI_CAPTURE_PAGE="$page" \
CROSSDESK_UI_CAPTURE_LANGUAGE="$language" \
CROSSDESK_UI_CAPTURE_SNAPSHOT="$snapshot" \
xmake r slint_ui_smoke_test
sips -s format png "$snapshot" \
--out "docs/images/desktop-$page-$locale.png"
done
done
```
`CROSSDESK_UI_CAPTURE_DEMO_ASSETS` 必须指向包含上述图片的目录。缺失图片会让捕获命令失败,避免再次导出空白缩略图;不设置该变量时,仍保留原有测试状态。
捕获模式兼容 `/tmp/crossdesk-ui-capture-page` 和 `/tmp/crossdesk-ui-capture-language` 两个旧覆盖文件;如存在,它们会优先于环境变量,运行前请检查。更新后逐张检查文字、布局、敏感信息和文档引用,并同步修改本页日期与源码版本。
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+12
View File
@@ -0,0 +1,12 @@
# **更新内容**
1. 新增原生iOS/iPadOS客户端v0.0.1,支持远程画面与音频、触控鼠标和键盘输入、多显示器切换、双向文件传输及接收远端文本剪贴板。
2. 完善iOS远程操作体验,提供相对位置与绝对位置鼠标模式、捏合缩放及悬浮控制菜单,支持查看连接状态、P2P/中继模式、分辨率与码率;修复切回前台后画面无法恢复、相对鼠标跳动及灵动岛遮挡控件等问题。
3. 新增帧率优先、画质优先和平衡三种画面偏好,优化带宽变化时的分辨率、帧率与码率调节,提升远程桌面的清晰度和流畅度。
4. 优化Windows与macOS原生视频采集、硬件编码和渲染流程,减少画面拷贝;Linux新增CUDA/OpenGL视频渲染,提升硬件解码画面的显示效率。
5. 优化P2P连接建立与UPnP支持,支持连接从中继升级为P2P,并改进ICE连接、视频恢复及音视频时间戳处理,提升弱网和网络变化时的连接稳定性。
6. 修复Windows锁屏界面无法远程控制,以及部分设备因OpenGL初始化失败导致启动崩溃的问题,优化锁屏画面采集与恢复。
7. 修复桌面端与iOS端设备在线状态更新不及时、桌面端启动后托盘图标未显示的问题,并将连接清理移出界面线程,减少断开连接时的卡顿。
8. 完善iOS与桌面端跨平台构建和打包流程,更新界面截图、构建指南、自托管说明及常见问题文档。
PC客户端版本为v1.5.0,iOS/iPadOS客户端使用独立版本号,本次为v0.0.1。iOS/iPadOS客户端要求iOS 16.0及以上版本和arm64真机,提供的构建包为未签名应用,需要自行签名后安装;目前仅支持作为控制端连接电脑。
+71
View File
@@ -0,0 +1,71 @@
# Windows 签名与 Release 测试
GitHub Actions 的 `Build and Release` 工作流构建 CrossDesk,调用 SignPath 签名,再发布已验证的 Windows 文件。
## 触发方式
| 触发方式 | 签名策略 | 输出 |
| --- | --- | --- |
| 普通分支 push,或未勾选签名的手动运行 | 无 | 开发构建工件 |
| 手动运行,勾选 `sign_windows_test` | `test-signing` | 测试证书签名的 Actions 工件 |
| 手动运行,勾选 `sign_windows_release` | `release-signing` | 正式证书签名的 Actions 工件 |
| push `v*` tag | `release-signing` | 验证通过后发布 GitHub Release 和下载站 |
两种手动签名选项不能同时启用。手动运行可勾选 `windows_only` 跳过其他平台;即使选择 tag 手动运行,也不会发布 Release、更新 `latest` 或同步下载站。
## SignPath 配置
GitHub 仓库需要 Secret `SIGNPATH_API_TOKEN` 和 Variable `SIGNPATH_ORGANIZATION_ID`。SignPath 项目 slug 为 `crossdesk`,已关联 GitHub.com Trusted Build System。
该工作流明确使用以下 Artifact Configuration:
| Slug | XML 文件 | 用途 |
| --- | --- | --- |
| `initial` | `apps/desktop/scripts/windows/signpath-installer.xml` | 现有安装包配置,只签外层 NSIS EXE |
| `windows-binaries` | `apps/desktop/scripts/windows/signpath-binaries.xml` | 签 ZIP 内四个 CrossDesk 自有二进制 |
`crossdesk` 后台已添加并验证 `windows-binaries`。新环境需要在项目的 Artifact Configurations 页面创建同名配置,使用仓库内对应的完整 XML。保留 `initial` 的默认配置和现有审批、来源验证要求。XML 文件仅提交到 GitHub 不会自动同步到 SignPath 后台。
两种策略都必须允许提交这些配置。正式策略需要有效的 Release 证书、提交者权限和审批人。若证书仍为 `CSR PENDING`,需要 SignPath 完成签发/导入;切换工作流参数不会使证书生效。后台的来源限制也必须允许当前测试分支,若不允许,应按项目原有发布审批流程处理。
## 签名顺序
1. 构建安装版,将 `crossdesk.exe`、`crossdesk_service.exe`、`crossdesk_session_helper.exe`、`wgc_plugin.dll` 打成 `installer-binaries.zip`。
2. 使用 `windows-binaries` 签名,验证四个文件后替换构建输出。
3. NSIS 打包已签名的文件,使用 `initial` 签名安装器,验证后替换安装器。
4. 构建便携版,将四个文件打成 `portable-binaries.zip`,同样签名并验证,然后生成包含已签名文件的便携 ZIP。
5. 上传最终安装器与便携 ZIP。tag 发布等待 Windows 签名和其他平台构建全部成功。
第三方 DLL 原样打包,不提交签名。外层 NSIS 签名不为内部文件添加独立签名;本流程在打包前签名自有文件。NSIS 生成的 `uninstall.exe` 尚未配置独立签名。
每次运行会按上述顺序提交三个签名请求。正式签名需要在 SignPath 中逐个审批,每个请求最多等待 60 分钟,整个 Windows job 最多 240 分钟。请求链接由官方 Action 输出,并在可用时写入运行摘要。
## 启动正式证书测试
在 GitHub → Actions → Build and Release → Run workflow 中选择测试分支:
- `sign_windows_release`: true
- `sign_windows_test`: false
- `windows_only`: true
也可在分支推送后运行:
```sh
gh workflow run build.yml --ref signpath-release-signing \
-f sign_windows_release=true -f sign_windows_test=false -f windows_only=true
```
正式验证要求 Authenticode 状态为 `Valid`、存在时间戳,并且 `signtool verify /pa /all /v` 返回成功。测试证书允许 `NotTrusted`,但拒绝无签名、哈希不匹配等状态。任一签名或验证失败均停止后续发布;不会回退到未签名发布。
原始签名输入保留为中间 Actions 工件。手动测试成功后,下载名为 `crossdesk-win-x64-...` 和 `crossdesk-win-x64-portable-...` 的最终工件,不要使用 `installer-binaries.zip`、`portable-binaries.zip` 等原始签名输入。
## 本地校验
```sh
pwsh -NoProfile -File apps/desktop/tests/windows_signing_test.ps1
actionlint .github/workflows/build.yml
```
PowerShell 测试使用模拟证书状态检查归档范围、还原顺序及失败行为,不能替代 Windows 上的真实 Authenticode 验证。
参考:[SignPath GitHub 集成](https://docs.signpath.io/trusted-build-systems/github)、[Artifact Configuration](https://docs.signpath.io/artifact-configuration/examples)、[SignTool 验证](https://learn.microsoft.com/en-us/windows/win32/seccrypto/using-signtool-to-verify-a-file-signature)。
+48
View File
@@ -0,0 +1,48 @@
# Windows 更新与安全软件兼容性
Windows 的更新按钮在后台下载发布信息中指定版本的安装包,完成校验及
Windows 附件安全检查后打开安装向导。客户端使用 WinHTTP,保留系统的
HTTPS 证书检查和代理配置,并用 `CrossDesk/<version> WindowsUpdater`
标识请求。
安装包保存在 `%LOCALAPPDATA%\CrossDesk\Updates` 下的独立目录中。
下载过程写入 `.part` 文件;HTTP 响应、文件长度、Windows 可执行文件头
均通过检查后才生成最终安装包。
客户端通过 Windows Attachment Services 的 `Save` 记录下载来源并请求
系统安全检查,再通过 `Execute` 打开安装包。这些 API 会按 Windows 策略
调用扫描、信任检查和必要的提示。安装程序由自身的管理员权限清单请求
UAC 授权。安全检查失败时停止流程,不回退到直接启动,也不删除来源标记。
## 发行版签名
应使用受信任的正式代码签名证书签署发布的主程序、服务、辅助程序、DLL
和最终安装包。只签安装包不能建立已安装主程序的发布者身份。
SignPath 的 `test-signing` 用于测试,不能代替公开发行所需的受信任签名。
当前流水线中的测试签名配置仍保持为可选项;正式策略需在取得证书及
SignPath 授权后配置。
检查实际分发文件的签名:
```powershell
Get-AuthenticodeSignature .\crossdesk.exe
Get-AuthenticodeSignature .\crossdesk-win-x64-<version>.exe
```
## 网络防护告警的处理
2026-09-09 的天擎日志记录了在域名解析阶段对
`downloads.crossdesk.cn` 和 `101.132.143.203` 的阻断。
该记录不能单独证明 HTTP 库、提权方式或文件内容是命中原因。
切换到标准 Windows API 也不能保证域名/IP 信誉告警消失。
出现同类告警时,保留告警时间、策略名称、域名/IP、程序版本及签名状态,
由安全管理员向厂商核查命中原因,同时检查服务器是否存在异常活动。
确认误报后按厂商流程纠正信誉或企业策略。应在策略允许的测试环境中
验证下载、取消、不完整文件、安全检查失败及安装向导启动流程。
相关 API:
- [WinHttpOpen](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpopen)
- [IAttachmentExecute::Save](https://learn.microsoft.com/windows/win32/api/shobjidl_core/nf-shobjidl_core-iattachmentexecute-save)
- [IAttachmentExecute::Execute](https://learn.microsoft.com/windows/win32/api/shobjidl_core/nf-shobjidl_core-iattachmentexecute-execute)