mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 00:09:39 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c76caa918 | ||
|
|
211d4de241 | ||
|
|
c2c74913e3 | ||
|
|
4f200095f4 | ||
|
|
bd0a7aa2f6 | ||
|
|
b87ab025be | ||
|
|
5b60695a52 | ||
|
|
0002bcea74 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: 5fc313c102...9313ae8b92
@@ -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)。
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user