Compare commits

...
52 changed files with 2219 additions and 885 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
+114 -41
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,6 +320,7 @@ 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
@@ -504,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: |
@@ -611,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: |
@@ -719,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
+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"
+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
@@ -168,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();
}
@@ -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';
@@ -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);
@@ -468,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;
{
@@ -139,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;
@@ -158,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_;
+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
+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")
@@ -100,7 +100,6 @@ 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;
@@ -75,7 +75,6 @@ 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 =
@@ -332,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;
@@ -442,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)
@@ -465,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;
@@ -474,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;
@@ -716,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)
@@ -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() }
@@ -425,7 +424,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
private struct BridgeConfiguration: Equatable {
let host: String
let signalPort: Int
let turnPort: Int
let enableSRTP: Bool
}
private var bridgeConfiguration: BridgeConfiguration?
@@ -453,14 +451,14 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@discardableResult
func configureBridge() -> Bool {
let host = signalHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard let signal = Int(signalPort), let turn = Int(turnPort),
(1...65535).contains(signal), (1...65535).contains(turn),
guard let signal = Int(signalPort),
(1...65535).contains(signal),
!host.isEmpty else {
signalStatus = "服务器配置无效"
return false
}
let configuration = BridgeConfiguration(host: host, signalPort: signal,
turnPort: turn, enableSRTP: enableSRTP)
enableSRTP: enableSRTP)
if bridgeConfiguration != configuration {
bridgeConfiguration = configuration
signalConnected = false
@@ -475,7 +473,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
bridge.setVideoAdaptationPolicy(videoAdaptationPolicy.bridgeValue)
bridge.configure(withSignalHost: host,
signalPort: signal,
turnPort: turn,
enableSRTP: enableSRTP)
return true
}
+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)