Initial commit
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / package-preview (push) Canceled after 0s
ci / package-installer (push) Canceled after 0s
ci / linux-agent (push) Canceled after 0s
ci / edge-service (push) Canceled after 0s
ci / coturn-pop (push) Canceled after 0s
ci / package-windows-host (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / package-preview (push) Canceled after 0s
ci / package-installer (push) Canceled after 0s
ci / linux-agent (push) Canceled after 0s
ci / edge-service (push) Canceled after 0s
ci / coturn-pop (push) Canceled after 0s
ci / package-windows-host (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy,rustfmt
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- run: cargo test --workspace
|
||||
|
||||
web:
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: client/web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: client/web/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run build
|
||||
|
||||
package-preview:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: client/web/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: client/web
|
||||
- name: Build preview package
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/package-preview.ps1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: remotedesk-m0-windows-preview
|
||||
path: |
|
||||
artifacts/*.zip
|
||||
artifacts/SHA256SUMS.txt
|
||||
if-no-files-found: error
|
||||
|
||||
package-installer:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: client/web/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: client/web
|
||||
- name: Test update manifest generator contract
|
||||
run: node ./packaging/windows/test-update-manifest.mjs
|
||||
- name: Build Windows installer
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/package-installer.ps1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: remotedesk-m0-windows-installer
|
||||
path: |
|
||||
artifacts/*.msi
|
||||
artifacts/INSTALLER-SHA256SUMS.txt
|
||||
if-no-files-found: error
|
||||
|
||||
linux-agent:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy,rustfmt
|
||||
- name: Install Linux media build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
- name: Test Linux Agent
|
||||
run: |
|
||||
cargo test --locked -p remotedesk-agent-runtime
|
||||
cargo clippy --locked -p remotedesk-agent-runtime --all-targets -- -D warnings
|
||||
cargo build --locked -p remotedesk-agent-runtime --bin remotedesk-file-session
|
||||
sh ./packaging/linux/verify-file-resume.sh target/debug/remotedesk-file-session
|
||||
- name: Build DEB
|
||||
run: sh ./packaging/linux/package-deb.sh
|
||||
- name: Build RPM
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rpm
|
||||
sh ./packaging/linux/package-rpm.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: remotedesk-linux-agent-packages
|
||||
path: |
|
||||
artifacts/*.deb
|
||||
artifacts/*.deb.sha256
|
||||
artifacts/*.rpm
|
||||
artifacts/*.rpm.sha256
|
||||
if-no-files-found: error
|
||||
|
||||
edge-service:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy,rustfmt
|
||||
- name: Test Edge service
|
||||
run: |
|
||||
cargo test --locked -p remotedesk-edge-service
|
||||
cargo clippy --locked -p remotedesk-edge-service --all-targets -- -D warnings
|
||||
- name: Build Edge DEB
|
||||
run: sh ./packaging/edge/package-deb.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: remotedesk-edge-package
|
||||
path: |
|
||||
artifacts/remotedesk-edge_*.deb
|
||||
artifacts/remotedesk-edge_*.deb.sha256
|
||||
if-no-files-found: error
|
||||
|
||||
coturn-pop:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install coturn tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y coturn
|
||||
- name: Start isolated TLS POP and verify transports
|
||||
run: |
|
||||
set -eu
|
||||
work=$(mktemp -d)
|
||||
trap 'test -f "$work/pid" && kill "$(cat "$work/pid")" 2>/dev/null || true; rm -rf "$work"' EXIT
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-subj '/CN=RemoteDesk Test CA' -keyout "$work/ca-key.pem" -out "$work/ca.pem"
|
||||
openssl req -newkey rsa:2048 -nodes -subj '/CN=127.0.0.1' \
|
||||
-addext 'subjectAltName=IP:127.0.0.1' -keyout "$work/turn-key.pem" -out "$work/turn.csr"
|
||||
openssl x509 -req -days 1 -in "$work/turn.csr" -CA "$work/ca.pem" \
|
||||
-CAkey "$work/ca-key.pem" -CAcreateserial -copy_extensions copy \
|
||||
-out "$work/turn-cert.pem"
|
||||
secret='ci-turn-rest-secret-0123456789abcdef'
|
||||
turnserver --fingerprint --use-auth-secret --static-auth-secret "$secret" \
|
||||
--realm ci.remotedesk.invalid --listening-ip 127.0.0.1 --relay-ip 127.0.0.1 \
|
||||
--external-ip 127.0.0.1 --listening-port 3478 --tls-listening-port 5349 \
|
||||
--min-port 49160 --max-port 49179 --cert "$work/turn-cert.pem" \
|
||||
--pkey "$work/turn-key.pem" --no-cli --no-multicast-peers \
|
||||
--pidfile "$work/pid" --log-file stdout &
|
||||
for attempt in $(seq 1 50); do
|
||||
if turnutils_stunclient -p 3478 127.0.0.1 >/dev/null 2>&1; then break; fi
|
||||
if [ "$attempt" -eq 50 ]; then echo 'coturn did not become ready' >&2; exit 1; fi
|
||||
sleep 0.1
|
||||
done
|
||||
REMOTEDESK_TURN_REST_SECRET="$secret" \
|
||||
REMOTEDESK_TURN_VERIFY_CA="$work/ca.pem" \
|
||||
sh ./packaging/edge/verify-coturn.sh
|
||||
|
||||
package-windows-host:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Build Windows controlled-endpoint installer
|
||||
shell: pwsh
|
||||
run: ./packaging/windows-host/package-host.ps1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: remotedesk-windows-host-installer
|
||||
path: |
|
||||
artifacts/RemoteDesk-Host-*.msi
|
||||
artifacts/HOST-INSTALLER-SHA256SUMS.txt
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,203 @@
|
||||
name: release-windows
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
concurrency:
|
||||
group: windows-production-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
environment: windows-production
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: client/web/package-lock.json
|
||||
- name: Install web dependencies
|
||||
working-directory: client/web
|
||||
run: npm ci
|
||||
- name: Validate release tag and repository version
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Unable to read Cargo workspace metadata' }
|
||||
$package = $metadata.packages | Where-Object name -eq 'remotedesk-control-service'
|
||||
if (-not $package) { throw 'remotedesk-control-service package metadata is missing' }
|
||||
$version = [string]$package.version
|
||||
if ($version -notmatch '^\d+\.\d+\.\d+$') { throw "Invalid release version: $version" }
|
||||
if ($env:GITHUB_REF_NAME -ne "v$version") {
|
||||
throw "Tag $env:GITHUB_REF_NAME does not match workspace version $version"
|
||||
}
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
- name: Import protected signing material
|
||||
id: signing
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_SIGNING_PFX_BASE64: ${{ secrets.WINDOWS_SIGNING_PFX_BASE64 }}
|
||||
WINDOWS_SIGNING_PFX_PASSWORD: ${{ secrets.WINDOWS_SIGNING_PFX_PASSWORD }}
|
||||
UPDATE_ED25519_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_ED25519_PRIVATE_KEY_PEM }}
|
||||
UPDATE_ED25519_PUBLIC_KEY_BASE64: ${{ secrets.UPDATE_ED25519_PUBLIC_KEY_BASE64 }}
|
||||
run: |
|
||||
foreach ($name in @(
|
||||
'WINDOWS_SIGNING_PFX_BASE64',
|
||||
'WINDOWS_SIGNING_PFX_PASSWORD',
|
||||
'UPDATE_ED25519_PRIVATE_KEY_PEM',
|
||||
'UPDATE_ED25519_PUBLIC_KEY_BASE64'
|
||||
)) {
|
||||
if ([string]::IsNullOrWhiteSpace((Get-Item "Env:$name").Value)) {
|
||||
throw "Required production secret $name is not configured"
|
||||
}
|
||||
}
|
||||
$pfxPath = Join-Path $env:RUNNER_TEMP "remotedesk-signing-$PID.pfx"
|
||||
$keyPath = Join-Path $env:RUNNER_TEMP "remotedesk-update-$PID.pem"
|
||||
try {
|
||||
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_SIGNING_PFX_BASE64))
|
||||
$password = ConvertTo-SecureString $env:WINDOWS_SIGNING_PFX_PASSWORD -AsPlainText -Force
|
||||
$imported = @(Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $password)
|
||||
$certificate = $imported | Where-Object HasPrivateKey | Select-Object -First 1
|
||||
if (-not $certificate) { throw 'PFX did not import a certificate with a private key' }
|
||||
[IO.File]::WriteAllText($keyPath, $env:UPDATE_ED25519_PRIVATE_KEY_PEM, [Text.UTF8Encoding]::new($false))
|
||||
"thumbprint=$($certificate.Thumbprint)" >> $env:GITHUB_OUTPUT
|
||||
"key_path=$keyPath" >> $env:GITHUB_OUTPUT
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $pfxPath) { Remove-Item -LiteralPath $pfxPath -Force }
|
||||
}
|
||||
- name: Build, sign and verify Windows installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
./packaging/windows/package-installer.ps1 `
|
||||
-SigningCertificateThumbprint '${{ steps.signing.outputs.thumbprint }}' `
|
||||
-TimestampUrl 'https://timestamp.digicert.com' `
|
||||
-ReleaseChannel stable
|
||||
./packaging/windows-host/package-host.ps1 `
|
||||
-SigningCertificateThumbprint '${{ steps.signing.outputs.thumbprint }}' `
|
||||
-TimestampUrl 'https://timestamp.digicert.com'
|
||||
- name: Create signed update manifest
|
||||
id: manifest
|
||||
shell: pwsh
|
||||
env:
|
||||
EXPECTED_UPDATE_PUBLIC_KEY: ${{ secrets.UPDATE_ED25519_PUBLIC_KEY_BASE64 }}
|
||||
run: |
|
||||
$version = '${{ steps.version.outputs.version }}'
|
||||
$installer = Get-ChildItem -LiteralPath artifacts -Filter "RemoteDesk-M0-$version-*-windows-x64.msi" -File
|
||||
if (@($installer).Count -ne 1) { throw 'Expected exactly one versioned x64 MSI' }
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $installer.FullName
|
||||
if ([string]$signature.Status -ne 'Valid' -or
|
||||
$signature.SignerCertificate.Thumbprint -ne '${{ steps.signing.outputs.thumbprint }}') {
|
||||
throw 'Final MSI Authenticode verification failed before manifest generation'
|
||||
}
|
||||
$hostInstaller = Get-ChildItem -LiteralPath artifacts -Filter "RemoteDesk-Host-$version-*-windows-x64.msi" -File
|
||||
if (@($hostInstaller).Count -ne 1) { throw 'Expected exactly one versioned x64 Host MSI' }
|
||||
$hostSignature = Get-AuthenticodeSignature -LiteralPath $hostInstaller.FullName
|
||||
if ([string]$hostSignature.Status -ne 'Valid' -or
|
||||
$hostSignature.SignerCertificate.Thumbprint -ne '${{ steps.signing.outputs.thumbprint }}') {
|
||||
throw 'Final Host MSI Authenticode verification failed before publication'
|
||||
}
|
||||
$url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:GITHUB_REF_NAME/$($installer.Name)"
|
||||
node ./packaging/windows/create-update-manifest.mjs `
|
||||
--installer $installer.FullName `
|
||||
--installer-url $url `
|
||||
--private-key '${{ steps.signing.outputs.key_path }}' `
|
||||
--version $version `
|
||||
--channel stable `
|
||||
--target windows-x64 `
|
||||
--output ./artifacts/stable.json `
|
||||
--public-key-output ./artifacts/update-public-key.txt
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Update manifest generation failed' }
|
||||
$actualKey = (Get-Content -LiteralPath ./artifacts/update-public-key.txt -Raw).Trim()
|
||||
if ($actualKey -cne $env:EXPECTED_UPDATE_PUBLIC_KEY.Trim()) {
|
||||
throw 'Generated update public key does not match the protected expected public key'
|
||||
}
|
||||
"installer=$($installer.FullName)" >> $env:GITHUB_OUTPUT
|
||||
"host_installer=$($hostInstaller.FullName)" >> $env:GITHUB_OUTPUT
|
||||
- name: Generate SPDX SBOM
|
||||
uses: anchore/sbom-action@v0
|
||||
with:
|
||||
path: .
|
||||
format: spdx-json
|
||||
output-file: artifacts/remotedesk-${{ steps.version.outputs.version }}.spdx.json
|
||||
upload-artifact: false
|
||||
- name: Attest final Windows installers
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: |
|
||||
${{ steps.manifest.outputs.installer }}
|
||||
${{ steps.manifest.outputs.host_installer }}
|
||||
- name: Upload immutable workflow artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: remotedesk-windows-${{ steps.version.outputs.version }}-signed
|
||||
path: |
|
||||
${{ steps.manifest.outputs.installer }}
|
||||
${{ steps.manifest.outputs.host_installer }}
|
||||
artifacts/INSTALLER-SHA256SUMS.txt
|
||||
artifacts/HOST-INSTALLER-SHA256SUMS.txt
|
||||
artifacts/stable.json
|
||||
artifacts/update-public-key.txt
|
||||
artifacts/remotedesk-${{ steps.version.outputs.version }}.spdx.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Publish GitHub release assets
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create $env:GITHUB_REF_NAME `
|
||||
'${{ steps.manifest.outputs.installer }}' `
|
||||
'${{ steps.manifest.outputs.host_installer }}' `
|
||||
./artifacts/INSTALLER-SHA256SUMS.txt `
|
||||
./artifacts/HOST-INSTALLER-SHA256SUMS.txt `
|
||||
./artifacts/stable.json `
|
||||
./artifacts/update-public-key.txt `
|
||||
./artifacts/remotedesk-${{ steps.version.outputs.version }}.spdx.json `
|
||||
--verify-tag `
|
||||
--generate-notes `
|
||||
--title "RemoteDesk ${{ steps.version.outputs.version }}"
|
||||
- name: Publish stable update channel
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
$channelTag = 'stable-channel'
|
||||
gh release view $channelTag *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
gh release create $channelTag `
|
||||
--target $env:GITHUB_SHA `
|
||||
--title 'RemoteDesk stable update channel' `
|
||||
--notes 'Machine-readable signed update channel. Installers remain immutable on versioned releases.' `
|
||||
--prerelease
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Unable to create stable update channel' }
|
||||
}
|
||||
gh release upload $channelTag `
|
||||
./artifacts/stable.json `
|
||||
./artifacts/update-public-key.txt `
|
||||
--clobber
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Unable to update stable channel assets' }
|
||||
- name: Remove signing material
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$thumbprint = '${{ steps.signing.outputs.thumbprint }}'
|
||||
if ($thumbprint -match '^[0-9A-Fa-f]{40}$') {
|
||||
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$thumbprint" -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$keyPath = '${{ steps.signing.outputs.key_path }}'
|
||||
if ($keyPath -and (Test-Path -LiteralPath $keyPath)) {
|
||||
Remove-Item -LiteralPath $keyPath -Force
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/target/
|
||||
/artifacts/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/.vite/
|
||||
**/.tsbuildinfo/
|
||||
*.log
|
||||
*.pfx
|
||||
*.p12
|
||||
*-private.pem
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
Generated
+10319
File diff suppressed because it is too large
Load Diff
+36
@@ -0,0 +1,36 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = [
|
||||
"agent/agent-core",
|
||||
"agent/agent-runtime",
|
||||
"agent/windows-agent",
|
||||
"client/app-shell",
|
||||
"client/crates/client-core",
|
||||
"client/helpers/control-service",
|
||||
"client/helpers/credential-store",
|
||||
"client/helpers/linux-terminal",
|
||||
"client/helpers/native-video",
|
||||
"client/helpers/rdp-session",
|
||||
"client/helpers/rdp-viewer",
|
||||
"client/helpers/windows-agent-viewer",
|
||||
"edge/edge-service",
|
||||
"protocol",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.19"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.89"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
|
||||
[patch.crates-io]
|
||||
ironrdp-client = { path = "vendor/ironrdp-client" }
|
||||
ironrdp-tls = { path = "vendor/ironrdp-tls" }
|
||||
ironrdp-viewer = { path = "vendor/ironrdp-viewer" }
|
||||
@@ -0,0 +1,113 @@
|
||||
# RemoteDesk
|
||||
|
||||
RemoteDesk 是一套面向个人工作站的 Windows 远程桌面客户端。项目不包含 VDI、桌面池或多租户调度,重点是从 Windows 控制端连接 Windows 和现代 Linux 被控端。
|
||||
|
||||
## 产品定位
|
||||
|
||||
RemoteDesk 采用双通道设计:
|
||||
|
||||
- Windows 被控端:使用系统自带的 RDP 服务,由独立 Rust/IronRDP 原生会话进程连接。
|
||||
- Linux 被控端:安装自研 RemoteDesk Linux Agent,支持 Wayland、Xorg 桌面和纯命令行终端。
|
||||
- Windows 控制端:提供唯一的桌面客户端,Linux 不提供控制端 UI。
|
||||
|
||||
```text
|
||||
+----------------------+
|
||||
| RemoteDesk Client |
|
||||
| Tauri + Rust helpers |
|
||||
+----------+-----------+
|
||||
|
|
||||
+--------------+--------------+
|
||||
| |
|
||||
RDP channel Native channel
|
||||
| WebRTC + DataChannel
|
||||
| |
|
||||
Windows RDP RemoteDesk Linux Agent
|
||||
```
|
||||
|
||||
## 目标能力
|
||||
|
||||
以下内容是项目路线目标,不表示当前仓库已经全部实现。实际可运行范围以 [实现状态](docs/implementation-status.md) 和 [使用指南](docs/user-guide.md) 为准。
|
||||
|
||||
- Windows 和 Linux 连接配置统一管理。
|
||||
- RDP 全屏、动态分辨率、多显示器、音频和剪贴板。
|
||||
- 远程分辨率支持自动跟随、原始分辨率、720p/1080p/1440p/4K 固定档和自定义尺寸。
|
||||
- 自研 Linux Agent 支持 Wayland、Xorg 当前会话和无桌面的命令行终端。
|
||||
- 命令行会话使用 PTY,以指定普通用户运行,不要求图形桌面已登录。
|
||||
- H.264 默认视频编码,按硬件能力扩展 HEVC 和 AV1。
|
||||
- Linux 桌面默认要求双端零拷贝:Agent 使用 DMA-BUF 硬件编码,Windows 原生 helper 使用 D3D11 surface 解码与呈现。
|
||||
- 客户端 GPU 默认自动选择,也支持全局或单主机手动指定。
|
||||
- 多显示器支持主屏、全部和自定义子集,保留负坐标、混合 DPI 与布局 generation。
|
||||
- RDP 主机使用 Windows Credential Manager 引用,HostProfile 不保存账号密码。
|
||||
- 断线重连、连接质量统计和自适应画质。
|
||||
- 智能比较直连与 CDN 路径;CDN 模式让两端进入最近 POP,并通过优质骨干网传输。
|
||||
- 使用系统密钥库保存凭据。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 不从零实现 RDP 协议,也不引入 C++/FreeRDP 客户端。
|
||||
- 不承诺 Windows 系统 RDP 服务端内部零拷贝;只能验证并报告 IronRDP 客户端实际得到 GPU surface 还是 CPU bitmap。
|
||||
- 不使用 JavaScript、Tauri IPC 或 CPU buffer 传递 Linux 桌面原始视频帧;WebView2 `<video>` 仅保留为显式兼容模式。
|
||||
- 不提供 VDI 桌面池、模板克隆和用户调度。
|
||||
- 不承诺所有 Wayland 合成器均支持无人值守控制。
|
||||
- 不使用缓存型 CDN 转码或缓存远程桌面内容。
|
||||
- 首期边缘模式不依赖完整云账号体系,设备权限仍由 Agent 本地保存。
|
||||
- 不依赖 xrdp、GNOME Remote Desktop 或 KDE KRdp。
|
||||
- 首个稳定版本不承诺在所有 Wayland 桌面上控制登录界面。
|
||||
|
||||
## 技术基线
|
||||
|
||||
| 模块 | 技术 |
|
||||
|---|---|
|
||||
| Windows 桌面客户端 | Tauri 2、React/TypeScript、Rust |
|
||||
| RDP 通道 | 独立 Rust helper、IronRDP、D3D11 |
|
||||
| Linux Agent | Rust、Tokio、PipeWire/Portal/libei、X11、PTY/PAM |
|
||||
| 媒体 | GStreamer、硬件编码、Opus |
|
||||
| Linux 会话显示 | 默认 Rust/GStreamer/D3D11 原生零拷贝 helper;WebView2 仅兼容模式 |
|
||||
| 命令行显示 | xterm.js + WebRTC DataChannel |
|
||||
| 本地数据 | SQLite |
|
||||
| 凭据 | Windows Credential Manager |
|
||||
| 边缘中继 | Rust Rendezvous/Allocator、双 POP TURN、优质骨干网 |
|
||||
| 静态 CDN | 签名 MSI/DEB/RPM、更新 manifest、SBOM |
|
||||
|
||||
具体依赖版本在进入编码阶段时锁定到当时可用的稳定版本。
|
||||
|
||||
## 仓库规划
|
||||
|
||||
```text
|
||||
RemoteDesk/
|
||||
├── client/ # Tauri UI 与 Rust native helpers
|
||||
├── agent/ # Rust Linux Agent workspace
|
||||
├── protocol/ # Protobuf 契约和兼容性测试
|
||||
├── edge/ # 独立 Edge Presence/Allocator/byte relay
|
||||
├── packaging/ # Windows、Linux Agent 和 Edge 打包
|
||||
├── tests/ # 集成和端到端测试
|
||||
└── docs/
|
||||
├── architecture.md
|
||||
└── roadmap.md
|
||||
```
|
||||
|
||||
## 设计文档
|
||||
|
||||
- [实现状态](docs/implementation-status.md)
|
||||
- [使用指南](docs/user-guide.md)
|
||||
- [总体架构](docs/architecture.md)
|
||||
- [Windows 客户端设计](docs/windows-client.md)
|
||||
- [Linux 被控端设计](docs/linux-agent.md)
|
||||
- [技术栈规划](docs/technology-stack.md)
|
||||
- [协议设计](docs/protocol.md)
|
||||
- [安全设计](docs/security.md)
|
||||
- [差网络自适应](docs/network-adaptation.md)
|
||||
- [CDN 与边缘中继](docs/edge-relay.md)
|
||||
- [客户端 GPU 加速](docs/gpu-acceleration.md)
|
||||
- [在线升级](docs/online-updates.md)
|
||||
- [M0 技术验证](docs/m0-validation.md)
|
||||
- [实施路线](docs/roadmap.md)
|
||||
- [开发指南](docs/development.md)
|
||||
|
||||
## 当前代码
|
||||
|
||||
当前工程已包含 Rust workspace、协议状态模型、多显示器布局、Windows Client Core、RDP 地址校验、回环控制服务、无凭据 IronRDP 协议与 TLS 证书探测、证书指纹固定、Windows Credential Manager 凭据适配器、外部 `mstsc`、带有界自动重连和 D3D11 CPU-upload swap chain 的 IronRDP 原生窗口、Ed25519 签名清单在线升级、native-video 管线规划与真实 D3D11 设备探针,以及 React 管理界面。被控端新增了可逆配置 Microsoft RDP 的独立 Windows Host MSI,以及包含 `agentd`、用户会话进程、PAM/PTY 终端进程、支持双向断点续传的文件进程、配对/WSS/Unix IPC、systemd、DEB/RPM/静态便携构建链的 Linux Agent 基础。独立 Edge 服务已实现短 TTL Presence、测量路径分配、coturn REST 短期凭据、限额透明 TCP byte relay 和接受会话内两端设备 key 签名的 SDP/ICE mailbox,并已接通 Linux 首次配对、终端/文件的签名准入、角色票据及端到端 TLS/WSS opaque relay。接受的桌面会话新增双方 PeerConnection、逐候选 ICE、session-bound TURN 凭据和 H.264/RTX/Opus/DataChannel 传输内核源码;Linux 用户会话将同一次 X11 BGRA 捕获送入 GStreamer H.264 编码器,经严格有界、分块和 SHA-256 校验的 root/user IPC 交给精确绑定用户及 Client 指纹的 WebRTC sender。Windows 控制 helper 已接入有界 RFC 6184 RTP 重组、丢包/积压后 PLI 与关键帧恢复,以及 Media Foundation H.264 MFT 到同 device NV12/D3D11 VideoProcessor/swap-chain 的原生呈现源码。协议 minor 14 还接入显式协商的 Linux 系统输出 Opus 捕获、有界 IPC/WebRTC 发送和 Windows WASAPI 本地播放;minor 15 增加独立 `clipboard_read`/`clipboard_write` 授权、X11 与 Windows `CF_UNICODETEXT` 双向文本剪贴板和 Offer/Request/Data 按需传输源码;RDP Edge 尚未接入。
|
||||
|
||||
`transport/hysteria2-agent` 另提供基于官方 Hysteria2 Go `core/v2` 的 UDP/QUIC 入口和客户端适配器。该入口只允许密码认证的 Hysteria2 UDP 会话,公网不开放 TCP、TCP proxy 或 SOCKS5;报文交付到本机 UDP 适配端口。现有 Rust Agent 的 TCP/WSS 会话协议尚未改为 UDP 数据报协议,因此该入口目前是可部署的传输适配层,不宣称桌面会话已直接接通。
|
||||
|
||||
Linux 图形的 X11 zlib 兼容捕获/输入、GStreamer H.264 WebRTC 发送端和 Windows 原生远端 H.264 接收/解码/呈现已接入源码;协议 minor 9 在首张原生帧后切换到 H.264-only,以呈现 ACK 驱动捕获并在丢包或媒体失败时恢复 zlib,避免长期双路传输完整画面。协议 minor 10 进一步接入显式光标抓取、Windows Raw Input 合并、有界相对鼠标和 XTEST 相对移动,失焦/重连/退出会解除抓取并释放远端输入。协议 minor 11 已接入认证后的 Wayland Portal、严格 PipeWire DMABUF-to-VA-H.264、呈现 ACK、resize 重建和 `reis` EIS 输入源码;minor 12 在断线后释放全部输入并保留 15 秒 Portal/EIS 租约,只有相同 Client 指纹、Linux 用户和轮换令牌才能使用新 WebRTC sender 恢复,并从新 IDR 继续。协议 minor 13 将现有 PeerConnection 媒体内核抽象为认证信令接口,直连 Client/Agent 可在证书固定、Ed25519 认证后的 WSS 上交换严格有界的 SDP/ICE,使用 host-candidate ICE 建立 H.264/RTX/Opus/DataChannel;失败经显式 abort/unavailable 同步后,X11 才回到 zlib,Wayland 仍拒绝 CPU 回退。minor 14 为直连和 Edge 桌面请求增加默认关闭的 Opus 协商,只有同版本 Client 显式开启且已取得授权媒体 sender 时才捕获 Linux 输出 monitor,并在 Windows 通过 IronRDP Opus 解码器和 WASAPI 播放;视频与音频失败互不连带。minor 15 的 X11 文本剪贴板只在双方显式协商对应方向且配对授权包含相同权限时启用,内容限制为规范化 UTF-8 文本和 32 KiB;Wayland 剪贴板仍未接入。新增媒体、音频、剪贴板路径和 GNOME/KDE/Xorg 实机验证仍未执行,因此运行时继续报告 `linux.wayland_desktop=false` 与 `linux.native_video=false`。Linux Terminal 的 Windows 控制 helper 已接通 WSS、证书固定、Ed25519 配对认证和 PTY 字节流,但尚未在真实 Linux/PAM 主机完成端到端运行验证。真实 PipeWire/DRI3/D3D11 呈现/Tauri 继续按 M0 技术验证计划逐项实现,不以模拟代码或仅存在安装包来代替平台验证。Windows Credential Manager 的写入、状态、删除、IronRDP NLA 凭据读取和 Linux 客户端身份存储已接入。
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "remotedesk-agent-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Platform-independent policy core for the RemoteDesk Linux agent"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
remotedesk-protocol = { path = "../../protocol" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,960 @@
|
||||
//! Platform-independent policy and state management for the Linux agent.
|
||||
//!
|
||||
//! This crate deliberately contains no `PipeWire`, `DRI3`, encoder, or GPU API
|
||||
//! implementation. Platform adapters must probe the real system and pass their
|
||||
//! observed results into these types.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use remotedesk_protocol::MemoryPathStatus;
|
||||
|
||||
pub use remotedesk_protocol::{
|
||||
DisplayDescriptor, DisplayId, DisplayLayout, DisplayRect, DisplayScale, DisplaySelection,
|
||||
DisplaySelectionMode, ZeroCopyPolicy,
|
||||
};
|
||||
|
||||
mod multi_display;
|
||||
|
||||
pub use multi_display::{
|
||||
DesktopBounds, DisplayCaptureSource, DisplayImportProbe, DisplayPathPlan,
|
||||
MultiDisplayCapturePlan, MultiDisplayCompliance, MultiDisplayGpuCandidate,
|
||||
MultiDisplayPipeline, MultiDisplayPipelineError, MultiDisplayPlanError, NormalizedPoint,
|
||||
SelectedDisplayLayout, SelectedDisplaySource,
|
||||
};
|
||||
|
||||
/// Capture mechanisms exposed by the Linux agent.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DesktopBackend {
|
||||
WaylandPipeWire,
|
||||
XorgPortalPipeWire,
|
||||
XorgDri3Experimental,
|
||||
XorgShmCompatibility,
|
||||
Terminal,
|
||||
}
|
||||
|
||||
impl DesktopBackend {
|
||||
#[must_use]
|
||||
pub const fn is_experimental(self) -> bool {
|
||||
matches!(self, Self::XorgDri3Experimental)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn carries_video(self) -> bool {
|
||||
!matches!(self, Self::Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable adapter identity supplied by a platform-specific GPU enumerator.
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct AdapterId(String);
|
||||
|
||||
impl AdapterId {
|
||||
/// Creates a stable adapter identifier.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ConfigurationError::EmptyAdapterId`] for an empty or
|
||||
/// whitespace-only identifier.
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ConfigurationError> {
|
||||
let value = value.into();
|
||||
if value.trim().is_empty() {
|
||||
return Err(ConfigurationError::EmptyAdapterId);
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AdapterId {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
|
||||
/// The memory path actually observed by a capture backend.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CaptureMemoryPath {
|
||||
/// A DMA-BUF remains on the adapter that produced it.
|
||||
DmaBuf { adapter: AdapterId },
|
||||
/// Frames are read into CPU-addressable memory, such as `XShm`.
|
||||
CpuMemory,
|
||||
/// The backend reports GPU ownership but cannot prove its memory path.
|
||||
OpaqueGpu { adapter: Option<AdapterId> },
|
||||
}
|
||||
|
||||
impl CaptureMemoryPath {
|
||||
#[must_use]
|
||||
pub fn adapter(&self) -> Option<&AdapterId> {
|
||||
match self {
|
||||
Self::DmaBuf { adapter } => Some(adapter),
|
||||
Self::OpaqueGpu { adapter } => adapter.as_ref(),
|
||||
Self::CpuMemory => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the observed capture memory to its protocol report status.
|
||||
#[must_use]
|
||||
pub const fn protocol_status(&self) -> MemoryPathStatus {
|
||||
match self {
|
||||
Self::DmaBuf { .. } => MemoryPathStatus::CpuCopyFree,
|
||||
Self::CpuMemory => MemoryPathStatus::Software,
|
||||
Self::OpaqueGpu { .. } => MemoryPathStatus::Opaque,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of trying to import the capture surface into one encoder candidate.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ImportProbeResult {
|
||||
/// Import succeeded without CPU mapping or an inter-adapter transfer.
|
||||
SameAdapterZeroCopy,
|
||||
/// Import works only after copying from the named source adapter.
|
||||
CrossAdapterCopy { source_adapter: AdapterId },
|
||||
/// Encoding requires an upload from CPU-addressable memory.
|
||||
CpuUpload,
|
||||
/// Frames remain in CPU memory and are encoded in software.
|
||||
Software,
|
||||
/// The driver accepts the surface but cannot expose enough detail to verify it.
|
||||
OpaqueGpuPath,
|
||||
/// The candidate cannot encode this capture surface.
|
||||
Unsupported { reason: String },
|
||||
}
|
||||
|
||||
impl ImportProbeResult {
|
||||
/// Maps a completed import probe to a protocol status. Unsupported probes
|
||||
/// cannot produce a path report and therefore map to `None`.
|
||||
#[must_use]
|
||||
pub const fn protocol_status(&self) -> Option<MemoryPathStatus> {
|
||||
match self {
|
||||
Self::SameAdapterZeroCopy => Some(MemoryPathStatus::SameAdapterGpuTransform),
|
||||
Self::CrossAdapterCopy { .. } => Some(MemoryPathStatus::CrossAdapterCopy),
|
||||
Self::CpuUpload => Some(MemoryPathStatus::CpuUpload),
|
||||
Self::Software => Some(MemoryPathStatus::Software),
|
||||
Self::OpaqueGpuPath => Some(MemoryPathStatus::Opaque),
|
||||
Self::Unsupported { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One encoder discovered and probed by the platform integration layer.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct GpuCandidate {
|
||||
pub adapter: AdapterId,
|
||||
pub encoder_name: String,
|
||||
pub import_probe: ImportProbeResult,
|
||||
}
|
||||
|
||||
impl GpuCandidate {
|
||||
/// Creates a candidate from a platform import probe.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ConfigurationError::EmptyEncoderName`] when `encoder_name`
|
||||
/// is empty or contains only whitespace.
|
||||
pub fn new(
|
||||
adapter: AdapterId,
|
||||
encoder_name: impl Into<String>,
|
||||
import_probe: ImportProbeResult,
|
||||
) -> Result<Self, ConfigurationError> {
|
||||
let encoder_name = encoder_name.into();
|
||||
if encoder_name.trim().is_empty() {
|
||||
return Err(ConfigurationError::EmptyEncoderName);
|
||||
}
|
||||
Ok(Self {
|
||||
adapter,
|
||||
encoder_name,
|
||||
import_probe,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The transfer mode selected for capture-to-encode.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TransferMode {
|
||||
SameAdapterZeroCopy,
|
||||
CrossAdapterCopy,
|
||||
CpuUpload,
|
||||
Software,
|
||||
OpaqueGpuPath,
|
||||
}
|
||||
|
||||
impl TransferMode {
|
||||
#[must_use]
|
||||
pub const fn is_verified_zero_copy(self) -> bool {
|
||||
self.protocol_status().satisfies_strict_zero_copy()
|
||||
}
|
||||
|
||||
/// Maps the selected capture-to-encode transfer to its protocol status.
|
||||
#[must_use]
|
||||
pub const fn protocol_status(self) -> MemoryPathStatus {
|
||||
match self {
|
||||
Self::SameAdapterZeroCopy => MemoryPathStatus::SameAdapterGpuTransform,
|
||||
Self::CrossAdapterCopy => MemoryPathStatus::CrossAdapterCopy,
|
||||
Self::CpuUpload => MemoryPathStatus::CpuUpload,
|
||||
Self::Software => MemoryPathStatus::Software,
|
||||
Self::OpaqueGpuPath => MemoryPathStatus::Opaque,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selected encoder and the memory behavior that justified the selection.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EncoderSelection {
|
||||
pub adapter: AdapterId,
|
||||
pub encoder_name: String,
|
||||
pub transfer_mode: TransferMode,
|
||||
}
|
||||
|
||||
impl EncoderSelection {
|
||||
/// Returns the status that must be placed in the Agent path report.
|
||||
#[must_use]
|
||||
pub const fn protocol_status(&self) -> MemoryPathStatus {
|
||||
self.transfer_mode.protocol_status()
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the best encoder from real import-probe results.
|
||||
///
|
||||
/// A verified same-adapter import always wins. Compatibility mode then prefers
|
||||
/// cross-adapter GPU copy, CPU upload, and opaque paths in that order.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SelectionError`] when the backend has no video pipeline, the
|
||||
/// capture result conflicts with the backend, the requested policy rejects the
|
||||
/// observed memory path, or no usable encoder probe is available.
|
||||
pub fn select_encoder(
|
||||
backend: DesktopBackend,
|
||||
policy: ZeroCopyPolicy,
|
||||
capture: &CaptureMemoryPath,
|
||||
candidates: &[GpuCandidate],
|
||||
) -> Result<EncoderSelection, SelectionError> {
|
||||
if !backend.carries_video() {
|
||||
return Err(SelectionError::TerminalHasNoVideoPipeline);
|
||||
}
|
||||
|
||||
validate_capture_for_backend(backend, capture)?;
|
||||
|
||||
if policy == ZeroCopyPolicy::RequiredEndToEnd
|
||||
&& !capture.protocol_status().satisfies_strict_zero_copy()
|
||||
{
|
||||
return Err(SelectionError::StrictPolicyRejectedCapture {
|
||||
path: capture.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let same_adapter_selection = if let CaptureMemoryPath::DmaBuf {
|
||||
adapter: capture_adapter,
|
||||
} = capture
|
||||
{
|
||||
candidates
|
||||
.iter()
|
||||
.find(|candidate| {
|
||||
candidate.adapter == *capture_adapter
|
||||
&& candidate.import_probe == ImportProbeResult::SameAdapterZeroCopy
|
||||
})
|
||||
.map(|candidate| selection(candidate, TransferMode::SameAdapterZeroCopy))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let selected = match same_adapter_selection
|
||||
.or_else(|| select_compatibility_fallback(capture, candidates))
|
||||
{
|
||||
Some(selected) => selected,
|
||||
None if policy == ZeroCopyPolicy::RequiredEndToEnd => {
|
||||
return Err(SelectionError::NoVerifiedSameAdapterEncoder {
|
||||
capture_adapter: capture.adapter().cloned(),
|
||||
});
|
||||
}
|
||||
None => return Err(SelectionError::NoCompatibleEncoder),
|
||||
};
|
||||
|
||||
if policy == ZeroCopyPolicy::RequiredEndToEnd
|
||||
&& !selected.protocol_status().satisfies_strict_zero_copy()
|
||||
{
|
||||
return Err(SelectionError::NoVerifiedSameAdapterEncoder {
|
||||
capture_adapter: capture.adapter().cloned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
fn validate_capture_for_backend(
|
||||
backend: DesktopBackend,
|
||||
capture: &CaptureMemoryPath,
|
||||
) -> Result<(), SelectionError> {
|
||||
match (backend, capture) {
|
||||
(
|
||||
DesktopBackend::WaylandPipeWire
|
||||
| DesktopBackend::XorgPortalPipeWire
|
||||
| DesktopBackend::XorgDri3Experimental
|
||||
| DesktopBackend::XorgShmCompatibility,
|
||||
CaptureMemoryPath::CpuMemory,
|
||||
) => Ok(()),
|
||||
(DesktopBackend::XorgShmCompatibility, _) => {
|
||||
Err(SelectionError::BackendCaptureMismatch { backend })
|
||||
}
|
||||
(DesktopBackend::Terminal, _) => Err(SelectionError::TerminalHasNoVideoPipeline),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_compatibility_fallback(
|
||||
capture: &CaptureMemoryPath,
|
||||
candidates: &[GpuCandidate],
|
||||
) -> Option<EncoderSelection> {
|
||||
const FALLBACKS: [TransferMode; 4] = [
|
||||
TransferMode::CrossAdapterCopy,
|
||||
TransferMode::CpuUpload,
|
||||
TransferMode::Software,
|
||||
TransferMode::OpaqueGpuPath,
|
||||
];
|
||||
|
||||
FALLBACKS.into_iter().find_map(|mode| {
|
||||
candidates
|
||||
.iter()
|
||||
.find(|candidate| probe_matches(mode, capture, candidate))
|
||||
.map(|candidate| selection(candidate, mode))
|
||||
})
|
||||
}
|
||||
|
||||
fn probe_matches(
|
||||
mode: TransferMode,
|
||||
capture: &CaptureMemoryPath,
|
||||
candidate: &GpuCandidate,
|
||||
) -> bool {
|
||||
match (mode, &candidate.import_probe) {
|
||||
(
|
||||
TransferMode::CrossAdapterCopy,
|
||||
ImportProbeResult::CrossAdapterCopy { source_adapter },
|
||||
) => capture.adapter() == Some(source_adapter) && candidate.adapter != *source_adapter,
|
||||
(TransferMode::CpuUpload, ImportProbeResult::CpuUpload)
|
||||
| (TransferMode::Software, ImportProbeResult::Software)
|
||||
| (TransferMode::OpaqueGpuPath, ImportProbeResult::OpaqueGpuPath) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn selection(candidate: &GpuCandidate, transfer_mode: TransferMode) -> EncoderSelection {
|
||||
EncoderSelection {
|
||||
adapter: candidate.adapter.clone(),
|
||||
encoder_name: candidate.encoder_name.clone(),
|
||||
transfer_mode,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ConfigurationError {
|
||||
EmptyAdapterId,
|
||||
EmptyEncoderName,
|
||||
InvalidResolution { width: u32, height: u32 },
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigurationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::EmptyAdapterId => formatter.write_str("adapter id must not be empty"),
|
||||
Self::EmptyEncoderName => formatter.write_str("encoder name must not be empty"),
|
||||
Self::InvalidResolution { width, height } => {
|
||||
write!(formatter, "invalid resolution {width}x{height}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigurationError {}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SelectionError {
|
||||
TerminalHasNoVideoPipeline,
|
||||
BackendCaptureMismatch { backend: DesktopBackend },
|
||||
StrictPolicyRejectedCapture { path: CaptureMemoryPath },
|
||||
NoVerifiedSameAdapterEncoder { capture_adapter: Option<AdapterId> },
|
||||
NoCompatibleEncoder,
|
||||
}
|
||||
|
||||
impl fmt::Display for SelectionError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::TerminalHasNoVideoPipeline => {
|
||||
formatter.write_str("terminal sessions do not use a video pipeline")
|
||||
}
|
||||
Self::BackendCaptureMismatch { backend } => {
|
||||
write!(
|
||||
formatter,
|
||||
"capture memory path does not match backend {backend:?}"
|
||||
)
|
||||
}
|
||||
Self::StrictPolicyRejectedCapture { path } => {
|
||||
write!(
|
||||
formatter,
|
||||
"strict zero-copy policy rejected capture path {path:?}"
|
||||
)
|
||||
}
|
||||
Self::NoVerifiedSameAdapterEncoder { capture_adapter } => write!(
|
||||
formatter,
|
||||
"no verified same-adapter encoder for capture adapter {capture_adapter:?}"
|
||||
),
|
||||
Self::NoCompatibleEncoder => formatter.write_str("no compatible encoder was found"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SelectionError {}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Resolution {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl Resolution {
|
||||
/// Creates non-zero output dimensions.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ConfigurationError::InvalidResolution`] when either dimension
|
||||
/// is zero.
|
||||
pub fn new(width: u32, height: u32) -> Result<Self, ConfigurationError> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(ConfigurationError::InvalidResolution { width, height });
|
||||
}
|
||||
Ok(Self { width, height })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PipelineState {
|
||||
Active,
|
||||
Revalidating,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PipelineTransitionError {
|
||||
InvalidState {
|
||||
expected: PipelineState,
|
||||
actual: PipelineState,
|
||||
},
|
||||
SelectionRejectedByPolicy {
|
||||
policy: ZeroCopyPolicy,
|
||||
status: MemoryPathStatus,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for PipelineTransitionError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidState { expected, actual } => write!(
|
||||
formatter,
|
||||
"pipeline transition requires {expected:?}, but current state is {actual:?}"
|
||||
),
|
||||
Self::SelectionRejectedByPolicy { policy, status } => write!(
|
||||
formatter,
|
||||
"pipeline policy {policy:?} rejected memory path status {status:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PipelineTransitionError {}
|
||||
|
||||
/// Tracks the generation that has been validated for the active dimensions.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VideoPipeline {
|
||||
policy: ZeroCopyPolicy,
|
||||
resolution: Resolution,
|
||||
epoch: u64,
|
||||
state: PipelineState,
|
||||
selection: Option<EncoderSelection>,
|
||||
}
|
||||
|
||||
impl VideoPipeline {
|
||||
/// Creates an active pipeline after validating its initial selection.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PipelineTransitionError::SelectionRejectedByPolicy`] when a
|
||||
/// required end-to-end zero-copy pipeline is given a non-compliant path.
|
||||
pub fn active(
|
||||
policy: ZeroCopyPolicy,
|
||||
resolution: Resolution,
|
||||
selection: EncoderSelection,
|
||||
) -> Result<Self, PipelineTransitionError> {
|
||||
validate_selection_for_policy(policy, &selection)?;
|
||||
Ok(Self {
|
||||
policy,
|
||||
resolution,
|
||||
epoch: 0,
|
||||
state: PipelineState::Active,
|
||||
selection: Some(selection),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn policy(&self) -> ZeroCopyPolicy {
|
||||
self.policy
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn resolution(&self) -> Resolution {
|
||||
self.resolution
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn epoch(&self) -> u64 {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> PipelineState {
|
||||
self.state
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn selection(&self) -> Option<&EncoderSelection> {
|
||||
self.selection.as_ref()
|
||||
}
|
||||
|
||||
/// Applies a requested output size. A real resize invalidates the previous
|
||||
/// import proof and forces both capture and encode paths to be probed again.
|
||||
pub fn resize(&mut self, resolution: Resolution) -> bool {
|
||||
if self.resolution == resolution {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.resolution = resolution;
|
||||
self.epoch = self.epoch.saturating_add(1);
|
||||
self.state = PipelineState::Revalidating;
|
||||
self.selection = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Marks the current epoch active after the caller performs fresh probes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PipelineTransitionError::InvalidState`] unless the pipeline is
|
||||
/// currently revalidating, or
|
||||
/// [`PipelineTransitionError::SelectionRejectedByPolicy`] when the fresh
|
||||
/// selection does not satisfy the pipeline's current policy.
|
||||
pub fn finish_revalidation(
|
||||
&mut self,
|
||||
selection: EncoderSelection,
|
||||
) -> Result<(), PipelineTransitionError> {
|
||||
if self.state != PipelineState::Revalidating {
|
||||
return Err(PipelineTransitionError::InvalidState {
|
||||
expected: PipelineState::Revalidating,
|
||||
actual: self.state,
|
||||
});
|
||||
}
|
||||
validate_selection_for_policy(self.policy, &selection)?;
|
||||
self.selection = Some(selection);
|
||||
self.state = PipelineState::Active;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_selection_for_policy(
|
||||
policy: ZeroCopyPolicy,
|
||||
selection: &EncoderSelection,
|
||||
) -> Result<(), PipelineTransitionError> {
|
||||
let status = selection.protocol_status();
|
||||
if policy == ZeroCopyPolicy::RequiredEndToEnd && !status.satisfies_strict_zero_copy() {
|
||||
return Err(PipelineTransitionError::SelectionRejectedByPolicy { policy, status });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn adapter(name: &str) -> AdapterId {
|
||||
AdapterId::new(name).unwrap()
|
||||
}
|
||||
|
||||
fn candidate(
|
||||
adapter_name: &str,
|
||||
encoder_name: &str,
|
||||
import_probe: ImportProbeResult,
|
||||
) -> GpuCandidate {
|
||||
GpuCandidate::new(adapter(adapter_name), encoder_name, import_probe).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_selects_verified_dma_buf_encoder() {
|
||||
let capture = CaptureMemoryPath::DmaBuf {
|
||||
adapter: adapter("renderD128"),
|
||||
};
|
||||
let candidates = [candidate(
|
||||
"renderD128",
|
||||
"vaapi-h264",
|
||||
ImportProbeResult::SameAdapterZeroCopy,
|
||||
)];
|
||||
|
||||
let selected = select_encoder(
|
||||
DesktopBackend::WaylandPipeWire,
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
&capture,
|
||||
&candidates,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(selected.adapter, adapter("renderD128"));
|
||||
assert_eq!(selected.transfer_mode, TransferMode::SameAdapterZeroCopy);
|
||||
assert_eq!(capture.protocol_status(), MemoryPathStatus::CpuCopyFree);
|
||||
assert_eq!(
|
||||
selected.protocol_status(),
|
||||
MemoryPathStatus::SameAdapterGpuTransform
|
||||
);
|
||||
assert!(selected.transfer_mode.is_verified_zero_copy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_fallback_has_an_explicit_protocol_status() {
|
||||
assert_eq!(
|
||||
CaptureMemoryPath::CpuMemory.protocol_status(),
|
||||
MemoryPathStatus::Software
|
||||
);
|
||||
assert_eq!(
|
||||
CaptureMemoryPath::OpaqueGpu { adapter: None }.protocol_status(),
|
||||
MemoryPathStatus::Opaque
|
||||
);
|
||||
assert_eq!(
|
||||
ImportProbeResult::CrossAdapterCopy {
|
||||
source_adapter: adapter("gpu-0")
|
||||
}
|
||||
.protocol_status(),
|
||||
Some(MemoryPathStatus::CrossAdapterCopy)
|
||||
);
|
||||
assert_eq!(
|
||||
ImportProbeResult::CpuUpload.protocol_status(),
|
||||
Some(MemoryPathStatus::CpuUpload)
|
||||
);
|
||||
assert_eq!(
|
||||
ImportProbeResult::Software.protocol_status(),
|
||||
Some(MemoryPathStatus::Software)
|
||||
);
|
||||
assert_eq!(
|
||||
ImportProbeResult::OpaqueGpuPath.protocol_status(),
|
||||
Some(MemoryPathStatus::Opaque)
|
||||
);
|
||||
assert_eq!(
|
||||
ImportProbeResult::Unsupported {
|
||||
reason: "format mismatch".into()
|
||||
}
|
||||
.protocol_status(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xorg_dri3_is_experimental_but_can_pass_strict_validation() {
|
||||
let backend = DesktopBackend::XorgDri3Experimental;
|
||||
let capture = CaptureMemoryPath::DmaBuf {
|
||||
adapter: adapter("card0"),
|
||||
};
|
||||
let candidates = [candidate(
|
||||
"card0",
|
||||
"nvenc-h264",
|
||||
ImportProbeResult::SameAdapterZeroCopy,
|
||||
)];
|
||||
|
||||
let selected = select_encoder(
|
||||
backend,
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
&capture,
|
||||
&candidates,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(backend.is_experimental());
|
||||
assert_eq!(selected.transfer_mode, TransferMode::SameAdapterZeroCopy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xorg_shm_is_rejected_by_strict_policy_and_allowed_by_compatibility() {
|
||||
let capture = CaptureMemoryPath::CpuMemory;
|
||||
let candidates = [candidate(
|
||||
"renderD128",
|
||||
"vaapi-h264",
|
||||
ImportProbeResult::CpuUpload,
|
||||
)];
|
||||
|
||||
let strict_error = select_encoder(
|
||||
DesktopBackend::XorgShmCompatibility,
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
&capture,
|
||||
&candidates,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
strict_error,
|
||||
SelectionError::StrictPolicyRejectedCapture {
|
||||
path: CaptureMemoryPath::CpuMemory
|
||||
}
|
||||
);
|
||||
|
||||
let selected = select_encoder(
|
||||
DesktopBackend::XorgShmCompatibility,
|
||||
ZeroCopyPolicy::Compatibility,
|
||||
&capture,
|
||||
&candidates,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected.transfer_mode, TransferMode::CpuUpload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_gpu_probe_chooses_the_capture_adapter() {
|
||||
let capture = CaptureMemoryPath::DmaBuf {
|
||||
adapter: adapter("gpu-integrated"),
|
||||
};
|
||||
let candidates = [
|
||||
candidate(
|
||||
"gpu-discrete",
|
||||
"nvenc-h264",
|
||||
ImportProbeResult::CrossAdapterCopy {
|
||||
source_adapter: adapter("gpu-integrated"),
|
||||
},
|
||||
),
|
||||
candidate(
|
||||
"gpu-integrated",
|
||||
"vaapi-h264",
|
||||
ImportProbeResult::SameAdapterZeroCopy,
|
||||
),
|
||||
];
|
||||
|
||||
let selected = select_encoder(
|
||||
DesktopBackend::WaylandPipeWire,
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
&capture,
|
||||
&candidates,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(selected.adapter, adapter("gpu-integrated"));
|
||||
assert_eq!(selected.encoder_name, "vaapi-h264");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_policy_rejects_cross_adapter_and_opaque_probes() {
|
||||
let capture = CaptureMemoryPath::DmaBuf {
|
||||
adapter: adapter("gpu-0"),
|
||||
};
|
||||
|
||||
for probe in [
|
||||
ImportProbeResult::CrossAdapterCopy {
|
||||
source_adapter: adapter("gpu-0"),
|
||||
},
|
||||
ImportProbeResult::OpaqueGpuPath,
|
||||
] {
|
||||
let candidates = [candidate("gpu-1", "encoder", probe)];
|
||||
let error = select_encoder(
|
||||
DesktopBackend::WaylandPipeWire,
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
&capture,
|
||||
&candidates,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
SelectionError::NoVerifiedSameAdapterEncoder {
|
||||
capture_adapter: Some(adapter("gpu-0"))
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_policy_explicitly_allows_cross_adapter_copy() {
|
||||
let capture = CaptureMemoryPath::DmaBuf {
|
||||
adapter: adapter("gpu-0"),
|
||||
};
|
||||
let candidates = [candidate(
|
||||
"gpu-1",
|
||||
"nvenc-h264",
|
||||
ImportProbeResult::CrossAdapterCopy {
|
||||
source_adapter: adapter("gpu-0"),
|
||||
},
|
||||
)];
|
||||
|
||||
let selected = select_encoder(
|
||||
DesktopBackend::WaylandPipeWire,
|
||||
ZeroCopyPolicy::Compatibility,
|
||||
&capture,
|
||||
&candidates,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(selected.transfer_mode, TransferMode::CrossAdapterCopy);
|
||||
assert_eq!(
|
||||
selected.protocol_status(),
|
||||
MemoryPathStatus::CrossAdapterCopy
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_capture_is_only_available_in_compatibility_mode() {
|
||||
let capture = CaptureMemoryPath::OpaqueGpu {
|
||||
adapter: Some(adapter("gpu-0")),
|
||||
};
|
||||
let candidates = [candidate(
|
||||
"gpu-0",
|
||||
"driver-managed-h264",
|
||||
ImportProbeResult::OpaqueGpuPath,
|
||||
)];
|
||||
|
||||
let strict_error = select_encoder(
|
||||
DesktopBackend::WaylandPipeWire,
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
&capture,
|
||||
&candidates,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
strict_error,
|
||||
SelectionError::StrictPolicyRejectedCapture {
|
||||
path: capture.clone()
|
||||
}
|
||||
);
|
||||
|
||||
let selected = select_encoder(
|
||||
DesktopBackend::WaylandPipeWire,
|
||||
ZeroCopyPolicy::Compatibility,
|
||||
&capture,
|
||||
&candidates,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected.transfer_mode, TransferMode::OpaqueGpuPath);
|
||||
assert!(!selected.transfer_mode.is_verified_zero_copy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_increments_epoch_and_requires_revalidation() {
|
||||
let initial_selection = EncoderSelection {
|
||||
adapter: adapter("gpu-0"),
|
||||
encoder_name: "vaapi-h264".into(),
|
||||
transfer_mode: TransferMode::SameAdapterZeroCopy,
|
||||
};
|
||||
let mut pipeline = VideoPipeline::active(
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
Resolution::new(1920, 1080).unwrap(),
|
||||
initial_selection.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!pipeline.resize(Resolution::new(1920, 1080).unwrap()));
|
||||
assert_eq!(pipeline.epoch(), 0);
|
||||
assert!(pipeline.resize(Resolution::new(2560, 1440).unwrap()));
|
||||
assert_eq!(pipeline.epoch(), 1);
|
||||
assert_eq!(pipeline.state(), PipelineState::Revalidating);
|
||||
assert!(pipeline.selection().is_none());
|
||||
|
||||
pipeline.finish_revalidation(initial_selection).unwrap();
|
||||
assert_eq!(pipeline.state(), PipelineState::Active);
|
||||
assert!(pipeline.selection().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_revalidation_requires_revalidating_state() {
|
||||
let selection = EncoderSelection {
|
||||
adapter: adapter("gpu-0"),
|
||||
encoder_name: "vaapi-h264".into(),
|
||||
transfer_mode: TransferMode::SameAdapterZeroCopy,
|
||||
};
|
||||
let mut pipeline = VideoPipeline::active(
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
Resolution::new(1920, 1080).unwrap(),
|
||||
selection.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
pipeline.finish_revalidation(selection),
|
||||
Err(PipelineTransitionError::InvalidState {
|
||||
expected: PipelineState::Revalidating,
|
||||
actual: PipelineState::Active,
|
||||
})
|
||||
);
|
||||
assert_eq!(pipeline.state(), PipelineState::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_revalidation_rejects_non_zero_copy_selections() {
|
||||
let zero_copy = EncoderSelection {
|
||||
adapter: adapter("gpu-0"),
|
||||
encoder_name: "vaapi-h264".into(),
|
||||
transfer_mode: TransferMode::SameAdapterZeroCopy,
|
||||
};
|
||||
|
||||
for (mode, status) in [
|
||||
(
|
||||
TransferMode::CrossAdapterCopy,
|
||||
MemoryPathStatus::CrossAdapterCopy,
|
||||
),
|
||||
(TransferMode::Software, MemoryPathStatus::Software),
|
||||
(TransferMode::OpaqueGpuPath, MemoryPathStatus::Opaque),
|
||||
] {
|
||||
let mut pipeline = VideoPipeline::active(
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
Resolution::new(1920, 1080).unwrap(),
|
||||
zero_copy.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(pipeline.resize(Resolution::new(1280, 720).unwrap()));
|
||||
let rejected = EncoderSelection {
|
||||
adapter: adapter("gpu-1"),
|
||||
encoder_name: "fallback-h264".into(),
|
||||
transfer_mode: mode,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pipeline.finish_revalidation(rejected),
|
||||
Err(PipelineTransitionError::SelectionRejectedByPolicy {
|
||||
policy: ZeroCopyPolicy::RequiredEndToEnd,
|
||||
status,
|
||||
})
|
||||
);
|
||||
assert_eq!(pipeline.state(), PipelineState::Revalidating);
|
||||
assert!(pipeline.selection().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_revalidation_accepts_software_selection() {
|
||||
let zero_copy = EncoderSelection {
|
||||
adapter: adapter("gpu-0"),
|
||||
encoder_name: "vaapi-h264".into(),
|
||||
transfer_mode: TransferMode::SameAdapterZeroCopy,
|
||||
};
|
||||
let mut pipeline = VideoPipeline::active(
|
||||
ZeroCopyPolicy::Compatibility,
|
||||
Resolution::new(1920, 1080).unwrap(),
|
||||
zero_copy,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(pipeline.resize(Resolution::new(1280, 720).unwrap()));
|
||||
|
||||
pipeline
|
||||
.finish_revalidation(EncoderSelection {
|
||||
adapter: adapter("cpu"),
|
||||
encoder_name: "software-h264".into(),
|
||||
transfer_mode: TransferMode::Software,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pipeline.state(), PipelineState::Active);
|
||||
assert_eq!(
|
||||
pipeline.selection().unwrap().protocol_status(),
|
||||
MemoryPathStatus::Software
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
[package]
|
||||
name = "remotedesk-agent-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Runnable Linux controlled-endpoint services for RemoteDesk"
|
||||
|
||||
[features]
|
||||
default = ["gstreamer-h264", "wayland-eis"]
|
||||
gstreamer-h264 = ["dep:gstreamer", "dep:gstreamer-app"]
|
||||
wayland-eis = ["dep:xkbcommon"]
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "remotedesk-agentd"
|
||||
path = "src/bin/remotedesk-agentd.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "remotedesk-agent-session"
|
||||
path = "src/bin/remotedesk-agent-session.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "remotedesk-shell-session"
|
||||
path = "src/bin/remotedesk-shell-session.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "remotedesk-file-session"
|
||||
path = "src/bin/remotedesk-file-session.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22"
|
||||
bytes = "1.12"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
ed25519-dalek = "2.2"
|
||||
flate2 = "1.1"
|
||||
futures-util = "0.3"
|
||||
rand = "0.9"
|
||||
rcgen = "0.14"
|
||||
remotedesk-protocol = { path = "../../protocol" }
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["json", "rustls-tls"] }
|
||||
rtc = "0.20.1"
|
||||
rustls = { version = "0.23", default-features = false, features = ["logging", "ring", "std", "tls12"] }
|
||||
rustls-native-certs = "0.8"
|
||||
rustls-pemfile = "2.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1.47", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
|
||||
url = "2.5"
|
||||
webrtc = "0.20.1"
|
||||
zeroize = "1.9"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gstreamer = { version = "0.24.5", optional = true }
|
||||
gstreamer-app = { version = "0.24.5", optional = true }
|
||||
portable-pty = "0.9"
|
||||
reis = { version = "0.7.1", features = ["tokio"] }
|
||||
rustix = { version = "1.1", features = ["time"] }
|
||||
tokio-tungstenite = "0.29"
|
||||
x11rb = { version = "0.13.2", features = ["xtest"] }
|
||||
xkbcommon = { version = "0.9", default-features = false, features = ["wayland"], optional = true }
|
||||
zbus = { version = "5.12", default-features = false, features = ["tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
remotedesk-edge-service = { path = "../../edge/edge-service" }
|
||||
tiny_http = "0.12"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
eprintln!("remotedesk-file-session is only supported on Linux");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
if let Err(error) = linux::run() {
|
||||
eprintln!("file session failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{self, Read as _, Seek as _, Write as _};
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use remotedesk_agent_runtime::{FileResult, FileTransferReady, MAX_FILE_BYTES};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
pub fn run() -> Result<(), Error> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let operation = args.next().ok_or("file operation is required")?;
|
||||
let path = safe_home_path(&args.next().ok_or("relative path is required")?)?;
|
||||
match operation.as_str() {
|
||||
"upload" => {
|
||||
let size: u64 = args.next().ok_or("upload size is required")?.parse()?;
|
||||
let sha256 = args.next().ok_or("upload SHA-256 is required")?;
|
||||
let transfer_id = args.next().ok_or("upload transfer ID is required")?;
|
||||
upload(&path, size, &sha256, &transfer_id)
|
||||
}
|
||||
"download" => {
|
||||
let offset: u64 = args.next().ok_or("download offset is required")?.parse()?;
|
||||
download(&path, offset)
|
||||
}
|
||||
_ => Err("unknown file operation".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_home_path(relative: &str) -> Result<PathBuf, Error> {
|
||||
if relative.is_empty() || relative.len() > 1024 || Path::new(relative).is_absolute() {
|
||||
return Err("remote path must be a non-empty relative path".into());
|
||||
}
|
||||
if Path::new(relative)
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
return Err("remote path contains a prohibited component".into());
|
||||
}
|
||||
let home = fs::canonicalize(std::env::var_os("HOME").ok_or("HOME is unavailable")?)?;
|
||||
let candidate = home.join(relative);
|
||||
let parent = candidate.parent().ok_or("remote path has no parent")?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let canonical_parent = fs::canonicalize(parent)?;
|
||||
if !canonical_parent.starts_with(&home) {
|
||||
return Err("remote path escapes the user home directory".into());
|
||||
}
|
||||
Ok(canonical_parent.join(
|
||||
candidate
|
||||
.file_name()
|
||||
.ok_or("remote path has no file name")?,
|
||||
))
|
||||
}
|
||||
|
||||
fn upload(
|
||||
path: &Path,
|
||||
size: u64,
|
||||
expected_sha256: &str,
|
||||
transfer_id: &str,
|
||||
) -> Result<(), Error> {
|
||||
validate_transfer(size, expected_sha256)?;
|
||||
validate_transfer_id(transfer_id)?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.ok_or("upload path has no UTF-8 file name")?;
|
||||
let temporary = path.with_file_name(format!(".{file_name}.remotedesk-{transfer_id}.part"));
|
||||
if fs::symlink_metadata(&temporary).is_ok_and(|metadata| !metadata.file_type().is_file()) {
|
||||
return Err("upload partial path is not a regular file".into());
|
||||
}
|
||||
let mut output = OpenOptions::new()
|
||||
.read(true)
|
||||
.append(true)
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(0x20_000)
|
||||
.open(&temporary)?;
|
||||
let offset = output.metadata()?.len();
|
||||
if offset > size {
|
||||
return Err("upload partial file exceeds the declared size".into());
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
output.rewind()?;
|
||||
copy_hash(
|
||||
&mut io::Read::by_ref(&mut output).take(offset),
|
||||
&mut io::sink(),
|
||||
&mut hasher,
|
||||
)?;
|
||||
output.seek(io::SeekFrom::End(0))?;
|
||||
let mut stdout = io::stdout().lock();
|
||||
writeln!(
|
||||
stdout,
|
||||
"{}",
|
||||
serde_json::to_string(&FileTransferReady { offset })?
|
||||
)?;
|
||||
stdout.flush()?;
|
||||
let remaining = size - offset;
|
||||
let mut input = io::stdin().lock().take(remaining);
|
||||
let copied = copy_hash(&mut input, &mut output, &mut hasher)?;
|
||||
output.sync_all()?;
|
||||
if copied != remaining {
|
||||
return Err("upload ended before the declared size; partial file was preserved".into());
|
||||
}
|
||||
let actual = hex(&hasher.finalize());
|
||||
if actual != expected_sha256 {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err("uploaded file SHA-256 did not match".into());
|
||||
}
|
||||
fs::rename(&temporary, path)?;
|
||||
writeln!(
|
||||
stdout,
|
||||
"{}",
|
||||
serde_json::to_string(&FileResult {
|
||||
size,
|
||||
sha256: actual
|
||||
})?
|
||||
)?;
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download(path: &Path, offset: u64) -> Result<(), Error> {
|
||||
let canonical = fs::canonicalize(path)?;
|
||||
let home = fs::canonicalize(std::env::var_os("HOME").ok_or("HOME is unavailable")?)?;
|
||||
if !canonical.starts_with(home) || !canonical.is_file() {
|
||||
return Err("download path is outside the user home or is not a file".into());
|
||||
}
|
||||
let size = fs::metadata(&canonical)?.len();
|
||||
if size > MAX_FILE_BYTES {
|
||||
return Err("file exceeds the 2 GiB transfer limit".into());
|
||||
}
|
||||
if offset > size {
|
||||
return Err("download offset exceeds the file size".into());
|
||||
}
|
||||
let mut file = fs::File::open(canonical)?;
|
||||
let mut hasher = Sha256::new();
|
||||
copy_hash(&mut file, &mut io::sink(), &mut hasher)?;
|
||||
let sha256 = hex(&hasher.finalize());
|
||||
file.seek(io::SeekFrom::Start(offset))?;
|
||||
let mut stdout = io::stdout().lock();
|
||||
writeln!(
|
||||
stdout,
|
||||
"{}",
|
||||
serde_json::to_string(&FileResult { size, sha256 })?
|
||||
)?;
|
||||
io::copy(&mut file, &mut stdout)?;
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_transfer(size: u64, sha256: &str) -> Result<(), Error> {
|
||||
if size > MAX_FILE_BYTES {
|
||||
return Err("file exceeds the 2 GiB transfer limit".into());
|
||||
}
|
||||
if sha256.len() != 64
|
||||
|| !sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||
{
|
||||
return Err("file SHA-256 must be 64 lowercase hexadecimal digits".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_transfer_id(value: &str) -> Result<(), Error> {
|
||||
if value.len() != 32
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||
{
|
||||
return Err("transfer ID must be 32 lowercase hexadecimal digits".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_hash(
|
||||
input: &mut impl io::Read,
|
||||
output: &mut impl io::Write,
|
||||
hasher: &mut Sha256,
|
||||
) -> io::Result<u64> {
|
||||
let mut total = 0_u64;
|
||||
let mut buffer = vec![0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = input.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
output.write_all(&buffer[..read])?;
|
||||
hasher.update(&buffer[..read]);
|
||||
total += u64::try_from(read).unwrap_or(u64::MAX);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut output = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
write!(output, "{byte:02x}").expect("String writes cannot fail");
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
eprintln!("remotedesk-shell-session is only supported on Linux");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead as _, Read as _, Write as _},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{Arc, Mutex, mpsc},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use clap::Parser;
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
use remotedesk_agent_runtime::{ShellCommand, ShellEvent};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "remotedesk-shell-session",
|
||||
version,
|
||||
about = "PAM-backed RemoteDesk PTY helper"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
user: String,
|
||||
#[arg(long, default_value_t = 120, value_parser = clap::value_parser!(u16).range(20..=500))]
|
||||
cols: u16,
|
||||
#[arg(long, default_value_t = 40, value_parser = clap::value_parser!(u16).range(5..=200))]
|
||||
rows: u16,
|
||||
}
|
||||
|
||||
pub fn run() -> Result<(), Error> {
|
||||
let cli = Cli::parse();
|
||||
validate_user(&cli.user)?;
|
||||
let runuser = find_runuser()?;
|
||||
let pty = native_pty_system().openpty(PtySize {
|
||||
rows: cli.rows,
|
||||
cols: cli.cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})?;
|
||||
let mut command = CommandBuilder::new(runuser);
|
||||
command.arg("--login");
|
||||
command.arg(&cli.user);
|
||||
let mut child = pty.slave.spawn_command(command)?;
|
||||
drop(pty.slave);
|
||||
let pty_reader = pty.master.try_clone_reader()?;
|
||||
let mut pty_writer = pty.master.take_writer()?;
|
||||
let output = Arc::new(Mutex::new(std::io::stdout()));
|
||||
write_event(&output, &ShellEvent::Ready)?;
|
||||
let reader_thread = spawn_output_reader(pty_reader, Arc::clone(&output));
|
||||
let receiver = spawn_command_reader();
|
||||
|
||||
let mut requested_close = false;
|
||||
let exit_code = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break i32::try_from(status.exit_code()).ok();
|
||||
}
|
||||
match receiver.recv_timeout(Duration::from_millis(50)) {
|
||||
Ok(Ok(ShellCommand::Input { data })) => {
|
||||
let decoded = STANDARD_NO_PAD.decode(data)?;
|
||||
if decoded.len() > 64 * 1024 {
|
||||
return Err("terminal input exceeds 64 KiB".into());
|
||||
}
|
||||
pty_writer.write_all(&decoded)?;
|
||||
pty_writer.flush()?;
|
||||
}
|
||||
Ok(Ok(ShellCommand::Resize { cols, rows })) => {
|
||||
if !(20..=500).contains(&cols) || !(5..=200).contains(&rows) {
|
||||
return Err("terminal dimensions are outside the supported range".into());
|
||||
}
|
||||
pty.master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})?;
|
||||
}
|
||||
Ok(Ok(ShellCommand::Signal { signal })) if signal == "interrupt" => {
|
||||
pty_writer.write_all(&[3])?;
|
||||
pty_writer.flush()?;
|
||||
}
|
||||
Ok(Ok(ShellCommand::Signal { signal })) if signal == "terminate" => {
|
||||
child.kill()?;
|
||||
requested_close = true;
|
||||
}
|
||||
Ok(Ok(ShellCommand::Signal { signal })) => {
|
||||
write_event(
|
||||
&output,
|
||||
&ShellEvent::Error {
|
||||
message: format!("unsupported signal: {signal}"),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(Err(error)) => write_event(&output, &ShellEvent::Error { message: error })?,
|
||||
Ok(Ok(ShellCommand::Close)) | Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
child.kill()?;
|
||||
requested_close = true;
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if requested_close {
|
||||
let status = child.wait()?;
|
||||
break i32::try_from(status.exit_code()).ok();
|
||||
}
|
||||
};
|
||||
drop(pty_writer);
|
||||
let _ = reader_thread.join();
|
||||
write_event(&output, &ShellEvent::Exited { exit_code })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_output_reader(
|
||||
mut reader: Box<dyn std::io::Read + Send>,
|
||||
output: Arc<Mutex<std::io::Stdout>>,
|
||||
) -> thread::JoinHandle<()> {
|
||||
thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 16 * 1024];
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(read) => {
|
||||
let event = ShellEvent::Output {
|
||||
data: STANDARD_NO_PAD.encode(&buffer[..read]),
|
||||
};
|
||||
if write_event(&output, &event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_command_reader() -> mpsc::Receiver<Result<ShellCommand, String>> {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let parsed = line.map_err(|error| error.to_string()).and_then(|line| {
|
||||
serde_json::from_str::<ShellCommand>(&line).map_err(|error| error.to_string())
|
||||
});
|
||||
if sender.send(parsed).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
receiver
|
||||
}
|
||||
|
||||
fn validate_user(user: &str) -> Result<(), Error> {
|
||||
if user.is_empty()
|
||||
|| user.len() > 32
|
||||
|| !user
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err("target user is invalid or prohibited".into());
|
||||
}
|
||||
let output = Command::new("id").args(["-u", user]).output()?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("Linux user does not exist: {user}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_runuser() -> Result<PathBuf, Error> {
|
||||
["/usr/sbin/runuser", "/usr/bin/runuser"]
|
||||
.into_iter()
|
||||
.map(Path::new)
|
||||
.find(|path| fs::metadata(path).is_ok_and(|metadata| metadata.is_file()))
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| "runuser from util-linux is required for PAM session setup".into())
|
||||
}
|
||||
|
||||
fn write_event(output: &Arc<Mutex<std::io::Stdout>>, event: &ShellEvent) -> Result<(), Error> {
|
||||
let mut output = output
|
||||
.lock()
|
||||
.map_err(|_| "terminal output lock was poisoned")?;
|
||||
serde_json::to_writer(&mut *output, event)?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
if let Err(error) = linux::run() {
|
||||
eprintln!("remotedesk-shell-session: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use std::fmt;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::DESKTOP_MAX_CLIPBOARD_BYTES;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PreparedClipboardText {
|
||||
pub sequence: u64,
|
||||
pub utf8_bytes: u32,
|
||||
pub sha256: String,
|
||||
pub data: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ClipboardTextError {
|
||||
InvalidSequence,
|
||||
TooLarge,
|
||||
ContainsNul,
|
||||
InvalidDigest,
|
||||
InvalidEncoding,
|
||||
LengthMismatch,
|
||||
}
|
||||
|
||||
impl fmt::Display for ClipboardTextError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::InvalidSequence => "clipboard sequence must be nonzero",
|
||||
Self::TooLarge => "clipboard text exceeds the size limit",
|
||||
Self::ContainsNul => "clipboard text contains NUL",
|
||||
Self::InvalidDigest => "clipboard digest is invalid",
|
||||
Self::InvalidEncoding => "clipboard data is not canonical UTF-8 Base64",
|
||||
Self::LengthMismatch => "clipboard data length does not match its offer",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClipboardTextError {}
|
||||
|
||||
pub fn prepare_clipboard_text(
|
||||
sequence: u64,
|
||||
text: &str,
|
||||
) -> Result<PreparedClipboardText, ClipboardTextError> {
|
||||
if sequence == 0 {
|
||||
return Err(ClipboardTextError::InvalidSequence);
|
||||
}
|
||||
let text = normalize_clipboard_text(text)?;
|
||||
let bytes = text.as_bytes();
|
||||
if bytes.len() > DESKTOP_MAX_CLIPBOARD_BYTES {
|
||||
return Err(ClipboardTextError::TooLarge);
|
||||
}
|
||||
Ok(PreparedClipboardText {
|
||||
sequence,
|
||||
utf8_bytes: bytes
|
||||
.len()
|
||||
.try_into()
|
||||
.map_err(|_| ClipboardTextError::TooLarge)?,
|
||||
sha256: digest_hex(bytes),
|
||||
data: STANDARD_NO_PAD.encode(bytes),
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_clipboard_offer(
|
||||
sequence: u64,
|
||||
utf8_bytes: u32,
|
||||
sha256: &str,
|
||||
) -> Result<(), ClipboardTextError> {
|
||||
if sequence == 0 {
|
||||
return Err(ClipboardTextError::InvalidSequence);
|
||||
}
|
||||
if usize::try_from(utf8_bytes).map_or(true, |size| size > DESKTOP_MAX_CLIPBOARD_BYTES) {
|
||||
return Err(ClipboardTextError::TooLarge);
|
||||
}
|
||||
if !valid_digest(sha256) {
|
||||
return Err(ClipboardTextError::InvalidDigest);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn decode_clipboard_text(
|
||||
data: &str,
|
||||
utf8_bytes: u32,
|
||||
sha256: &str,
|
||||
) -> Result<String, ClipboardTextError> {
|
||||
validate_clipboard_offer(1, utf8_bytes, sha256)?;
|
||||
if data.len() > DESKTOP_MAX_CLIPBOARD_BYTES.div_ceil(3) * 4 {
|
||||
return Err(ClipboardTextError::TooLarge);
|
||||
}
|
||||
let decoded = STANDARD_NO_PAD
|
||||
.decode(data)
|
||||
.map_err(|_| ClipboardTextError::InvalidEncoding)?;
|
||||
if STANDARD_NO_PAD.encode(&decoded) != data {
|
||||
return Err(ClipboardTextError::InvalidEncoding);
|
||||
}
|
||||
let expected_bytes = usize::try_from(utf8_bytes).map_err(|_| ClipboardTextError::TooLarge)?;
|
||||
if decoded.len() != expected_bytes {
|
||||
return Err(ClipboardTextError::LengthMismatch);
|
||||
}
|
||||
if digest_hex(&decoded) != sha256 {
|
||||
return Err(ClipboardTextError::InvalidDigest);
|
||||
}
|
||||
let text = String::from_utf8(decoded).map_err(|_| ClipboardTextError::InvalidEncoding)?;
|
||||
if text.contains('\0') || normalize_clipboard_text(&text)? != text {
|
||||
return Err(ClipboardTextError::InvalidEncoding);
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub fn normalize_clipboard_text(text: &str) -> Result<String, ClipboardTextError> {
|
||||
if text.contains('\0') {
|
||||
return Err(ClipboardTextError::ContainsNul);
|
||||
}
|
||||
Ok(text.replace("\r\n", "\n").replace('\r', "\n"))
|
||||
}
|
||||
|
||||
fn valid_digest(value: &str) -> bool {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
|
||||
}
|
||||
|
||||
fn digest_hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.fold(String::with_capacity(64), |mut output, byte| {
|
||||
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clipboard_text_is_normalized_bounded_and_round_trips() {
|
||||
let prepared = prepare_clipboard_text(1, "hello\r\n世界").unwrap();
|
||||
assert_eq!(prepared.text, "hello\n世界");
|
||||
assert_eq!(
|
||||
decode_clipboard_text(&prepared.data, prepared.utf8_bytes, &prepared.sha256).unwrap(),
|
||||
prepared.text
|
||||
);
|
||||
assert!(prepare_clipboard_text(0, "hello").is_err());
|
||||
assert!(prepare_clipboard_text(1, "bad\0text").is_err());
|
||||
assert!(prepare_clipboard_text(1, &"x".repeat(DESKTOP_MAX_CLIPBOARD_BYTES + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_data_rejects_noncanonical_or_changed_content() {
|
||||
let prepared = prepare_clipboard_text(9, "hello").unwrap();
|
||||
assert!(decode_clipboard_text("aGVsbG8=", 5, &prepared.sha256).is_err());
|
||||
assert!(decode_clipboard_text(&prepared.data, 4, &prepared.sha256).is_err());
|
||||
assert!(decode_clipboard_text(&prepared.data, 5, &"00".repeat(32)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::{
|
||||
DESKTOP_FRAME_CHUNK_BYTES, DESKTOP_MAX_COMPRESSED_BYTES, DESKTOP_MAX_FRAME_BYTES,
|
||||
DESKTOP_MAX_PIXELS, DesktopFrameEncoding, DesktopFrameMetadata, DesktopInputEvent,
|
||||
MAX_RELATIVE_POINTER_DELTA,
|
||||
};
|
||||
|
||||
const MAX_DESKTOP_DIMENSION: u16 = 8_192;
|
||||
const MAX_DESKTOP_CHUNKS: usize = DESKTOP_MAX_COMPRESSED_BYTES.div_ceil(DESKTOP_FRAME_CHUNK_BYTES);
|
||||
const SLOW_ACK_STREAK: u8 = 2;
|
||||
const FAST_ACK_STREAK: u8 = 30;
|
||||
const MAX_SLOW_ACK: Duration = Duration::from_millis(250);
|
||||
const MIN_COMPRESSION_LEVEL: u8 = 1;
|
||||
const MAX_COMPRESSION_LEVEL: u8 = 6;
|
||||
const COMPRESSION_SLOW_ACK_STREAK: u8 = 2;
|
||||
const COMPRESSION_ENCODE_OVERRUN_STREAK: u8 = 2;
|
||||
const COMPRESSION_HEALTHY_ACK_STREAK: u8 = 60;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DesktopFramePacer {
|
||||
ceiling_fps: u8,
|
||||
current_fps: u8,
|
||||
slow_streak: u8,
|
||||
fast_streak: u8,
|
||||
}
|
||||
|
||||
impl DesktopFramePacer {
|
||||
pub fn new(ceiling_fps: u8) -> Option<Self> {
|
||||
(1..=30).contains(&ceiling_fps).then_some(Self {
|
||||
ceiling_fps,
|
||||
current_fps: ceiling_fps,
|
||||
slow_streak: 0,
|
||||
fast_streak: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current_fps(&self) -> u8 {
|
||||
self.current_fps
|
||||
}
|
||||
|
||||
pub fn frame_interval(&self) -> Duration {
|
||||
Duration::from_micros(1_000_000 / u64::from(self.current_fps))
|
||||
}
|
||||
|
||||
pub fn observe_presentation_ack(&mut self, elapsed: Duration) -> bool {
|
||||
let interval_micros = self.frame_interval().as_micros();
|
||||
let slow =
|
||||
elapsed > MAX_SLOW_ACK || elapsed.as_micros() > interval_micros.saturating_mul(2);
|
||||
if slow {
|
||||
self.slow_streak = self.slow_streak.saturating_add(1);
|
||||
self.fast_streak = 0;
|
||||
if self.slow_streak < SLOW_ACK_STREAK || self.current_fps == 1 {
|
||||
return false;
|
||||
}
|
||||
let reduced = self.current_fps.saturating_mul(3) / 4;
|
||||
self.current_fps = reduced.max(1).min(self.current_fps.saturating_sub(1));
|
||||
self.slow_streak = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
self.slow_streak = 0;
|
||||
if elapsed.as_micros() <= interval_micros && self.current_fps < self.ceiling_fps {
|
||||
self.fast_streak = self.fast_streak.saturating_add(1);
|
||||
if self.fast_streak >= FAST_ACK_STREAK {
|
||||
self.current_fps = self.current_fps.saturating_add(1).min(self.ceiling_fps);
|
||||
self.fast_streak = 0;
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
self.fast_streak = 0;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DesktopCompressionController {
|
||||
current_level: u8,
|
||||
slow_ack_streak: u8,
|
||||
healthy_ack_streak: u8,
|
||||
encode_overrun_streak: u8,
|
||||
encode_budget_available: bool,
|
||||
}
|
||||
|
||||
impl Default for DesktopCompressionController {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_level: MIN_COMPRESSION_LEVEL,
|
||||
slow_ack_streak: 0,
|
||||
healthy_ack_streak: 0,
|
||||
encode_overrun_streak: 0,
|
||||
encode_budget_available: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopCompressionController {
|
||||
pub fn current_level(&self) -> u8 {
|
||||
self.current_level
|
||||
}
|
||||
|
||||
pub fn observe_encode(&mut self, elapsed: Duration, frame_interval: Duration) -> bool {
|
||||
let elapsed_micros = elapsed.as_micros();
|
||||
let interval_micros = frame_interval.as_micros();
|
||||
self.encode_budget_available = elapsed_micros.saturating_mul(3) <= interval_micros;
|
||||
let overrun = elapsed_micros.saturating_mul(2) > interval_micros;
|
||||
if !overrun {
|
||||
self.encode_overrun_streak = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
self.encode_overrun_streak = self.encode_overrun_streak.saturating_add(1);
|
||||
self.slow_ack_streak = 0;
|
||||
self.healthy_ack_streak = 0;
|
||||
if self.encode_overrun_streak < COMPRESSION_ENCODE_OVERRUN_STREAK
|
||||
|| self.current_level == MIN_COMPRESSION_LEVEL
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.current_level = self.current_level.saturating_sub(1);
|
||||
self.encode_overrun_streak = 0;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn observe_presentation_ack(
|
||||
&mut self,
|
||||
elapsed: Duration,
|
||||
frame_interval: Duration,
|
||||
) -> bool {
|
||||
let interval_micros = frame_interval.as_micros();
|
||||
let slow =
|
||||
elapsed > MAX_SLOW_ACK || elapsed.as_micros() > interval_micros.saturating_mul(2);
|
||||
if slow {
|
||||
self.healthy_ack_streak = 0;
|
||||
if !self.encode_budget_available || self.current_level == MAX_COMPRESSION_LEVEL {
|
||||
self.slow_ack_streak = 0;
|
||||
return false;
|
||||
}
|
||||
self.slow_ack_streak = self.slow_ack_streak.saturating_add(1);
|
||||
if self.slow_ack_streak < COMPRESSION_SLOW_ACK_STREAK {
|
||||
return false;
|
||||
}
|
||||
self.current_level = self
|
||||
.current_level
|
||||
.saturating_add(1)
|
||||
.min(MAX_COMPRESSION_LEVEL);
|
||||
self.slow_ack_streak = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
self.slow_ack_streak = 0;
|
||||
if self.current_level == MIN_COMPRESSION_LEVEL {
|
||||
self.healthy_ack_streak = 0;
|
||||
return false;
|
||||
}
|
||||
if elapsed.as_micros() > interval_micros {
|
||||
self.healthy_ack_streak = 0;
|
||||
return false;
|
||||
}
|
||||
self.healthy_ack_streak = self.healthy_ack_streak.saturating_add(1);
|
||||
if self.healthy_ack_streak < COMPRESSION_HEALTHY_ACK_STREAK {
|
||||
return false;
|
||||
}
|
||||
self.current_level = self.current_level.saturating_sub(1);
|
||||
self.healthy_ack_streak = 0;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum DesktopFrameBuildError {
|
||||
InvalidDimensions,
|
||||
InvalidFrameLength,
|
||||
FrameTooLarge,
|
||||
InvalidCompressionLevel,
|
||||
CompressionFailed,
|
||||
InvalidMetadata,
|
||||
UnexpectedChunk,
|
||||
CompressedFrameTooLarge,
|
||||
HashMismatch,
|
||||
DecompressionFailed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DesktopFrameBuildError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::InvalidDimensions => "desktop frame dimensions are invalid",
|
||||
Self::InvalidFrameLength => "desktop frame byte length is invalid",
|
||||
Self::FrameTooLarge => "desktop frame exceeds the pixel limit",
|
||||
Self::InvalidCompressionLevel => "desktop compression level is invalid",
|
||||
Self::CompressionFailed => "desktop frame compression failed",
|
||||
Self::InvalidMetadata => "desktop frame metadata is invalid",
|
||||
Self::UnexpectedChunk => "desktop frame chunk is missing, duplicated, or out of order",
|
||||
Self::CompressedFrameTooLarge => "compressed desktop frame exceeds the limit",
|
||||
Self::HashMismatch => "desktop frame hash does not match",
|
||||
Self::DecompressionFailed => "desktop frame decompression failed",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DesktopFrameBuildError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EncodedDesktopFrame {
|
||||
pub metadata: DesktopFrameMetadata,
|
||||
pub chunks: Vec<Vec<u8>>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
pub fn encode_desktop_frame(
|
||||
sequence: u64,
|
||||
width: u16,
|
||||
height: u16,
|
||||
captured_at_unix_ms: u64,
|
||||
capture_latency_us: u64,
|
||||
bgra: &[u8],
|
||||
) -> Result<EncodedDesktopFrame, DesktopFrameBuildError> {
|
||||
encode_desktop_frame_with_level(
|
||||
sequence,
|
||||
width,
|
||||
height,
|
||||
captured_at_unix_ms,
|
||||
capture_latency_us,
|
||||
MIN_COMPRESSION_LEVEL,
|
||||
bgra,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn encode_desktop_frame_with_level(
|
||||
sequence: u64,
|
||||
width: u16,
|
||||
height: u16,
|
||||
captured_at_unix_ms: u64,
|
||||
capture_latency_us: u64,
|
||||
compression_level: u8,
|
||||
bgra: &[u8],
|
||||
) -> Result<EncodedDesktopFrame, DesktopFrameBuildError> {
|
||||
if !(MIN_COMPRESSION_LEVEL..=MAX_COMPRESSION_LEVEL).contains(&compression_level) {
|
||||
return Err(DesktopFrameBuildError::InvalidCompressionLevel);
|
||||
}
|
||||
let expected = checked_frame_len(width, height)?;
|
||||
if bgra.len() != expected {
|
||||
return Err(DesktopFrameBuildError::InvalidFrameLength);
|
||||
}
|
||||
let encode_started = Instant::now();
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::new(u32::from(compression_level)));
|
||||
encoder
|
||||
.write_all(bgra)
|
||||
.map_err(|_| DesktopFrameBuildError::CompressionFailed)?;
|
||||
let compressed = encoder
|
||||
.finish()
|
||||
.map_err(|_| DesktopFrameBuildError::CompressionFailed)?;
|
||||
let encode_latency_us = encode_started
|
||||
.elapsed()
|
||||
.as_micros()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX);
|
||||
if compressed.len() > DESKTOP_MAX_COMPRESSED_BYTES {
|
||||
return Err(DesktopFrameBuildError::CompressedFrameTooLarge);
|
||||
}
|
||||
let chunks = compressed
|
||||
.chunks(DESKTOP_FRAME_CHUNK_BYTES)
|
||||
.map(<[u8]>::to_vec)
|
||||
.collect::<Vec<_>>();
|
||||
let chunk_count =
|
||||
u16::try_from(chunks.len()).map_err(|_| DesktopFrameBuildError::CompressedFrameTooLarge)?;
|
||||
let metadata = DesktopFrameMetadata {
|
||||
sequence,
|
||||
width,
|
||||
height,
|
||||
encoding: DesktopFrameEncoding::ZlibBgra,
|
||||
uncompressed_bytes: expected,
|
||||
compressed_bytes: compressed.len(),
|
||||
chunk_count,
|
||||
captured_at_unix_ms,
|
||||
capture_latency_us,
|
||||
encode_latency_us,
|
||||
compression_level,
|
||||
};
|
||||
validate_metadata(&metadata)?;
|
||||
Ok(EncodedDesktopFrame {
|
||||
metadata,
|
||||
chunks,
|
||||
sha256: hex_sha256(bgra),
|
||||
})
|
||||
}
|
||||
|
||||
pub struct DesktopFrameAssembler {
|
||||
metadata: DesktopFrameMetadata,
|
||||
compressed: Vec<u8>,
|
||||
next_index: u16,
|
||||
}
|
||||
|
||||
impl DesktopFrameAssembler {
|
||||
pub fn new(metadata: DesktopFrameMetadata) -> Result<Self, DesktopFrameBuildError> {
|
||||
validate_metadata(&metadata)?;
|
||||
Ok(Self {
|
||||
compressed: Vec::with_capacity(metadata.compressed_bytes),
|
||||
metadata,
|
||||
next_index: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sequence(&self) -> u64 {
|
||||
self.metadata.sequence
|
||||
}
|
||||
|
||||
pub fn push(&mut self, index: u16, data: &[u8]) -> Result<(), DesktopFrameBuildError> {
|
||||
if index != self.next_index
|
||||
|| index >= self.metadata.chunk_count
|
||||
|| data.is_empty()
|
||||
|| data.len() > DESKTOP_FRAME_CHUNK_BYTES
|
||||
|| self.compressed.len().saturating_add(data.len()) > self.metadata.compressed_bytes
|
||||
{
|
||||
return Err(DesktopFrameBuildError::UnexpectedChunk);
|
||||
}
|
||||
self.compressed.extend_from_slice(data);
|
||||
self.next_index = self.next_index.saturating_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(self, expected_sha256: &str) -> Result<Vec<u8>, DesktopFrameBuildError> {
|
||||
if self.next_index != self.metadata.chunk_count
|
||||
|| self.compressed.len() != self.metadata.compressed_bytes
|
||||
|| expected_sha256.len() != 64
|
||||
|| !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(DesktopFrameBuildError::UnexpectedChunk);
|
||||
}
|
||||
let mut decoded = Vec::with_capacity(self.metadata.uncompressed_bytes);
|
||||
let limit = u64::try_from(self.metadata.uncompressed_bytes)
|
||||
.unwrap_or(u64::MAX)
|
||||
.saturating_add(1);
|
||||
ZlibDecoder::new(self.compressed.as_slice())
|
||||
.take(limit)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|_| DesktopFrameBuildError::DecompressionFailed)?;
|
||||
if decoded.len() != self.metadata.uncompressed_bytes {
|
||||
return Err(DesktopFrameBuildError::DecompressionFailed);
|
||||
}
|
||||
if !hex_sha256(&decoded).eq_ignore_ascii_case(expected_sha256) {
|
||||
return Err(DesktopFrameBuildError::HashMismatch);
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_desktop_input(
|
||||
event: &DesktopInputEvent,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> Result<(), DesktopFrameBuildError> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(DesktopFrameBuildError::InvalidDimensions);
|
||||
}
|
||||
if let DesktopInputEvent::PointerMove { x, y } = event
|
||||
&& (*x >= width || *y >= height)
|
||||
{
|
||||
return Err(DesktopFrameBuildError::InvalidDimensions);
|
||||
}
|
||||
if let DesktopInputEvent::PointerDelta { delta_x, delta_y } = event
|
||||
&& ((*delta_x == 0 && *delta_y == 0)
|
||||
|| delta_x.unsigned_abs() > MAX_RELATIVE_POINTER_DELTA
|
||||
|| delta_y.unsigned_abs() > MAX_RELATIVE_POINTER_DELTA)
|
||||
{
|
||||
return Err(DesktopFrameBuildError::InvalidDimensions);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_metadata(metadata: &DesktopFrameMetadata) -> Result<(), DesktopFrameBuildError> {
|
||||
let expected = checked_frame_len(metadata.width, metadata.height)?;
|
||||
let chunks = usize::from(metadata.chunk_count);
|
||||
if metadata.encoding != DesktopFrameEncoding::ZlibBgra
|
||||
|| metadata.uncompressed_bytes != expected
|
||||
|| !(MIN_COMPRESSION_LEVEL..=MAX_COMPRESSION_LEVEL).contains(&metadata.compression_level)
|
||||
|| metadata.compressed_bytes == 0
|
||||
|| metadata.compressed_bytes > DESKTOP_MAX_COMPRESSED_BYTES
|
||||
|| chunks == 0
|
||||
|| chunks > MAX_DESKTOP_CHUNKS
|
||||
|| chunks
|
||||
!= metadata
|
||||
.compressed_bytes
|
||||
.div_ceil(DESKTOP_FRAME_CHUNK_BYTES)
|
||||
{
|
||||
return Err(DesktopFrameBuildError::InvalidMetadata);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checked_frame_len(width: u16, height: u16) -> Result<usize, DesktopFrameBuildError> {
|
||||
if width == 0 || height == 0 || width > MAX_DESKTOP_DIMENSION || height > MAX_DESKTOP_DIMENSION
|
||||
{
|
||||
return Err(DesktopFrameBuildError::InvalidDimensions);
|
||||
}
|
||||
let pixels = usize::from(width)
|
||||
.checked_mul(usize::from(height))
|
||||
.ok_or(DesktopFrameBuildError::FrameTooLarge)?;
|
||||
if pixels > DESKTOP_MAX_PIXELS {
|
||||
return Err(DesktopFrameBuildError::FrameTooLarge);
|
||||
}
|
||||
pixels
|
||||
.checked_mul(4)
|
||||
.filter(|bytes| *bytes <= DESKTOP_MAX_FRAME_BYTES)
|
||||
.ok_or(DesktopFrameBuildError::FrameTooLarge)
|
||||
}
|
||||
|
||||
fn hex_sha256(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_pacer_reacts_to_presentation_backpressure_with_bounded_recovery() {
|
||||
assert!(DesktopFramePacer::new(0).is_none());
|
||||
assert!(DesktopFramePacer::new(31).is_none());
|
||||
let mut pacer = DesktopFramePacer::new(30).unwrap();
|
||||
assert_eq!(pacer.current_fps(), 30);
|
||||
assert!(!pacer.observe_presentation_ack(Duration::from_millis(100)));
|
||||
assert!(pacer.observe_presentation_ack(Duration::from_millis(100)));
|
||||
assert_eq!(pacer.current_fps(), 22);
|
||||
|
||||
for _ in 0..FAST_ACK_STREAK - 1 {
|
||||
assert!(!pacer.observe_presentation_ack(Duration::from_millis(1)));
|
||||
}
|
||||
assert!(pacer.observe_presentation_ack(Duration::from_millis(1)));
|
||||
assert_eq!(pacer.current_fps(), 23);
|
||||
assert!(pacer.current_fps() <= 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_pacer_requires_consecutive_samples_and_never_drops_below_one() {
|
||||
let mut pacer = DesktopFramePacer::new(2).unwrap();
|
||||
assert!(!pacer.observe_presentation_ack(Duration::from_secs(1)));
|
||||
assert!(!pacer.observe_presentation_ack(Duration::from_millis(1)));
|
||||
assert!(!pacer.observe_presentation_ack(Duration::from_secs(1)));
|
||||
assert!(pacer.observe_presentation_ack(Duration::from_secs(1)));
|
||||
assert_eq!(pacer.current_fps(), 1);
|
||||
for _ in 0..10 {
|
||||
assert!(!pacer.observe_presentation_ack(Duration::from_secs(2)));
|
||||
}
|
||||
assert_eq!(pacer.current_fps(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_controller_balances_network_pressure_and_encode_budget() {
|
||||
let mut controller = DesktopCompressionController::default();
|
||||
let interval = Duration::from_millis(100);
|
||||
assert_eq!(controller.current_level(), 1);
|
||||
assert!(!controller.observe_encode(Duration::from_millis(10), interval));
|
||||
assert!(!controller.observe_presentation_ack(Duration::from_millis(300), interval));
|
||||
assert!(controller.observe_presentation_ack(Duration::from_millis(300), interval));
|
||||
assert_eq!(controller.current_level(), 2);
|
||||
|
||||
assert!(!controller.observe_encode(Duration::from_millis(60), interval));
|
||||
assert!(controller.observe_encode(Duration::from_millis(60), interval));
|
||||
assert_eq!(controller.current_level(), 1);
|
||||
assert!(!controller.observe_presentation_ack(Duration::from_millis(300), interval));
|
||||
assert!(!controller.observe_presentation_ack(Duration::from_millis(300), interval));
|
||||
assert_eq!(controller.current_level(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_controller_recovers_to_low_latency_after_stable_acks() {
|
||||
let mut controller = DesktopCompressionController::default();
|
||||
let interval = Duration::from_millis(100);
|
||||
assert!(!controller.observe_encode(Duration::from_millis(10), interval));
|
||||
assert!(!controller.observe_presentation_ack(Duration::from_millis(300), interval));
|
||||
assert!(controller.observe_presentation_ack(Duration::from_millis(300), interval));
|
||||
for _ in 0..COMPRESSION_HEALTHY_ACK_STREAK - 1 {
|
||||
assert!(!controller.observe_presentation_ack(Duration::from_millis(10), interval));
|
||||
}
|
||||
assert!(controller.observe_presentation_ack(Duration::from_millis(10), interval));
|
||||
assert_eq!(controller.current_level(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_round_trip_is_bounded_and_hash_verified() {
|
||||
let pixels = [0_u8, 1, 2, 255].repeat(320 * 200);
|
||||
let encoded = encode_desktop_frame(7, 320, 200, 10, 20, &pixels).unwrap();
|
||||
let mut assembler = DesktopFrameAssembler::new(encoded.metadata.clone()).unwrap();
|
||||
for (index, chunk) in encoded.chunks.iter().enumerate() {
|
||||
assembler
|
||||
.push(u16::try_from(index).unwrap(), chunk)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(assembler.finish(&encoded.sha256).unwrap(), pixels);
|
||||
assert_eq!(encoded.metadata.compression_level, 1);
|
||||
let denser = encode_desktop_frame_with_level(8, 320, 200, 10, 20, 6, &pixels).unwrap();
|
||||
assert_eq!(denser.metadata.compression_level, 6);
|
||||
let mut denser_assembler = DesktopFrameAssembler::new(denser.metadata).unwrap();
|
||||
for (index, chunk) in denser.chunks.iter().enumerate() {
|
||||
denser_assembler
|
||||
.push(u16::try_from(index).unwrap(), chunk)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(denser_assembler.finish(&denser.sha256).unwrap(), pixels);
|
||||
assert_eq!(
|
||||
encode_desktop_frame_with_level(8, 320, 200, 10, 20, 0, &pixels).unwrap_err(),
|
||||
DesktopFrameBuildError::InvalidCompressionLevel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembler_rejects_reordering_truncation_hashes_and_zip_bombs() {
|
||||
let pixels: Vec<u8> = (0..(200 * 200 * 4))
|
||||
.map(|index| {
|
||||
let mut value = index as u32 + 0x9e37_79b9;
|
||||
value ^= value << 13;
|
||||
value ^= value >> 17;
|
||||
value ^= value << 5;
|
||||
(value >> 24) as u8
|
||||
})
|
||||
.collect();
|
||||
let encoded = encode_desktop_frame(9, 200, 200, 10, 20, &pixels).unwrap();
|
||||
let mut reordered = DesktopFrameAssembler::new(encoded.metadata.clone()).unwrap();
|
||||
assert_eq!(
|
||||
reordered.push(1, &encoded.chunks[0]),
|
||||
Err(DesktopFrameBuildError::UnexpectedChunk)
|
||||
);
|
||||
let mut truncated = DesktopFrameAssembler::new(encoded.metadata.clone()).unwrap();
|
||||
let first_chunk = &encoded.chunks[0];
|
||||
truncated
|
||||
.push(0, &first_chunk[..first_chunk.len().saturating_sub(1)])
|
||||
.unwrap();
|
||||
assert!(truncated.finish(&encoded.sha256).is_err());
|
||||
|
||||
let mut complete = DesktopFrameAssembler::new(encoded.metadata).unwrap();
|
||||
for (index, chunk) in encoded.chunks.iter().enumerate() {
|
||||
complete.push(u16::try_from(index).unwrap(), chunk).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
complete.finish(&"00".repeat(32)),
|
||||
Err(DesktopFrameBuildError::HashMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_input_must_remain_inside_negotiated_frame() {
|
||||
assert!(
|
||||
validate_desktop_input(
|
||||
&DesktopInputEvent::PointerMove { x: 1919, y: 1079 },
|
||||
1920,
|
||||
1080
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
validate_desktop_input(
|
||||
&DesktopInputEvent::PointerMove { x: 1920, y: 0 },
|
||||
1920,
|
||||
1080
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_pointer_input_is_nonzero_and_bounded() {
|
||||
assert!(
|
||||
validate_desktop_input(
|
||||
&DesktopInputEvent::PointerDelta {
|
||||
delta_x: -4_096,
|
||||
delta_y: 4_096,
|
||||
},
|
||||
1920,
|
||||
1080,
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
validate_desktop_input(
|
||||
&DesktopInputEvent::PointerDelta {
|
||||
delta_x: 0,
|
||||
delta_y: 0,
|
||||
},
|
||||
1920,
|
||||
1080,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_desktop_input(
|
||||
&DesktopInputEvent::PointerDelta {
|
||||
delta_x: 4_097,
|
||||
delta_y: 0,
|
||||
},
|
||||
1920,
|
||||
1080,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use ed25519_dalek::{Signer as _, SigningKey};
|
||||
use rand::RngCore as _;
|
||||
use remotedesk_protocol::{
|
||||
EdgeNegotiationEndpoint, EdgeNegotiationEnvelopeV1, EdgeNegotiationKind,
|
||||
EdgeNegotiationOperation, SIGNAL_NONCE_LENGTH,
|
||||
};
|
||||
use reqwest::{Client, StatusCode, redirect::Policy};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::error::Error as _;
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
use url::{Host, Url};
|
||||
|
||||
const MAX_RESPONSE_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EdgeNegotiationSession {
|
||||
pub request_id: String,
|
||||
pub session_id: String,
|
||||
pub expires_unix: u64,
|
||||
}
|
||||
|
||||
pub struct EdgeNegotiationClient {
|
||||
http: Client,
|
||||
send_url: Url,
|
||||
poll_url: Url,
|
||||
endpoint: EdgeNegotiationEndpoint,
|
||||
signing: SigningKey,
|
||||
session: EdgeNegotiationSession,
|
||||
send_sequence: u32,
|
||||
peer_sequence: u32,
|
||||
generation: u32,
|
||||
}
|
||||
|
||||
impl fmt::Debug for EdgeNegotiationClient {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("EdgeNegotiationClient")
|
||||
.field("http", &"[configured]")
|
||||
.field("api_origin", &self.send_url.origin().ascii_serialization())
|
||||
.field("send_url", &self.send_url.path())
|
||||
.field("poll_url", &self.poll_url.path())
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("signing", &"[REDACTED]")
|
||||
.field("request_id", &self.session.request_id)
|
||||
.field("session_id", &self.session.session_id)
|
||||
.field("send_sequence", &self.send_sequence)
|
||||
.field("peer_sequence", &self.peer_sequence)
|
||||
.field("generation", &self.generation)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl EdgeNegotiationClient {
|
||||
/// Creates one stateful endpoint client for an accepted Edge session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for an unsafe URL, malformed session binding or HTTP client failure.
|
||||
pub fn new(
|
||||
api_url: &str,
|
||||
endpoint: EdgeNegotiationEndpoint,
|
||||
signing: SigningKey,
|
||||
session: EdgeNegotiationSession,
|
||||
) -> Result<Self, EdgeNegotiationError> {
|
||||
let api_url = Url::parse(api_url).map_err(|_| EdgeNegotiationError::InvalidUrl)?;
|
||||
validate_url(&api_url)?;
|
||||
validate_session(&session)?;
|
||||
let http = Client::builder()
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(Policy::none())
|
||||
.user_agent(concat!(
|
||||
"RemoteDesk-Edge-Negotiation/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.build()
|
||||
.map_err(|_| EdgeNegotiationError::ClientInitialization)?;
|
||||
let send_url = api_url
|
||||
.join("v1/negotiation/send")
|
||||
.map_err(|_| EdgeNegotiationError::InvalidUrl)?;
|
||||
let poll_url = api_url
|
||||
.join("v1/negotiation/poll")
|
||||
.map_err(|_| EdgeNegotiationError::InvalidUrl)?;
|
||||
Ok(Self {
|
||||
send_url,
|
||||
poll_url,
|
||||
http,
|
||||
endpoint,
|
||||
signing,
|
||||
session,
|
||||
send_sequence: 0,
|
||||
peer_sequence: 0,
|
||||
generation: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends one offer, answer, candidate, end marker or explicit ICE restart.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a categorized schema, transport, HTTP or response-binding error.
|
||||
pub async fn send(
|
||||
&mut self,
|
||||
kind: EdgeNegotiationKind,
|
||||
payload: String,
|
||||
) -> Result<EdgeNegotiationMessage, EdgeNegotiationError> {
|
||||
let sequence = self
|
||||
.send_sequence
|
||||
.checked_add(1)
|
||||
.ok_or(EdgeNegotiationError::SequenceExhausted)?;
|
||||
let generation = if kind == EdgeNegotiationKind::Restart {
|
||||
self.generation
|
||||
.checked_add(1)
|
||||
.ok_or(EdgeNegotiationError::GenerationExhausted)?
|
||||
} else {
|
||||
self.generation
|
||||
};
|
||||
let envelope = self.signed_envelope(
|
||||
EdgeNegotiationOperation::Send,
|
||||
sequence,
|
||||
generation,
|
||||
Some(kind),
|
||||
payload,
|
||||
)?;
|
||||
let response = self
|
||||
.http
|
||||
.post(self.send_url.clone())
|
||||
.json(&envelope)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| classify_transport(&error))?;
|
||||
if response.status() != StatusCode::CREATED {
|
||||
return Err(EdgeNegotiationError::HttpStatus(response.status().as_u16()));
|
||||
}
|
||||
let bytes = bounded_response(response).await?;
|
||||
let message: EdgeNegotiationMessage =
|
||||
serde_json::from_slice(&bytes).map_err(|_| EdgeNegotiationError::InvalidResponse)?;
|
||||
validate_message(
|
||||
&message,
|
||||
self.endpoint,
|
||||
sequence,
|
||||
generation,
|
||||
kind,
|
||||
&envelope.payload,
|
||||
)?;
|
||||
self.send_sequence = sequence;
|
||||
self.generation = generation;
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Polls bounded messages sent by the peer after the last accepted cursor.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a categorized transport, HTTP, schema, ordering or generation error.
|
||||
pub async fn poll(&mut self) -> Result<EdgeNegotiationPoll, EdgeNegotiationError> {
|
||||
let envelope = self.signed_envelope(
|
||||
EdgeNegotiationOperation::Poll,
|
||||
self.peer_sequence,
|
||||
0,
|
||||
None,
|
||||
String::new(),
|
||||
)?;
|
||||
let response = self
|
||||
.http
|
||||
.post(self.poll_url.clone())
|
||||
.json(&envelope)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| classify_transport(&error))?;
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(EdgeNegotiationError::HttpStatus(response.status().as_u16()));
|
||||
}
|
||||
let bytes = bounded_response(response).await?;
|
||||
let poll: EdgeNegotiationPoll =
|
||||
serde_json::from_slice(&bytes).map_err(|_| EdgeNegotiationError::InvalidResponse)?;
|
||||
if poll.request_id != self.session.request_id || poll.session_id != self.session.session_id
|
||||
{
|
||||
return Err(EdgeNegotiationError::InvalidResponse);
|
||||
}
|
||||
let expected_peer = match self.endpoint {
|
||||
EdgeNegotiationEndpoint::Client => EdgeNegotiationEndpoint::Agent,
|
||||
EdgeNegotiationEndpoint::Agent => EdgeNegotiationEndpoint::Client,
|
||||
};
|
||||
let mut sequence = self.peer_sequence;
|
||||
let mut generation = self.generation;
|
||||
for message in &poll.messages {
|
||||
sequence = sequence
|
||||
.checked_add(1)
|
||||
.ok_or(EdgeNegotiationError::SequenceExhausted)?;
|
||||
let endpoint = EdgeNegotiationEndpoint::parse(&message.endpoint)
|
||||
.map_err(|_| EdgeNegotiationError::InvalidResponse)?;
|
||||
let kind = EdgeNegotiationKind::parse(&message.kind)
|
||||
.map_err(|_| EdgeNegotiationError::InvalidResponse)?;
|
||||
if endpoint != expected_peer || message.sequence != sequence {
|
||||
return Err(EdgeNegotiationError::InvalidResponse);
|
||||
}
|
||||
if kind == EdgeNegotiationKind::Restart {
|
||||
generation = generation
|
||||
.checked_add(1)
|
||||
.ok_or(EdgeNegotiationError::GenerationExhausted)?;
|
||||
}
|
||||
if message.generation != generation {
|
||||
return Err(EdgeNegotiationError::InvalidResponse);
|
||||
}
|
||||
}
|
||||
if poll.generation != generation {
|
||||
return Err(EdgeNegotiationError::InvalidResponse);
|
||||
}
|
||||
self.peer_sequence = sequence;
|
||||
self.generation = generation;
|
||||
Ok(poll)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn send_sequence(&self) -> u32 {
|
||||
self.send_sequence
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn peer_sequence(&self) -> u32 {
|
||||
self.peer_sequence
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn generation(&self) -> u32 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
fn signed_envelope(
|
||||
&self,
|
||||
operation: EdgeNegotiationOperation,
|
||||
sequence: u32,
|
||||
generation: u32,
|
||||
kind: Option<EdgeNegotiationKind>,
|
||||
payload: String,
|
||||
) -> Result<SignedNegotiationEnvelope, EdgeNegotiationError> {
|
||||
let now = crate::unix_timestamp();
|
||||
if self.session.expires_unix <= now {
|
||||
return Err(EdgeNegotiationError::SessionExpired);
|
||||
}
|
||||
let mut nonce = [0_u8; SIGNAL_NONCE_LENGTH];
|
||||
rand::rng().fill_bytes(&mut nonce);
|
||||
let model = EdgeNegotiationEnvelopeV1 {
|
||||
request_id: self.session.request_id.clone(),
|
||||
session_id: self.session.session_id.clone(),
|
||||
endpoint: self.endpoint,
|
||||
operation,
|
||||
sequence,
|
||||
generation,
|
||||
kind,
|
||||
payload,
|
||||
nonce,
|
||||
issued_unix: now,
|
||||
expires_unix: now.saturating_add(30).min(self.session.expires_unix),
|
||||
};
|
||||
let signing_input = model
|
||||
.stable_signing_input()
|
||||
.map_err(|_| EdgeNegotiationError::InvalidMessage)?;
|
||||
Ok(SignedNegotiationEnvelope {
|
||||
request_id: model.request_id,
|
||||
session_id: model.session_id,
|
||||
endpoint: model.endpoint.as_str().into(),
|
||||
operation: model.operation.as_str().into(),
|
||||
sequence: model.sequence,
|
||||
generation: model.generation,
|
||||
kind: model.kind.map(|value| value.as_str().into()),
|
||||
payload: model.payload,
|
||||
nonce: STANDARD_NO_PAD.encode(model.nonce),
|
||||
issued_unix: model.issued_unix,
|
||||
expires_unix: model.expires_unix,
|
||||
signature: STANDARD_NO_PAD.encode(self.signing.sign(&signing_input).to_bytes()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
pub struct EdgeNegotiationMessage {
|
||||
pub endpoint: String,
|
||||
pub sequence: u32,
|
||||
pub generation: u32,
|
||||
pub kind: String,
|
||||
pub payload: String,
|
||||
pub accepted_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
pub struct EdgeNegotiationPoll {
|
||||
pub request_id: String,
|
||||
pub session_id: String,
|
||||
pub generation: u32,
|
||||
pub messages: Vec<EdgeNegotiationMessage>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SignedNegotiationEnvelope {
|
||||
request_id: String,
|
||||
session_id: String,
|
||||
endpoint: String,
|
||||
operation: String,
|
||||
sequence: u32,
|
||||
generation: u32,
|
||||
kind: Option<String>,
|
||||
payload: String,
|
||||
nonce: String,
|
||||
issued_unix: u64,
|
||||
expires_unix: u64,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EdgeNegotiationError {
|
||||
InvalidUrl,
|
||||
InsecureUrl,
|
||||
InvalidSession,
|
||||
SessionExpired,
|
||||
InvalidMessage,
|
||||
ClientInitialization,
|
||||
TransportTimeout,
|
||||
TransportConnect,
|
||||
TransportTls,
|
||||
TransportOther,
|
||||
HttpStatus(u16),
|
||||
InvalidResponse,
|
||||
SequenceExhausted,
|
||||
GenerationExhausted,
|
||||
}
|
||||
|
||||
impl fmt::Display for EdgeNegotiationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let category = match self {
|
||||
Self::InvalidUrl => "invalid_url",
|
||||
Self::InsecureUrl => "insecure_url",
|
||||
Self::InvalidSession => "invalid_session",
|
||||
Self::SessionExpired => "session_expired",
|
||||
Self::InvalidMessage => "invalid_message",
|
||||
Self::ClientInitialization => "client_initialization",
|
||||
Self::TransportTimeout => "transport_timeout",
|
||||
Self::TransportConnect => "transport_connect",
|
||||
Self::TransportTls => "transport_tls",
|
||||
Self::TransportOther => "transport_other",
|
||||
Self::HttpStatus(_) => "http_status",
|
||||
Self::InvalidResponse => "invalid_response",
|
||||
Self::SequenceExhausted => "sequence_exhausted",
|
||||
Self::GenerationExhausted => "generation_exhausted",
|
||||
};
|
||||
formatter.write_str(category)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EdgeNegotiationError {}
|
||||
|
||||
fn validate_url(url: &Url) -> Result<(), EdgeNegotiationError> {
|
||||
if url.username() != ""
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| url.path() != "/"
|
||||
|| url.port() == Some(0)
|
||||
{
|
||||
return Err(EdgeNegotiationError::InvalidUrl);
|
||||
}
|
||||
let loopback_http = url.scheme() == "http" && is_loopback_host(url.host().as_ref());
|
||||
if url.scheme() != "https" && !loopback_http {
|
||||
return Err(EdgeNegotiationError::InsecureUrl);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_loopback_host(host: Option<&Host<&str>>) -> bool {
|
||||
matches!(host, Some(Host::Domain("localhost")))
|
||||
|| matches!(host, Some(Host::Ipv4(address)) if address.is_loopback())
|
||||
|| matches!(host, Some(Host::Ipv6(address)) if address.is_loopback())
|
||||
}
|
||||
|
||||
fn validate_session(session: &EdgeNegotiationSession) -> Result<(), EdgeNegotiationError> {
|
||||
let valid = |value: &str| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
|
||||
};
|
||||
if !valid(&session.request_id) || !valid(&session.session_id) {
|
||||
return Err(EdgeNegotiationError::InvalidSession);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_message(
|
||||
message: &EdgeNegotiationMessage,
|
||||
endpoint: EdgeNegotiationEndpoint,
|
||||
sequence: u32,
|
||||
generation: u32,
|
||||
kind: EdgeNegotiationKind,
|
||||
payload: &str,
|
||||
) -> Result<(), EdgeNegotiationError> {
|
||||
if EdgeNegotiationEndpoint::parse(&message.endpoint).ok() != Some(endpoint)
|
||||
|| message.sequence != sequence
|
||||
|| message.generation != generation
|
||||
|| EdgeNegotiationKind::parse(&message.kind).ok() != Some(kind)
|
||||
|| message.payload != payload
|
||||
|| message.accepted_unix == 0
|
||||
{
|
||||
return Err(EdgeNegotiationError::InvalidResponse);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn bounded_response(
|
||||
mut response: reqwest::Response,
|
||||
) -> Result<Vec<u8>, EdgeNegotiationError> {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
|
||||
{
|
||||
return Err(EdgeNegotiationError::InvalidResponse);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| classify_transport(&error))?
|
||||
{
|
||||
if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
|
||||
return Err(EdgeNegotiationError::InvalidResponse);
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn classify_transport(error: &reqwest::Error) -> EdgeNegotiationError {
|
||||
if error.is_timeout() {
|
||||
EdgeNegotiationError::TransportTimeout
|
||||
} else if error.is_connect() {
|
||||
EdgeNegotiationError::TransportConnect
|
||||
} else if error
|
||||
.source()
|
||||
.is_some_and(|source| source.to_string().to_ascii_lowercase().contains("tls"))
|
||||
{
|
||||
EdgeNegotiationError::TransportTls
|
||||
} else {
|
||||
EdgeNegotiationError::TransportOther
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session() -> EdgeNegotiationSession {
|
||||
EdgeNegotiationSession {
|
||||
request_id: "request-1".into(),
|
||||
session_id: "session-1".into(),
|
||||
expires_unix: crate::unix_timestamp() + 60,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rejects_insecure_origins_and_redacts_key() {
|
||||
let key = SigningKey::from_bytes(&[7; 32]);
|
||||
assert!(
|
||||
EdgeNegotiationClient::new(
|
||||
"https://edge.example.test/",
|
||||
EdgeNegotiationEndpoint::Client,
|
||||
key.clone(),
|
||||
session(),
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
EdgeNegotiationClient::new(
|
||||
"http://edge.example.test/",
|
||||
EdgeNegotiationEndpoint::Client,
|
||||
key.clone(),
|
||||
session(),
|
||||
)
|
||||
.unwrap_err(),
|
||||
EdgeNegotiationError::InsecureUrl
|
||||
);
|
||||
let client = EdgeNegotiationClient::new(
|
||||
"http://127.0.0.1:7080/",
|
||||
EdgeNegotiationEndpoint::Client,
|
||||
key,
|
||||
session(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!format!("{client:?}").contains("07070707"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_send_and_poll_envelopes_bind_state_without_advancing_it() {
|
||||
let key = SigningKey::from_bytes(&[9; 32]);
|
||||
let client = EdgeNegotiationClient::new(
|
||||
"http://127.0.0.1:7080/",
|
||||
EdgeNegotiationEndpoint::Client,
|
||||
key,
|
||||
session(),
|
||||
)
|
||||
.unwrap();
|
||||
let send = client
|
||||
.signed_envelope(
|
||||
EdgeNegotiationOperation::Send,
|
||||
1,
|
||||
1,
|
||||
Some(EdgeNegotiationKind::Offer),
|
||||
"v=0\r\n".into(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(send.endpoint, "client");
|
||||
assert_eq!(send.operation, "send");
|
||||
assert_eq!(send.sequence, 1);
|
||||
assert_eq!(send.generation, 1);
|
||||
assert_eq!(send.kind.as_deref(), Some("offer"));
|
||||
assert_eq!(STANDARD_NO_PAD.decode(send.signature).unwrap().len(), 64);
|
||||
let poll = client
|
||||
.signed_envelope(EdgeNegotiationOperation::Poll, 0, 0, None, String::new())
|
||||
.unwrap();
|
||||
assert_eq!(poll.operation, "poll");
|
||||
assert_eq!(poll.sequence, 0);
|
||||
assert!(poll.kind.is_none());
|
||||
assert_eq!(client.send_sequence(), 0);
|
||||
assert_eq!(client.peer_sequence(), 0);
|
||||
assert_eq!(client.generation(), 1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
use rustls::pki_types::ServerName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use url::{Host, Url};
|
||||
|
||||
const MAX_RELAY_ADDRESS_BYTES: usize = 512;
|
||||
const MAX_RELAY_TICKET_BYTES: usize = 8192;
|
||||
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeRelayAccess {
|
||||
pub pop_id: String,
|
||||
pub relay_address: String,
|
||||
pub expires_unix: u64,
|
||||
pub max_bytes: u64,
|
||||
pub ticket: String,
|
||||
}
|
||||
|
||||
pub trait RelayIo: AsyncRead + AsyncWrite + Send + Unpin {}
|
||||
impl<T> RelayIo for T where T: AsyncRead + AsyncWrite + Send + Unpin {}
|
||||
pub type RelayStream = Box<dyn RelayIo>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EdgeRelayError {
|
||||
InvalidAccess,
|
||||
Expired,
|
||||
Resolution,
|
||||
Connect,
|
||||
CertificateStore,
|
||||
Tls,
|
||||
Handshake,
|
||||
}
|
||||
|
||||
impl fmt::Display for EdgeRelayError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "Edge relay connection failed: {self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EdgeRelayError {}
|
||||
|
||||
/// Connects to an Edge relay using a role-bound one-use ticket.
|
||||
///
|
||||
/// Public relay authorities use platform-validated TLS. Plain TCP is accepted
|
||||
/// only for loopback integration tests and local deployments.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable category for invalid access data, expiry, connection, TLS,
|
||||
/// or relay handshake failures.
|
||||
pub async fn connect_edge_relay(access: &EdgeRelayAccess) -> Result<RelayStream, EdgeRelayError> {
|
||||
let (host, port, loopback) = validate_access(access)?;
|
||||
let tcp = timeout(
|
||||
RELAY_CONNECT_TIMEOUT,
|
||||
TcpStream::connect((host.as_str(), port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| EdgeRelayError::Connect)?
|
||||
.map_err(|_| EdgeRelayError::Resolution)?;
|
||||
tcp.set_nodelay(true).map_err(|_| EdgeRelayError::Connect)?;
|
||||
let mut stream: RelayStream = if loopback {
|
||||
Box::new(tcp)
|
||||
} else {
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
let (added, _) = roots.add_parsable_certificates(native.certs);
|
||||
if added == 0 {
|
||||
return Err(EdgeRelayError::CertificateStore);
|
||||
}
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
let server_name =
|
||||
ServerName::try_from(host.clone()).map_err(|_| EdgeRelayError::InvalidAccess)?;
|
||||
let tls = timeout(
|
||||
RELAY_CONNECT_TIMEOUT,
|
||||
TlsConnector::from(Arc::new(config)).connect(server_name, tcp),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| EdgeRelayError::Tls)?
|
||||
.map_err(|_| EdgeRelayError::Tls)?;
|
||||
Box::new(tls)
|
||||
};
|
||||
let hello = serde_json::to_vec(&serde_json::json!({ "ticket": access.ticket }))
|
||||
.map_err(|_| EdgeRelayError::Handshake)?;
|
||||
stream
|
||||
.write_all(&hello)
|
||||
.await
|
||||
.map_err(|_| EdgeRelayError::Handshake)?;
|
||||
stream
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.map_err(|_| EdgeRelayError::Handshake)?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|_| EdgeRelayError::Handshake)?;
|
||||
let mut ready = Vec::with_capacity(8);
|
||||
timeout(RELAY_CONNECT_TIMEOUT, async {
|
||||
let mut byte = [0_u8; 1];
|
||||
while ready.len() < 16 {
|
||||
stream.read_exact(&mut byte).await?;
|
||||
ready.push(byte[0]);
|
||||
if byte[0] == b'\n' {
|
||||
return Ok::<(), std::io::Error>(());
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::other("relay response exceeded limit"))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| EdgeRelayError::Handshake)?
|
||||
.map_err(|_| EdgeRelayError::Handshake)?;
|
||||
if ready != b"READY\n" {
|
||||
return Err(EdgeRelayError::Handshake);
|
||||
}
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn validate_access(access: &EdgeRelayAccess) -> Result<(String, u16, bool), EdgeRelayError> {
|
||||
if access.relay_address.is_empty()
|
||||
|| access.relay_address.len() > MAX_RELAY_ADDRESS_BYTES
|
||||
|| access.ticket.is_empty()
|
||||
|| access.ticket.len() > MAX_RELAY_TICKET_BYTES
|
||||
|| access.max_bytes == 0
|
||||
|| access.expires_unix <= crate::unix_timestamp()
|
||||
{
|
||||
return Err(if access.expires_unix <= crate::unix_timestamp() {
|
||||
EdgeRelayError::Expired
|
||||
} else {
|
||||
EdgeRelayError::InvalidAccess
|
||||
});
|
||||
}
|
||||
let url = Url::parse(&format!("relay://{}/", access.relay_address))
|
||||
.map_err(|_| EdgeRelayError::InvalidAccess)?;
|
||||
if url.username() != ""
|
||||
|| url.password().is_some()
|
||||
|| url.path() != "/"
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(EdgeRelayError::InvalidAccess);
|
||||
}
|
||||
let port = url.port().ok_or(EdgeRelayError::InvalidAccess)?;
|
||||
let host = url.host().ok_or(EdgeRelayError::InvalidAccess)?;
|
||||
let loopback = matches!(host, Host::Domain("localhost"))
|
||||
|| matches!(host, Host::Domain(value) if value.parse::<IpAddr>().is_ok_and(|address| address.is_loopback()))
|
||||
|| matches!(host, Host::Ipv4(address) if address.is_loopback())
|
||||
|| matches!(host, Host::Ipv6(address) if address.is_loopback());
|
||||
let host = match host {
|
||||
Host::Domain(value) => value.to_owned(),
|
||||
Host::Ipv4(address) => address.to_string(),
|
||||
Host::Ipv6(address) => address.to_string(),
|
||||
};
|
||||
Ok((host, port, loopback))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn access(address: &str) -> EdgeRelayAccess {
|
||||
EdgeRelayAccess {
|
||||
pop_id: "test-pop".into(),
|
||||
relay_address: address.into(),
|
||||
expires_unix: crate::unix_timestamp() + 60,
|
||||
max_bytes: 1024,
|
||||
ticket: "ticket".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_access_requires_bounded_secret_free_authority() {
|
||||
assert!(validate_access(&access("127.0.0.1:7444")).unwrap().2);
|
||||
assert!(
|
||||
!validate_access(&access("relay.example.test:443"))
|
||||
.unwrap()
|
||||
.2
|
||||
);
|
||||
assert!(validate_access(&access("user:secret@relay.test:443")).is_err());
|
||||
assert!(validate_access(&access("relay.test")).is_err());
|
||||
assert!(validate_access(&access("relay.test:443/path")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
const MAX_DIMENSION: u16 = 8_192;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EncodedH264AccessUnit {
|
||||
pub data: Vec<u8>,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub duration: Duration,
|
||||
pub keyframe: bool,
|
||||
pub encode_latency: Duration,
|
||||
pub encoder: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum H264EncoderError {
|
||||
InvalidConfiguration,
|
||||
RuntimeUnavailable,
|
||||
PipelineUnavailable,
|
||||
PipelineFailure,
|
||||
OutputTimeout,
|
||||
InvalidOutput,
|
||||
StrictZeroCopyUnavailable,
|
||||
KeyframeUnavailable,
|
||||
}
|
||||
|
||||
impl H264EncoderError {
|
||||
#[must_use]
|
||||
pub const fn reason_code(self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidConfiguration => "invalid_configuration",
|
||||
Self::RuntimeUnavailable => "gstreamer_unavailable",
|
||||
Self::PipelineUnavailable => "h264_encoder_unavailable",
|
||||
Self::PipelineFailure => "h264_pipeline_failed",
|
||||
Self::OutputTimeout => "h264_output_timeout",
|
||||
Self::InvalidOutput => "invalid_h264_output",
|
||||
Self::StrictZeroCopyUnavailable => "strict_zero_copy_unavailable",
|
||||
Self::KeyframeUnavailable => "h264_keyframe_unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for H264EncoderError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.reason_code())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for H264EncoderError {}
|
||||
|
||||
#[cfg(feature = "gstreamer-h264")]
|
||||
mod implementation {
|
||||
use super::{EncodedH264AccessUnit, H264EncoderError, MAX_DIMENSION, annex_b_contains_idr};
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer_app as gst_app;
|
||||
use gstreamer_app::prelude::*;
|
||||
use std::{
|
||||
os::fd::{AsRawFd as _, BorrowedFd},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
struct EncoderCandidate {
|
||||
factory: &'static str,
|
||||
pipeline_fragment: String,
|
||||
}
|
||||
|
||||
pub struct H264Encoder {
|
||||
pipeline: gst::Pipeline,
|
||||
source: gst_app::AppSrc,
|
||||
sink: gst_app::AppSink,
|
||||
width: u16,
|
||||
height: u16,
|
||||
frames_per_second: u8,
|
||||
next_pts_ns: u64,
|
||||
encoder: &'static str,
|
||||
}
|
||||
|
||||
pub struct PortalH264Encoder {
|
||||
pipeline: gst::Pipeline,
|
||||
sink: gst_app::AppSink,
|
||||
max_width: u16,
|
||||
max_height: u16,
|
||||
frames_per_second: u8,
|
||||
encoder: &'static str,
|
||||
}
|
||||
|
||||
impl H264Encoder {
|
||||
pub fn new(
|
||||
width: u16,
|
||||
height: u16,
|
||||
frames_per_second: u8,
|
||||
) -> Result<Self, H264EncoderError> {
|
||||
validate_configuration(width, height, frames_per_second)?;
|
||||
gst::init().map_err(|_| H264EncoderError::RuntimeUnavailable)?;
|
||||
let bitrate_kbps = target_bitrate_kbps(width, height, frames_per_second);
|
||||
let key_interval = u16::from(frames_per_second).saturating_mul(2);
|
||||
let candidates = [
|
||||
EncoderCandidate {
|
||||
factory: "vah264enc",
|
||||
pipeline_fragment: format!(
|
||||
"vah264enc rate-control=cbr bitrate={bitrate_kbps} key-int-max={key_interval}"
|
||||
),
|
||||
},
|
||||
EncoderCandidate {
|
||||
factory: "vaapih264enc",
|
||||
pipeline_fragment: format!(
|
||||
"vaapih264enc rate-control=cbr bitrate={bitrate_kbps} keyframe-period={key_interval}"
|
||||
),
|
||||
},
|
||||
EncoderCandidate {
|
||||
factory: "nvh264enc",
|
||||
pipeline_fragment: format!(
|
||||
"nvh264enc zerolatency=true bitrate={bitrate_kbps} gop-size={key_interval}"
|
||||
),
|
||||
},
|
||||
EncoderCandidate {
|
||||
factory: "qsvh264enc",
|
||||
pipeline_fragment: format!(
|
||||
"qsvh264enc low-latency=true bitrate={bitrate_kbps} gop-size={key_interval}"
|
||||
),
|
||||
},
|
||||
EncoderCandidate {
|
||||
factory: "openh264enc",
|
||||
pipeline_fragment: format!(
|
||||
"openh264enc bitrate={} gop-size={key_interval} complexity=low",
|
||||
bitrate_kbps.saturating_mul(1_000)
|
||||
),
|
||||
},
|
||||
EncoderCandidate {
|
||||
factory: "x264enc",
|
||||
pipeline_fragment: format!(
|
||||
"x264enc tune=zerolatency speed-preset=ultrafast bitrate={bitrate_kbps} key-int-max={key_interval} bframes=0"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
for candidate in candidates {
|
||||
if gst::ElementFactory::find(candidate.factory).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(encoder) = Self::build(
|
||||
width,
|
||||
height,
|
||||
frames_per_second,
|
||||
candidate.factory,
|
||||
&candidate.pipeline_fragment,
|
||||
) {
|
||||
return Ok(encoder);
|
||||
}
|
||||
}
|
||||
Err(H264EncoderError::PipelineUnavailable)
|
||||
}
|
||||
|
||||
fn build(
|
||||
width: u16,
|
||||
height: u16,
|
||||
frames_per_second: u8,
|
||||
encoder: &'static str,
|
||||
encoder_fragment: &str,
|
||||
) -> Result<Self, H264EncoderError> {
|
||||
let description = format!(
|
||||
"appsrc name=remotedesk_source is-live=true block=true format=time caps=video/x-raw,format=BGRA,width={width},height={height},framerate={frames_per_second}/1 \
|
||||
! queue max-size-buffers=2 max-size-bytes=0 max-size-time=0 \
|
||||
! videoconvert ! {encoder_fragment} \
|
||||
! h264parse config-interval=-1 \
|
||||
! video/x-h264,stream-format=byte-stream,alignment=au,profile=constrained-baseline \
|
||||
! appsink name=remotedesk_sink sync=false max-buffers=2 drop=true"
|
||||
);
|
||||
let pipeline = gst::parse::launch(&description)
|
||||
.map_err(|_| H264EncoderError::PipelineFailure)?
|
||||
.downcast::<gst::Pipeline>()
|
||||
.map_err(|_| H264EncoderError::PipelineFailure)?;
|
||||
let source = pipeline
|
||||
.by_name("remotedesk_source")
|
||||
.ok_or(H264EncoderError::PipelineFailure)?
|
||||
.downcast::<gst_app::AppSrc>()
|
||||
.map_err(|_| H264EncoderError::PipelineFailure)?;
|
||||
let sink = pipeline
|
||||
.by_name("remotedesk_sink")
|
||||
.ok_or(H264EncoderError::PipelineFailure)?
|
||||
.downcast::<gst_app::AppSink>()
|
||||
.map_err(|_| H264EncoderError::PipelineFailure)?;
|
||||
pipeline
|
||||
.set_state(gst::State::Playing)
|
||||
.map_err(|_| H264EncoderError::PipelineFailure)?;
|
||||
Ok(Self {
|
||||
pipeline,
|
||||
source,
|
||||
sink,
|
||||
width,
|
||||
height,
|
||||
frames_per_second,
|
||||
next_pts_ns: 0,
|
||||
encoder,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn matches(&self, width: u16, height: u16, frames_per_second: u8) -> bool {
|
||||
self.width == width
|
||||
&& self.height == height
|
||||
&& self.frames_per_second == frames_per_second
|
||||
}
|
||||
|
||||
pub fn encode(&mut self, bgra: &[u8]) -> Result<EncodedH264AccessUnit, H264EncoderError> {
|
||||
let expected = usize::from(self.width)
|
||||
.checked_mul(usize::from(self.height))
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.ok_or(H264EncoderError::InvalidConfiguration)?;
|
||||
if bgra.len() != expected {
|
||||
return Err(H264EncoderError::InvalidConfiguration);
|
||||
}
|
||||
let started = Instant::now();
|
||||
let duration = Duration::from_secs_f64(1.0 / f64::from(self.frames_per_second));
|
||||
let duration_ns = u64::try_from(duration.as_nanos())
|
||||
.map_err(|_| H264EncoderError::InvalidConfiguration)?;
|
||||
let mut buffer = gst::Buffer::from_mut_slice(bgra.to_vec());
|
||||
{
|
||||
let buffer = buffer.get_mut().ok_or(H264EncoderError::PipelineFailure)?;
|
||||
buffer.set_pts(gst::ClockTime::from_nseconds(self.next_pts_ns));
|
||||
buffer.set_duration(gst::ClockTime::from_nseconds(duration_ns));
|
||||
}
|
||||
self.next_pts_ns = self.next_pts_ns.saturating_add(duration_ns);
|
||||
self.source
|
||||
.push_buffer(buffer)
|
||||
.map_err(|_| H264EncoderError::PipelineFailure)?;
|
||||
let sample = self
|
||||
.sink
|
||||
.try_pull_sample(gst::ClockTime::from_mseconds(750))
|
||||
.ok_or(H264EncoderError::OutputTimeout)?;
|
||||
let output = sample
|
||||
.buffer()
|
||||
.ok_or(H264EncoderError::InvalidOutput)?
|
||||
.map_readable()
|
||||
.map_err(|_| H264EncoderError::InvalidOutput)?;
|
||||
let data = output.as_slice().to_vec();
|
||||
if data.is_empty() {
|
||||
return Err(H264EncoderError::InvalidOutput);
|
||||
}
|
||||
Ok(EncodedH264AccessUnit {
|
||||
keyframe: annex_b_contains_idr(&data),
|
||||
data,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
duration,
|
||||
encode_latency: started.elapsed(),
|
||||
encoder: self.encoder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PortalH264Encoder {
|
||||
pub fn new(
|
||||
pipewire_fd: BorrowedFd<'_>,
|
||||
pipewire_node_id: u32,
|
||||
max_width: u16,
|
||||
max_height: u16,
|
||||
frames_per_second: u8,
|
||||
) -> Result<Self, H264EncoderError> {
|
||||
validate_configuration(max_width, max_height, frames_per_second)?;
|
||||
if pipewire_node_id == 0 {
|
||||
return Err(H264EncoderError::InvalidConfiguration);
|
||||
}
|
||||
gst::init().map_err(|_| H264EncoderError::RuntimeUnavailable)?;
|
||||
let bitrate_kbps = target_bitrate_kbps(max_width, max_height, frames_per_second);
|
||||
let key_interval = u16::from(frames_per_second).saturating_mul(2);
|
||||
let candidates = [
|
||||
PortalEncoderCandidate {
|
||||
factory: "vah264enc",
|
||||
post_processor: "vapostproc",
|
||||
surface_caps: "video/x-raw(memory:VAMemory)",
|
||||
encoder_fragment: format!(
|
||||
"vah264enc rate-control=cbr bitrate={bitrate_kbps} key-int-max={key_interval}"
|
||||
),
|
||||
},
|
||||
PortalEncoderCandidate {
|
||||
factory: "vaapih264enc",
|
||||
post_processor: "vaapipostproc",
|
||||
surface_caps: "video/x-raw(memory:VASurface)",
|
||||
encoder_fragment: format!(
|
||||
"vaapih264enc rate-control=cbr bitrate={bitrate_kbps} keyframe-period={key_interval}"
|
||||
),
|
||||
},
|
||||
];
|
||||
for candidate in candidates {
|
||||
if gst::ElementFactory::find("pipewiresrc").is_none()
|
||||
|| gst::ElementFactory::find(candidate.post_processor).is_none()
|
||||
|| gst::ElementFactory::find(candidate.factory).is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let description = portal_pipeline_description(
|
||||
pipewire_fd.as_raw_fd(),
|
||||
pipewire_node_id,
|
||||
max_width,
|
||||
max_height,
|
||||
frames_per_second,
|
||||
&candidate,
|
||||
);
|
||||
let Ok(element) = gst::parse::launch(&description) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pipeline) = element.downcast::<gst::Pipeline>() else {
|
||||
continue;
|
||||
};
|
||||
let Some(sink) = pipeline
|
||||
.by_name("remotedesk_portal_sink")
|
||||
.and_then(|element| element.downcast::<gst_app::AppSink>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if pipeline.set_state(gst::State::Playing).is_err() {
|
||||
let _ = pipeline.set_state(gst::State::Null);
|
||||
continue;
|
||||
}
|
||||
return Ok(Self {
|
||||
pipeline,
|
||||
sink,
|
||||
max_width,
|
||||
max_height,
|
||||
frames_per_second,
|
||||
encoder: candidate.factory,
|
||||
});
|
||||
}
|
||||
Err(H264EncoderError::StrictZeroCopyUnavailable)
|
||||
}
|
||||
|
||||
pub fn next_access_unit(&mut self) -> Result<EncodedH264AccessUnit, H264EncoderError> {
|
||||
let started = Instant::now();
|
||||
let sample = self
|
||||
.sink
|
||||
.try_pull_sample(gst::ClockTime::from_mseconds(750))
|
||||
.ok_or(H264EncoderError::OutputTimeout)?;
|
||||
let caps = sample.caps().ok_or(H264EncoderError::InvalidOutput)?;
|
||||
let structure = caps.structure(0).ok_or(H264EncoderError::InvalidOutput)?;
|
||||
let width = structure
|
||||
.get::<i32>("width")
|
||||
.ok()
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.filter(|value| (200..=self.max_width).contains(value))
|
||||
.ok_or(H264EncoderError::InvalidOutput)?;
|
||||
let height = structure
|
||||
.get::<i32>("height")
|
||||
.ok()
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.filter(|value| (200..=self.max_height).contains(value))
|
||||
.ok_or(H264EncoderError::InvalidOutput)?;
|
||||
let buffer = sample.buffer().ok_or(H264EncoderError::InvalidOutput)?;
|
||||
let duration = buffer.duration().map_or_else(
|
||||
|| Duration::from_secs_f64(1.0 / f64::from(self.frames_per_second)),
|
||||
|duration| Duration::from_nanos(duration.nseconds()),
|
||||
);
|
||||
let output = buffer
|
||||
.map_readable()
|
||||
.map_err(|_| H264EncoderError::InvalidOutput)?;
|
||||
let data = output.as_slice().to_vec();
|
||||
if data.is_empty() {
|
||||
return Err(H264EncoderError::InvalidOutput);
|
||||
}
|
||||
Ok(EncodedH264AccessUnit {
|
||||
keyframe: annex_b_contains_idr(&data),
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
encode_latency: started.elapsed(),
|
||||
encoder: self.encoder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PortalH264Encoder {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.pipeline.set_state(gst::State::Null);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PortalEncoderCandidate {
|
||||
pub(super) factory: &'static str,
|
||||
pub(super) post_processor: &'static str,
|
||||
pub(super) surface_caps: &'static str,
|
||||
pub(super) encoder_fragment: String,
|
||||
}
|
||||
|
||||
pub(super) fn portal_pipeline_description(
|
||||
pipewire_fd: i32,
|
||||
pipewire_node_id: u32,
|
||||
max_width: u16,
|
||||
max_height: u16,
|
||||
frames_per_second: u8,
|
||||
candidate: &PortalEncoderCandidate,
|
||||
) -> String {
|
||||
format!(
|
||||
"pipewiresrc name=remotedesk_portal_source fd={pipewire_fd} path={pipewire_node_id} do-timestamp=true \
|
||||
! video/x-raw(memory:DMABuf) \
|
||||
! queue max-size-buffers=2 max-size-bytes=0 max-size-time=0 leaky=downstream \
|
||||
! {} \
|
||||
! {},format=NV12,width=[200,{max_width}],height=[200,{max_height}],framerate=[1/1,{frames_per_second}/1] \
|
||||
! {} \
|
||||
! h264parse config-interval=-1 \
|
||||
! video/x-h264,stream-format=byte-stream,alignment=au,profile=constrained-baseline \
|
||||
! appsink name=remotedesk_portal_sink sync=false max-buffers=2 drop=true",
|
||||
candidate.post_processor, candidate.surface_caps, candidate.encoder_fragment
|
||||
)
|
||||
}
|
||||
|
||||
impl Drop for H264Encoder {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.pipeline.set_state(gst::State::Null);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_configuration(
|
||||
width: u16,
|
||||
height: u16,
|
||||
frames_per_second: u8,
|
||||
) -> Result<(), H264EncoderError> {
|
||||
if !(200..=MAX_DIMENSION).contains(&width)
|
||||
|| !(200..=MAX_DIMENSION).contains(&height)
|
||||
|| !(1..=30).contains(&frames_per_second)
|
||||
{
|
||||
return Err(H264EncoderError::InvalidConfiguration);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn target_bitrate_kbps(width: u16, height: u16, frames_per_second: u8) -> u64 {
|
||||
let pixels_per_second = u64::from(width)
|
||||
.saturating_mul(u64::from(height))
|
||||
.saturating_mul(u64::from(frames_per_second));
|
||||
(pixels_per_second / 12_000).clamp(1_000, 20_000)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "gstreamer-h264"))]
|
||||
mod implementation {
|
||||
use super::{EncodedH264AccessUnit, H264EncoderError};
|
||||
use std::os::fd::BorrowedFd;
|
||||
|
||||
pub struct H264Encoder;
|
||||
pub struct PortalH264Encoder;
|
||||
|
||||
impl H264Encoder {
|
||||
pub fn new(_: u16, _: u16, _: u8) -> Result<Self, H264EncoderError> {
|
||||
Err(H264EncoderError::RuntimeUnavailable)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn matches(&self, _: u16, _: u16, _: u8) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn encode(&mut self, _: &[u8]) -> Result<EncodedH264AccessUnit, H264EncoderError> {
|
||||
Err(H264EncoderError::RuntimeUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
impl PortalH264Encoder {
|
||||
pub fn new(
|
||||
_: BorrowedFd<'_>,
|
||||
_: u32,
|
||||
_: u16,
|
||||
_: u16,
|
||||
_: u8,
|
||||
) -> Result<Self, H264EncoderError> {
|
||||
Err(H264EncoderError::RuntimeUnavailable)
|
||||
}
|
||||
|
||||
pub fn next_access_unit(&mut self) -> Result<EncodedH264AccessUnit, H264EncoderError> {
|
||||
Err(H264EncoderError::RuntimeUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use implementation::{H264Encoder, PortalH264Encoder};
|
||||
|
||||
fn annex_b_contains_idr(data: &[u8]) -> bool {
|
||||
let mut index = 0;
|
||||
while index + 4 <= data.len() {
|
||||
let start_code = if data[index..].starts_with(&[0, 0, 1]) {
|
||||
3
|
||||
} else if data[index..].starts_with(&[0, 0, 0, 1]) {
|
||||
4
|
||||
} else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let header = index + start_code;
|
||||
if header < data.len() && data[header] & 0x1f == 5 {
|
||||
return true;
|
||||
}
|
||||
index = header.saturating_add(1);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::annex_b_contains_idr;
|
||||
|
||||
#[cfg(feature = "gstreamer-h264")]
|
||||
use super::implementation::{PortalEncoderCandidate, portal_pipeline_description};
|
||||
|
||||
#[test]
|
||||
fn detects_idr_in_three_and_four_byte_annex_b_start_codes() {
|
||||
assert!(annex_b_contains_idr(&[0, 0, 1, 0x65, 1]));
|
||||
assert!(annex_b_contains_idr(&[0, 0, 0, 1, 0x67, 0, 0, 1, 0x65]));
|
||||
assert!(!annex_b_contains_idr(&[0, 0, 0, 1, 0x41, 1]));
|
||||
}
|
||||
|
||||
#[cfg(feature = "gstreamer-h264")]
|
||||
#[test]
|
||||
fn portal_pipeline_requires_dmabuf_and_hardware_surfaces() {
|
||||
let candidate = PortalEncoderCandidate {
|
||||
factory: "vah264enc",
|
||||
post_processor: "vapostproc",
|
||||
surface_caps: "video/x-raw(memory:VAMemory)",
|
||||
encoder_fragment: "vah264enc rate-control=cbr bitrate=4000 key-int-max=30".into(),
|
||||
};
|
||||
let description = portal_pipeline_description(7, 42, 1920, 1080, 30, &candidate);
|
||||
assert!(description.contains("video/x-raw(memory:DMABuf)"));
|
||||
assert!(description.contains("video/x-raw(memory:VAMemory)"));
|
||||
assert!(description.contains("fd=7 path=42"));
|
||||
for forbidden in ["appsrc", "videoconvert", "x264enc", "openh264enc"] {
|
||||
assert!(!description.contains(forbidden));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Runtime support shared by the `RemoteDesk` Linux agent processes.
|
||||
|
||||
mod clipboard;
|
||||
mod desktop;
|
||||
mod edge_negotiation;
|
||||
mod edge_presence;
|
||||
mod edge_relay;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod h264_encoder;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_desktop;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod opus_capture;
|
||||
mod pairing;
|
||||
mod protocol;
|
||||
mod state;
|
||||
#[cfg(all(target_os = "linux", feature = "wayland-eis"))]
|
||||
mod wayland_eis;
|
||||
#[cfg(all(target_os = "linux", not(feature = "wayland-eis")))]
|
||||
mod wayland_eis {
|
||||
use crate::DesktopInputEvent;
|
||||
use std::{fmt, os::fd::BorrowedFd};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WaylandEisError {
|
||||
Unsupported,
|
||||
}
|
||||
impl WaylandEisError {
|
||||
pub const fn code(self) -> &'static str {
|
||||
"wayland_eis_unavailable"
|
||||
}
|
||||
}
|
||||
impl fmt::Display for WaylandEisError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Wayland EIS input is unavailable in this static build")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for WaylandEisError {}
|
||||
pub struct WaylandEisInput;
|
||||
impl WaylandEisInput {
|
||||
pub async fn connect(
|
||||
_: BorrowedFd<'_>,
|
||||
_: u16,
|
||||
_: u16,
|
||||
_: Option<&str>,
|
||||
) -> Result<Self, WaylandEisError> {
|
||||
Err(WaylandEisError::Unsupported)
|
||||
}
|
||||
pub fn set_frame_size(&mut self, _: u16, _: u16) -> Result<(), WaylandEisError> {
|
||||
Err(WaylandEisError::Unsupported)
|
||||
}
|
||||
pub fn input(&mut self, _: &DesktopInputEvent) -> Result<(), WaylandEisError> {
|
||||
Err(WaylandEisError::Unsupported)
|
||||
}
|
||||
pub fn release_all(&mut self) -> Result<(), WaylandEisError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland_portal;
|
||||
mod webrtc_session;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod x11_clipboard;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod x11_desktop;
|
||||
|
||||
pub use clipboard::{
|
||||
ClipboardTextError, PreparedClipboardText, decode_clipboard_text, normalize_clipboard_text,
|
||||
prepare_clipboard_text, validate_clipboard_offer,
|
||||
};
|
||||
pub use desktop::{
|
||||
DesktopCompressionController, DesktopFrameAssembler, DesktopFrameBuildError, DesktopFramePacer,
|
||||
EncodedDesktopFrame, encode_desktop_frame, encode_desktop_frame_with_level,
|
||||
validate_desktop_input,
|
||||
};
|
||||
pub use edge_negotiation::{
|
||||
EdgeNegotiationClient, EdgeNegotiationError, EdgeNegotiationMessage, EdgeNegotiationPoll,
|
||||
EdgeNegotiationSession,
|
||||
};
|
||||
pub use edge_presence::{
|
||||
DesktopMediaEvent, DesktopMediaSession, EdgePresenceClient, EdgePresenceConfig,
|
||||
EdgePresenceError, edge_device_id,
|
||||
};
|
||||
pub use edge_relay::{EdgeRelayAccess, RelayStream, connect_edge_relay};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use h264_encoder::{EncodedH264AccessUnit, H264Encoder, H264EncoderError, PortalH264Encoder};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux_desktop::{DesktopBackendKind, LinuxDesktop};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use opus_capture::{EncodedOpusPacket, OpusCapture, OpusCaptureError};
|
||||
pub use pairing::{AuthChallenge, PairingCode, verify_auth_signature};
|
||||
pub use protocol::{
|
||||
CLIPBOARD_PROTOCOL_MINOR, ClientCommand, DESKTOP_FRAME_CHUNK_BYTES, DESKTOP_H264_CHUNK_BYTES,
|
||||
DESKTOP_MAX_CLIPBOARD_BYTES, DESKTOP_MAX_COMPRESSED_BYTES, DESKTOP_MAX_FRAME_BYTES,
|
||||
DESKTOP_MAX_H264_ACCESS_UNIT_BYTES, DESKTOP_MAX_OPUS_PACKET_BYTES, DESKTOP_MAX_PIXELS,
|
||||
DesktopButton, DesktopFrameEncoding, DesktopFrameMetadata, DesktopH264Metadata,
|
||||
DesktopInputEvent, DesktopKeyState, DesktopVideoMode, FILE_CHUNK_BYTES, FileResult,
|
||||
FileTransferReady, IpcMessage, IpcResponse, MAX_FILE_BYTES, MAX_RELATIVE_POINTER_DELTA,
|
||||
OPUS_AUDIO_PROTOCOL_MINOR, PROTOCOL_MAJOR, PROTOCOL_MINOR, ServerEvent, ShellCommand,
|
||||
ShellEvent,
|
||||
};
|
||||
pub use state::{
|
||||
AgentState, ClientGrant, DeviceIdentity, EdgePresenceStatus, PairingGrant, StateError,
|
||||
unix_timestamp,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use wayland_eis::{WaylandEisError, WaylandEisInput};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use wayland_portal::{
|
||||
WaylandPortalCapabilities, WaylandPortalProbeError, WaylandPortalSession,
|
||||
WaylandPortalSessionError, WaylandPortalStream, open_wayland_portal_session,
|
||||
probe_wayland_portal,
|
||||
};
|
||||
pub use webrtc_session::{
|
||||
EdgeWebRtcRole, EncodedMediaSender, RemoteH264AccessUnit, RemoteH264Event, RemoteH264Stream,
|
||||
RemoteMediaTrack, RemoteOpusPacket, RemoteOpusStream, WebRtcConnectionState, WebRtcIceServer,
|
||||
WebRtcSession, WebRtcSessionConfig, WebRtcSessionError, WebRtcSignalMessage,
|
||||
WebRtcSignalTransport, establish_edge_webrtc, establish_webrtc,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use x11_clipboard::X11Clipboard;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use x11_desktop::{CapturedDesktopFrame, X11Desktop};
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use crate::{CapturedDesktopFrame, DesktopInputEvent, X11Desktop};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DesktopBackendKind {
|
||||
Wayland,
|
||||
X11,
|
||||
Headless,
|
||||
}
|
||||
|
||||
impl DesktopBackendKind {
|
||||
#[must_use]
|
||||
pub fn detect(wayland_display: Option<&OsStr>, x11_display: Option<&OsStr>) -> Self {
|
||||
if wayland_display.is_some_and(|value| !value.is_empty()) {
|
||||
Self::Wayland
|
||||
} else if x11_display.is_some_and(|value| !value.is_empty()) {
|
||||
Self::X11
|
||||
} else {
|
||||
Self::Headless
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_environment() -> Self {
|
||||
Self::detect(
|
||||
std::env::var_os("WAYLAND_DISPLAY").as_deref(),
|
||||
std::env::var_os("DISPLAY").as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Wayland => "wayland",
|
||||
Self::X11 => "x11",
|
||||
Self::Headless => "headless",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum LinuxDesktop {
|
||||
X11(X11Desktop),
|
||||
}
|
||||
|
||||
impl LinuxDesktop {
|
||||
pub fn connect(backend: DesktopBackendKind) -> Result<Self, Error> {
|
||||
match backend {
|
||||
DesktopBackendKind::X11 => X11Desktop::connect().map(Self::X11),
|
||||
DesktopBackendKind::Wayland => {
|
||||
Err("Wayland requires an authorized Portal desktop session".into())
|
||||
}
|
||||
DesktopBackendKind::Headless => Err("no graphical desktop session is available".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capture(
|
||||
&mut self,
|
||||
max_width: u16,
|
||||
max_height: u16,
|
||||
) -> Result<CapturedDesktopFrame, Error> {
|
||||
match self {
|
||||
Self::X11(desktop) => desktop.capture(max_width, max_height),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input(&mut self, event: &DesktopInputEvent) -> Result<(), Error> {
|
||||
match self {
|
||||
Self::X11(desktop) => desktop.input(event),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn release_all(&mut self) -> Result<(), Error> {
|
||||
match self {
|
||||
Self::X11(desktop) => desktop.release_all(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll_clipboard_text(&mut self) -> Result<Option<String>, Error> {
|
||||
match self {
|
||||
Self::X11(desktop) => desktop.poll_clipboard_text(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_clipboard_text(&mut self, text: &str) -> Result<(), Error> {
|
||||
match self {
|
||||
Self::X11(desktop) => desktop.set_clipboard_text(text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backend_detection_prefers_wayland_and_rejects_empty_environment_values() {
|
||||
assert_eq!(
|
||||
DesktopBackendKind::detect(Some(OsStr::new("wayland-0")), Some(OsStr::new(":0"))),
|
||||
DesktopBackendKind::Wayland
|
||||
);
|
||||
assert_eq!(
|
||||
DesktopBackendKind::detect(None, Some(OsStr::new(":0"))),
|
||||
DesktopBackendKind::X11
|
||||
);
|
||||
assert_eq!(
|
||||
DesktopBackendKind::detect(Some(OsStr::new("")), Some(OsStr::new(""))),
|
||||
DesktopBackendKind::Headless
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
use crate::DESKTOP_MAX_OPUS_PACKET_BYTES;
|
||||
|
||||
pub const OPUS_SAMPLE_RATE: u32 = 48_000;
|
||||
pub const OPUS_CHANNELS: u16 = 2;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EncodedOpusPacket {
|
||||
pub data: Vec<u8>,
|
||||
pub duration: Duration,
|
||||
pub encode_latency: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OpusCaptureError {
|
||||
RuntimeUnavailable,
|
||||
AudioMonitorUnavailable,
|
||||
PipelineFailure,
|
||||
OutputTimeout,
|
||||
InvalidOutput,
|
||||
}
|
||||
|
||||
impl OpusCaptureError {
|
||||
#[must_use]
|
||||
pub const fn reason_code(self) -> &'static str {
|
||||
match self {
|
||||
Self::RuntimeUnavailable => "gstreamer_unavailable",
|
||||
Self::AudioMonitorUnavailable => "audio_monitor_unavailable",
|
||||
Self::PipelineFailure => "opus_pipeline_failed",
|
||||
Self::OutputTimeout => "opus_output_timeout",
|
||||
Self::InvalidOutput => "invalid_opus_output",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OpusCaptureError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.reason_code())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OpusCaptureError {}
|
||||
|
||||
#[cfg(feature = "gstreamer-h264")]
|
||||
mod implementation {
|
||||
use super::{
|
||||
DESKTOP_MAX_OPUS_PACKET_BYTES, EncodedOpusPacket, OPUS_CHANNELS, OPUS_SAMPLE_RATE,
|
||||
OpusCaptureError, valid_monitor_name,
|
||||
};
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer_app as gst_app;
|
||||
use gstreamer_app::prelude::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_MONITOR: &str = "@DEFAULT_MONITOR@";
|
||||
|
||||
pub struct OpusCapture {
|
||||
pipeline: gst::Pipeline,
|
||||
sink: gst_app::AppSink,
|
||||
}
|
||||
|
||||
impl OpusCapture {
|
||||
pub fn new() -> Result<Self, OpusCaptureError> {
|
||||
gst::init().map_err(|_| OpusCaptureError::RuntimeUnavailable)?;
|
||||
for factory in ["pulsesrc"] {
|
||||
if gst::ElementFactory::find(factory).is_none()
|
||||
|| gst::ElementFactory::find("opusenc").is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let description = format!(
|
||||
"{factory} name=remotedesk_audio_source do-timestamp=true \
|
||||
! queue leaky=downstream max-size-buffers=8 max-size-bytes=0 max-size-time=0 \
|
||||
! audioconvert ! audioresample \
|
||||
! audio/x-raw,format=S16LE,rate={OPUS_SAMPLE_RATE},channels={OPUS_CHANNELS} \
|
||||
! opusenc bitrate=96000 frame-size=20 audio-type=restricted-lowdelay inband-fec=true packet-loss-percentage=5 dtx=true \
|
||||
! audio/x-opus,rate={OPUS_SAMPLE_RATE},channels={OPUS_CHANNELS},channel-mapping-family=0 \
|
||||
! appsink name=remotedesk_audio_sink sync=false max-buffers=8 drop=true"
|
||||
);
|
||||
let Ok(element) = gst::parse::launch(&description) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pipeline) = element.downcast::<gst::Pipeline>() else {
|
||||
continue;
|
||||
};
|
||||
let Some(source) = pipeline.by_name("remotedesk_audio_source") else {
|
||||
continue;
|
||||
};
|
||||
let monitor = std::env::var("REMOTEDESK_AUDIO_MONITOR")
|
||||
.ok()
|
||||
.filter(|value| valid_monitor_name(value))
|
||||
.unwrap_or_else(|| DEFAULT_MONITOR.to_owned());
|
||||
source.set_property("device", monitor);
|
||||
let Some(sink) = pipeline
|
||||
.by_name("remotedesk_audio_sink")
|
||||
.and_then(|element| element.downcast::<gst_app::AppSink>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if pipeline.set_state(gst::State::Playing).is_err() {
|
||||
let _ = pipeline.set_state(gst::State::Null);
|
||||
continue;
|
||||
}
|
||||
return Ok(Self { pipeline, sink });
|
||||
}
|
||||
Err(OpusCaptureError::AudioMonitorUnavailable)
|
||||
}
|
||||
|
||||
pub fn next_packet(&mut self) -> Result<EncodedOpusPacket, OpusCaptureError> {
|
||||
let started = Instant::now();
|
||||
let sample = self
|
||||
.sink
|
||||
.try_pull_sample(gst::ClockTime::from_mseconds(250))
|
||||
.ok_or(OpusCaptureError::OutputTimeout)?;
|
||||
let caps = sample.caps().ok_or(OpusCaptureError::InvalidOutput)?;
|
||||
let structure = caps.structure(0).ok_or(OpusCaptureError::InvalidOutput)?;
|
||||
if structure.name() != "audio/x-opus"
|
||||
|| structure.get::<i32>("rate").ok() != Some(OPUS_SAMPLE_RATE as i32)
|
||||
|| structure.get::<i32>("channels").ok() != Some(i32::from(OPUS_CHANNELS))
|
||||
{
|
||||
return Err(OpusCaptureError::InvalidOutput);
|
||||
}
|
||||
let buffer = sample.buffer().ok_or(OpusCaptureError::InvalidOutput)?;
|
||||
let duration = buffer
|
||||
.duration()
|
||||
.map(|value| Duration::from_nanos(value.nseconds()))
|
||||
.filter(|value| {
|
||||
*value >= Duration::from_micros(2_500) && *value <= Duration::from_millis(120)
|
||||
})
|
||||
.ok_or(OpusCaptureError::InvalidOutput)?;
|
||||
let mapped = buffer
|
||||
.map_readable()
|
||||
.map_err(|_| OpusCaptureError::InvalidOutput)?;
|
||||
let data = mapped.as_slice();
|
||||
if data.is_empty() || data.len() > DESKTOP_MAX_OPUS_PACKET_BYTES {
|
||||
return Err(OpusCaptureError::InvalidOutput);
|
||||
}
|
||||
Ok(EncodedOpusPacket {
|
||||
data: data.to_vec(),
|
||||
duration,
|
||||
encode_latency: started.elapsed(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OpusCapture {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.pipeline.set_state(gst::State::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "gstreamer-h264"))]
|
||||
mod implementation {
|
||||
use super::{EncodedOpusPacket, OpusCaptureError};
|
||||
|
||||
pub struct OpusCapture;
|
||||
|
||||
impl OpusCapture {
|
||||
pub fn new() -> Result<Self, OpusCaptureError> {
|
||||
Err(OpusCaptureError::RuntimeUnavailable)
|
||||
}
|
||||
|
||||
pub fn next_packet(&mut self) -> Result<EncodedOpusPacket, OpusCaptureError> {
|
||||
Err(OpusCaptureError::RuntimeUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use implementation::OpusCapture;
|
||||
|
||||
fn valid_monitor_name(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 256
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'@'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn monitor_name_is_bounded_and_not_pipeline_syntax() {
|
||||
assert!(valid_monitor_name("alsa_output.pci.monitor"));
|
||||
assert!(valid_monitor_name("@DEFAULT_MONITOR@"));
|
||||
assert!(!valid_monitor_name(""));
|
||||
assert!(!valid_monitor_name("monitor ! fakesink"));
|
||||
assert!(!valid_monitor_name("monitor\nnext"));
|
||||
assert!(!valid_monitor_name(&"x".repeat(257)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use rand::RngCore as _;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
const AUTH_DOMAIN: &[u8] = b"remotedesk-agent-auth-v1\0";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PairingCode(String);
|
||||
|
||||
impl PairingCode {
|
||||
#[must_use]
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0_u8; 8];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
let value = u64::from_le_bytes(bytes) % 100_000_000;
|
||||
Self(format!("{value:08}"))
|
||||
}
|
||||
|
||||
/// Parses the fixed-width decimal representation shown to the local user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error unless `value` contains exactly eight ASCII digits.
|
||||
pub fn parse(value: &str) -> Result<Self, &'static str> {
|
||||
if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err("pairing code must contain exactly eight ASCII digits");
|
||||
}
|
||||
Ok(Self(value.to_owned()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn salted_digest(&self, salt: &[u8]) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"remotedesk-pairing-code-v1\0");
|
||||
digest.update(salt);
|
||||
digest.update(self.0.as_bytes());
|
||||
STANDARD_NO_PAD.encode(digest.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AuthChallenge([u8; 32]);
|
||||
|
||||
impl AuthChallenge {
|
||||
#[must_use]
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0_u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn encoded(&self) -> String {
|
||||
STANDARD_NO_PAD.encode(self.0)
|
||||
}
|
||||
|
||||
/// Parses the challenge representation sent in [`crate::ServerEvent::Hello`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `value` is not valid unpadded base64 or does not decode to 32 bytes.
|
||||
pub fn parse_encoded(value: &str) -> Result<Self, &'static str> {
|
||||
let bytes = STANDARD_NO_PAD
|
||||
.decode(value)
|
||||
.map_err(|_| "authentication challenge is not valid base64")?;
|
||||
let challenge: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| "authentication challenge must contain 32 bytes")?;
|
||||
Ok(Self(challenge))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn signing_payload(&self) -> Vec<u8> {
|
||||
let mut payload = Vec::with_capacity(AUTH_DOMAIN.len() + self.0.len());
|
||||
payload.extend_from_slice(AUTH_DOMAIN);
|
||||
payload.extend_from_slice(&self.0);
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies that a client signed the domain-separated server challenge.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for malformed keys/signatures or a failed signature check.
|
||||
pub fn verify_auth_signature(
|
||||
public_key: &str,
|
||||
signature: &str,
|
||||
challenge: &AuthChallenge,
|
||||
) -> Result<(), &'static str> {
|
||||
let public_bytes = STANDARD_NO_PAD
|
||||
.decode(public_key)
|
||||
.map_err(|_| "client public key is not valid base64")?;
|
||||
let signature_bytes = STANDARD_NO_PAD
|
||||
.decode(signature)
|
||||
.map_err(|_| "client signature is not valid base64")?;
|
||||
let public_array: [u8; 32] = public_bytes
|
||||
.try_into()
|
||||
.map_err(|_| "client public key must contain 32 bytes")?;
|
||||
let signature = Signature::from_slice(&signature_bytes)
|
||||
.map_err(|_| "client signature must contain 64 bytes")?;
|
||||
let key = VerifyingKey::from_bytes(&public_array).map_err(|_| "invalid client public key")?;
|
||||
key.verify(&challenge.signing_payload(), &signature)
|
||||
.map_err(|_| "client signature verification failed")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer as _, SigningKey};
|
||||
|
||||
#[test]
|
||||
fn pairing_codes_are_strictly_formatted() {
|
||||
let code = PairingCode::generate();
|
||||
assert_eq!(code.expose().len(), 8);
|
||||
assert!(code.expose().bytes().all(|byte| byte.is_ascii_digit()));
|
||||
assert!(PairingCode::parse("12345678").is_ok());
|
||||
assert!(PairingCode::parse("1234-678").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digest_is_salted_and_deterministic() {
|
||||
let code = PairingCode::parse("12345678").unwrap();
|
||||
assert_eq!(code.salted_digest(b"one"), code.salted_digest(b"one"));
|
||||
assert_ne!(code.salted_digest(b"one"), code.salted_digest(b"two"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_signature_is_verified() {
|
||||
let signing = SigningKey::from_bytes(&[7_u8; 32]);
|
||||
let challenge = AuthChallenge([9_u8; 32]);
|
||||
let signature = signing.sign(&challenge.signing_payload());
|
||||
let public = STANDARD_NO_PAD.encode(signing.verifying_key().as_bytes());
|
||||
let signature = STANDARD_NO_PAD.encode(signature.to_bytes());
|
||||
assert!(verify_auth_signature(&public, &signature, &challenge).is_ok());
|
||||
|
||||
let other = AuthChallenge([8_u8; 32]);
|
||||
assert!(verify_auth_signature(&public, &signature, &other).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_challenge_round_trips_strictly() {
|
||||
let challenge = AuthChallenge([3_u8; 32]);
|
||||
assert_eq!(
|
||||
AuthChallenge::parse_encoded(&challenge.encoded()).unwrap(),
|
||||
challenge
|
||||
);
|
||||
assert!(AuthChallenge::parse_encoded("not base64").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROTOCOL_MAJOR: u16 = 1;
|
||||
pub const PROTOCOL_MINOR: u16 = 15;
|
||||
pub const OPUS_AUDIO_PROTOCOL_MINOR: u16 = 14;
|
||||
pub const CLIPBOARD_PROTOCOL_MINOR: u16 = 15;
|
||||
pub const MAX_RELATIVE_POINTER_DELTA: u16 = 4_096;
|
||||
pub const FILE_CHUNK_BYTES: usize = 45 * 1024;
|
||||
pub const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
pub const DESKTOP_FRAME_CHUNK_BYTES: usize = 45 * 1024;
|
||||
pub const DESKTOP_MAX_PIXELS: usize = 33_177_600;
|
||||
pub const DESKTOP_MAX_FRAME_BYTES: usize = DESKTOP_MAX_PIXELS * 4;
|
||||
pub const DESKTOP_MAX_COMPRESSED_BYTES: usize = 64 * 1024 * 1024;
|
||||
pub const DESKTOP_H264_CHUNK_BYTES: usize = 45 * 1024;
|
||||
pub const DESKTOP_MAX_H264_ACCESS_UNIT_BYTES: usize = 16 * 1024 * 1024;
|
||||
pub const DESKTOP_MAX_OPUS_PACKET_BYTES: usize = 4 * 1024;
|
||||
pub const DESKTOP_MAX_CLIPBOARD_BYTES: usize = 32 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DesktopFrameEncoding {
|
||||
ZlibBgra,
|
||||
WebRtcH264,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DesktopVideoMode {
|
||||
ZlibBgra,
|
||||
WebRtcH264,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DesktopFrameMetadata {
|
||||
pub sequence: u64,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub encoding: DesktopFrameEncoding,
|
||||
pub uncompressed_bytes: usize,
|
||||
pub compressed_bytes: usize,
|
||||
pub chunk_count: u16,
|
||||
pub captured_at_unix_ms: u64,
|
||||
pub capture_latency_us: u64,
|
||||
pub encode_latency_us: u64,
|
||||
pub compression_level: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DesktopH264Metadata {
|
||||
pub sequence: u64,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub captured_at_unix_ms: u64,
|
||||
pub capture_latency_us: u64,
|
||||
pub access_unit_bytes: usize,
|
||||
pub chunk_count: u16,
|
||||
pub duration_us: u64,
|
||||
pub keyframe: bool,
|
||||
pub encode_latency_us: u64,
|
||||
pub encoder: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DesktopButton {
|
||||
Left,
|
||||
Middle,
|
||||
Right,
|
||||
Back,
|
||||
Forward,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DesktopKeyState {
|
||||
Pressed,
|
||||
Released,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum DesktopInputEvent {
|
||||
PointerMove {
|
||||
x: u16,
|
||||
y: u16,
|
||||
},
|
||||
PointerDelta {
|
||||
delta_x: i16,
|
||||
delta_y: i16,
|
||||
},
|
||||
PointerButton {
|
||||
button: DesktopButton,
|
||||
pressed: bool,
|
||||
},
|
||||
Wheel {
|
||||
horizontal: i16,
|
||||
vertical: i16,
|
||||
},
|
||||
Key {
|
||||
keysym: u32,
|
||||
state: DesktopKeyState,
|
||||
},
|
||||
ReleaseAll,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ClientCommand {
|
||||
Pair {
|
||||
protocol_major: u16,
|
||||
protocol_minor: u16,
|
||||
client_name: String,
|
||||
client_public_key: String,
|
||||
signature: String,
|
||||
code: String,
|
||||
},
|
||||
Authenticate {
|
||||
protocol_major: u16,
|
||||
protocol_minor: u16,
|
||||
client_public_key: String,
|
||||
signature: String,
|
||||
},
|
||||
Status,
|
||||
OpenTerminal {
|
||||
user: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
},
|
||||
BeginDirectWebRtc {
|
||||
user: String,
|
||||
max_width: u16,
|
||||
max_height: u16,
|
||||
frames_per_second: u8,
|
||||
#[serde(default)]
|
||||
opus_audio: bool,
|
||||
#[serde(default)]
|
||||
clipboard_read: bool,
|
||||
#[serde(default)]
|
||||
clipboard_write: bool,
|
||||
},
|
||||
DirectWebRtcSignal {
|
||||
kind: String,
|
||||
payload: String,
|
||||
},
|
||||
AbortDirectWebRtc,
|
||||
OpenDesktop {
|
||||
user: String,
|
||||
max_width: u16,
|
||||
max_height: u16,
|
||||
frames_per_second: u8,
|
||||
#[serde(default)]
|
||||
resume_token: Option<String>,
|
||||
#[serde(default)]
|
||||
client_fingerprint: Option<String>,
|
||||
#[serde(default)]
|
||||
edge_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
webrtc_h264: bool,
|
||||
#[serde(default)]
|
||||
opus_audio: bool,
|
||||
#[serde(default)]
|
||||
clipboard_read: bool,
|
||||
#[serde(default)]
|
||||
clipboard_write: bool,
|
||||
},
|
||||
DesktopInput {
|
||||
event: DesktopInputEvent,
|
||||
},
|
||||
DesktopFrameAck {
|
||||
sequence: u64,
|
||||
},
|
||||
DesktopH264FrameAck {
|
||||
sequence: u64,
|
||||
},
|
||||
DesktopVideoMode {
|
||||
mode: DesktopVideoMode,
|
||||
},
|
||||
DesktopResize {
|
||||
max_width: u16,
|
||||
max_height: u16,
|
||||
},
|
||||
DesktopPing {
|
||||
nonce: u64,
|
||||
},
|
||||
DesktopClipboardOffer {
|
||||
sequence: u64,
|
||||
utf8_bytes: u32,
|
||||
sha256: String,
|
||||
},
|
||||
DesktopClipboardRequest {
|
||||
sequence: u64,
|
||||
},
|
||||
DesktopClipboardData {
|
||||
sequence: u64,
|
||||
data: String,
|
||||
},
|
||||
TerminalInput {
|
||||
data: String,
|
||||
},
|
||||
TerminalResize {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
},
|
||||
TerminalSignal {
|
||||
signal: String,
|
||||
},
|
||||
UploadFile {
|
||||
user: String,
|
||||
path: String,
|
||||
size: u64,
|
||||
sha256: String,
|
||||
transfer_id: String,
|
||||
},
|
||||
DownloadFile {
|
||||
user: String,
|
||||
path: String,
|
||||
offset: u64,
|
||||
},
|
||||
FileChunk {
|
||||
data: String,
|
||||
},
|
||||
FileCommit,
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ServerEvent {
|
||||
Hello {
|
||||
protocol_major: u16,
|
||||
protocol_minor: u16,
|
||||
device_public_key: String,
|
||||
tls_certificate_sha256: String,
|
||||
challenge: String,
|
||||
terminal: bool,
|
||||
desktop: bool,
|
||||
files: bool,
|
||||
edge_presence_configured: bool,
|
||||
edge_presence_online: bool,
|
||||
edge_signaling_configured: bool,
|
||||
edge_signaling_online: bool,
|
||||
},
|
||||
Paired {
|
||||
client_fingerprint: String,
|
||||
permissions: Vec<String>,
|
||||
},
|
||||
Authenticated {
|
||||
client_fingerprint: String,
|
||||
permissions: Vec<String>,
|
||||
},
|
||||
Status {
|
||||
session_agents: usize,
|
||||
terminal: bool,
|
||||
desktop: bool,
|
||||
files: bool,
|
||||
edge_presence_configured: bool,
|
||||
edge_presence_online: bool,
|
||||
edge_signaling_configured: bool,
|
||||
edge_signaling_online: bool,
|
||||
},
|
||||
TerminalOpened,
|
||||
TerminalOutput {
|
||||
data: String,
|
||||
},
|
||||
TerminalExited {
|
||||
exit_code: Option<i32>,
|
||||
},
|
||||
DirectWebRtcReady,
|
||||
DirectWebRtcSignal {
|
||||
kind: String,
|
||||
payload: String,
|
||||
},
|
||||
DirectWebRtcUnavailable {
|
||||
reason_code: String,
|
||||
},
|
||||
DesktopOpened {
|
||||
width: u16,
|
||||
height: u16,
|
||||
encoding: DesktopFrameEncoding,
|
||||
resume_token: String,
|
||||
resumed: bool,
|
||||
},
|
||||
DesktopFrameStart {
|
||||
metadata: DesktopFrameMetadata,
|
||||
},
|
||||
DesktopFrameChunk {
|
||||
sequence: u64,
|
||||
index: u16,
|
||||
data: String,
|
||||
},
|
||||
DesktopFrameComplete {
|
||||
sequence: u64,
|
||||
sha256: String,
|
||||
},
|
||||
DesktopH264Start {
|
||||
metadata: DesktopH264Metadata,
|
||||
},
|
||||
DesktopH264Chunk {
|
||||
sequence: u64,
|
||||
index: u16,
|
||||
data: String,
|
||||
},
|
||||
DesktopH264Complete {
|
||||
sequence: u64,
|
||||
sha256: String,
|
||||
},
|
||||
DesktopH264Unavailable {
|
||||
reason_code: String,
|
||||
},
|
||||
DesktopOpusPacket {
|
||||
sequence: u64,
|
||||
duration_us: u64,
|
||||
encode_latency_us: u64,
|
||||
data: String,
|
||||
},
|
||||
DesktopOpusUnavailable {
|
||||
reason_code: String,
|
||||
},
|
||||
DesktopVideoModeChanged {
|
||||
mode: DesktopVideoMode,
|
||||
},
|
||||
DesktopClosed {
|
||||
reason: String,
|
||||
},
|
||||
DesktopPong {
|
||||
nonce: u64,
|
||||
},
|
||||
DesktopClipboardOffer {
|
||||
sequence: u64,
|
||||
utf8_bytes: u32,
|
||||
sha256: String,
|
||||
},
|
||||
DesktopClipboardRequest {
|
||||
sequence: u64,
|
||||
},
|
||||
DesktopClipboardData {
|
||||
sequence: u64,
|
||||
data: String,
|
||||
},
|
||||
FileReady {
|
||||
direction: String,
|
||||
size: u64,
|
||||
sha256: Option<String>,
|
||||
offset: u64,
|
||||
},
|
||||
FileChunk {
|
||||
data: String,
|
||||
},
|
||||
FileComplete {
|
||||
size: u64,
|
||||
sha256: String,
|
||||
},
|
||||
Error {
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum IpcMessage {
|
||||
Hello {
|
||||
protocol_major: u16,
|
||||
protocol_minor: u16,
|
||||
pid: u32,
|
||||
uid: u32,
|
||||
desktop_backend: String,
|
||||
#[serde(default)]
|
||||
desktop_socket: Option<String>,
|
||||
},
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum IpcResponse {
|
||||
Accepted { protocol_minor: u16 },
|
||||
Alive,
|
||||
Error { code: String, message: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ShellCommand {
|
||||
Input { data: String },
|
||||
Resize { cols: u16, rows: u16 },
|
||||
Signal { signal: String },
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ShellEvent {
|
||||
Ready,
|
||||
Output { data: String },
|
||||
Exited { exit_code: Option<i32> },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct FileResult {
|
||||
pub size: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct FileTransferReady {
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn externally_tagged_commands_reject_unknown_types() {
|
||||
let error = serde_json::from_str::<ClientCommand>(r#"{"type":"format_disk"}"#).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown variant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_terminal_round_trips() {
|
||||
let command = ClientCommand::OpenTerminal {
|
||||
user: "alice".into(),
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
};
|
||||
let encoded = serde_json::to_string(&command).unwrap();
|
||||
let decoded: ClientCommand = serde_json::from_str(&encoded).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
ClientCommand::OpenTerminal {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_commands_are_explicit_and_bounded_by_the_runtime() {
|
||||
let command = ClientCommand::UploadFile {
|
||||
user: "alice".into(),
|
||||
path: "Documents/report.bin".into(),
|
||||
size: 42,
|
||||
sha256: "ab".repeat(32),
|
||||
transfer_id: "cd".repeat(16),
|
||||
};
|
||||
let encoded = serde_json::to_string(&command).unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&encoded).unwrap(),
|
||||
ClientCommand::UploadFile { size: 42, .. }
|
||||
));
|
||||
assert_eq!(FILE_CHUNK_BYTES, 46_080);
|
||||
assert_eq!(MAX_FILE_BYTES, 2_147_483_648);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_commands_are_explicit_and_do_not_accept_unknown_input_fields() {
|
||||
let command = ClientCommand::OpenDesktop {
|
||||
user: "alice".into(),
|
||||
max_width: 1_920,
|
||||
max_height: 1_080,
|
||||
frames_per_second: 15,
|
||||
resume_token: None,
|
||||
client_fingerprint: None,
|
||||
edge_session_id: None,
|
||||
webrtc_h264: false,
|
||||
opus_audio: false,
|
||||
clipboard_read: false,
|
||||
clipboard_write: false,
|
||||
};
|
||||
let encoded = serde_json::to_string(&command).unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&encoded).unwrap(),
|
||||
ClientCommand::OpenDesktop {
|
||||
frames_per_second: 15,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
serde_json::from_str::<DesktopInputEvent>(
|
||||
r#"{"kind":"pointer_move","x":10,"y":20,"command":"shell"}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let relative: DesktopInputEvent =
|
||||
serde_json::from_str(r#"{"kind":"pointer_delta","delta_x":-12,"delta_y":34}"#).unwrap();
|
||||
assert_eq!(
|
||||
relative,
|
||||
DesktopInputEvent::PointerDelta {
|
||||
delta_x: -12,
|
||||
delta_y: 34
|
||||
}
|
||||
);
|
||||
assert_eq!(DESKTOP_FRAME_CHUNK_BYTES, FILE_CHUNK_BYTES);
|
||||
let ping = serde_json::to_string(&ClientCommand::DesktopPing { nonce: 42 }).unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&ping).unwrap(),
|
||||
ClientCommand::DesktopPing { nonce: 42 }
|
||||
));
|
||||
assert!(
|
||||
serde_json::from_str::<ClientCommand>(
|
||||
r#"{"type":"desktop_ping","nonce":42,"ignored":true}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let resize = serde_json::to_string(&ClientCommand::DesktopResize {
|
||||
max_width: 1600,
|
||||
max_height: 900,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&resize).unwrap(),
|
||||
ClientCommand::DesktopResize {
|
||||
max_width: 1600,
|
||||
max_height: 900
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
serde_json::from_str::<ClientCommand>(
|
||||
r#"{"type":"desktop_resize","max_width":1600,"max_height":900,"ignored":true}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let resumed: ClientCommand = serde_json::from_str(
|
||||
r#"{"type":"open_desktop","user":"alice","max_width":1600,"max_height":900,"frames_per_second":15,"resume_token":"token"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
resumed,
|
||||
ClientCommand::OpenDesktop {
|
||||
resume_token: Some(token),
|
||||
client_fingerprint: None,
|
||||
edge_session_id: None,
|
||||
webrtc_h264: false,
|
||||
opus_audio: false,
|
||||
clipboard_read: false,
|
||||
clipboard_write: false,
|
||||
..
|
||||
} if token == "token"
|
||||
));
|
||||
let opus_open: ClientCommand = serde_json::from_str(
|
||||
r#"{"type":"open_desktop","user":"alice","max_width":1600,"max_height":900,"frames_per_second":15,"webrtc_h264":true,"opus_audio":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
opus_open,
|
||||
ClientCommand::OpenDesktop {
|
||||
webrtc_h264: true,
|
||||
opus_audio: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
let opened = serde_json::to_string(&ServerEvent::DesktopOpened {
|
||||
width: 1_600,
|
||||
height: 900,
|
||||
encoding: DesktopFrameEncoding::ZlibBgra,
|
||||
resume_token: "rotated-token".into(),
|
||||
resumed: true,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&opened).unwrap(),
|
||||
ServerEvent::DesktopOpened {
|
||||
width: 1_600,
|
||||
height: 900,
|
||||
encoding: DesktopFrameEncoding::ZlibBgra,
|
||||
resume_token,
|
||||
resumed: true,
|
||||
} if resume_token == "rotated-token"
|
||||
));
|
||||
let native_opened = serde_json::to_string(&ServerEvent::DesktopOpened {
|
||||
width: 1_920,
|
||||
height: 1_080,
|
||||
encoding: DesktopFrameEncoding::WebRtcH264,
|
||||
resume_token: "wayland-rotated-token".into(),
|
||||
resumed: true,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&native_opened).unwrap(),
|
||||
ServerEvent::DesktopOpened {
|
||||
encoding: DesktopFrameEncoding::WebRtcH264,
|
||||
resume_token,
|
||||
resumed: true,
|
||||
..
|
||||
} if resume_token == "wayland-rotated-token"
|
||||
));
|
||||
assert_eq!(PROTOCOL_MINOR, 15);
|
||||
let direct_begin = ClientCommand::BeginDirectWebRtc {
|
||||
user: "alice".into(),
|
||||
max_width: 1_920,
|
||||
max_height: 1_080,
|
||||
frames_per_second: 30,
|
||||
opus_audio: true,
|
||||
clipboard_read: true,
|
||||
clipboard_write: false,
|
||||
};
|
||||
let direct_begin = serde_json::to_string(&direct_begin).unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&direct_begin).unwrap(),
|
||||
ClientCommand::BeginDirectWebRtc {
|
||||
user,
|
||||
max_width: 1_920,
|
||||
max_height: 1_080,
|
||||
frames_per_second: 30,
|
||||
opus_audio: true,
|
||||
clipboard_read: true,
|
||||
clipboard_write: false,
|
||||
} if user == "alice"
|
||||
));
|
||||
let legacy_direct: ClientCommand = serde_json::from_str(
|
||||
r#"{"type":"begin_direct_web_rtc","user":"alice","max_width":1920,"max_height":1080,"frames_per_second":30}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
legacy_direct,
|
||||
ClientCommand::BeginDirectWebRtc {
|
||||
opus_audio: false,
|
||||
clipboard_read: false,
|
||||
clipboard_write: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
let clipboard_offer = ClientCommand::DesktopClipboardOffer {
|
||||
sequence: 7,
|
||||
utf8_bytes: 5,
|
||||
sha256: "ab".repeat(32),
|
||||
};
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&serde_json::to_string(&clipboard_offer).unwrap()).unwrap(),
|
||||
ClientCommand::DesktopClipboardOffer {
|
||||
sequence: 7,
|
||||
utf8_bytes: 5,
|
||||
..
|
||||
}
|
||||
));
|
||||
let clipboard_data = ServerEvent::DesktopClipboardData {
|
||||
sequence: 7,
|
||||
data: "aGVsbG8".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&serde_json::to_string(&clipboard_data).unwrap()).unwrap(),
|
||||
ServerEvent::DesktopClipboardData { sequence: 7, data }
|
||||
if data == "aGVsbG8"
|
||||
));
|
||||
assert_eq!(DESKTOP_MAX_CLIPBOARD_BYTES, 32_768);
|
||||
let direct_signal = ServerEvent::DirectWebRtcSignal {
|
||||
kind: "ice_end".into(),
|
||||
payload: String::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&serde_json::to_string(&direct_signal).unwrap()).unwrap(),
|
||||
ServerEvent::DirectWebRtcSignal { kind, payload }
|
||||
if kind == "ice_end" && payload.is_empty()
|
||||
));
|
||||
let client_signal = ClientCommand::DirectWebRtcSignal {
|
||||
kind: "ice_candidate".into(),
|
||||
payload: "candidate".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&serde_json::to_string(&client_signal).unwrap()).unwrap(),
|
||||
ClientCommand::DirectWebRtcSignal { kind, payload }
|
||||
if kind == "ice_candidate" && payload == "candidate"
|
||||
));
|
||||
assert!(matches!(
|
||||
serde_json::from_str(
|
||||
&serde_json::to_string(&ClientCommand::AbortDirectWebRtc).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
ClientCommand::AbortDirectWebRtc
|
||||
));
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&serde_json::to_string(&ServerEvent::DirectWebRtcReady).unwrap())
|
||||
.unwrap(),
|
||||
ServerEvent::DirectWebRtcReady
|
||||
));
|
||||
let unavailable = ServerEvent::DirectWebRtcUnavailable {
|
||||
reason_code: "connect_timeout".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&serde_json::to_string(&unavailable).unwrap()).unwrap(),
|
||||
ServerEvent::DirectWebRtcUnavailable { reason_code }
|
||||
if reason_code == "connect_timeout"
|
||||
));
|
||||
let native_mode = serde_json::to_string(&ClientCommand::DesktopVideoMode {
|
||||
mode: DesktopVideoMode::WebRtcH264,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&native_mode).unwrap(),
|
||||
ClientCommand::DesktopVideoMode {
|
||||
mode: DesktopVideoMode::WebRtcH264
|
||||
}
|
||||
));
|
||||
let native_ack =
|
||||
serde_json::to_string(&ClientCommand::DesktopH264FrameAck { sequence: 91 }).unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&native_ack).unwrap(),
|
||||
ClientCommand::DesktopH264FrameAck { sequence: 91 }
|
||||
));
|
||||
let opus = ServerEvent::DesktopOpusPacket {
|
||||
sequence: 17,
|
||||
duration_us: 20_000,
|
||||
encode_latency_us: 1_500,
|
||||
data: "AQID".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&serde_json::to_string(&opus).unwrap()).unwrap(),
|
||||
ServerEvent::DesktopOpusPacket {
|
||||
sequence: 17,
|
||||
duration_us: 20_000,
|
||||
encode_latency_us: 1_500,
|
||||
data,
|
||||
} if data == "AQID"
|
||||
));
|
||||
let opus_unavailable = ServerEvent::DesktopOpusUnavailable {
|
||||
reason_code: "audio_monitor_unavailable".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&serde_json::to_string(&opus_unavailable).unwrap()).unwrap(),
|
||||
ServerEvent::DesktopOpusUnavailable { reason_code }
|
||||
if reason_code == "audio_monitor_unavailable"
|
||||
));
|
||||
let mode_changed = serde_json::to_string(&ServerEvent::DesktopVideoModeChanged {
|
||||
mode: DesktopVideoMode::WebRtcH264,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&mode_changed).unwrap(),
|
||||
ServerEvent::DesktopVideoModeChanged {
|
||||
mode: DesktopVideoMode::WebRtcH264
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
serde_json::from_str::<ClientCommand>(
|
||||
r#"{"type":"desktop_video_mode","mode":"webrtc_h264","ignored":true}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let closed = serde_json::to_string(&ServerEvent::DesktopClosed {
|
||||
reason: "client_closed".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str(&closed).unwrap(),
|
||||
ServerEvent::DesktopClosed { reason } if reason == "client_closed"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_frame_metadata_requires_the_negotiated_compression_level() {
|
||||
let metadata = DesktopFrameMetadata {
|
||||
sequence: 9,
|
||||
width: 320,
|
||||
height: 200,
|
||||
encoding: DesktopFrameEncoding::ZlibBgra,
|
||||
uncompressed_bytes: 256_000,
|
||||
compressed_bytes: 4_096,
|
||||
chunk_count: 1,
|
||||
captured_at_unix_ms: 10,
|
||||
capture_latency_us: 20,
|
||||
encode_latency_us: 30,
|
||||
compression_level: 4,
|
||||
};
|
||||
let encoded = serde_json::to_value(&metadata).unwrap();
|
||||
assert_eq!(encoded["compression_level"], 4);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<DesktopFrameMetadata>(encoded.clone())
|
||||
.unwrap()
|
||||
.compression_level,
|
||||
4
|
||||
);
|
||||
let mut missing = encoded.as_object().unwrap().clone();
|
||||
missing.remove("compression_level");
|
||||
assert!(serde_json::from_value::<DesktopFrameMetadata>(missing.into()).is_err());
|
||||
let mut unknown = encoded.as_object().unwrap().clone();
|
||||
unknown.insert("quality".into(), serde_json::json!("auto"));
|
||||
assert!(serde_json::from_value::<DesktopFrameMetadata>(unknown.into()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_ipc_events_round_trip_without_relaxing_unknown_fields() {
|
||||
let event = ServerEvent::DesktopH264Start {
|
||||
metadata: DesktopH264Metadata {
|
||||
sequence: 11,
|
||||
width: 1_920,
|
||||
height: 1_080,
|
||||
captured_at_unix_ms: 1_700_000_000_000,
|
||||
capture_latency_us: 1_000,
|
||||
access_unit_bytes: 4_096,
|
||||
chunk_count: 1,
|
||||
duration_us: 33_333,
|
||||
keyframe: true,
|
||||
encode_latency_us: 2_000,
|
||||
encoder: "vah264enc".into(),
|
||||
},
|
||||
};
|
||||
let encoded = serde_json::to_value(event).unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_value::<ServerEvent>(encoded.clone()).unwrap(),
|
||||
ServerEvent::DesktopH264Start { metadata }
|
||||
if metadata.sequence == 11 && metadata.keyframe
|
||||
));
|
||||
let mut invalid = encoded.as_object().unwrap().clone();
|
||||
invalid.insert("unexpected".into(), serde_json::Value::Bool(true));
|
||||
assert!(serde_json::from_value::<ServerEvent>(invalid.into()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
use std::{
|
||||
fmt,
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
io::Write as _,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::RngCore as _;
|
||||
use rcgen::generate_simple_self_signed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::PairingCode;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StateError {
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
Invalid(String),
|
||||
Certificate(rcgen::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for StateError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "state I/O failed: {error}"),
|
||||
Self::Json(error) => write!(formatter, "state JSON failed: {error}"),
|
||||
Self::Invalid(error) => write!(formatter, "invalid state: {error}"),
|
||||
Self::Certificate(error) => {
|
||||
write!(formatter, "TLS certificate generation failed: {error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StateError {}
|
||||
|
||||
impl From<std::io::Error> for StateError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for StateError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Json(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rcgen::Error> for StateError {
|
||||
fn from(value: rcgen::Error) -> Self {
|
||||
Self::Certificate(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DeviceIdentity {
|
||||
pub schema: u8,
|
||||
pub private_key: String,
|
||||
pub public_key: String,
|
||||
pub tls_certificate_pem: String,
|
||||
pub tls_private_key_pem: String,
|
||||
}
|
||||
|
||||
impl DeviceIdentity {
|
||||
fn generate(hostname: &str) -> Result<Self, StateError> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
let signing = SigningKey::from_bytes(&bytes);
|
||||
let certified = generate_simple_self_signed(vec![hostname.to_owned(), "localhost".into()])?;
|
||||
Ok(Self {
|
||||
schema: 1,
|
||||
private_key: STANDARD_NO_PAD.encode(signing.to_bytes()),
|
||||
public_key: STANDARD_NO_PAD.encode(signing.verifying_key().as_bytes()),
|
||||
tls_certificate_pem: certified.cert.pem(),
|
||||
tls_private_key_pem: certified.signing_key.serialize_pem(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the SHA-256 fingerprint of the first DER certificate in the PEM chain.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the stored PEM cannot be parsed or has no certificate.
|
||||
pub fn tls_certificate_fingerprint(&self) -> Result<String, StateError> {
|
||||
let mut reader = std::io::BufReader::new(self.tls_certificate_pem.as_bytes());
|
||||
let certificate = rustls_pemfile::certs(&mut reader)
|
||||
.next()
|
||||
.transpose()?
|
||||
.ok_or_else(|| StateError::Invalid("TLS certificate PEM is empty".into()))?;
|
||||
Ok(hex_digest(&Sha256::digest(certificate.as_ref())))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ClientGrant {
|
||||
pub name: String,
|
||||
pub public_key: String,
|
||||
pub fingerprint: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub allowed_users: Vec<String>,
|
||||
pub paired_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct PairingGrant {
|
||||
pub salt: String,
|
||||
pub code_digest: String,
|
||||
pub expires_at: u64,
|
||||
pub attempts_remaining: u8,
|
||||
pub permissions: Vec<String>,
|
||||
pub allowed_users: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct EdgePresenceStatus {
|
||||
pub schema: u8,
|
||||
pub configured: bool,
|
||||
pub online: bool,
|
||||
pub device_id: String,
|
||||
pub region: String,
|
||||
pub gateway_id: String,
|
||||
pub expires_unix: Option<u64>,
|
||||
pub last_success_unix: Option<u64>,
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub signal_requests_received: u64,
|
||||
#[serde(default)]
|
||||
pub signal_requests_accepted: u64,
|
||||
#[serde(default)]
|
||||
pub signal_requests_rejected: u64,
|
||||
#[serde(default)]
|
||||
pub last_signal_unix: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub last_signal_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AgentState {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl AgentState {
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Loads the device identity, creating it with owner-only permissions when absent.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when state I/O, parsing, or certificate generation fails.
|
||||
pub fn ensure_identity(&self, hostname: &str) -> Result<DeviceIdentity, StateError> {
|
||||
fs::create_dir_all(&self.root)?;
|
||||
set_owner_only_directory(&self.root)?;
|
||||
let path = self.root.join("device.json");
|
||||
if path.is_file() {
|
||||
return read_json(&path);
|
||||
}
|
||||
let identity = DeviceIdentity::generate(hostname)?;
|
||||
write_json_atomic(&path, &identity)?;
|
||||
set_owner_only_file(&path)?;
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Loads the existing device identity.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the identity is absent, unreadable, or malformed.
|
||||
pub fn identity(&self) -> Result<DeviceIdentity, StateError> {
|
||||
read_json(&self.root.join("device.json"))
|
||||
}
|
||||
|
||||
/// Loads the latest non-secret Edge Presence state written by the daemon.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the status file exists but cannot be read or parsed.
|
||||
pub fn edge_presence_status(&self) -> Result<Option<EdgePresenceStatus>, StateError> {
|
||||
let path = self.root.join("edge-presence.json");
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
read_json(&path).map(Some)
|
||||
}
|
||||
|
||||
/// Atomically writes non-secret Edge Presence health for the local status CLI.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when owner-only state cannot be persisted.
|
||||
pub fn write_edge_presence_status(
|
||||
&self,
|
||||
status: &EdgePresenceStatus,
|
||||
) -> Result<(), StateError> {
|
||||
let path = self.root.join("edge-presence.json");
|
||||
write_json_atomic(&path, status)?;
|
||||
set_owner_only_file(&path)
|
||||
}
|
||||
|
||||
/// Creates a rate-limited, expiring pairing grant and returns its one-time code.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for an invalid TTL or a state persistence failure.
|
||||
pub fn create_pairing_grant(
|
||||
&self,
|
||||
ttl_seconds: u64,
|
||||
permissions: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
) -> Result<PairingCode, StateError> {
|
||||
if !(30..=900).contains(&ttl_seconds) {
|
||||
return Err(StateError::Invalid(
|
||||
"pairing TTL must be between 30 and 900 seconds".into(),
|
||||
));
|
||||
}
|
||||
let code = PairingCode::generate();
|
||||
let mut salt = [0_u8; 16];
|
||||
rand::rng().fill_bytes(&mut salt);
|
||||
let grant = PairingGrant {
|
||||
salt: STANDARD_NO_PAD.encode(salt),
|
||||
code_digest: code.salted_digest(&salt),
|
||||
expires_at: unix_timestamp().saturating_add(ttl_seconds),
|
||||
attempts_remaining: 5,
|
||||
permissions,
|
||||
allowed_users,
|
||||
};
|
||||
write_json_atomic(&self.root.join("pairing.json"), &grant)?;
|
||||
set_owner_only_file(&self.root.join("pairing.json"))?;
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Reports whether the current local pairing window can admit one remote relay request.
|
||||
///
|
||||
/// This does not expose or consume the pairing code. The code is still verified and consumed
|
||||
/// only inside the certificate-pinned Agent session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when an existing pairing grant cannot be read or parsed.
|
||||
pub fn pairing_grant_allows(&self, user: &str, permission: &str) -> Result<bool, StateError> {
|
||||
let path = self.root.join("pairing.json");
|
||||
if !path.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let grant: PairingGrant = read_json(&path)?;
|
||||
if unix_timestamp() > grant.expires_at || grant.attempts_remaining == 0 {
|
||||
let _ = fs::remove_file(path);
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(grant.allowed_users.iter().any(|allowed| allowed == user)
|
||||
&& grant
|
||||
.permissions
|
||||
.iter()
|
||||
.any(|allowed| allowed == permission))
|
||||
}
|
||||
|
||||
/// Validates and consumes the current one-time pairing grant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for a missing, expired, malformed, exhausted, or incorrect code.
|
||||
pub fn consume_pairing_grant(&self, code: &str) -> Result<PairingGrant, StateError> {
|
||||
let path = self.root.join("pairing.json");
|
||||
let mut grant: PairingGrant = read_json(&path)?;
|
||||
if unix_timestamp() > grant.expires_at {
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(StateError::Invalid("pairing code expired".into()));
|
||||
}
|
||||
if grant.attempts_remaining == 0 {
|
||||
let _ = fs::remove_file(&path);
|
||||
return Err(StateError::Invalid("pairing attempt limit reached".into()));
|
||||
}
|
||||
let salt = STANDARD_NO_PAD
|
||||
.decode(&grant.salt)
|
||||
.map_err(|_| StateError::Invalid("pairing salt is invalid".into()))?;
|
||||
let matches = PairingCode::parse(code)
|
||||
.is_ok_and(|parsed| parsed.salted_digest(&salt) == grant.code_digest);
|
||||
if !matches {
|
||||
grant.attempts_remaining = grant.attempts_remaining.saturating_sub(1);
|
||||
write_json_atomic(&path, &grant)?;
|
||||
return Err(StateError::Invalid("pairing code did not match".into()));
|
||||
}
|
||||
fs::remove_file(path)?;
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
/// Loads all paired client grants.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the authorization store cannot be read or parsed.
|
||||
pub fn clients(&self) -> Result<Vec<ClientGrant>, StateError> {
|
||||
let path = self.root.join("clients.json");
|
||||
if !path.is_file() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
read_json(&path)
|
||||
}
|
||||
|
||||
/// Adds or replaces a client grant after validating its Ed25519 public key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for invalid client metadata or a state persistence failure.
|
||||
pub fn grant_client(
|
||||
&self,
|
||||
name: String,
|
||||
public_key: String,
|
||||
permissions: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
) -> Result<ClientGrant, StateError> {
|
||||
let key = STANDARD_NO_PAD
|
||||
.decode(&public_key)
|
||||
.map_err(|_| StateError::Invalid("client public key is not valid base64".into()))?;
|
||||
let key_array: [u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| StateError::Invalid("client public key must contain 32 bytes".into()))?;
|
||||
ed25519_dalek::VerifyingKey::from_bytes(&key_array)
|
||||
.map_err(|_| StateError::Invalid("client public key is invalid".into()))?;
|
||||
if name.trim().is_empty() || name.len() > 80 {
|
||||
return Err(StateError::Invalid(
|
||||
"client name must contain 1 to 80 characters".into(),
|
||||
));
|
||||
}
|
||||
let fingerprint = public_key_fingerprint(&key_array);
|
||||
let client = ClientGrant {
|
||||
name,
|
||||
public_key,
|
||||
fingerprint,
|
||||
permissions,
|
||||
allowed_users,
|
||||
paired_at: unix_timestamp(),
|
||||
};
|
||||
let mut clients = self.clients()?;
|
||||
clients.retain(|existing| existing.public_key != client.public_key);
|
||||
clients.push(client.clone());
|
||||
write_json_atomic(&self.root.join("clients.json"), &clients)?;
|
||||
set_owner_only_file(&self.root.join("clients.json"))?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Removes a paired client by fingerprint.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the authorization store cannot be read or updated.
|
||||
pub fn revoke_client(&self, fingerprint: &str) -> Result<bool, StateError> {
|
||||
let mut clients = self.clients()?;
|
||||
let original_len = clients.len();
|
||||
clients.retain(|client| client.fingerprint != fingerprint);
|
||||
if clients.len() == original_len {
|
||||
return Ok(false);
|
||||
}
|
||||
write_json_atomic(&self.root.join("clients.json"), &clients)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn unix_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn public_key_fingerprint(key: &[u8; 32]) -> String {
|
||||
let digest = Sha256::digest(key);
|
||||
hex_digest(&digest[..16])
|
||||
}
|
||||
|
||||
fn hex_digest(digest: &[u8]) -> String {
|
||||
let mut output = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, StateError> {
|
||||
Ok(serde_json::from_slice(&fs::read(path)?)?)
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), StateError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| StateError::Invalid("state path has no parent".into()))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let temporary = parent.join(format!(
|
||||
".{}.{}.tmp",
|
||||
path.file_name().unwrap_or_default().to_string_lossy(),
|
||||
std::process::id()
|
||||
));
|
||||
let mut file = create_private_file(&temporary)?;
|
||||
serde_json::to_writer_pretty(&mut file, value)?;
|
||||
file.write_all(b"\n")?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temporary, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn create_private_file(path: &Path) -> Result<fs::File, StateError> {
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
Ok(fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn create_private_file(path: &Path) -> Result<fs::File, StateError> {
|
||||
Ok(fs::File::create(path)?)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_owner_only_directory(path: &Path) -> Result<(), StateError> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn set_owner_only_directory(_path: &Path) -> Result<(), StateError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_owner_only_file(path: &Path) -> Result<(), StateError> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn set_owner_only_file(_path: &Path) -> Result<(), StateError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_root(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"remotedesk-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
rand::random::<u64>()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_is_stable() {
|
||||
let root = test_root("identity");
|
||||
let state = AgentState::new(&root);
|
||||
let first = state.ensure_identity("test-host").unwrap();
|
||||
let second = state.ensure_identity("different-host").unwrap();
|
||||
assert_eq!(first.public_key, second.public_key);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_code_is_one_time_and_client_can_be_revoked() {
|
||||
let root = test_root("pairing");
|
||||
let state = AgentState::new(&root);
|
||||
state.ensure_identity("test-host").unwrap();
|
||||
let code = state
|
||||
.create_pairing_grant(60, vec!["terminal".into()], vec!["alice".into()])
|
||||
.unwrap();
|
||||
assert!(state.pairing_grant_allows("alice", "terminal").unwrap());
|
||||
assert!(!state.pairing_grant_allows("bob", "terminal").unwrap());
|
||||
assert!(!state.pairing_grant_allows("alice", "files").unwrap());
|
||||
let grant = state.consume_pairing_grant(code.expose()).unwrap();
|
||||
assert!(!state.pairing_grant_allows("alice", "terminal").unwrap());
|
||||
assert!(state.consume_pairing_grant(code.expose()).is_err());
|
||||
let signing = SigningKey::from_bytes(&[3_u8; 32]);
|
||||
let client = state
|
||||
.grant_client(
|
||||
"test client".into(),
|
||||
STANDARD_NO_PAD.encode(signing.verifying_key().as_bytes()),
|
||||
grant.permissions,
|
||||
grant.allowed_users,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.clients().unwrap().len(), 1);
|
||||
assert!(state.revoke_client(&client.fingerprint).unwrap());
|
||||
assert!(state.clients().unwrap().is_empty());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_pairing_is_rate_limited() {
|
||||
let root = test_root("rate-limit");
|
||||
let state = AgentState::new(&root);
|
||||
state.ensure_identity("test-host").unwrap();
|
||||
let code = state.create_pairing_grant(60, vec![], vec![]).unwrap();
|
||||
let wrong_code = if code.expose() == "00000000" {
|
||||
"00000001"
|
||||
} else {
|
||||
"00000000"
|
||||
};
|
||||
assert!(state.consume_pairing_grant("malformed").is_err());
|
||||
for _ in 0..4 {
|
||||
assert!(state.consume_pairing_grant(wrong_code).is_err());
|
||||
}
|
||||
let grant: PairingGrant = read_json(&root.join("pairing.json")).unwrap();
|
||||
assert_eq!(grant.attempts_remaining, 0);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_presence_status_round_trips_without_credentials() {
|
||||
let root = test_root("edge-presence");
|
||||
let state = AgentState::new(&root);
|
||||
let status = EdgePresenceStatus {
|
||||
schema: 1,
|
||||
configured: true,
|
||||
online: true,
|
||||
device_id: "ab".repeat(32),
|
||||
region: "test-region".into(),
|
||||
gateway_id: "gateway-1".into(),
|
||||
expires_unix: Some(100),
|
||||
last_success_unix: Some(40),
|
||||
last_error: None,
|
||||
signal_requests_received: 3,
|
||||
signal_requests_accepted: 2,
|
||||
signal_requests_rejected: 1,
|
||||
last_signal_unix: Some(50),
|
||||
last_signal_error: None,
|
||||
};
|
||||
state.write_edge_presence_status(&status).unwrap();
|
||||
assert_eq!(state.edge_presence_status().unwrap(), Some(status));
|
||||
let persisted = fs::read_to_string(root.join("edge-presence.json")).unwrap();
|
||||
assert!(!persisted.contains("token"));
|
||||
assert!(!persisted.contains("url"));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
io::Read as _,
|
||||
os::{fd::BorrowedFd, unix::net::UnixStream},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures_util::StreamExt as _;
|
||||
use reis::{
|
||||
ei,
|
||||
enumflags2::BitFlags,
|
||||
event::{Device, DeviceCapability, EiEvent},
|
||||
};
|
||||
use rustix::time::{ClockId, clock_gettime};
|
||||
use tokio::{task::JoinHandle, time::timeout};
|
||||
use xkbcommon::xkb;
|
||||
|
||||
use crate::{DesktopButton, DesktopInputEvent, DesktopKeyState, validate_desktop_input};
|
||||
|
||||
const EIS_READY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const EVDEV_KEYCODE_OFFSET: u32 = 8;
|
||||
const BTN_LEFT: u32 = 0x110;
|
||||
const BTN_RIGHT: u32 = 0x111;
|
||||
const BTN_MIDDLE: u32 = 0x112;
|
||||
const BTN_SIDE: u32 = 0x113;
|
||||
const BTN_EXTRA: u32 = 0x114;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WaylandEisError {
|
||||
SocketCloneFailed,
|
||||
ContextFailed,
|
||||
HandshakeFailed,
|
||||
DeviceDiscoveryTimedOut,
|
||||
RequiredDeviceUnavailable,
|
||||
KeymapUnavailable,
|
||||
KeymapInvalid,
|
||||
UnsupportedKeysym,
|
||||
DevicePaused,
|
||||
ProtocolDisconnected,
|
||||
FlushFailed,
|
||||
InvalidInput,
|
||||
}
|
||||
|
||||
impl WaylandEisError {
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::SocketCloneFailed => "eis_socket_clone_failed",
|
||||
Self::ContextFailed => "eis_context_failed",
|
||||
Self::HandshakeFailed => "eis_handshake_failed",
|
||||
Self::DeviceDiscoveryTimedOut => "eis_device_discovery_timed_out",
|
||||
Self::RequiredDeviceUnavailable => "eis_required_device_unavailable",
|
||||
Self::KeymapUnavailable => "eis_keymap_unavailable",
|
||||
Self::KeymapInvalid => "eis_keymap_invalid",
|
||||
Self::UnsupportedKeysym => "eis_unsupported_keysym",
|
||||
Self::DevicePaused => "eis_device_paused",
|
||||
Self::ProtocolDisconnected => "eis_protocol_disconnected",
|
||||
Self::FlushFailed => "eis_flush_failed",
|
||||
Self::InvalidInput => "eis_invalid_input",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WaylandEisError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.code())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WaylandEisError {}
|
||||
|
||||
struct DeviceState {
|
||||
device: Device,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EisState {
|
||||
devices: Vec<DeviceState>,
|
||||
keysym_to_keycode: HashMap<u32, u32>,
|
||||
pressed_keys: Vec<(Device, u32)>,
|
||||
pressed_buttons: Vec<(Device, u32)>,
|
||||
next_sequence: u32,
|
||||
failure: Option<WaylandEisError>,
|
||||
}
|
||||
|
||||
pub struct WaylandEisInput {
|
||||
connection: reis::event::Connection,
|
||||
state: Arc<Mutex<EisState>>,
|
||||
event_task: JoinHandle<()>,
|
||||
frame_width: u16,
|
||||
frame_height: u16,
|
||||
mapping_id: Option<String>,
|
||||
}
|
||||
|
||||
impl WaylandEisInput {
|
||||
pub async fn connect(
|
||||
eis_fd: BorrowedFd<'_>,
|
||||
frame_width: u16,
|
||||
frame_height: u16,
|
||||
mapping_id: Option<&str>,
|
||||
) -> Result<Self, WaylandEisError> {
|
||||
if !valid_frame_size(frame_width, frame_height) {
|
||||
return Err(WaylandEisError::InvalidInput);
|
||||
}
|
||||
let owned_fd = eis_fd
|
||||
.try_clone_to_owned()
|
||||
.map_err(|_| WaylandEisError::SocketCloneFailed)?;
|
||||
let context = ei::Context::new(UnixStream::from(owned_fd))
|
||||
.map_err(|_| WaylandEisError::ContextFailed)?;
|
||||
let (connection, mut events) = timeout(
|
||||
EIS_READY_TIMEOUT,
|
||||
context.handshake_tokio(
|
||||
"RemoteDesk Wayland input",
|
||||
ei::handshake::ContextType::Sender,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| WaylandEisError::HandshakeFailed)?
|
||||
.map_err(|_| WaylandEisError::HandshakeFailed)?;
|
||||
let state = Arc::new(Mutex::new(EisState::default()));
|
||||
let event_state = Arc::clone(&state);
|
||||
let event_connection = connection.clone();
|
||||
let event_task = tokio::task::spawn_local(async move {
|
||||
while let Some(event) = events.next().await {
|
||||
let Ok(event) = event else {
|
||||
mark_failed(&event_state, WaylandEisError::ProtocolDisconnected);
|
||||
return;
|
||||
};
|
||||
if let Err(error) = handle_eis_event(&event_connection, &event_state, event) {
|
||||
mark_failed(&event_state, error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
mark_failed(&event_state, WaylandEisError::ProtocolDisconnected);
|
||||
});
|
||||
let ready = async {
|
||||
loop {
|
||||
{
|
||||
let state = state.lock().expect("EIS state mutex poisoned");
|
||||
if let Some(error) = state.failure {
|
||||
return Err(error);
|
||||
}
|
||||
if required_devices_ready(&state) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
};
|
||||
match timeout(EIS_READY_TIMEOUT, ready).await {
|
||||
Ok(Ok(())) => Ok(Self {
|
||||
connection,
|
||||
state,
|
||||
event_task,
|
||||
frame_width,
|
||||
frame_height,
|
||||
mapping_id: mapping_id.map(str::to_owned),
|
||||
}),
|
||||
Ok(Err(error)) => {
|
||||
event_task.abort();
|
||||
Err(error)
|
||||
}
|
||||
Err(_) => {
|
||||
event_task.abort();
|
||||
Err(WaylandEisError::DeviceDiscoveryTimedOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_frame_size(&mut self, width: u16, height: u16) -> Result<(), WaylandEisError> {
|
||||
if !valid_frame_size(width, height) {
|
||||
return Err(WaylandEisError::InvalidInput);
|
||||
}
|
||||
self.frame_width = width;
|
||||
self.frame_height = height;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn input(&mut self, event: &DesktopInputEvent) -> Result<(), WaylandEisError> {
|
||||
validate_desktop_input(event, self.frame_width, self.frame_height)
|
||||
.map_err(|_| WaylandEisError::InvalidInput)?;
|
||||
let mut state = self.state.lock().expect("EIS state mutex poisoned");
|
||||
if let Some(error) = state.failure {
|
||||
return Err(error);
|
||||
}
|
||||
match event {
|
||||
DesktopInputEvent::PointerMove { x, y } => {
|
||||
let device = active_device::<ei::PointerAbsolute>(&state)?;
|
||||
let (mapped_x, mapped_y) = map_absolute(
|
||||
&device,
|
||||
*x,
|
||||
*y,
|
||||
self.frame_width,
|
||||
self.frame_height,
|
||||
self.mapping_id.as_deref(),
|
||||
)?;
|
||||
device
|
||||
.interface::<ei::PointerAbsolute>()
|
||||
.ok_or(WaylandEisError::RequiredDeviceUnavailable)?
|
||||
.motion_absolute(mapped_x, mapped_y);
|
||||
frame_device(&device, self.connection.serial());
|
||||
}
|
||||
DesktopInputEvent::PointerDelta { delta_x, delta_y } => {
|
||||
let device = active_device::<ei::Pointer>(&state)?;
|
||||
device
|
||||
.interface::<ei::Pointer>()
|
||||
.ok_or(WaylandEisError::RequiredDeviceUnavailable)?
|
||||
.motion_relative(f32::from(*delta_x), f32::from(*delta_y));
|
||||
frame_device(&device, self.connection.serial());
|
||||
}
|
||||
DesktopInputEvent::PointerButton { button, pressed } => {
|
||||
let device = active_device::<ei::Button>(&state)?;
|
||||
let code = button_code(*button);
|
||||
device
|
||||
.interface::<ei::Button>()
|
||||
.ok_or(WaylandEisError::RequiredDeviceUnavailable)?
|
||||
.button(
|
||||
code,
|
||||
if *pressed {
|
||||
ei::button::ButtonState::Press
|
||||
} else {
|
||||
ei::button::ButtonState::Released
|
||||
},
|
||||
);
|
||||
update_pressed(&mut state.pressed_buttons, &device, code, *pressed);
|
||||
frame_device(&device, self.connection.serial());
|
||||
}
|
||||
DesktopInputEvent::Wheel {
|
||||
horizontal,
|
||||
vertical,
|
||||
} => {
|
||||
let device = active_device::<ei::Scroll>(&state)?;
|
||||
device
|
||||
.interface::<ei::Scroll>()
|
||||
.ok_or(WaylandEisError::RequiredDeviceUnavailable)?
|
||||
.scroll_discrete(
|
||||
i32::from(*horizontal).clamp(-32, 32) * 120,
|
||||
i32::from(*vertical).clamp(-32, 32) * 120,
|
||||
);
|
||||
frame_device(&device, self.connection.serial());
|
||||
}
|
||||
DesktopInputEvent::Key {
|
||||
keysym,
|
||||
state: key_state,
|
||||
} => {
|
||||
let device = active_device::<ei::Keyboard>(&state)?;
|
||||
let keycode = *state
|
||||
.keysym_to_keycode
|
||||
.get(keysym)
|
||||
.ok_or(WaylandEisError::UnsupportedKeysym)?;
|
||||
let pressed = *key_state == DesktopKeyState::Pressed;
|
||||
device
|
||||
.interface::<ei::Keyboard>()
|
||||
.ok_or(WaylandEisError::RequiredDeviceUnavailable)?
|
||||
.key(
|
||||
keycode,
|
||||
if pressed {
|
||||
ei::keyboard::KeyState::Press
|
||||
} else {
|
||||
ei::keyboard::KeyState::Released
|
||||
},
|
||||
);
|
||||
update_pressed(&mut state.pressed_keys, &device, keycode, pressed);
|
||||
frame_device(&device, self.connection.serial());
|
||||
}
|
||||
DesktopInputEvent::ReleaseAll => release_all_locked(&self.connection, &mut state),
|
||||
}
|
||||
self.connection
|
||||
.flush()
|
||||
.map_err(|_| WaylandEisError::FlushFailed)
|
||||
}
|
||||
|
||||
pub fn release_all(&mut self) -> Result<(), WaylandEisError> {
|
||||
let mut state = self.state.lock().expect("EIS state mutex poisoned");
|
||||
release_all_locked(&self.connection, &mut state);
|
||||
self.connection
|
||||
.flush()
|
||||
.map_err(|_| WaylandEisError::FlushFailed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WaylandEisInput {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.release_all();
|
||||
if let Ok(mut state) = self.state.lock() {
|
||||
let serial = self.connection.serial();
|
||||
for device in state.devices.iter_mut().filter(|device| device.active) {
|
||||
device.device.device().stop_emulating(serial);
|
||||
device.active = false;
|
||||
}
|
||||
let _ = self.connection.flush();
|
||||
}
|
||||
self.event_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_eis_event(
|
||||
connection: &reis::event::Connection,
|
||||
shared: &Arc<Mutex<EisState>>,
|
||||
event: EiEvent,
|
||||
) -> Result<(), WaylandEisError> {
|
||||
match event {
|
||||
EiEvent::SeatAdded(event) => {
|
||||
event.seat.bind_capabilities(required_capabilities());
|
||||
}
|
||||
EiEvent::DeviceAdded(event) => {
|
||||
event.device.device().ready();
|
||||
let keymap = if event.device.has_capability(DeviceCapability::Keyboard) {
|
||||
Some(build_keysym_map(&event.device)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut state = shared.lock().expect("EIS state mutex poisoned");
|
||||
if let Some(keymap) = keymap {
|
||||
state.keysym_to_keycode.extend(keymap);
|
||||
}
|
||||
state.devices.push(DeviceState {
|
||||
device: event.device,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
EiEvent::DeviceResumed(event) => {
|
||||
let mut state = shared.lock().expect("EIS state mutex poisoned");
|
||||
let sequence = state.next_sequence;
|
||||
state.next_sequence = state.next_sequence.wrapping_add(1);
|
||||
event
|
||||
.device
|
||||
.device()
|
||||
.start_emulating(event.serial, sequence);
|
||||
if let Some(device) = state
|
||||
.devices
|
||||
.iter_mut()
|
||||
.find(|device| device.device == event.device)
|
||||
{
|
||||
device.active = true;
|
||||
}
|
||||
}
|
||||
EiEvent::DevicePaused(event) => {
|
||||
let mut state = shared.lock().expect("EIS state mutex poisoned");
|
||||
if state
|
||||
.devices
|
||||
.iter()
|
||||
.any(|device| device.device == event.device && device.active)
|
||||
{
|
||||
event.device.device().stop_emulating(event.serial);
|
||||
}
|
||||
clear_device_state(&mut state, &event.device);
|
||||
}
|
||||
EiEvent::DeviceRemoved(event) => {
|
||||
let mut state = shared.lock().expect("EIS state mutex poisoned");
|
||||
clear_device_state(&mut state, &event.device);
|
||||
state.devices.retain(|device| device.device != event.device);
|
||||
}
|
||||
EiEvent::Disconnected(_) => {
|
||||
mark_failed(shared, WaylandEisError::ProtocolDisconnected);
|
||||
return Err(WaylandEisError::ProtocolDisconnected);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
connection.flush().map_err(|_| WaylandEisError::FlushFailed)
|
||||
}
|
||||
|
||||
fn required_capabilities() -> BitFlags<DeviceCapability> {
|
||||
let mut capabilities = BitFlags::empty();
|
||||
for capability in [
|
||||
DeviceCapability::Pointer,
|
||||
DeviceCapability::PointerAbsolute,
|
||||
DeviceCapability::Keyboard,
|
||||
DeviceCapability::Scroll,
|
||||
DeviceCapability::Button,
|
||||
] {
|
||||
capabilities.insert(capability);
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
fn required_devices_ready(state: &EisState) -> bool {
|
||||
required_capabilities().iter().all(|capability| {
|
||||
state
|
||||
.devices
|
||||
.iter()
|
||||
.any(|device| device.active && device.device.has_capability(capability))
|
||||
}) && !state.keysym_to_keycode.is_empty()
|
||||
}
|
||||
|
||||
fn active_device<T: ei::Interface>(state: &EisState) -> Result<Device, WaylandEisError> {
|
||||
state
|
||||
.devices
|
||||
.iter()
|
||||
.find(|device| device.active && device.device.interface::<T>().is_some())
|
||||
.map(|device| device.device.clone())
|
||||
.ok_or(WaylandEisError::DevicePaused)
|
||||
}
|
||||
|
||||
fn build_keysym_map(device: &Device) -> Result<HashMap<u32, u32>, WaylandEisError> {
|
||||
let keymap = device.keymap().ok_or(WaylandEisError::KeymapUnavailable)?;
|
||||
if keymap.type_ != ei::keyboard::KeymapType::Xkb {
|
||||
return Err(WaylandEisError::KeymapInvalid);
|
||||
}
|
||||
let fd = keymap
|
||||
.fd
|
||||
.try_clone()
|
||||
.map_err(|_| WaylandEisError::KeymapInvalid)?;
|
||||
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
|
||||
let mut keymap_text = String::new();
|
||||
std::fs::File::from(fd)
|
||||
.read_to_string(&mut keymap_text)
|
||||
.map_err(|_| WaylandEisError::KeymapInvalid)?;
|
||||
let keymap = xkb::Keymap::new_from_string(
|
||||
&context,
|
||||
keymap_text,
|
||||
xkb::KEYMAP_FORMAT_TEXT_V1,
|
||||
xkb::KEYMAP_COMPILE_NO_FLAGS,
|
||||
)
|
||||
.ok_or(WaylandEisError::KeymapInvalid)?;
|
||||
let mut mapping = HashMap::new();
|
||||
for raw_keycode in keymap.min_keycode().raw()..=keymap.max_keycode().raw() {
|
||||
let keycode = xkb::Keycode::new(raw_keycode);
|
||||
let Some(evdev_keycode) = raw_keycode.checked_sub(EVDEV_KEYCODE_OFFSET) else {
|
||||
continue;
|
||||
};
|
||||
for layout in 0..keymap.num_layouts_for_key(keycode) {
|
||||
for level in 0..keymap.num_levels_for_key(keycode, layout) {
|
||||
for keysym in keymap.key_get_syms_by_level(keycode, layout, level) {
|
||||
mapping.entry(keysym.raw()).or_insert(evdev_keycode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if mapping.is_empty() {
|
||||
return Err(WaylandEisError::KeymapInvalid);
|
||||
}
|
||||
Ok(mapping)
|
||||
}
|
||||
|
||||
fn map_absolute(
|
||||
device: &Device,
|
||||
x: u16,
|
||||
y: u16,
|
||||
frame_width: u16,
|
||||
frame_height: u16,
|
||||
mapping_id: Option<&str>,
|
||||
) -> Result<(f32, f32), WaylandEisError> {
|
||||
let regions = device.regions();
|
||||
let region = if let Some(mapping_id) = mapping_id {
|
||||
regions
|
||||
.iter()
|
||||
.find(|region| region.mapping_id.as_deref() == Some(mapping_id))
|
||||
} else if regions.len() == 1 {
|
||||
regions.first()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.ok_or(WaylandEisError::RequiredDeviceUnavailable)?;
|
||||
if region.width == 0 || region.height == 0 || frame_width <= 1 || frame_height <= 1 {
|
||||
return Err(WaylandEisError::InvalidInput);
|
||||
}
|
||||
let mapped_x = region.x as f32
|
||||
+ f32::from(x) * (region.width.saturating_sub(1) as f32) / f32::from(frame_width - 1);
|
||||
let mapped_y = region.y as f32
|
||||
+ f32::from(y) * (region.height.saturating_sub(1) as f32) / f32::from(frame_height - 1);
|
||||
Ok((mapped_x, mapped_y))
|
||||
}
|
||||
|
||||
fn frame_device(device: &Device, serial: u32) {
|
||||
device.device().frame(serial, monotonic_micros());
|
||||
}
|
||||
|
||||
fn monotonic_micros() -> u64 {
|
||||
let time = clock_gettime(ClockId::Monotonic);
|
||||
u64::try_from(time.tv_sec)
|
||||
.unwrap_or(0)
|
||||
.saturating_mul(1_000_000)
|
||||
.saturating_add(u64::try_from(time.tv_nsec).unwrap_or(0) / 1_000)
|
||||
}
|
||||
|
||||
fn release_all_locked(connection: &reis::event::Connection, state: &mut EisState) {
|
||||
let serial = connection.serial();
|
||||
for (device, keycode) in state.pressed_keys.drain(..) {
|
||||
if let Some(keyboard) = device.interface::<ei::Keyboard>() {
|
||||
keyboard.key(keycode, ei::keyboard::KeyState::Released);
|
||||
frame_device(&device, serial);
|
||||
}
|
||||
}
|
||||
for (device, button_code) in state.pressed_buttons.drain(..) {
|
||||
if let Some(button) = device.interface::<ei::Button>() {
|
||||
button.button(button_code, ei::button::ButtonState::Released);
|
||||
frame_device(&device, serial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_pressed(entries: &mut Vec<(Device, u32)>, device: &Device, code: u32, pressed: bool) {
|
||||
if pressed {
|
||||
if !entries
|
||||
.iter()
|
||||
.any(|(current, current_code)| current == device && *current_code == code)
|
||||
{
|
||||
entries.push((device.clone(), code));
|
||||
}
|
||||
} else {
|
||||
entries.retain(|(current, current_code)| current != device || *current_code != code);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_device_state(state: &mut EisState, device: &Device) {
|
||||
if let Some(current) = state
|
||||
.devices
|
||||
.iter_mut()
|
||||
.find(|current| current.device == *device)
|
||||
{
|
||||
current.active = false;
|
||||
}
|
||||
state.pressed_keys.retain(|(current, _)| current != device);
|
||||
state
|
||||
.pressed_buttons
|
||||
.retain(|(current, _)| current != device);
|
||||
}
|
||||
|
||||
fn mark_failed(state: &Arc<Mutex<EisState>>, error: WaylandEisError) {
|
||||
state.lock().expect("EIS state mutex poisoned").failure = Some(error);
|
||||
}
|
||||
|
||||
const fn valid_frame_size(width: u16, height: u16) -> bool {
|
||||
width >= 200 && width <= 8_192 && height >= 200 && height <= 8_192
|
||||
}
|
||||
|
||||
const fn button_code(button: DesktopButton) -> u32 {
|
||||
match button {
|
||||
DesktopButton::Left => BTN_LEFT,
|
||||
DesktopButton::Middle => BTN_MIDDLE,
|
||||
DesktopButton::Right => BTN_RIGHT,
|
||||
DesktopButton::Back => BTN_SIDE,
|
||||
DesktopButton::Forward => BTN_EXTRA,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn desktop_buttons_map_to_linux_input_event_codes() {
|
||||
assert_eq!(button_code(DesktopButton::Left), 0x110);
|
||||
assert_eq!(button_code(DesktopButton::Right), 0x111);
|
||||
assert_eq!(button_code(DesktopButton::Middle), 0x112);
|
||||
assert_eq!(button_code(DesktopButton::Back), 0x113);
|
||||
assert_eq!(button_code(DesktopButton::Forward), 0x114);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eis_errors_expose_stable_reason_codes() {
|
||||
assert_eq!(
|
||||
WaylandEisError::DeviceDiscoveryTimedOut.code(),
|
||||
"eis_device_discovery_timed_out"
|
||||
);
|
||||
assert_eq!(
|
||||
WaylandEisError::UnsupportedKeysym.code(),
|
||||
"eis_unsupported_keysym"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotonic_timestamp_is_nonzero_and_nondecreasing() {
|
||||
let first = monotonic_micros();
|
||||
let second = monotonic_micros();
|
||||
assert!(first > 0);
|
||||
assert!(second >= first);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
os::fd::{AsFd as _, BorrowedFd},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures_util::StreamExt as _;
|
||||
use rand::RngCore as _;
|
||||
use zbus::{
|
||||
Connection, Proxy,
|
||||
proxy::SignalStream,
|
||||
zvariant::{OwnedFd, OwnedObjectPath, OwnedValue, Value},
|
||||
};
|
||||
|
||||
const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
|
||||
const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const REMOTE_DESKTOP_INTERFACE: &str = "org.freedesktop.portal.RemoteDesktop";
|
||||
const SCREEN_CAST_INTERFACE: &str = "org.freedesktop.portal.ScreenCast";
|
||||
const REQUEST_INTERFACE: &str = "org.freedesktop.portal.Request";
|
||||
const SESSION_INTERFACE: &str = "org.freedesktop.portal.Session";
|
||||
|
||||
const DEVICE_KEYBOARD: u32 = 1;
|
||||
const DEVICE_POINTER: u32 = 2;
|
||||
const SOURCE_MONITOR: u32 = 1;
|
||||
const CURSOR_EMBEDDED: u32 = 2;
|
||||
const MIN_REMOTE_DESKTOP_EIS_VERSION: u32 = 2;
|
||||
const PORTAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const MAX_PORTAL_STREAMS: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WaylandPortalStream {
|
||||
pub pipewire_node_id: u32,
|
||||
pub mapping_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WaylandPortalCapabilities {
|
||||
pub remote_desktop_version: u32,
|
||||
pub screen_cast_version: u32,
|
||||
pub available_device_types: u32,
|
||||
pub available_source_types: u32,
|
||||
pub available_cursor_modes: u32,
|
||||
}
|
||||
|
||||
impl WaylandPortalCapabilities {
|
||||
#[must_use]
|
||||
pub const fn supports_remote_desktop(self) -> bool {
|
||||
self.remote_desktop_version >= MIN_REMOTE_DESKTOP_EIS_VERSION
|
||||
&& self.available_device_types & (DEVICE_KEYBOARD | DEVICE_POINTER)
|
||||
== (DEVICE_KEYBOARD | DEVICE_POINTER)
|
||||
&& self.available_source_types & SOURCE_MONITOR == SOURCE_MONITOR
|
||||
&& self.available_cursor_modes & CURSOR_EMBEDDED == CURSOR_EMBEDDED
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WaylandPortalSession {
|
||||
connection: Connection,
|
||||
session_handle: OwnedObjectPath,
|
||||
pipewire_fd: OwnedFd,
|
||||
eis_fd: OwnedFd,
|
||||
streams: Vec<WaylandPortalStream>,
|
||||
capabilities: WaylandPortalCapabilities,
|
||||
}
|
||||
|
||||
impl WaylandPortalSession {
|
||||
#[must_use]
|
||||
pub fn session_handle(&self) -> &OwnedObjectPath {
|
||||
&self.session_handle
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn pipewire_fd(&self) -> BorrowedFd<'_> {
|
||||
self.pipewire_fd.as_fd()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn eis_fd(&self) -> BorrowedFd<'_> {
|
||||
self.eis_fd.as_fd()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn streams(&self) -> &[WaylandPortalStream] {
|
||||
&self.streams
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn capabilities(&self) -> WaylandPortalCapabilities {
|
||||
self.capabilities
|
||||
}
|
||||
|
||||
pub async fn close(&self) -> Result<(), WaylandPortalSessionError> {
|
||||
close_portal_session(&self.connection, &self.session_handle).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WaylandPortalProbeError {
|
||||
SessionBusUnavailable,
|
||||
RemoteDesktopUnavailable,
|
||||
ScreenCastUnavailable,
|
||||
RemoteDesktopPropertiesUnavailable,
|
||||
ScreenCastPropertiesUnavailable,
|
||||
RequiredCapabilityUnavailable,
|
||||
}
|
||||
|
||||
impl fmt::Display for WaylandPortalProbeError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::SessionBusUnavailable => "the user D-Bus session is unavailable",
|
||||
Self::RemoteDesktopUnavailable => "the RemoteDesktop Portal is unavailable",
|
||||
Self::ScreenCastUnavailable => "the ScreenCast Portal is unavailable",
|
||||
Self::RemoteDesktopPropertiesUnavailable => {
|
||||
"the RemoteDesktop Portal capabilities are unavailable"
|
||||
}
|
||||
Self::ScreenCastPropertiesUnavailable => {
|
||||
"the ScreenCast Portal capabilities are unavailable"
|
||||
}
|
||||
Self::RequiredCapabilityUnavailable => {
|
||||
"the Portal does not provide monitor capture with keyboard, pointer, EIS, and an embedded cursor"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WaylandPortalProbeError {
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::SessionBusUnavailable => "session_bus_unavailable",
|
||||
Self::RemoteDesktopUnavailable => "remote_desktop_unavailable",
|
||||
Self::ScreenCastUnavailable => "screen_cast_unavailable",
|
||||
Self::RemoteDesktopPropertiesUnavailable => "remote_desktop_properties_unavailable",
|
||||
Self::ScreenCastPropertiesUnavailable => "screen_cast_properties_unavailable",
|
||||
Self::RequiredCapabilityUnavailable => "required_capability_unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WaylandPortalProbeError {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WaylandPortalSessionError {
|
||||
Probe(WaylandPortalProbeError),
|
||||
InvalidBusIdentity,
|
||||
RequestSubscriptionFailed,
|
||||
RequestCallFailed,
|
||||
UnexpectedRequestHandle,
|
||||
RequestTimedOut,
|
||||
RequestClosed,
|
||||
UserCancelled,
|
||||
RequestDenied,
|
||||
MalformedResponse,
|
||||
InvalidSessionHandle,
|
||||
InvalidStreamList,
|
||||
PipeWireRemoteUnavailable,
|
||||
EisUnavailable,
|
||||
SessionCloseFailed,
|
||||
}
|
||||
|
||||
impl fmt::Display for WaylandPortalSessionError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Probe(error) => error.fmt(formatter),
|
||||
Self::InvalidBusIdentity => formatter.write_str("the user D-Bus identity is invalid"),
|
||||
Self::RequestSubscriptionFailed => {
|
||||
formatter.write_str("the Portal request response could not be subscribed")
|
||||
}
|
||||
Self::RequestCallFailed => formatter.write_str("the Portal request could not be sent"),
|
||||
Self::UnexpectedRequestHandle => {
|
||||
formatter.write_str("the Portal returned an unexpected request handle")
|
||||
}
|
||||
Self::RequestTimedOut => formatter.write_str("the Portal request timed out"),
|
||||
Self::RequestClosed => {
|
||||
formatter.write_str("the Portal request closed without a response")
|
||||
}
|
||||
Self::UserCancelled => formatter.write_str("the user cancelled the Portal request"),
|
||||
Self::RequestDenied => formatter.write_str("the Portal denied the request"),
|
||||
Self::MalformedResponse => formatter.write_str("the Portal response is malformed"),
|
||||
Self::InvalidSessionHandle => {
|
||||
formatter.write_str("the Portal session handle is invalid")
|
||||
}
|
||||
Self::InvalidStreamList => formatter.write_str("the Portal stream list is invalid"),
|
||||
Self::PipeWireRemoteUnavailable => {
|
||||
formatter.write_str("the Portal PipeWire remote is unavailable")
|
||||
}
|
||||
Self::EisUnavailable => formatter.write_str("the Portal EIS connection is unavailable"),
|
||||
Self::SessionCloseFailed => {
|
||||
formatter.write_str("the Portal session could not be closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WaylandPortalSessionError {
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Probe(error) => error.code(),
|
||||
Self::InvalidBusIdentity => "invalid_bus_identity",
|
||||
Self::RequestSubscriptionFailed => "request_subscription_failed",
|
||||
Self::RequestCallFailed => "request_call_failed",
|
||||
Self::UnexpectedRequestHandle => "unexpected_request_handle",
|
||||
Self::RequestTimedOut => "request_timed_out",
|
||||
Self::RequestClosed => "request_closed",
|
||||
Self::UserCancelled => "user_cancelled",
|
||||
Self::RequestDenied => "request_denied",
|
||||
Self::MalformedResponse => "malformed_response",
|
||||
Self::InvalidSessionHandle => "invalid_session_handle",
|
||||
Self::InvalidStreamList => "invalid_stream_list",
|
||||
Self::PipeWireRemoteUnavailable => "pipewire_remote_unavailable",
|
||||
Self::EisUnavailable => "eis_unavailable",
|
||||
Self::SessionCloseFailed => "session_close_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WaylandPortalSessionError {}
|
||||
|
||||
pub async fn probe_wayland_portal() -> Result<WaylandPortalCapabilities, WaylandPortalProbeError> {
|
||||
let connection = Connection::session()
|
||||
.await
|
||||
.map_err(|_| WaylandPortalProbeError::SessionBusUnavailable)?;
|
||||
probe_wayland_portal_on(&connection).await
|
||||
}
|
||||
|
||||
async fn probe_wayland_portal_on(
|
||||
connection: &Connection,
|
||||
) -> Result<WaylandPortalCapabilities, WaylandPortalProbeError> {
|
||||
let remote_desktop = Proxy::new(
|
||||
connection,
|
||||
PORTAL_DESTINATION,
|
||||
PORTAL_PATH,
|
||||
REMOTE_DESKTOP_INTERFACE,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| WaylandPortalProbeError::RemoteDesktopUnavailable)?;
|
||||
let screen_cast = Proxy::new(
|
||||
connection,
|
||||
PORTAL_DESTINATION,
|
||||
PORTAL_PATH,
|
||||
SCREEN_CAST_INTERFACE,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| WaylandPortalProbeError::ScreenCastUnavailable)?;
|
||||
|
||||
let remote_desktop_version = remote_desktop
|
||||
.get_property("version")
|
||||
.await
|
||||
.map_err(|_| WaylandPortalProbeError::RemoteDesktopPropertiesUnavailable)?;
|
||||
let available_device_types = remote_desktop
|
||||
.get_property("AvailableDeviceTypes")
|
||||
.await
|
||||
.map_err(|_| WaylandPortalProbeError::RemoteDesktopPropertiesUnavailable)?;
|
||||
let screen_cast_version = screen_cast
|
||||
.get_property("version")
|
||||
.await
|
||||
.map_err(|_| WaylandPortalProbeError::ScreenCastPropertiesUnavailable)?;
|
||||
let available_source_types = screen_cast
|
||||
.get_property("AvailableSourceTypes")
|
||||
.await
|
||||
.map_err(|_| WaylandPortalProbeError::ScreenCastPropertiesUnavailable)?;
|
||||
let available_cursor_modes = screen_cast
|
||||
.get_property("AvailableCursorModes")
|
||||
.await
|
||||
.map_err(|_| WaylandPortalProbeError::ScreenCastPropertiesUnavailable)?;
|
||||
let capabilities = WaylandPortalCapabilities {
|
||||
remote_desktop_version,
|
||||
screen_cast_version,
|
||||
available_device_types,
|
||||
available_source_types,
|
||||
available_cursor_modes,
|
||||
};
|
||||
if !capabilities.supports_remote_desktop() {
|
||||
return Err(WaylandPortalProbeError::RequiredCapabilityUnavailable);
|
||||
}
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
pub async fn open_wayland_portal_session() -> Result<WaylandPortalSession, WaylandPortalSessionError>
|
||||
{
|
||||
let connection = Connection::session().await.map_err(|_| {
|
||||
WaylandPortalSessionError::Probe(WaylandPortalProbeError::SessionBusUnavailable)
|
||||
})?;
|
||||
let capabilities = probe_wayland_portal_on(&connection)
|
||||
.await
|
||||
.map_err(WaylandPortalSessionError::Probe)?;
|
||||
let remote_desktop = Proxy::new(
|
||||
&connection,
|
||||
PORTAL_DESTINATION,
|
||||
PORTAL_PATH,
|
||||
REMOTE_DESKTOP_INTERFACE,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
WaylandPortalSessionError::Probe(WaylandPortalProbeError::RemoteDesktopUnavailable)
|
||||
})?;
|
||||
let screen_cast = Proxy::new(
|
||||
&connection,
|
||||
PORTAL_DESTINATION,
|
||||
PORTAL_PATH,
|
||||
SCREEN_CAST_INTERFACE,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
WaylandPortalSessionError::Probe(WaylandPortalProbeError::ScreenCastUnavailable)
|
||||
})?;
|
||||
|
||||
let create_request_token = portal_token("create");
|
||||
let session_token = portal_token("session");
|
||||
let (create_request_path, create_request) =
|
||||
subscribe_request(&connection, &create_request_token).await?;
|
||||
let mut create_responses = create_request
|
||||
.receive_signal("Response")
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestSubscriptionFailed)?;
|
||||
let mut create_options = HashMap::<&str, Value<'_>>::new();
|
||||
create_options.insert("handle_token", Value::from(create_request_token.as_str()));
|
||||
create_options.insert("session_handle_token", Value::from(session_token.as_str()));
|
||||
let returned_request: OwnedObjectPath = remote_desktop
|
||||
.call("CreateSession", &(create_options,))
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestCallFailed)?;
|
||||
let mut create_results = await_request_response(
|
||||
&create_request_path,
|
||||
&returned_request,
|
||||
&mut create_responses,
|
||||
)
|
||||
.await?;
|
||||
let session_handle = create_results
|
||||
.remove("session_handle")
|
||||
.ok_or(WaylandPortalSessionError::InvalidSessionHandle)
|
||||
.and_then(|value| {
|
||||
String::try_from(value).map_err(|_| WaylandPortalSessionError::InvalidSessionHandle)
|
||||
})
|
||||
.and_then(|value| {
|
||||
OwnedObjectPath::try_from(value)
|
||||
.map_err(|_| WaylandPortalSessionError::InvalidSessionHandle)
|
||||
})?;
|
||||
let expected_session_path = portal_object_path("session", &connection, &session_token)?;
|
||||
if session_handle != expected_session_path {
|
||||
let _ = close_portal_session(&connection, &session_handle).await;
|
||||
return Err(WaylandPortalSessionError::InvalidSessionHandle);
|
||||
}
|
||||
|
||||
let opened = async {
|
||||
select_devices(
|
||||
&connection,
|
||||
&remote_desktop,
|
||||
&session_handle,
|
||||
DEVICE_KEYBOARD | DEVICE_POINTER,
|
||||
)
|
||||
.await?;
|
||||
select_sources(&connection, &screen_cast, &session_handle).await?;
|
||||
let (selected_devices, streams) =
|
||||
start_session(&connection, &remote_desktop, &session_handle).await?;
|
||||
if selected_devices & (DEVICE_KEYBOARD | DEVICE_POINTER)
|
||||
!= (DEVICE_KEYBOARD | DEVICE_POINTER)
|
||||
{
|
||||
return Err(WaylandPortalSessionError::RequestDenied);
|
||||
}
|
||||
|
||||
let pipewire_options = HashMap::<&str, Value<'_>>::new();
|
||||
let pipewire_fd: OwnedFd = screen_cast
|
||||
.call("OpenPipeWireRemote", &(&session_handle, pipewire_options))
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::PipeWireRemoteUnavailable)?;
|
||||
let eis_options = HashMap::<&str, Value<'_>>::new();
|
||||
let eis_fd: OwnedFd = remote_desktop
|
||||
.call("ConnectToEIS", &(&session_handle, eis_options))
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::EisUnavailable)?;
|
||||
Ok((pipewire_fd, eis_fd, streams))
|
||||
}
|
||||
.await;
|
||||
let (pipewire_fd, eis_fd, streams) = match opened {
|
||||
Ok(opened) => opened,
|
||||
Err(error) => {
|
||||
let _ = close_portal_session(&connection, &session_handle).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(WaylandPortalSession {
|
||||
connection: connection.clone(),
|
||||
session_handle,
|
||||
pipewire_fd,
|
||||
eis_fd,
|
||||
streams,
|
||||
capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
async fn close_portal_session(
|
||||
connection: &Connection,
|
||||
session_handle: &OwnedObjectPath,
|
||||
) -> Result<(), WaylandPortalSessionError> {
|
||||
let session = Proxy::new(
|
||||
connection,
|
||||
PORTAL_DESTINATION,
|
||||
session_handle.as_str(),
|
||||
SESSION_INTERFACE,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::SessionCloseFailed)?;
|
||||
let _: () = session
|
||||
.call("Close", &())
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::SessionCloseFailed)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn select_devices(
|
||||
connection: &Connection,
|
||||
remote_desktop: &Proxy<'_>,
|
||||
session_handle: &OwnedObjectPath,
|
||||
device_types: u32,
|
||||
) -> Result<(), WaylandPortalSessionError> {
|
||||
let token = portal_token("devices");
|
||||
let (request_path, request) = subscribe_request(connection, &token).await?;
|
||||
let mut responses = request
|
||||
.receive_signal("Response")
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestSubscriptionFailed)?;
|
||||
let mut options = HashMap::<&str, Value<'_>>::new();
|
||||
options.insert("handle_token", Value::from(token.as_str()));
|
||||
options.insert("types", Value::from(device_types));
|
||||
let returned: OwnedObjectPath = remote_desktop
|
||||
.call("SelectDevices", &(session_handle, options))
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestCallFailed)?;
|
||||
await_request_response(&request_path, &returned, &mut responses).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn select_sources(
|
||||
connection: &Connection,
|
||||
screen_cast: &Proxy<'_>,
|
||||
session_handle: &OwnedObjectPath,
|
||||
) -> Result<(), WaylandPortalSessionError> {
|
||||
let token = portal_token("sources");
|
||||
let (request_path, request) = subscribe_request(connection, &token).await?;
|
||||
let mut responses = request
|
||||
.receive_signal("Response")
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestSubscriptionFailed)?;
|
||||
let mut options = HashMap::<&str, Value<'_>>::new();
|
||||
options.insert("handle_token", Value::from(token.as_str()));
|
||||
options.insert("types", Value::from(SOURCE_MONITOR));
|
||||
options.insert("multiple", Value::from(false));
|
||||
options.insert("cursor_mode", Value::from(CURSOR_EMBEDDED));
|
||||
let returned: OwnedObjectPath = screen_cast
|
||||
.call("SelectSources", &(session_handle, options))
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestCallFailed)?;
|
||||
await_request_response(&request_path, &returned, &mut responses).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_session(
|
||||
connection: &Connection,
|
||||
remote_desktop: &Proxy<'_>,
|
||||
session_handle: &OwnedObjectPath,
|
||||
) -> Result<(u32, Vec<WaylandPortalStream>), WaylandPortalSessionError> {
|
||||
let token = portal_token("start");
|
||||
let (request_path, request) = subscribe_request(connection, &token).await?;
|
||||
let mut responses = request
|
||||
.receive_signal("Response")
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestSubscriptionFailed)?;
|
||||
let mut options = HashMap::<&str, Value<'_>>::new();
|
||||
options.insert("handle_token", Value::from(token.as_str()));
|
||||
let returned: OwnedObjectPath = remote_desktop
|
||||
.call("Start", &(session_handle, "", options))
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestCallFailed)?;
|
||||
let mut results = await_request_response(&request_path, &returned, &mut responses).await?;
|
||||
let devices = results
|
||||
.remove("devices")
|
||||
.ok_or(WaylandPortalSessionError::MalformedResponse)
|
||||
.and_then(|value| {
|
||||
u32::try_from(value).map_err(|_| WaylandPortalSessionError::MalformedResponse)
|
||||
})?;
|
||||
let streams = results
|
||||
.remove("streams")
|
||||
.ok_or(WaylandPortalSessionError::InvalidStreamList)
|
||||
.and_then(parse_streams)?;
|
||||
Ok((devices, streams))
|
||||
}
|
||||
|
||||
async fn subscribe_request<'a>(
|
||||
connection: &'a Connection,
|
||||
token: &str,
|
||||
) -> Result<(OwnedObjectPath, Proxy<'a>), WaylandPortalSessionError> {
|
||||
let path = portal_object_path("request", connection, token)?;
|
||||
let proxy = Proxy::new_owned(
|
||||
connection.clone(),
|
||||
PORTAL_DESTINATION,
|
||||
path.clone(),
|
||||
REQUEST_INTERFACE,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestSubscriptionFailed)?;
|
||||
Ok((path, proxy))
|
||||
}
|
||||
|
||||
async fn await_request_response(
|
||||
expected_path: &OwnedObjectPath,
|
||||
returned_path: &OwnedObjectPath,
|
||||
responses: &mut SignalStream<'_>,
|
||||
) -> Result<HashMap<String, OwnedValue>, WaylandPortalSessionError> {
|
||||
if expected_path != returned_path {
|
||||
return Err(WaylandPortalSessionError::UnexpectedRequestHandle);
|
||||
}
|
||||
let response = tokio::time::timeout(PORTAL_REQUEST_TIMEOUT, responses.next())
|
||||
.await
|
||||
.map_err(|_| WaylandPortalSessionError::RequestTimedOut)?
|
||||
.ok_or(WaylandPortalSessionError::RequestClosed)?;
|
||||
let (response_code, results): (u32, HashMap<String, OwnedValue>) = response
|
||||
.body()
|
||||
.deserialize()
|
||||
.map_err(|_| WaylandPortalSessionError::MalformedResponse)?;
|
||||
match response_code {
|
||||
0 => Ok(results),
|
||||
1 => Err(WaylandPortalSessionError::UserCancelled),
|
||||
_ => Err(WaylandPortalSessionError::RequestDenied),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_streams(value: OwnedValue) -> Result<Vec<WaylandPortalStream>, WaylandPortalSessionError> {
|
||||
let streams: Vec<(u32, HashMap<String, OwnedValue>)> = value
|
||||
.try_into()
|
||||
.map_err(|_| WaylandPortalSessionError::InvalidStreamList)?;
|
||||
if streams.is_empty() || streams.len() > MAX_PORTAL_STREAMS {
|
||||
return Err(WaylandPortalSessionError::InvalidStreamList);
|
||||
}
|
||||
streams
|
||||
.into_iter()
|
||||
.map(|(pipewire_node_id, mut properties)| {
|
||||
if pipewire_node_id == 0 {
|
||||
return Err(WaylandPortalSessionError::InvalidStreamList);
|
||||
}
|
||||
let mapping_id = properties
|
||||
.remove("mapping_id")
|
||||
.map(String::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| WaylandPortalSessionError::InvalidStreamList)?;
|
||||
if mapping_id.as_ref().is_some_and(|value| {
|
||||
value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| !value.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
}) {
|
||||
return Err(WaylandPortalSessionError::InvalidStreamList);
|
||||
}
|
||||
Ok(WaylandPortalStream {
|
||||
pipewire_node_id,
|
||||
mapping_id,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn portal_token(prefix: &str) -> String {
|
||||
let mut random = [0_u8; 12];
|
||||
rand::rng().fill_bytes(&mut random);
|
||||
let mut token = String::with_capacity(prefix.len() + 1 + random.len() * 2);
|
||||
token.push_str(prefix);
|
||||
token.push('_');
|
||||
for byte in random {
|
||||
use fmt::Write as _;
|
||||
write!(token, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
fn portal_object_path(
|
||||
kind: &str,
|
||||
connection: &Connection,
|
||||
token: &str,
|
||||
) -> Result<OwnedObjectPath, WaylandPortalSessionError> {
|
||||
if !matches!(kind, "request" | "session")
|
||||
|| token.is_empty()
|
||||
|| !token
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
return Err(WaylandPortalSessionError::InvalidBusIdentity);
|
||||
}
|
||||
let sender = connection
|
||||
.unique_name()
|
||||
.ok_or(WaylandPortalSessionError::InvalidBusIdentity)?
|
||||
.as_str()
|
||||
.strip_prefix(':')
|
||||
.ok_or(WaylandPortalSessionError::InvalidBusIdentity)?
|
||||
.replace('.', "_");
|
||||
if sender.is_empty()
|
||||
|| !sender
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
return Err(WaylandPortalSessionError::InvalidBusIdentity);
|
||||
}
|
||||
OwnedObjectPath::try_from(format!(
|
||||
"/org/freedesktop/portal/desktop/{kind}/{sender}/{token}"
|
||||
))
|
||||
.map_err(|_| WaylandPortalSessionError::InvalidBusIdentity)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn complete_capabilities() -> WaylandPortalCapabilities {
|
||||
WaylandPortalCapabilities {
|
||||
remote_desktop_version: MIN_REMOTE_DESKTOP_EIS_VERSION,
|
||||
screen_cast_version: 1,
|
||||
available_device_types: DEVICE_KEYBOARD | DEVICE_POINTER,
|
||||
available_source_types: SOURCE_MONITOR,
|
||||
available_cursor_modes: CURSOR_EMBEDDED,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_portal_gate_requires_capture_input_eis_and_embedded_cursor() {
|
||||
let complete = complete_capabilities();
|
||||
assert!(complete.supports_remote_desktop());
|
||||
|
||||
for incomplete in [
|
||||
WaylandPortalCapabilities {
|
||||
remote_desktop_version: MIN_REMOTE_DESKTOP_EIS_VERSION - 1,
|
||||
..complete
|
||||
},
|
||||
WaylandPortalCapabilities {
|
||||
available_device_types: DEVICE_POINTER,
|
||||
..complete
|
||||
},
|
||||
WaylandPortalCapabilities {
|
||||
available_source_types: 0,
|
||||
..complete
|
||||
},
|
||||
WaylandPortalCapabilities {
|
||||
available_cursor_modes: 0,
|
||||
..complete
|
||||
},
|
||||
] {
|
||||
assert!(!incomplete.supports_remote_desktop());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_tokens_are_bounded_canonical_object_path_elements() {
|
||||
let first = portal_token("create");
|
||||
let second = portal_token("create");
|
||||
assert_ne!(first, second);
|
||||
assert!(first.len() <= 64);
|
||||
assert!(
|
||||
first
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_errors_expose_only_stable_reason_codes() {
|
||||
assert_eq!(
|
||||
WaylandPortalSessionError::UserCancelled.code(),
|
||||
"user_cancelled"
|
||||
);
|
||||
assert_eq!(
|
||||
WaylandPortalSessionError::Probe(
|
||||
WaylandPortalProbeError::RequiredCapabilityUnavailable
|
||||
)
|
||||
.code(),
|
||||
"required_capability_unavailable"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use x11rb::connection::Connection as _;
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::protocol::xproto::{
|
||||
Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, EventMask, PropMode,
|
||||
SELECTION_NOTIFY_EVENT, SelectionNotifyEvent, SelectionRequestEvent, Window, WindowClass,
|
||||
};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
use x11rb::wrapper::ConnectionExt as _;
|
||||
use x11rb::{COPY_DEPTH_FROM_PARENT, CURRENT_TIME};
|
||||
|
||||
use crate::{DESKTOP_MAX_CLIPBOARD_BYTES, normalize_clipboard_text};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
pub struct X11Clipboard {
|
||||
window: Window,
|
||||
clipboard: Atom,
|
||||
targets: Atom,
|
||||
utf8_string: Atom,
|
||||
text: Atom,
|
||||
property: Atom,
|
||||
owned_text: Option<String>,
|
||||
request_pending: bool,
|
||||
request_started: Option<Instant>,
|
||||
next_poll: Instant,
|
||||
}
|
||||
|
||||
impl X11Clipboard {
|
||||
pub fn new(connection: &RustConnection, root: Window) -> Result<Self, Error> {
|
||||
let window = connection.generate_id()?;
|
||||
connection
|
||||
.create_window(
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
window,
|
||||
root,
|
||||
-1,
|
||||
-1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
0,
|
||||
&CreateWindowAux::new().event_mask(EventMask::PROPERTY_CHANGE),
|
||||
)?
|
||||
.check()?;
|
||||
let clipboard = atom(connection, b"CLIPBOARD")?;
|
||||
let targets = atom(connection, b"TARGETS")?;
|
||||
let utf8_string = atom(connection, b"UTF8_STRING")?;
|
||||
let text = atom(connection, b"TEXT")?;
|
||||
let property = atom(connection, b"REMOTEDESK_CLIPBOARD")?;
|
||||
connection.flush()?;
|
||||
Ok(Self {
|
||||
window,
|
||||
clipboard,
|
||||
targets,
|
||||
utf8_string,
|
||||
text,
|
||||
property,
|
||||
owned_text: None,
|
||||
request_pending: false,
|
||||
request_started: None,
|
||||
next_poll: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(&mut self, connection: &RustConnection, text: &str) -> Result<(), Error> {
|
||||
let text = normalize_clipboard_text(text)?;
|
||||
if text.len() > DESKTOP_MAX_CLIPBOARD_BYTES {
|
||||
return Err("X11 clipboard text exceeds the size limit".into());
|
||||
}
|
||||
connection
|
||||
.set_selection_owner(self.window, self.clipboard, CURRENT_TIME)?
|
||||
.check()?;
|
||||
let owner = connection
|
||||
.get_selection_owner(self.clipboard)?
|
||||
.reply()?
|
||||
.owner;
|
||||
if owner != self.window {
|
||||
return Err("X11 clipboard ownership was not granted".into());
|
||||
}
|
||||
self.owned_text = Some(text);
|
||||
self.request_pending = false;
|
||||
self.request_started = None;
|
||||
self.next_poll = Instant::now() + POLL_INTERVAL;
|
||||
connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn poll_text(&mut self, connection: &RustConnection) -> Result<Option<String>, Error> {
|
||||
let mut received = self.service_events(connection)?;
|
||||
if self.request_pending
|
||||
&& self
|
||||
.request_started
|
||||
.is_some_and(|started| started.elapsed() >= REQUEST_TIMEOUT)
|
||||
{
|
||||
self.request_pending = false;
|
||||
self.request_started = None;
|
||||
}
|
||||
if !self.request_pending && Instant::now() >= self.next_poll {
|
||||
self.next_poll = Instant::now() + POLL_INTERVAL;
|
||||
let owner = connection
|
||||
.get_selection_owner(self.clipboard)?
|
||||
.reply()?
|
||||
.owner;
|
||||
if owner != u32::from(AtomEnum::NONE) && owner != self.window {
|
||||
connection.convert_selection(
|
||||
self.window,
|
||||
self.clipboard,
|
||||
self.utf8_string,
|
||||
self.property,
|
||||
CURRENT_TIME,
|
||||
)?;
|
||||
connection.flush()?;
|
||||
self.request_pending = true;
|
||||
self.request_started = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
if received.is_none() {
|
||||
received = self.service_events(connection)?;
|
||||
}
|
||||
Ok(received)
|
||||
}
|
||||
|
||||
fn service_events(&mut self, connection: &RustConnection) -> Result<Option<String>, Error> {
|
||||
let mut received = None;
|
||||
while let Some(event) = connection.poll_for_event()? {
|
||||
match event {
|
||||
Event::SelectionRequest(request) if request.selection == self.clipboard => {
|
||||
self.reply_to_request(connection, request)?;
|
||||
}
|
||||
Event::SelectionNotify(event)
|
||||
if event.requestor == self.window && event.selection == self.clipboard =>
|
||||
{
|
||||
self.request_pending = false;
|
||||
self.request_started = None;
|
||||
if event.property != u32::from(AtomEnum::NONE) {
|
||||
received = self.read_property(connection)?;
|
||||
}
|
||||
}
|
||||
Event::SelectionClear(event)
|
||||
if event.owner == self.window && event.selection == self.clipboard =>
|
||||
{
|
||||
self.owned_text = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(received)
|
||||
}
|
||||
|
||||
fn reply_to_request(
|
||||
&self,
|
||||
connection: &RustConnection,
|
||||
request: SelectionRequestEvent,
|
||||
) -> Result<(), Error> {
|
||||
let property = if request.property == u32::from(AtomEnum::NONE) {
|
||||
request.target
|
||||
} else {
|
||||
request.property
|
||||
};
|
||||
let mut response_property: Atom = u32::from(AtomEnum::NONE);
|
||||
if request.target == self.targets {
|
||||
connection.change_property32(
|
||||
PropMode::REPLACE,
|
||||
request.requestor,
|
||||
property,
|
||||
AtomEnum::ATOM,
|
||||
&[self.targets, self.utf8_string, self.text],
|
||||
)?;
|
||||
response_property = property;
|
||||
} else if matches!(
|
||||
request.target,
|
||||
target if target == self.utf8_string || target == self.text
|
||||
) && let Some(text) = self.owned_text.as_ref()
|
||||
{
|
||||
connection.change_property8(
|
||||
PropMode::REPLACE,
|
||||
request.requestor,
|
||||
property,
|
||||
request.target,
|
||||
text.as_bytes(),
|
||||
)?;
|
||||
response_property = property;
|
||||
}
|
||||
connection.send_event(
|
||||
false,
|
||||
request.requestor,
|
||||
EventMask::NO_EVENT,
|
||||
SelectionNotifyEvent {
|
||||
response_type: SELECTION_NOTIFY_EVENT,
|
||||
sequence: 0,
|
||||
time: request.time,
|
||||
requestor: request.requestor,
|
||||
selection: request.selection,
|
||||
target: request.target,
|
||||
property: response_property,
|
||||
},
|
||||
)?;
|
||||
connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_property(&self, connection: &RustConnection) -> Result<Option<String>, Error> {
|
||||
let long_length = u32::try_from(DESKTOP_MAX_CLIPBOARD_BYTES.div_ceil(4) + 1)
|
||||
.map_err(|_| "clipboard property limit exceeds X11 range")?;
|
||||
let reply = connection
|
||||
.get_property(
|
||||
true,
|
||||
self.window,
|
||||
self.property,
|
||||
AtomEnum::ANY,
|
||||
0,
|
||||
long_length,
|
||||
)?
|
||||
.reply()?;
|
||||
if reply.bytes_after != 0
|
||||
|| reply.format != 8
|
||||
|| reply.value.len() > DESKTOP_MAX_CLIPBOARD_BYTES
|
||||
|| !matches!(
|
||||
reply.type_,
|
||||
target if target == self.utf8_string
|
||||
|| target == self.text
|
||||
|| target == u32::from(AtomEnum::STRING)
|
||||
)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let text = String::from_utf8(reply.value)?;
|
||||
Ok(Some(normalize_clipboard_text(&text)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn atom(connection: &RustConnection, name: &[u8]) -> Result<Atom, Error> {
|
||||
Ok(connection.intern_atom(false, name)?.reply()?.atom)
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use x11rb::CURRENT_TIME;
|
||||
use x11rb::connection::Connection as _;
|
||||
use x11rb::protocol::xproto::{
|
||||
BUTTON_PRESS_EVENT, BUTTON_RELEASE_EVENT, ConnectionExt as _, ImageFormat, ImageOrder,
|
||||
KEY_PRESS_EVENT, KEY_RELEASE_EVENT, MOTION_NOTIFY_EVENT, Visualtype, Window,
|
||||
};
|
||||
use x11rb::protocol::xtest::ConnectionExt as _;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
use crate::{
|
||||
DESKTOP_MAX_PIXELS, DesktopButton, DesktopInputEvent, DesktopKeyState, X11Clipboard,
|
||||
validate_desktop_input,
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
pub struct CapturedDesktopFrame {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub bgra: Vec<u8>,
|
||||
pub captured_at_unix_ms: u64,
|
||||
pub capture_latency_us: u64,
|
||||
}
|
||||
|
||||
pub struct X11Desktop {
|
||||
connection: RustConnection,
|
||||
root: Window,
|
||||
root_width: u16,
|
||||
root_height: u16,
|
||||
depth: u8,
|
||||
bits_per_pixel: u8,
|
||||
scanline_pad: u8,
|
||||
image_order: ImageOrder,
|
||||
visual: Visualtype,
|
||||
output_width: u16,
|
||||
output_height: u16,
|
||||
keysym_to_keycode: HashMap<u32, u8>,
|
||||
pressed_keys: BTreeSet<u8>,
|
||||
pressed_buttons: BTreeSet<u8>,
|
||||
clipboard: X11Clipboard,
|
||||
}
|
||||
|
||||
impl X11Desktop {
|
||||
pub fn connect() -> Result<Self, Error> {
|
||||
let (connection, screen_index) = x11rb::connect(None)?;
|
||||
let setup = connection.setup();
|
||||
let screen = setup
|
||||
.roots
|
||||
.get(screen_index)
|
||||
.ok_or("X11 selected screen is unavailable")?;
|
||||
let format = setup
|
||||
.pixmap_formats
|
||||
.iter()
|
||||
.find(|format| format.depth == screen.root_depth)
|
||||
.ok_or("X11 root pixmap format is unavailable")?;
|
||||
if !matches!(format.bits_per_pixel, 16 | 24 | 32) {
|
||||
return Err("X11 root bits-per-pixel is unsupported".into());
|
||||
}
|
||||
let visual = setup
|
||||
.roots
|
||||
.iter()
|
||||
.flat_map(|screen| &screen.allowed_depths)
|
||||
.flat_map(|depth| &depth.visuals)
|
||||
.find(|visual| visual.visual_id == screen.root_visual)
|
||||
.cloned()
|
||||
.ok_or("X11 root visual is unavailable")?;
|
||||
if !valid_rgb_masks(&visual) {
|
||||
return Err("X11 root visual does not expose RGB masks".into());
|
||||
}
|
||||
let root = screen.root;
|
||||
let root_width = screen.width_in_pixels;
|
||||
let root_height = screen.height_in_pixels;
|
||||
if root_width == 0
|
||||
|| root_height == 0
|
||||
|| usize::from(root_width) * usize::from(root_height) > DESKTOP_MAX_PIXELS
|
||||
|| root_width > i16::MAX as u16
|
||||
|| root_height > i16::MAX as u16
|
||||
{
|
||||
return Err("X11 root desktop dimensions exceed the capture limit".into());
|
||||
}
|
||||
let depth = screen.root_depth;
|
||||
let bits_per_pixel = format.bits_per_pixel;
|
||||
let scanline_pad = format.scanline_pad;
|
||||
let image_order = setup.image_byte_order;
|
||||
let min_keycode = setup.min_keycode;
|
||||
let keycode_count = setup
|
||||
.max_keycode
|
||||
.saturating_sub(min_keycode)
|
||||
.saturating_add(1);
|
||||
let keyboard = connection
|
||||
.get_keyboard_mapping(min_keycode, keycode_count)?
|
||||
.reply()?;
|
||||
if keyboard.keysyms_per_keycode == 0 {
|
||||
return Err("X11 keyboard mapping is empty".into());
|
||||
}
|
||||
let keysym_to_keycode = keyboard
|
||||
.keysyms
|
||||
.chunks_exact(usize::from(keyboard.keysyms_per_keycode))
|
||||
.enumerate()
|
||||
.flat_map(|(offset, symbols)| {
|
||||
let keycode = min_keycode.saturating_add(u8::try_from(offset).unwrap_or(u8::MAX));
|
||||
symbols
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|keysym| *keysym != 0)
|
||||
.map(move |keysym| (keysym, keycode))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let clipboard = X11Clipboard::new(&connection, root)?;
|
||||
let desktop = Self {
|
||||
root,
|
||||
root_width,
|
||||
root_height,
|
||||
depth,
|
||||
bits_per_pixel,
|
||||
scanline_pad,
|
||||
image_order,
|
||||
visual,
|
||||
output_width: root_width,
|
||||
output_height: root_height,
|
||||
keysym_to_keycode,
|
||||
connection,
|
||||
pressed_keys: BTreeSet::new(),
|
||||
pressed_buttons: BTreeSet::new(),
|
||||
clipboard,
|
||||
};
|
||||
desktop.connection.xtest_get_version(2, 2)?.reply()?;
|
||||
Ok(desktop)
|
||||
}
|
||||
|
||||
pub fn root_size(&self) -> (u16, u16) {
|
||||
(self.root_width, self.root_height)
|
||||
}
|
||||
|
||||
pub fn capture(
|
||||
&mut self,
|
||||
max_width: u16,
|
||||
max_height: u16,
|
||||
) -> Result<CapturedDesktopFrame, Error> {
|
||||
let started = Instant::now();
|
||||
let reply = self
|
||||
.connection
|
||||
.get_image(
|
||||
ImageFormat::Z_PIXMAP,
|
||||
self.root,
|
||||
0,
|
||||
0,
|
||||
self.root_width,
|
||||
self.root_height,
|
||||
u32::MAX,
|
||||
)?
|
||||
.reply()?;
|
||||
if reply.depth != self.depth {
|
||||
return Err("X11 capture depth changed during the session".into());
|
||||
}
|
||||
let native = convert_ximage_to_bgra(
|
||||
&reply.data,
|
||||
self.root_width,
|
||||
self.root_height,
|
||||
self.bits_per_pixel,
|
||||
self.scanline_pad,
|
||||
self.image_order,
|
||||
&self.visual,
|
||||
)?;
|
||||
let (width, height) = fit_size(self.root_width, self.root_height, max_width, max_height)?;
|
||||
let bgra = if (width, height) == (self.root_width, self.root_height) {
|
||||
native
|
||||
} else {
|
||||
scale_bgra_nearest(&native, self.root_width, self.root_height, width, height)
|
||||
};
|
||||
self.output_width = width;
|
||||
self.output_height = height;
|
||||
Ok(CapturedDesktopFrame {
|
||||
width,
|
||||
height,
|
||||
bgra,
|
||||
captured_at_unix_ms: unix_millis(),
|
||||
capture_latency_us: started.elapsed().as_micros().try_into().unwrap_or(u64::MAX),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn input(&mut self, event: &DesktopInputEvent) -> Result<(), Error> {
|
||||
validate_desktop_input(event, self.output_width, self.output_height)?;
|
||||
match event {
|
||||
DesktopInputEvent::PointerMove { x, y } => {
|
||||
let root_x = scale_axis(*x, self.output_width, self.root_width);
|
||||
let root_y = scale_axis(*y, self.output_height, self.root_height);
|
||||
self.fake_input(MOTION_NOTIFY_EVENT, 0, root_x, root_y)?;
|
||||
}
|
||||
DesktopInputEvent::PointerDelta { delta_x, delta_y } => {
|
||||
// XTEST interprets coordinates relative to the current pointer when root is None.
|
||||
self.connection
|
||||
.xtest_fake_input(
|
||||
MOTION_NOTIFY_EVENT,
|
||||
0,
|
||||
CURRENT_TIME,
|
||||
0,
|
||||
*delta_x,
|
||||
*delta_y,
|
||||
0,
|
||||
)?
|
||||
.check()?;
|
||||
}
|
||||
DesktopInputEvent::PointerButton { button, pressed } => {
|
||||
let detail = button_detail(*button);
|
||||
self.fake_input(
|
||||
if *pressed {
|
||||
BUTTON_PRESS_EVENT
|
||||
} else {
|
||||
BUTTON_RELEASE_EVENT
|
||||
},
|
||||
detail,
|
||||
0,
|
||||
0,
|
||||
)?;
|
||||
if *pressed {
|
||||
self.pressed_buttons.insert(detail);
|
||||
} else {
|
||||
self.pressed_buttons.remove(&detail);
|
||||
}
|
||||
}
|
||||
DesktopInputEvent::Wheel {
|
||||
horizontal,
|
||||
vertical,
|
||||
} => {
|
||||
self.send_wheel(*horizontal, 6, 7)?;
|
||||
self.send_wheel(*vertical, 5, 4)?;
|
||||
}
|
||||
DesktopInputEvent::Key { keysym, state } => {
|
||||
let keycode = *self
|
||||
.keysym_to_keycode
|
||||
.get(keysym)
|
||||
.ok_or("X11 keyboard layout does not contain the requested keysym")?;
|
||||
let pressed = *state == DesktopKeyState::Pressed;
|
||||
self.fake_input(
|
||||
if pressed {
|
||||
KEY_PRESS_EVENT
|
||||
} else {
|
||||
KEY_RELEASE_EVENT
|
||||
},
|
||||
keycode,
|
||||
0,
|
||||
0,
|
||||
)?;
|
||||
if pressed {
|
||||
self.pressed_keys.insert(keycode);
|
||||
} else {
|
||||
self.pressed_keys.remove(&keycode);
|
||||
}
|
||||
}
|
||||
DesktopInputEvent::ReleaseAll => self.release_all()?,
|
||||
}
|
||||
self.connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn poll_clipboard_text(&mut self) -> Result<Option<String>, Error> {
|
||||
self.clipboard.poll_text(&self.connection)
|
||||
}
|
||||
|
||||
pub fn set_clipboard_text(&mut self, text: &str) -> Result<(), Error> {
|
||||
self.clipboard.set_text(&self.connection, text)
|
||||
}
|
||||
|
||||
pub fn release_all(&mut self) -> Result<(), Error> {
|
||||
let keys = self.pressed_keys.iter().copied().collect::<Vec<_>>();
|
||||
let buttons = self.pressed_buttons.iter().copied().collect::<Vec<_>>();
|
||||
for key in keys {
|
||||
self.fake_input(KEY_RELEASE_EVENT, key, 0, 0)?;
|
||||
}
|
||||
for button in buttons {
|
||||
self.fake_input(BUTTON_RELEASE_EVENT, button, 0, 0)?;
|
||||
}
|
||||
self.pressed_keys.clear();
|
||||
self.pressed_buttons.clear();
|
||||
self.connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_wheel(
|
||||
&self,
|
||||
amount: i16,
|
||||
negative_button: u8,
|
||||
positive_button: u8,
|
||||
) -> Result<(), Error> {
|
||||
let button = if amount < 0 {
|
||||
negative_button
|
||||
} else {
|
||||
positive_button
|
||||
};
|
||||
for _ in 0..amount.unsigned_abs().min(32) {
|
||||
self.fake_input(BUTTON_PRESS_EVENT, button, 0, 0)?;
|
||||
self.fake_input(BUTTON_RELEASE_EVENT, button, 0, 0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fake_input(&self, event_type: u8, detail: u8, x: i16, y: i16) -> Result<(), Error> {
|
||||
self.connection
|
||||
.xtest_fake_input(event_type, detail, CURRENT_TIME, self.root, x, y, 0)?
|
||||
.check()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for X11Desktop {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.release_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_size(
|
||||
source_width: u16,
|
||||
source_height: u16,
|
||||
max_width: u16,
|
||||
max_height: u16,
|
||||
) -> Result<(u16, u16), Error> {
|
||||
if source_width == 0
|
||||
|| source_height == 0
|
||||
|| !(200..=8_192).contains(&max_width)
|
||||
|| !(200..=8_192).contains(&max_height)
|
||||
{
|
||||
return Err("desktop capture dimensions are invalid".into());
|
||||
}
|
||||
let (width, height) = if source_width <= max_width && source_height <= max_height {
|
||||
(source_width, source_height)
|
||||
} else if u32::from(max_width) * u32::from(source_height)
|
||||
<= u32::from(max_height) * u32::from(source_width)
|
||||
{
|
||||
let height =
|
||||
(u32::from(source_height) * u32::from(max_width) / u32::from(source_width)).max(1);
|
||||
(max_width, u16::try_from(height).unwrap_or(max_height))
|
||||
} else {
|
||||
let width =
|
||||
(u32::from(source_width) * u32::from(max_height) / u32::from(source_height)).max(1);
|
||||
(u16::try_from(width).unwrap_or(max_width), max_height)
|
||||
};
|
||||
let pixels = usize::from(width) * usize::from(height);
|
||||
if pixels > DESKTOP_MAX_PIXELS {
|
||||
return Err("desktop capture exceeds the pixel limit".into());
|
||||
}
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
fn scale_axis(value: u16, source_extent: u16, target_extent: u16) -> i16 {
|
||||
if source_extent <= 1 || target_extent <= 1 {
|
||||
return 0;
|
||||
}
|
||||
let scaled = u32::from(value) * u32::from(target_extent - 1) / u32::from(source_extent - 1);
|
||||
i16::try_from(scaled.min(i16::MAX as u32)).unwrap_or(i16::MAX)
|
||||
}
|
||||
|
||||
fn button_detail(button: DesktopButton) -> u8 {
|
||||
match button {
|
||||
DesktopButton::Left => 1,
|
||||
DesktopButton::Middle => 2,
|
||||
DesktopButton::Right => 3,
|
||||
DesktopButton::Back => 8,
|
||||
DesktopButton::Forward => 9,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_ximage_to_bgra(
|
||||
data: &[u8],
|
||||
width: u16,
|
||||
height: u16,
|
||||
bits_per_pixel: u8,
|
||||
scanline_pad: u8,
|
||||
image_order: ImageOrder,
|
||||
visual: &Visualtype,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let row_bits = usize::from(width) * usize::from(bits_per_pixel);
|
||||
let pad_bits = usize::from(scanline_pad);
|
||||
if pad_bits < 8 || !pad_bits.is_power_of_two() {
|
||||
return Err("X11 scanline padding is invalid".into());
|
||||
}
|
||||
let stride = row_bits.div_ceil(pad_bits) * pad_bits / 8;
|
||||
let required = stride
|
||||
.checked_mul(usize::from(height))
|
||||
.ok_or("X11 image byte length overflow")?;
|
||||
if data.len() < required {
|
||||
return Err("X11 image data is truncated".into());
|
||||
}
|
||||
let bytes_per_pixel = usize::from(bits_per_pixel.div_ceil(8));
|
||||
let mut bgra = Vec::with_capacity(usize::from(width) * usize::from(height) * 4);
|
||||
for row in data[..required].chunks_exact(stride) {
|
||||
for pixel in row[..usize::from(width) * bytes_per_pixel].chunks_exact(bytes_per_pixel) {
|
||||
let value = read_pixel(pixel, image_order);
|
||||
bgra.extend_from_slice(&[
|
||||
masked_channel(value, visual.blue_mask),
|
||||
masked_channel(value, visual.green_mask),
|
||||
masked_channel(value, visual.red_mask),
|
||||
255,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Ok(bgra)
|
||||
}
|
||||
|
||||
fn valid_rgb_masks(visual: &Visualtype) -> bool {
|
||||
let masks = [visual.red_mask, visual.green_mask, visual.blue_mask];
|
||||
masks.iter().all(|mask| contiguous_mask(*mask))
|
||||
&& masks[0] & masks[1] == 0
|
||||
&& masks[0] & masks[2] == 0
|
||||
&& masks[1] & masks[2] == 0
|
||||
}
|
||||
|
||||
fn contiguous_mask(mask: u32) -> bool {
|
||||
if mask == 0 {
|
||||
return false;
|
||||
}
|
||||
let shifted = mask >> mask.trailing_zeros();
|
||||
shifted & shifted.wrapping_add(1) == 0
|
||||
}
|
||||
|
||||
fn read_pixel(bytes: &[u8], order: ImageOrder) -> u32 {
|
||||
match order {
|
||||
ImageOrder::LSB_FIRST => bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(0_u32, |value, (index, byte)| {
|
||||
value | (u32::from(*byte) << (index * 8))
|
||||
}),
|
||||
ImageOrder::MSB_FIRST => bytes
|
||||
.iter()
|
||||
.fold(0_u32, |value, byte| (value << 8) | u32::from(*byte)),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn masked_channel(pixel: u32, mask: u32) -> u8 {
|
||||
let shift = mask.trailing_zeros();
|
||||
let maximum = mask >> shift;
|
||||
if maximum == 0 {
|
||||
return 0;
|
||||
}
|
||||
let value = (pixel & mask) >> shift;
|
||||
let scaled = (u64::from(value) * 255 + u64::from(maximum) / 2) / u64::from(maximum);
|
||||
u8::try_from(scaled).unwrap_or(255)
|
||||
}
|
||||
|
||||
fn scale_bgra_nearest(
|
||||
source: &[u8],
|
||||
source_width: u16,
|
||||
source_height: u16,
|
||||
target_width: u16,
|
||||
target_height: u16,
|
||||
) -> Vec<u8> {
|
||||
let mut target = vec![0_u8; usize::from(target_width) * usize::from(target_height) * 4];
|
||||
for y in 0..usize::from(target_height) {
|
||||
let source_y = y * usize::from(source_height) / usize::from(target_height);
|
||||
for x in 0..usize::from(target_width) {
|
||||
let source_x = x * usize::from(source_width) / usize::from(target_width);
|
||||
let source_offset = (source_y * usize::from(source_width) + source_x) * 4;
|
||||
let target_offset = (y * usize::from(target_width) + x) * 4;
|
||||
target[target_offset..target_offset + 4]
|
||||
.copy_from_slice(&source[source_offset..source_offset + 4]);
|
||||
}
|
||||
}
|
||||
target
|
||||
}
|
||||
|
||||
fn unix_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn visual() -> Visualtype {
|
||||
Visualtype {
|
||||
visual_id: 1,
|
||||
class: x11rb::protocol::xproto::VisualClass::TRUE_COLOR,
|
||||
bits_per_rgb_value: 8,
|
||||
colormap_entries: 256,
|
||||
red_mask: 0x00ff_0000,
|
||||
green_mask: 0x0000_ff00,
|
||||
blue_mask: 0x0000_00ff,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_little_endian_xrgb_is_converted_to_opaque_bgra() {
|
||||
let converted = convert_ximage_to_bgra(
|
||||
&[0x33, 0x22, 0x11, 0x00],
|
||||
1,
|
||||
1,
|
||||
32,
|
||||
32,
|
||||
ImageOrder::LSB_FIRST,
|
||||
&visual(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(converted, [0x33, 0x22, 0x11, 0xff]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_preserves_aspect_ratio_without_upscaling() {
|
||||
assert_eq!(fit_size(1920, 1080, 1280, 720).unwrap(), (1280, 720));
|
||||
assert_eq!(fit_size(1280, 720, 1920, 1080).unwrap(), (1280, 720));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_scaler_preserves_pixel_channel_order() {
|
||||
let source = [1, 2, 3, 255, 4, 5, 6, 255];
|
||||
assert_eq!(scale_bgra_nearest(&source, 2, 1, 1, 1), [1, 2, 3, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_masks_must_be_distinct_and_contiguous() {
|
||||
assert!(valid_rgb_masks(&visual()));
|
||||
let mut invalid = visual();
|
||||
invalid.red_mask = 0x00f0_f000;
|
||||
assert!(!valid_rgb_masks(&invalid));
|
||||
invalid.red_mask = invalid.green_mask;
|
||||
assert!(!valid_rgb_masks(&invalid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "remotedesk-windows-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "RemoteDesk Windows controlled-endpoint agent companion"
|
||||
|
||||
[[bin]]
|
||||
name = "remotedesk-windows-agent"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
flate2 = "1.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.47", features = ["macros", "net", "rt-multi-thread", "io-util", "sync", "time"] }
|
||||
windows = { version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
@@ -0,0 +1,134 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Application {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub arguments: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Vec<Application>> {
|
||||
let path = config_path();
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let data = fs::read_to_string(&path)
|
||||
.with_context(|| format!("读取应用配置失败: {}", path.display()))?;
|
||||
let applications: Vec<Application> =
|
||||
serde_json::from_str(&data).context("应用配置 JSON 无效")?;
|
||||
for application in &applications {
|
||||
validate(application)?;
|
||||
}
|
||||
for (index, application) in applications.iter().enumerate() {
|
||||
if applications[index + 1..]
|
||||
.iter()
|
||||
.any(|other| other.id == application.id)
|
||||
{
|
||||
anyhow::bail!("应用 ID 重复: {}", application.id);
|
||||
}
|
||||
}
|
||||
Ok(applications)
|
||||
}
|
||||
|
||||
pub fn launch(id: &str) -> Result<Application> {
|
||||
let application = load()?
|
||||
.into_iter()
|
||||
.find(|item| item.id == id)
|
||||
.ok_or_else(|| anyhow::anyhow!("未配置应用: {id}"))?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new(&application.path)
|
||||
.args(&application.arguments)
|
||||
.spawn()
|
||||
.with_context(|| format!("启动应用失败: {}", application.name))?;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
anyhow::bail!("应用启动仅支持 Windows");
|
||||
Ok(application)
|
||||
}
|
||||
|
||||
fn validate(application: &Application) -> Result<()> {
|
||||
if application.id.is_empty()
|
||||
|| application.id.len() > 64
|
||||
|| !application
|
||||
.id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
{
|
||||
anyhow::bail!("应用 ID 无效: {}", application.id);
|
||||
}
|
||||
if application.name.is_empty()
|
||||
|| application.name.len() > 128
|
||||
|| application.name.chars().any(char::is_control)
|
||||
{
|
||||
anyhow::bail!("应用名称无效: {}", application.id);
|
||||
}
|
||||
if !is_windows_absolute_local_path(&application.path)
|
||||
|| application.path.contains("\\\\")
|
||||
|| application.path.chars().any(char::is_control)
|
||||
{
|
||||
anyhow::bail!("应用路径必须是绝对本地路径: {}", application.id);
|
||||
}
|
||||
if application
|
||||
.arguments
|
||||
.iter()
|
||||
.any(|arg| arg.len() > 2048 || arg.chars().any(char::is_control))
|
||||
{
|
||||
anyhow::bail!("应用参数无效: {}", application.id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_windows_absolute_local_path(path: &str) -> bool {
|
||||
let bytes = path.as_bytes();
|
||||
bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'\\' | b'/')
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
if let Some(program_data) = std::env::var_os("ProgramData") {
|
||||
return PathBuf::from(program_data)
|
||||
.join("RemoteDesk")
|
||||
.join("Host")
|
||||
.join("applications.json");
|
||||
}
|
||||
PathBuf::from("applications.json")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_application_ids_and_local_paths() {
|
||||
let valid = Application {
|
||||
id: "notepad".into(),
|
||||
name: "记事本".into(),
|
||||
path: r"C:\Windows\notepad.exe".into(),
|
||||
arguments: vec![],
|
||||
};
|
||||
assert!(validate(&valid).is_ok());
|
||||
assert!(
|
||||
validate(&Application {
|
||||
id: "bad/id".into(),
|
||||
..valid.clone()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate(&Application {
|
||||
path: r"\\server\app.exe".into(),
|
||||
..valid
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
mod applications;
|
||||
|
||||
const PREFERRED_CAPTURE_BACKEND: &str = "dxgi-desktop-duplication-strict";
|
||||
const SOFTWARE_FALLBACK_BACKEND: &str = "gdi-bgra-zlib";
|
||||
const DEFAULT_FALLBACK_FPS: u8 = 10;
|
||||
const MAX_FALLBACK_FPS: u8 = 15;
|
||||
const NO_LATENCY_SAMPLE: u64 = u64::MAX;
|
||||
static CAPTURE_LATENCY_US: AtomicU64 = AtomicU64::new(NO_LATENCY_SAMPLE);
|
||||
static ENCODE_LATENCY_US: AtomicU64 = AtomicU64::new(NO_LATENCY_SAMPLE);
|
||||
static FRAME_PROCESSING_LATENCY_US: AtomicU64 = AtomicU64::new(NO_LATENCY_SAMPLE);
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "remotedesk-windows-agent",
|
||||
about = "RemoteDesk Windows controlled endpoint agent"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
Status {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
Run,
|
||||
Serve {
|
||||
#[arg(long, default_value = "0.0.0.0:39501")]
|
||||
listen: SocketAddr,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Status {
|
||||
product: &'static str,
|
||||
platform: &'static str,
|
||||
transport: &'static str,
|
||||
capture_backend: &'static str,
|
||||
input_backend: &'static str,
|
||||
session_runtime: &'static str,
|
||||
strict_gpu_pipeline: &'static str,
|
||||
software_fallback_backend: &'static str,
|
||||
software_fallback_available: bool,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Command::Status { json } => {
|
||||
let status = Status {
|
||||
product: "RemoteDesk Windows Agent",
|
||||
platform: "windows",
|
||||
transport: "native-agent",
|
||||
capture_backend: PREFERRED_CAPTURE_BACKEND,
|
||||
input_backend: "windows-send-input",
|
||||
session_runtime: "native runtime not started",
|
||||
strict_gpu_pipeline: "not_implemented",
|
||||
software_fallback_backend: SOFTWARE_FALLBACK_BACKEND,
|
||||
software_fallback_available: cfg!(windows),
|
||||
};
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&status)?);
|
||||
} else {
|
||||
println!("RemoteDesk Windows Agent");
|
||||
println!("Transport: native Agent protocol");
|
||||
println!("Capture: strict DXGI Desktop Duplication / D3D11");
|
||||
println!("Input: SendInput");
|
||||
println!("Session runtime: not started");
|
||||
println!("Software fallback: GDI / BGRA / zlib available");
|
||||
}
|
||||
}
|
||||
Command::Run => run_server("0.0.0.0:39501".parse()?)?,
|
||||
Command::Serve { listen } => run_server(listen)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_server(listen: SocketAddr) -> anyhow::Result<()> {
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
runtime.block_on(async move {
|
||||
let listener = TcpListener::bind(listen).await?;
|
||||
println!("RemoteDesk Windows Agent listening on {listen}");
|
||||
if listen.ip().is_unspecified() {
|
||||
eprintln!(
|
||||
"WARNING: unauthenticated Windows Agent is exposed on every network interface"
|
||||
);
|
||||
}
|
||||
loop {
|
||||
let (stream, peer) = listener.accept().await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = handle_client(stream).await {
|
||||
eprintln!("Windows Agent client {peer} failed: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Hello<'a> {
|
||||
kind: &'a str,
|
||||
protocol_major: u16,
|
||||
protocol_minor: u16,
|
||||
capture_backend: &'a str,
|
||||
preferred_capture_backend: &'a str,
|
||||
software_fallback_backend: &'a str,
|
||||
software_fallback_available: bool,
|
||||
input_backend: &'a str,
|
||||
authenticated: bool,
|
||||
}
|
||||
|
||||
async fn handle_client(stream: TcpStream) -> anyhow::Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let hello = serde_json::to_string(&Hello {
|
||||
kind: "windows_agent_hello",
|
||||
protocol_major: 1,
|
||||
protocol_minor: 0,
|
||||
capture_backend: PREFERRED_CAPTURE_BACKEND,
|
||||
preferred_capture_backend: PREFERRED_CAPTURE_BACKEND,
|
||||
software_fallback_backend: SOFTWARE_FALLBACK_BACKEND,
|
||||
software_fallback_available: cfg!(windows),
|
||||
input_backend: "windows-send-input",
|
||||
authenticated: false,
|
||||
})?;
|
||||
writer.write_all(hello.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
if line.len() > 16 * 1024 {
|
||||
anyhow::bail!("agent command exceeds 16 KiB");
|
||||
}
|
||||
let command: AgentCommand = serde_json::from_str(&line)?;
|
||||
if command.kind == "list_applications" {
|
||||
let response = match applications::load() {
|
||||
Ok(items) => serde_json::json!({ "kind": "applications", "applications": items }),
|
||||
Err(error) => {
|
||||
serde_json::json!({ "kind": "applications_error", "error": error.to_string() })
|
||||
}
|
||||
};
|
||||
writer
|
||||
.write_all(serde_json::to_string(&response)?.as_bytes())
|
||||
.await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
} else if command.kind == "input" {
|
||||
#[cfg(windows)]
|
||||
match apply_input(&command) {
|
||||
Ok(()) => writer.write_all(b"{\"kind\":\"input_ok\"}\n").await?,
|
||||
Err(error) => writer.write_all(serde_json::json!({"kind":"input_failed","error":error.to_string()}).to_string().as_bytes()).await?,
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
writer.write_all(b"{\"kind\":\"input_failed\",\"error\":\"windows_only\"}\n").await?;
|
||||
} else if command.kind == "open_application" {
|
||||
if !command.allow_software_fallback {
|
||||
writer.write_all(b"{\"kind\":\"application_error\",\"error\":\"software_fallback_required\"}\n").await?;
|
||||
continue;
|
||||
}
|
||||
match command
|
||||
.application
|
||||
.as_deref()
|
||||
.map(applications::launch)
|
||||
.transpose()
|
||||
{
|
||||
Ok(Some(_application)) => {
|
||||
let fps = command
|
||||
.frames_per_second
|
||||
.unwrap_or(DEFAULT_FALLBACK_FPS)
|
||||
.clamp(1, MAX_FALLBACK_FPS);
|
||||
writer.write_all(format!("{{\"kind\":\"desktop_opened\",\"capture_backend\":\"{SOFTWARE_FALLBACK_BACKEND}\",\"pixel_format\":\"bgra8\",\"compression\":\"zlib\",\"frames_per_second\":{fps}}}\n").as_bytes()).await?;
|
||||
stream_software_desktop(&mut writer, fps).await?;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(None) => {
|
||||
writer.write_all(b"{\"kind\":\"application_error\",\"error\":\"application_id_required\"}\n").await?;
|
||||
}
|
||||
Err(error) => {
|
||||
let response = serde_json::json!({ "kind": "application_error", "error": error.to_string() });
|
||||
writer
|
||||
.write_all(serde_json::to_string(&response)?.as_bytes())
|
||||
.await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
}
|
||||
}
|
||||
if command.application.is_some() {
|
||||
continue;
|
||||
}
|
||||
} else if command.kind == "open_desktop" {
|
||||
if !command.allow_software_fallback {
|
||||
let response = serde_json::to_string(&serde_json::json!({
|
||||
"kind": "desktop_unavailable",
|
||||
"error": "strict_gpu_pipeline_unavailable",
|
||||
"software_fallback_available": cfg!(windows)
|
||||
}))?;
|
||||
writer.write_all(response.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
continue;
|
||||
}
|
||||
let fps = command
|
||||
.frames_per_second
|
||||
.unwrap_or(DEFAULT_FALLBACK_FPS)
|
||||
.clamp(1, MAX_FALLBACK_FPS);
|
||||
writer
|
||||
.write_all(
|
||||
format!(
|
||||
"{{\"kind\":\"desktop_opened\",\"capture_backend\":\"{SOFTWARE_FALLBACK_BACKEND}\",\"pixel_format\":\"bgra8\",\"compression\":\"zlib\",\"frames_per_second\":{fps}}}\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
stream_software_desktop(&mut writer, fps).await?;
|
||||
return Ok(());
|
||||
} else if command.kind == "status" {
|
||||
let response = serde_json::to_string(&serde_json::json!({
|
||||
"kind": "status",
|
||||
"ready": true,
|
||||
"media": "strict_gpu_pipeline_unavailable",
|
||||
"input": "not_started",
|
||||
"preferred_capture_backend": PREFERRED_CAPTURE_BACKEND,
|
||||
"software_fallback_backend": SOFTWARE_FALLBACK_BACKEND,
|
||||
"software_fallback_available": cfg!(windows),
|
||||
"capture_latency_ms": latency_ms(&CAPTURE_LATENCY_US),
|
||||
"encode_latency_ms": latency_ms(&ENCODE_LATENCY_US),
|
||||
"frame_processing_latency_ms": latency_ms(&FRAME_PROCESSING_LATENCY_US)
|
||||
}))?;
|
||||
writer.write_all(response.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn latency_ms(value: &AtomicU64) -> Option<f64> {
|
||||
let micros = value.load(Ordering::Relaxed);
|
||||
(micros != NO_LATENCY_SAMPLE).then(|| Duration::from_micros(micros).as_secs_f64() * 1_000.0)
|
||||
}
|
||||
|
||||
fn record_latency(value: &AtomicU64, elapsed: Duration) {
|
||||
let micros = u64::try_from(elapsed.as_micros()).unwrap_or(NO_LATENCY_SAMPLE - 1);
|
||||
value.store(micros, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentCommand {
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
allow_software_fallback: bool,
|
||||
frames_per_second: Option<u8>,
|
||||
#[serde(default)]
|
||||
application: Option<String>,
|
||||
#[serde(default)]
|
||||
input_type: Option<String>,
|
||||
#[serde(default)]
|
||||
code: Option<u16>,
|
||||
#[serde(default)]
|
||||
down: Option<bool>,
|
||||
#[serde(default)]
|
||||
x: Option<i32>,
|
||||
#[serde(default)]
|
||||
y: Option<i32>,
|
||||
#[serde(default)]
|
||||
buttons: Option<u32>,
|
||||
#[serde(default)]
|
||||
mouse_action: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn apply_input(command: &AgentCommand) -> anyhow::Result<()> {
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{mouse_event, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_MOVE, MOUSEEVENTF_VIRTUALDESK, VIRTUAL_KEY};
|
||||
unsafe {
|
||||
match command.input_type.as_deref() {
|
||||
Some("key") => {
|
||||
let flags = if command.down == Some(false) { KEYEVENTF_KEYUP } else { KEYBD_EVENT_FLAGS(0) };
|
||||
let input = INPUT { r#type: INPUT_KEYBOARD, Anonymous: INPUT_0 { ki: KEYBDINPUT { wVk: VIRTUAL_KEY(command.code.unwrap_or(0)), wScan: 0, dwFlags: flags, time: 0, dwExtraInfo: 0 } } };
|
||||
SendInput(&[input], std::mem::size_of::<INPUT>() as i32);
|
||||
}
|
||||
Some("mouse") => {
|
||||
let x = command.x.unwrap_or(0).clamp(0, 65535) as i32;
|
||||
let y = command.y.unwrap_or(0).clamp(0, 65535) as i32;
|
||||
let mut flags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK | MOUSEEVENTF_MOVE;
|
||||
if let Some(action) = command.mouse_action.as_deref() {
|
||||
flags |= match action {
|
||||
"left_down" => windows::Win32::UI::Input::KeyboardAndMouse::MOUSEEVENTF_LEFTDOWN,
|
||||
"left_up" => windows::Win32::UI::Input::KeyboardAndMouse::MOUSEEVENTF_LEFTUP,
|
||||
"right_down" => windows::Win32::UI::Input::KeyboardAndMouse::MOUSEEVENTF_RIGHTDOWN,
|
||||
"right_up" => windows::Win32::UI::Input::KeyboardAndMouse::MOUSEEVENTF_RIGHTUP,
|
||||
"middle_down" => windows::Win32::UI::Input::KeyboardAndMouse::MOUSEEVENTF_MIDDLEDOWN,
|
||||
"middle_up" => windows::Win32::UI::Input::KeyboardAndMouse::MOUSEEVENTF_MIDDLEUP,
|
||||
"wheel" => windows::Win32::UI::Input::KeyboardAndMouse::MOUSEEVENTF_WHEEL,
|
||||
_ => MOUSEEVENTF_MOVE,
|
||||
};
|
||||
}
|
||||
mouse_event(flags, x, y, command.buttons.unwrap_or(0) as i32, 0);
|
||||
}
|
||||
_ => anyhow::bail!("invalid input command"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn stream_software_desktop(
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
fps: u8,
|
||||
) -> anyhow::Result<()> {
|
||||
let (sender, mut receiver) = tokio::sync::mpsc::channel::<anyhow::Result<Vec<u8>>>(1);
|
||||
std::thread::Builder::new()
|
||||
.name("gdi-bgra-zlib-capture".to_owned())
|
||||
.spawn(move || {
|
||||
let result = fallback::GdiCapture::new().and_then(|mut capture| {
|
||||
let interval = Duration::from_secs_f64(1.0 / f64::from(fps));
|
||||
loop {
|
||||
let started = std::time::Instant::now();
|
||||
let encoded = capture.capture_frame()?;
|
||||
record_latency(&CAPTURE_LATENCY_US, encoded.capture_latency);
|
||||
record_latency(&ENCODE_LATENCY_US, encoded.encode_latency);
|
||||
record_latency(&FRAME_PROCESSING_LATENCY_US, started.elapsed());
|
||||
if sender.blocking_send(Ok(encoded.bytes)).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(interval.saturating_sub(started.elapsed()));
|
||||
}
|
||||
});
|
||||
if let Err(error) = result {
|
||||
let _ = sender.blocking_send(Err(error));
|
||||
}
|
||||
})?;
|
||||
while let Some(frame) = receiver.recv().await {
|
||||
match frame {
|
||||
Ok(frame) => writer.write_all(&frame).await?,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
let bytes = &message.as_bytes()[..message.len().min(16 * 1024)];
|
||||
let length = u32::try_from(bytes.len()).unwrap_or(16 * 1024);
|
||||
writer.write_all(b"RDWE").await?;
|
||||
writer.write_all(&length.to_le_bytes()).await?;
|
||||
writer.write_all(bytes).await?;
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
async fn stream_software_desktop(
|
||||
_writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
_fps: u8,
|
||||
) -> anyhow::Result<()> {
|
||||
anyhow::bail!("gdi_software_fallback_unavailable")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod fallback {
|
||||
use anyhow::Context as _;
|
||||
use flate2::{Compression, write::ZlibEncoder};
|
||||
use std::io::Write as _;
|
||||
use std::time::Duration;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
BI_RGB, BITMAPINFO, BITMAPINFOHEADER, BitBlt, CAPTUREBLT, CreateCompatibleBitmap,
|
||||
CreateCompatibleDC, DIB_RGB_COLORS, DeleteDC, DeleteObject, GetDC, GetDIBits, HBITMAP, HDC,
|
||||
HGDIOBJ, ReleaseDC, SRCCOPY, SelectObject,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
|
||||
SM_YVIRTUALSCREEN,
|
||||
};
|
||||
|
||||
const FRAME_MAGIC: &[u8; 4] = b"RDWF";
|
||||
const FRAME_VERSION: u8 = 2;
|
||||
const FRAME_HEADER_LEN: usize = 40;
|
||||
const CODEC_BGRA_ZLIB: u8 = 1;
|
||||
const MAX_DIMENSION: i32 = 16_384;
|
||||
const MAX_RAW_FRAME_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
pub(super) struct GdiCapture {
|
||||
screen_dc: HDC,
|
||||
memory_dc: HDC,
|
||||
bitmap: HBITMAP,
|
||||
previous: HGDIOBJ,
|
||||
origin_x: i32,
|
||||
origin_y: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(super) struct EncodedFrame {
|
||||
pub(super) bytes: Vec<u8>,
|
||||
pub(super) capture_latency: Duration,
|
||||
pub(super) encode_latency: Duration,
|
||||
}
|
||||
|
||||
impl GdiCapture {
|
||||
pub(super) fn new() -> anyhow::Result<Self> {
|
||||
// The handles are owned by this capture thread and released by Drop.
|
||||
unsafe {
|
||||
let origin_x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
let origin_y = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||
let width = GetSystemMetrics(SM_CXVIRTUALSCREEN);
|
||||
let height = GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||
validate_geometry(width, height)?;
|
||||
let byte_len = frame_byte_len(width, height)?;
|
||||
let screen_dc = GetDC(None);
|
||||
anyhow::ensure!(!screen_dc.0.is_null(), "GetDC failed");
|
||||
let memory_dc = CreateCompatibleDC(Some(screen_dc));
|
||||
if memory_dc.0.is_null() {
|
||||
ReleaseDC(None, screen_dc);
|
||||
anyhow::bail!("CreateCompatibleDC failed");
|
||||
}
|
||||
let bitmap = CreateCompatibleBitmap(screen_dc, width, height);
|
||||
if bitmap.0.is_null() {
|
||||
let _ = DeleteDC(memory_dc);
|
||||
ReleaseDC(None, screen_dc);
|
||||
anyhow::bail!("CreateCompatibleBitmap failed");
|
||||
}
|
||||
let previous = SelectObject(memory_dc, bitmap.into());
|
||||
if previous.0.is_null() || previous.0 as isize == -1 {
|
||||
let _ = DeleteObject(bitmap.into());
|
||||
let _ = DeleteDC(memory_dc);
|
||||
ReleaseDC(None, screen_dc);
|
||||
anyhow::bail!("SelectObject failed");
|
||||
}
|
||||
Ok(Self {
|
||||
screen_dc,
|
||||
memory_dc,
|
||||
bitmap,
|
||||
previous,
|
||||
origin_x,
|
||||
origin_y,
|
||||
width,
|
||||
height,
|
||||
pixels: vec![0; byte_len],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn capture_frame(&mut self) -> anyhow::Result<EncodedFrame> {
|
||||
let width = u32::try_from(self.width).context("desktop width is invalid")?;
|
||||
let height = u32::try_from(self.height).context("desktop height is invalid")?;
|
||||
let header_size = u32::try_from(size_of::<BITMAPINFOHEADER>())?;
|
||||
let image_size =
|
||||
u32::try_from(self.pixels.len()).context("desktop frame is too large")?;
|
||||
let capture_started = std::time::Instant::now();
|
||||
unsafe {
|
||||
BitBlt(
|
||||
self.memory_dc,
|
||||
0,
|
||||
0,
|
||||
self.width,
|
||||
self.height,
|
||||
Some(self.screen_dc),
|
||||
self.origin_x,
|
||||
self.origin_y,
|
||||
SRCCOPY | CAPTUREBLT,
|
||||
)
|
||||
.context("BitBlt failed")?;
|
||||
let mut bitmap_info = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: header_size,
|
||||
biWidth: self.width,
|
||||
biHeight: -self.height,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB.0,
|
||||
biSizeImage: image_size,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let lines = GetDIBits(
|
||||
self.memory_dc,
|
||||
self.bitmap,
|
||||
0,
|
||||
height,
|
||||
Some(self.pixels.as_mut_ptr().cast()),
|
||||
&raw mut bitmap_info,
|
||||
DIB_RGB_COLORS,
|
||||
);
|
||||
anyhow::ensure!(lines == self.height, "GetDIBits returned {lines} scanlines");
|
||||
}
|
||||
let capture_latency = capture_started.elapsed();
|
||||
let encode_started = std::time::Instant::now();
|
||||
let mut bytes = encode_frame(width, height, &self.pixels)?;
|
||||
let encode_latency = encode_started.elapsed();
|
||||
write_duration(&mut bytes[24..32], capture_latency);
|
||||
write_duration(&mut bytes[32..40], encode_latency);
|
||||
Ok(EncodedFrame {
|
||||
bytes,
|
||||
capture_latency,
|
||||
encode_latency,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GdiCapture {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = SelectObject(self.memory_dc, self.previous);
|
||||
let _ = DeleteObject(self.bitmap.into());
|
||||
let _ = DeleteDC(self.memory_dc);
|
||||
ReleaseDC(None, self.screen_dc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_geometry(width: i32, height: i32) -> anyhow::Result<()> {
|
||||
anyhow::ensure!(
|
||||
width > 0 && height > 0 && width <= MAX_DIMENSION && height <= MAX_DIMENSION,
|
||||
"unsupported virtual desktop size {width}x{height}"
|
||||
);
|
||||
frame_byte_len(width, height).map(|_| ())
|
||||
}
|
||||
|
||||
fn frame_byte_len(width: i32, height: i32) -> anyhow::Result<usize> {
|
||||
let length = usize::try_from(width)?
|
||||
.checked_mul(usize::try_from(height)?)
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.context("desktop frame size overflow")?;
|
||||
anyhow::ensure!(
|
||||
length <= MAX_RAW_FRAME_BYTES,
|
||||
"desktop frame exceeds 256 MiB"
|
||||
);
|
||||
Ok(length)
|
||||
}
|
||||
|
||||
fn encode_frame(width: u32, height: u32, pixels: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
|
||||
encoder.write_all(pixels)?;
|
||||
let compressed = encoder.finish()?;
|
||||
anyhow::ensure!(
|
||||
compressed.len() <= MAX_RAW_FRAME_BYTES,
|
||||
"compressed frame exceeds 256 MiB"
|
||||
);
|
||||
let raw_len = u32::try_from(pixels.len()).context("raw frame is too large")?;
|
||||
let compressed_len =
|
||||
u32::try_from(compressed.len()).context("compressed frame is too large")?;
|
||||
let mut frame = Vec::with_capacity(FRAME_HEADER_LEN + compressed.len());
|
||||
frame.extend_from_slice(FRAME_MAGIC);
|
||||
frame.push(FRAME_VERSION);
|
||||
frame.push(CODEC_BGRA_ZLIB);
|
||||
frame.extend_from_slice(&0_u16.to_le_bytes());
|
||||
frame.extend_from_slice(&width.to_le_bytes());
|
||||
frame.extend_from_slice(&height.to_le_bytes());
|
||||
frame.extend_from_slice(&raw_len.to_le_bytes());
|
||||
frame.extend_from_slice(&compressed_len.to_le_bytes());
|
||||
frame.extend_from_slice(&[0; 16]);
|
||||
frame.extend_from_slice(&compressed);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
fn write_duration(target: &mut [u8], duration: Duration) {
|
||||
let micros = u64::try_from(duration.as_micros()).unwrap_or(u64::MAX);
|
||||
target.copy_from_slice(µs.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn software_fallback_is_opt_in() {
|
||||
let default: AgentCommand = serde_json::from_str(r#"{"kind":"open_desktop"}"#).unwrap();
|
||||
assert!(!default.allow_software_fallback);
|
||||
let enabled: AgentCommand = serde_json::from_str(
|
||||
r#"{"kind":"open_desktop","allow_software_fallback":true,"frames_per_second":12}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(enabled.allow_software_fallback);
|
||||
assert_eq!(enabled.frames_per_second, Some(12));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# RemoteDesk 客户端核心
|
||||
|
||||
`client/crates/client-core` 是 Windows 主程序与原生 Helper 共用的纯策略层。它不创建设备、不读取 Windows Credential Manager,也不持有远程帧或密码。
|
||||
|
||||
## 多显示器布局
|
||||
|
||||
`ClientDisplayPlanner` 接收协议层已经验证过的 `DisplayLayout` 和绑定同一远端 topology generation 的 `DisplaySelection`:
|
||||
|
||||
- `DisplaySelection::single` 选择一个远端显示器。
|
||||
- `DisplaySelection::all` 选择完整远端桌面。
|
||||
- `DisplaySelection::custom` 按用户顺序选择一个子集。
|
||||
- `DisplayResolution::{SourceNative, FollowWindow, Fixed}` 控制合成画布尺寸。
|
||||
|
||||
远端显示器允许负坐标。规划器保留原始 `source_rect`,同时生成从 `(0, 0)` 开始的 `normalized_source_rect` 和缩放后的 `output_rect`。混合 DPI 的 `DisplayScale` 按显示器保留;若选择未包含远端主屏,第一个被选显示器成为该计划的有效主屏。
|
||||
|
||||
规划前必须提供 `DisplayPlanLimits`。显示器数量、源像素总量和缩放后像素总量超过限制都会失败,不会让 Helper 先分配大表面再报错。
|
||||
|
||||
同一标准化布局会生成明确的后端命令:
|
||||
|
||||
- `RdpDisplayControlPlan` 将主屏放在数组首项并以主屏 `(0, 0)` 生成允许负坐标的 RDP Dynamic Monitor Layout。
|
||||
- `LinuxNativeDisplayPlan` 指定 Agent 捕获显示器、主屏和编码画布;它只调整编码/合成尺寸,不修改 Wayland/Xorg 实体显示模式。
|
||||
|
||||
规划器的 client generation 从 1 开始。远端 topology/选择、分辨率、本地窗口 output/DPI、GPU pipeline 或后端发生变化时 generation 递增并返回 `DisplayRevalidationReason`;完全相同的请求复用 generation。失败请求不推进 generation。Helper 回调和 path report 必须绑定当前 generation,旧结果应丢弃。
|
||||
|
||||
严格零拷贝继续由 `GpuPolicy::RequiredEndToEnd` 控制。本地窗口所在 output 的 DXGI Adapter 是 display adapter;decoder、renderer 或手动 Adapter 与它不一致时,RDP 和 Linux native 计划都拒绝。`Compatibility` 才允许生成带 cross-adapter copy 标记的计划。RDP 服务端路径是否零拷贝仍需真实 IronRDP/ETW 验证,客户端计划本身不能作此证明。
|
||||
|
||||
## RDP 凭据
|
||||
|
||||
HostProfile 只能保存 `HostProfileCredentialBinding`,其中只有 `CredentialRef` 和非秘密状态,不包含用户名或密码。引用必须位于 `RemoteDesk/RDP/<profile-key>` 命名空间,profile key 仅允许 ASCII 字母、数字及 `-_.:@`,空值、外部命名空间、控制字符和超长值会被拒绝。
|
||||
|
||||
`client/helpers/credential-store` 使用 Windows Credential Manager generic credential,并通过 `CredentialRef::target()` 精确指定应用条目。独立本地控制台遮罩录入账号和密码;React/HTTP、控制服务和 viewer 命令行只传引用。IronRDP viewer 直接读取凭据并在进程内构造 NLA 配置,密码不会写回 client-core 状态、日志、命令行或序列化 HostProfile。
|
||||
|
||||
`CredentialRef` 和 `HostProfileCredentialBinding` 的 `Debug` 输出会隐藏 target。`RdpCredentialStatus` 只表达 `Unchecked`、`Missing`、`Ready` 或 `NeedsUserUpdate`。
|
||||
|
||||
## 验证
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
$taskTemp = (Resolve-Path 'target\tmp').Path
|
||||
$env:TEMP = $taskTemp
|
||||
$env:TMP = $taskTemp
|
||||
cargo test -p remotedesk-client-core
|
||||
cargo clippy -p remotedesk-client-core --all-targets -- -D warnings
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "remotedesk-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "remotedesk"
|
||||
path = "src/main.rs"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
getrandom = "0.3.3"
|
||||
sha2 = "0.10"
|
||||
tauri = { version = "2.8.5", default-features = false, features = ["wry"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
tauri-build = { version = "2.4.1", features = [] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,4 @@
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
tauri_build::build();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#5ed39a"/>
|
||||
<g fill="none" stroke="#101b16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 8.5v3.2M8.4 10.1h3.2"/>
|
||||
<path d="M21.8 8.5v3.2M20.2 10.1h3.2"/>
|
||||
<path d="M10 18.5v3.2M8.4 20.1h3.2"/>
|
||||
<path d="M21.8 18.5v3.2M20.2 20.1h3.2"/>
|
||||
<path d="M12.5 14.2h7M12.5 16.4h7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,318 @@
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!("RemoteDesk desktop shell is available only on Windows");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_app {
|
||||
use std::env;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read as _, Write as _};
|
||||
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
use tauri::{Manager as _, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const PERSISTENT_WEB_PORT: u16 = 4173;
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
struct ControlServiceHandle {
|
||||
shutdown: Sender<()>,
|
||||
}
|
||||
|
||||
impl Drop for ControlServiceHandle {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown.send(());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let (control_service, port) = start_control_service(app.handle().clone())?;
|
||||
app.manage(control_service);
|
||||
|
||||
// WebView2 treats localhost as a trusted loopback origin more
|
||||
// consistently than a numeric loopback URL across enterprise
|
||||
// policies and older runtime builds.
|
||||
let url = tauri::Url::parse(&format!("http://localhost:{port}/"))?;
|
||||
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
|
||||
.title("RemoteDesk")
|
||||
.inner_size(1280.0, 800.0)
|
||||
.min_inner_size(960.0, 600.0)
|
||||
.devtools(cfg!(debug_assertions))
|
||||
.build()?;
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("RemoteDesk desktop runtime failed");
|
||||
}
|
||||
|
||||
fn start_control_service(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(ControlServiceHandle, u16), Box<dyn std::error::Error>> {
|
||||
let web_root = resolve_web_root()?;
|
||||
let service_path = resolve_control_service()?;
|
||||
// Keep the WebView origin stable so its localStorage (host profiles and
|
||||
// preferences) survives application restarts and MSI upgrades. Fall back
|
||||
// to an ephemeral port only when another local service owns the port.
|
||||
let port = reserve_persistent_or_ephemeral_port()?;
|
||||
let shell_token = new_shell_token()?;
|
||||
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
|
||||
let (shutdown_tx, shutdown_rx) = mpsc::channel();
|
||||
|
||||
thread::Builder::new()
|
||||
.name("remotedesk-control-service-manager".to_owned())
|
||||
.spawn(move || {
|
||||
let mut child =
|
||||
match spawn_control_service(&service_path, &web_root, port, &shell_token) {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
let _ = ready_tx.send(Err(error.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = wait_for_control_service(&mut child, port, &shell_token) {
|
||||
let _ = ready_tx.send(Err(error.to_string()));
|
||||
stop_child(&mut child);
|
||||
return;
|
||||
}
|
||||
if ready_tx.send(Ok(port)).is_err() {
|
||||
stop_child(&mut child);
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
match shutdown_rx.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(()) | Err(RecvTimeoutError::Disconnected) => {
|
||||
stop_child(&mut child);
|
||||
return;
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
app_handle.exit(if status.success() { 0 } else { 1 });
|
||||
return;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(_) => {
|
||||
stop_child(&mut child);
|
||||
app_handle.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
let handle = ControlServiceHandle {
|
||||
shutdown: shutdown_tx,
|
||||
};
|
||||
match ready_rx.recv_timeout(STARTUP_TIMEOUT) {
|
||||
Ok(Ok(ready_port)) => Ok((handle, ready_port)),
|
||||
Ok(Err(error)) => Err(io::Error::other(error).into()),
|
||||
Err(_) => Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"control service did not become ready within 10 seconds",
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn reserve_persistent_or_ephemeral_port() -> io::Result<u16> {
|
||||
if TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, PERSISTENT_WEB_PORT)).is_ok() {
|
||||
return Ok(PERSISTENT_WEB_PORT);
|
||||
}
|
||||
reserve_loopback_port()
|
||||
}
|
||||
|
||||
fn resolve_web_root() -> io::Result<PathBuf> {
|
||||
if let Some(path) = environment_path("REMOTEDESK_WEB_ROOT") {
|
||||
return validate_web_root(path);
|
||||
}
|
||||
let executable = env::current_exe()?;
|
||||
let executable_dir = executable
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("desktop executable has no parent directory"))?;
|
||||
if let Some(install_root) = executable_dir.parent() {
|
||||
let installed = install_root.join("web");
|
||||
if installed.join("index.html").is_file() {
|
||||
return validate_web_root(installed);
|
||||
}
|
||||
}
|
||||
validate_web_root(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("web")
|
||||
.join("dist"),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_web_root(path: PathBuf) -> io::Result<PathBuf> {
|
||||
let canonical = path.canonicalize()?;
|
||||
if !canonical.join("index.html").is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"RemoteDesk web assets do not contain index.html",
|
||||
));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn resolve_control_service() -> io::Result<PathBuf> {
|
||||
let path = if let Some(path) = environment_path("REMOTEDESK_CONTROL_SERVICE") {
|
||||
path
|
||||
} else {
|
||||
env::current_exe()?
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("desktop executable has no parent directory"))?
|
||||
.join("remotedesk-control-service.exe")
|
||||
};
|
||||
let canonical = path.canonicalize()?;
|
||||
if !canonical.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"RemoteDesk control service executable is missing",
|
||||
));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn environment_path(name: &str) -> Option<PathBuf> {
|
||||
env::var_os(name)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn reserve_loopback_port() -> io::Result<u16> {
|
||||
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
drop(listener);
|
||||
if port < 1_024 {
|
||||
return Err(io::Error::other("Windows selected a reserved local port"));
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
fn new_shell_token() -> io::Result<String> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
getrandom::fill(&mut bytes).map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let mut token = String::with_capacity(bytes.len() * 2);
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
for byte in bytes {
|
||||
token.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
token.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn spawn_control_service(
|
||||
service: &Path,
|
||||
web_root: &Path,
|
||||
port: u16,
|
||||
shell_token: &str,
|
||||
) -> io::Result<Child> {
|
||||
let mut command = Command::new(service);
|
||||
command
|
||||
.arg("--web-root")
|
||||
.arg(web_root)
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.arg("--exit-on-stdin-close")
|
||||
.env("REMOTEDESK_DESKTOP_SHELL", "tauri-2")
|
||||
.env("REMOTEDESK_SHELL_TOKEN", shell_token)
|
||||
.stdin(Stdio::piped())
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
if let Some(log) = open_control_service_log() {
|
||||
command.stdout(Stdio::from(log.try_clone()?));
|
||||
command.stderr(Stdio::from(log));
|
||||
} else {
|
||||
command.stdout(Stdio::null());
|
||||
command.stderr(Stdio::null());
|
||||
}
|
||||
command.spawn()
|
||||
}
|
||||
|
||||
fn open_control_service_log() -> Option<File> {
|
||||
let root = env::var_os("LOCALAPPDATA")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)?
|
||||
.join("RemoteDesk")
|
||||
.join("logs");
|
||||
fs::create_dir_all(&root).ok()?;
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(root.join("control-service.log"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn wait_for_control_service(child: &mut Child, port: u16, shell_token: &str) -> io::Result<()> {
|
||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||
let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
|
||||
let expected_proof = format!("{:x}", Sha256::digest(shell_token.as_bytes()));
|
||||
let expected_body = format!(r#"{{"ready":true,"proof":"{expected_proof}"}}"#).into_bytes();
|
||||
while Instant::now() < deadline {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Err(io::Error::other(format!(
|
||||
"control service exited during startup with {status}"
|
||||
)));
|
||||
}
|
||||
if let Ok(mut stream) =
|
||||
TcpStream::connect_timeout(&address.into(), Duration::from_millis(100))
|
||||
{
|
||||
stream.set_read_timeout(Some(Duration::from_millis(250)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(250)))?;
|
||||
let request = format!(
|
||||
"GET /api/v1/shell/ready HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
if stream.write_all(request.as_bytes()).is_ok() {
|
||||
let mut response = Vec::new();
|
||||
if stream.take(4_096).read_to_end(&mut response).is_ok()
|
||||
&& response.starts_with(b"HTTP/1.1 200 ")
|
||||
&& response
|
||||
.windows(expected_body.len())
|
||||
.any(|window| window == expected_body)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"control service did not open its loopback listener",
|
||||
))
|
||||
}
|
||||
|
||||
fn stop_child(child: &mut Child) {
|
||||
drop(child.stdin.take());
|
||||
let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
|
||||
while Instant::now() < deadline {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => return,
|
||||
Ok(None) => thread::sleep(Duration::from_millis(50)),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
windows_app::run();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "RemoteDesk",
|
||||
"version": "0.2.19",
|
||||
"identifier": "com.remotedesk.client",
|
||||
"build": {
|
||||
"frontendDist": "../web/dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"capabilities": []
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"icon": [
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "remotedesk-client-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "remotedesk_client_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
getrandom = { version = "0.3", features = ["std"] }
|
||||
hmac = "=0.13.0"
|
||||
remotedesk-protocol = { path = "../../../protocol" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = "=0.11.0"
|
||||
zeroize = "1.8"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
@@ -0,0 +1,228 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Namespace used for application-owned Windows Credential Manager entries.
|
||||
pub const RDP_CREDENTIAL_TARGET_PREFIX: &str = "RemoteDesk/RDP/";
|
||||
|
||||
const MAX_PROFILE_KEY_LENGTH: usize = 128;
|
||||
|
||||
/// Opaque reference to an RDP credential owned by the OS credential store.
|
||||
///
|
||||
/// This is a lookup key, never a username, password, token, or serialized
|
||||
/// credential. Its custom [`Debug`] implementation intentionally redacts the
|
||||
/// target so diagnostics cannot disclose even the host/profile association.
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct CredentialRef {
|
||||
target: String,
|
||||
}
|
||||
|
||||
impl CredentialRef {
|
||||
/// Validates an application-owned credential target.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CredentialRefError`] when the target is empty, outside the
|
||||
/// `RemoteDesk/RDP/` namespace, too long, or has an unsafe profile key.
|
||||
pub fn new(target: impl Into<String>) -> Result<Self, CredentialRefError> {
|
||||
let target = target.into();
|
||||
if target.is_empty() {
|
||||
return Err(CredentialRefError::EmptyTarget);
|
||||
}
|
||||
let Some(profile_key) = target.strip_prefix(RDP_CREDENTIAL_TARGET_PREFIX) else {
|
||||
return Err(CredentialRefError::InvalidNamespace);
|
||||
};
|
||||
if profile_key.is_empty() {
|
||||
return Err(CredentialRefError::EmptyProfileKey);
|
||||
}
|
||||
if profile_key.len() > MAX_PROFILE_KEY_LENGTH {
|
||||
return Err(CredentialRefError::ProfileKeyTooLong {
|
||||
actual: profile_key.len(),
|
||||
max: MAX_PROFILE_KEY_LENGTH,
|
||||
});
|
||||
}
|
||||
if !profile_key.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'@')
|
||||
}) {
|
||||
return Err(CredentialRefError::InvalidProfileKey);
|
||||
}
|
||||
Ok(Self { target })
|
||||
}
|
||||
|
||||
/// Returns the non-secret OS credential target for a real platform adapter.
|
||||
#[must_use]
|
||||
pub fn target(&self) -> &str {
|
||||
&self.target
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CredentialRef {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("CredentialRef([redacted])")
|
||||
}
|
||||
}
|
||||
|
||||
/// Validation error for a credential-store lookup reference.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CredentialRefError {
|
||||
EmptyTarget,
|
||||
InvalidNamespace,
|
||||
EmptyProfileKey,
|
||||
ProfileKeyTooLong { actual: usize, max: usize },
|
||||
InvalidProfileKey,
|
||||
}
|
||||
|
||||
impl fmt::Display for CredentialRefError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::EmptyTarget => formatter.write_str("credential target is empty"),
|
||||
Self::InvalidNamespace => write!(
|
||||
formatter,
|
||||
"credential target must use the {RDP_CREDENTIAL_TARGET_PREFIX} namespace"
|
||||
),
|
||||
Self::EmptyProfileKey => formatter.write_str("credential target profile key is empty"),
|
||||
Self::ProfileKeyTooLong { actual, max } => write!(
|
||||
formatter,
|
||||
"credential target profile key length {actual} exceeds maximum {max}"
|
||||
),
|
||||
Self::InvalidProfileKey => formatter.write_str(
|
||||
"credential target profile key contains unsupported or control characters",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CredentialRefError {}
|
||||
|
||||
/// Non-secret result of checking a credential reference in the OS store.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RdpCredentialStatus {
|
||||
Unchecked,
|
||||
Missing,
|
||||
Ready,
|
||||
NeedsUserUpdate,
|
||||
}
|
||||
|
||||
/// The only RDP credential state that a host profile may retain.
|
||||
///
|
||||
/// A platform Credential Manager adapter may update [`Self::status`], but it
|
||||
/// must never put retrieved account or password material into this value.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct HostProfileCredentialBinding {
|
||||
credential_ref: CredentialRef,
|
||||
status: RdpCredentialStatus,
|
||||
}
|
||||
|
||||
impl HostProfileCredentialBinding {
|
||||
#[must_use]
|
||||
pub const fn new(credential_ref: CredentialRef) -> Self {
|
||||
Self {
|
||||
credential_ref,
|
||||
status: RdpCredentialStatus::Unchecked,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn credential_ref(&self) -> &CredentialRef {
|
||||
&self.credential_ref
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn status(&self) -> RdpCredentialStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
/// Records only non-secret availability metadata returned by an OS adapter.
|
||||
pub const fn set_status(&mut self, status: RdpCredentialStatus) {
|
||||
self.status = status;
|
||||
}
|
||||
|
||||
/// Changes the OS lookup reference and invalidates the previous status.
|
||||
pub fn replace(&mut self, credential_ref: CredentialRef) {
|
||||
self.credential_ref = credential_ref;
|
||||
self.status = RdpCredentialStatus::Unchecked;
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HostProfileCredentialBinding {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("HostProfileCredentialBinding")
|
||||
.field("credential_ref", &"[redacted]")
|
||||
.field("status", &self.status)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_reference_is_an_opaque_application_target() {
|
||||
let reference = CredentialRef::new("RemoteDesk/RDP/profile-123").unwrap();
|
||||
assert_eq!(reference.target(), "RemoteDesk/RDP/profile-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_foreign_and_unsafe_targets_fail_closed() {
|
||||
assert_eq!(
|
||||
CredentialRef::new("").unwrap_err(),
|
||||
CredentialRefError::EmptyTarget
|
||||
);
|
||||
assert_eq!(
|
||||
CredentialRef::new("RemoteDesk/RDP/").unwrap_err(),
|
||||
CredentialRefError::EmptyProfileKey
|
||||
);
|
||||
assert_eq!(
|
||||
CredentialRef::new("TERMSRV/server").unwrap_err(),
|
||||
CredentialRefError::InvalidNamespace
|
||||
);
|
||||
assert_eq!(
|
||||
CredentialRef::new("RemoteDesk/RDP/profile/other").unwrap_err(),
|
||||
CredentialRefError::InvalidProfileKey
|
||||
);
|
||||
assert_eq!(
|
||||
CredentialRef::new("RemoteDesk/RDP/profile\nsecret").unwrap_err(),
|
||||
CredentialRefError::InvalidProfileKey
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_target_is_rejected() {
|
||||
let target = format!("{RDP_CREDENTIAL_TARGET_PREFIX}{}", "a".repeat(129));
|
||||
assert_eq!(
|
||||
CredentialRef::new(target).unwrap_err(),
|
||||
CredentialRefError::ProfileKeyTooLong {
|
||||
actual: 129,
|
||||
max: 128,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_target_and_contains_no_secret_fields() {
|
||||
let marker = "sensitive-host-marker";
|
||||
let reference = CredentialRef::new(format!("RemoteDesk/RDP/{marker}")).unwrap();
|
||||
let binding = HostProfileCredentialBinding::new(reference.clone());
|
||||
|
||||
let reference_debug = format!("{reference:?}");
|
||||
let binding_debug = format!("{binding:?}");
|
||||
assert!(!reference_debug.contains(marker));
|
||||
assert!(!binding_debug.contains(marker));
|
||||
assert!(!reference_debug.to_ascii_lowercase().contains("password"));
|
||||
assert!(!binding_debug.to_ascii_lowercase().contains("password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_state_contains_only_reference_and_non_secret_status() {
|
||||
let first = CredentialRef::new("RemoteDesk/RDP/first").unwrap();
|
||||
let second = CredentialRef::new("RemoteDesk/RDP/second").unwrap();
|
||||
let mut binding = HostProfileCredentialBinding::new(first);
|
||||
|
||||
assert_eq!(binding.status(), RdpCredentialStatus::Unchecked);
|
||||
binding.set_status(RdpCredentialStatus::Ready);
|
||||
assert_eq!(binding.status(), RdpCredentialStatus::Ready);
|
||||
binding.replace(second.clone());
|
||||
assert_eq!(binding.credential_ref(), &second);
|
||||
assert_eq!(binding.status(), RdpCredentialStatus::Unchecked);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
use remotedesk_protocol::{MemoryPathStatus, ZeroCopyPolicy};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AdapterId(String);
|
||||
|
||||
impl AdapterId {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AdapterId {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GpuSelection {
|
||||
WindowDisplayAdapter,
|
||||
Manual(AdapterId),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GpuPolicy {
|
||||
pub selection: GpuSelection,
|
||||
pub zero_copy_policy: ZeroCopyPolicy,
|
||||
}
|
||||
|
||||
impl Default for GpuPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selection: GpuSelection::WindowDisplayAdapter,
|
||||
zero_copy_policy: ZeroCopyPolicy::RequiredEndToEnd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PipelineRequest {
|
||||
pub policy: GpuPolicy,
|
||||
pub window_display_adapter: AdapterId,
|
||||
pub decoder_adapter: AdapterId,
|
||||
pub available_adapters: Vec<AdapterId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct D3d11PipelinePlan {
|
||||
pub decode_adapter: AdapterId,
|
||||
pub render_adapter: AdapterId,
|
||||
pub display_adapter: AdapterId,
|
||||
pub planned_memory_path: MemoryPathStatus,
|
||||
pub zero_copy_policy: ZeroCopyPolicy,
|
||||
}
|
||||
|
||||
impl D3d11PipelinePlan {
|
||||
#[must_use]
|
||||
pub fn is_zero_copy_candidate(&self) -> bool {
|
||||
self.planned_memory_path.satisfies_strict_zero_copy()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PipelinePlanError {
|
||||
AdapterUnavailable(AdapterId),
|
||||
CrossAdapterRejected {
|
||||
decode_adapter: AdapterId,
|
||||
render_adapter: AdapterId,
|
||||
display_adapter: AdapterId,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for PipelinePlanError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::AdapterUnavailable(adapter) => {
|
||||
write!(formatter, "selected adapter {adapter} is unavailable")
|
||||
}
|
||||
Self::CrossAdapterRejected {
|
||||
decode_adapter,
|
||||
render_adapter,
|
||||
display_adapter,
|
||||
} => write!(
|
||||
formatter,
|
||||
"RequiredEndToEnd policy rejects decode adapter {decode_adapter} -> render adapter {render_adapter} -> display adapter {display_adapter} path"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PipelinePlanError {}
|
||||
|
||||
/// Builds a policy-only D3D11 pipeline plan without creating GPU resources.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PipelinePlanError::AdapterUnavailable`] when a selected adapter
|
||||
/// is absent, or [`PipelinePlanError::CrossAdapterRejected`] when an end-to-end
|
||||
/// zero-copy policy would require an inter-adapter transfer.
|
||||
pub fn plan_d3d11_pipeline(
|
||||
request: PipelineRequest,
|
||||
) -> Result<D3d11PipelinePlan, PipelinePlanError> {
|
||||
let render_adapter = match &request.policy.selection {
|
||||
GpuSelection::WindowDisplayAdapter => request.window_display_adapter.clone(),
|
||||
GpuSelection::Manual(adapter) => adapter.clone(),
|
||||
};
|
||||
let display_adapter = request.window_display_adapter;
|
||||
|
||||
if !request.available_adapters.contains(&render_adapter) {
|
||||
return Err(PipelinePlanError::AdapterUnavailable(render_adapter));
|
||||
}
|
||||
if !request
|
||||
.available_adapters
|
||||
.contains(&request.decoder_adapter)
|
||||
{
|
||||
return Err(PipelinePlanError::AdapterUnavailable(
|
||||
request.decoder_adapter,
|
||||
));
|
||||
}
|
||||
if !request.available_adapters.contains(&display_adapter) {
|
||||
return Err(PipelinePlanError::AdapterUnavailable(display_adapter));
|
||||
}
|
||||
|
||||
let planned_memory_path =
|
||||
if request.decoder_adapter == render_adapter && render_adapter == display_adapter {
|
||||
MemoryPathStatus::CpuCopyFree
|
||||
} else {
|
||||
MemoryPathStatus::CrossAdapterCopy
|
||||
};
|
||||
|
||||
if request.policy.zero_copy_policy == ZeroCopyPolicy::RequiredEndToEnd
|
||||
&& !planned_memory_path.satisfies_strict_zero_copy()
|
||||
{
|
||||
return Err(PipelinePlanError::CrossAdapterRejected {
|
||||
decode_adapter: request.decoder_adapter,
|
||||
render_adapter,
|
||||
display_adapter,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(D3d11PipelinePlan {
|
||||
decode_adapter: request.decoder_adapter,
|
||||
render_adapter,
|
||||
display_adapter,
|
||||
planned_memory_path,
|
||||
zero_copy_policy: request.policy.zero_copy_policy,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn adapter(value: &str) -> AdapterId {
|
||||
AdapterId::new(value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_policy_uses_window_display_adapter() {
|
||||
let plan = plan_d3d11_pipeline(PipelineRequest {
|
||||
policy: GpuPolicy::default(),
|
||||
window_display_adapter: adapter("display-gpu"),
|
||||
decoder_adapter: adapter("display-gpu"),
|
||||
available_adapters: vec![adapter("display-gpu"), adapter("other")],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.render_adapter, adapter("display-gpu"));
|
||||
assert_eq!(plan.display_adapter, adapter("display-gpu"));
|
||||
assert!(plan.is_zero_copy_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_end_to_end_manual_adapter_must_match_display_adapter() {
|
||||
let error = plan_d3d11_pipeline(PipelineRequest {
|
||||
policy: GpuPolicy {
|
||||
selection: GpuSelection::Manual(adapter("manual-gpu")),
|
||||
zero_copy_policy: ZeroCopyPolicy::RequiredEndToEnd,
|
||||
},
|
||||
window_display_adapter: adapter("display-gpu"),
|
||||
decoder_adapter: adapter("manual-gpu"),
|
||||
available_adapters: vec![adapter("display-gpu"), adapter("manual-gpu")],
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
PipelinePlanError::CrossAdapterRejected {
|
||||
decode_adapter: adapter("manual-gpu"),
|
||||
render_adapter: adapter("manual-gpu"),
|
||||
display_adapter: adapter("display-gpu"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_end_to_end_policy_rejects_cross_adapter_transfer() {
|
||||
let error = plan_d3d11_pipeline(PipelineRequest {
|
||||
policy: GpuPolicy::default(),
|
||||
window_display_adapter: adapter("render"),
|
||||
decoder_adapter: adapter("decode"),
|
||||
available_adapters: vec![adapter("render"), adapter("decode")],
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
PipelinePlanError::CrossAdapterRejected { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_policy_marks_cross_adapter_copy() {
|
||||
let plan = plan_d3d11_pipeline(PipelineRequest {
|
||||
policy: GpuPolicy {
|
||||
selection: GpuSelection::WindowDisplayAdapter,
|
||||
zero_copy_policy: ZeroCopyPolicy::Compatibility,
|
||||
},
|
||||
window_display_adapter: adapter("render"),
|
||||
decoder_adapter: adapter("decode"),
|
||||
available_adapters: vec![adapter("render"), adapter("decode")],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.planned_memory_path, MemoryPathStatus::CrossAdapterCopy);
|
||||
assert_eq!(plan.zero_copy_policy, ZeroCopyPolicy::Compatibility);
|
||||
assert!(!plan.is_zero_copy_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_allows_manual_render_adapter_to_differ_from_display() {
|
||||
let plan = plan_d3d11_pipeline(PipelineRequest {
|
||||
policy: GpuPolicy {
|
||||
selection: GpuSelection::Manual(adapter("manual-gpu")),
|
||||
zero_copy_policy: ZeroCopyPolicy::Compatibility,
|
||||
},
|
||||
window_display_adapter: adapter("display-gpu"),
|
||||
decoder_adapter: adapter("manual-gpu"),
|
||||
available_adapters: vec![adapter("display-gpu"), adapter("manual-gpu")],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.decode_adapter, adapter("manual-gpu"));
|
||||
assert_eq!(plan.render_adapter, adapter("manual-gpu"));
|
||||
assert_eq!(plan.display_adapter, adapter("display-gpu"));
|
||||
assert_eq!(plan.planned_memory_path, MemoryPathStatus::CrossAdapterCopy);
|
||||
assert!(!plan.is_zero_copy_candidate());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use std::{fmt, io};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::{
|
||||
fs::File,
|
||||
mem,
|
||||
os::windows::io::{AsRawHandle as _, FromRawHandle as _, RawHandle},
|
||||
ptr,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::{
|
||||
Foundation::HANDLE,
|
||||
System::{
|
||||
JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, TerminateJobObject,
|
||||
},
|
||||
Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE},
|
||||
},
|
||||
};
|
||||
|
||||
pub struct HelperJob {
|
||||
#[cfg(windows)]
|
||||
handle: File,
|
||||
}
|
||||
|
||||
impl fmt::Debug for HelperJob {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.debug_struct("HelperJob").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl HelperJob {
|
||||
#[cfg(windows)]
|
||||
pub fn create_and_assign(pid: u32) -> io::Result<Self> {
|
||||
// SAFETY: null security attributes and name create a private unnamed job handle.
|
||||
let job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
|
||||
if job.is_null() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: CreateJobObjectW returned an owned kernel handle.
|
||||
let handle = unsafe { File::from_raw_handle(job as RawHandle) };
|
||||
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
let limits_size =
|
||||
u32::try_from(mem::size_of_val(&limits)).expect("job limit information size fits u32");
|
||||
// SAFETY: the job handle and immutable limit structure are valid for the call.
|
||||
if unsafe {
|
||||
SetInformationJobObject(
|
||||
handle.as_raw_handle() as HANDLE,
|
||||
JobObjectExtendedLimitInformation,
|
||||
ptr::from_ref(&limits).cast(),
|
||||
limits_size,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// AssignProcessToJobObject requires PROCESS_SET_QUOTA and PROCESS_TERMINATE.
|
||||
// SAFETY: OpenProcess returns an owned handle or null.
|
||||
let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if process.is_null() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: OpenProcess returned an owned process handle.
|
||||
let process = unsafe { File::from_raw_handle(process as RawHandle) };
|
||||
// SAFETY: both handles are live and the process was opened with required access rights.
|
||||
if unsafe {
|
||||
AssignProcessToJobObject(
|
||||
handle.as_raw_handle() as HANDLE,
|
||||
process.as_raw_handle() as HANDLE,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(Self { handle })
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn create_and_assign(_pid: u32) -> io::Result<Self> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"helper Job Objects require Windows",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn terminate(&self, exit_code: u32) -> io::Result<()> {
|
||||
// SAFETY: handle is a live Job Object owned by this wrapper.
|
||||
if unsafe { TerminateJobObject(self.handle.as_raw_handle() as HANDLE, exit_code) } == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn terminate(&self, _exit_code: u32) -> io::Result<()> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"helper Job Objects require Windows",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn job_termination_stops_an_assigned_process() {
|
||||
let mut child = Command::new("ping.exe")
|
||||
.args(["-n", "30", "127.0.0.1"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let job = HelperJob::create_and_assign(child.id()).unwrap();
|
||||
job.terminate(23).unwrap();
|
||||
let status = child.wait().unwrap();
|
||||
assert!(!status.success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Pure client-side policy and state models.
|
||||
//!
|
||||
//! Windows-only integration in this crate owns the authenticated local Named
|
||||
//! Pipe boundary shared by the control service and native helpers. Rendering
|
||||
//! and remote transport remain outside this crate.
|
||||
|
||||
mod credentials;
|
||||
mod display;
|
||||
mod gpu;
|
||||
mod helper_job;
|
||||
mod pipe_peer;
|
||||
mod reachability;
|
||||
mod render_report;
|
||||
mod secure_pipe;
|
||||
mod session;
|
||||
|
||||
pub use credentials::{
|
||||
CredentialRef, CredentialRefError, HostProfileCredentialBinding, RDP_CREDENTIAL_TARGET_PREFIX,
|
||||
RdpCredentialStatus,
|
||||
};
|
||||
pub use display::{
|
||||
BackendDisplayPlan, CanvasRect, ClientDisplayBackend, ClientDisplayPlan,
|
||||
ClientDisplayPlanRequest, ClientDisplayPlanner, DisplayPlanError, DisplayPlanLimits,
|
||||
DisplayResolution, DisplayRevalidationReason, LinuxNativeDisplayPlan, LocalWindowOutput,
|
||||
NormalizedDisplayPlan, PixelBudgetStage, PixelSize, PlannedDisplay, RdpDisplayControlPlan,
|
||||
RdpMonitorLayout,
|
||||
};
|
||||
pub use gpu::{
|
||||
AdapterId, D3d11PipelinePlan, GpuPolicy, GpuSelection, PipelinePlanError, PipelineRequest,
|
||||
plan_d3d11_pipeline,
|
||||
};
|
||||
pub use helper_job::HelperJob;
|
||||
pub use pipe_peer::{
|
||||
ChallengeMacVerification, HelperProcessIdentity, PipePeerError, PipePeerVerifier,
|
||||
SessionMaterial, VerifiedPipePeer,
|
||||
};
|
||||
pub use reachability::{DEFAULT_RDP_PORT, RdpEndpoint, RdpEndpointError};
|
||||
pub use remotedesk_protocol::{
|
||||
ClientRenderPathReport, DisplayDescriptor, DisplayId, DisplayLayout, DisplayLayoutError,
|
||||
DisplayRect, DisplayScale, DisplaySelection, DisplaySelectionError, DisplaySelectionMode,
|
||||
MemoryPathStatus, MemorySurfaceType, PathReportBinding, VerificationSource, ZeroCopyPolicy,
|
||||
};
|
||||
pub use render_report::{ClientRenderReportIngress, RenderReportPayload};
|
||||
pub use secure_pipe::{
|
||||
PIPE_BOOTSTRAP_KEY_ENV, RdpViewerLaunchConfig, SecurePipeBootstrap, SecurePipeClientSession,
|
||||
SecurePipeServer, current_process_identity, process_identity, receive_secure_pipe_payload,
|
||||
receive_secure_pipe_session,
|
||||
};
|
||||
pub use session::{SessionError, SessionMachine, SessionState};
|
||||
@@ -0,0 +1,229 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HelperProcessIdentity {
|
||||
pub pid: u32,
|
||||
/// An opaque token obtained from the OS process creation time/identity API.
|
||||
pub creation_token: u64,
|
||||
pub build_hash: String,
|
||||
}
|
||||
|
||||
impl HelperProcessIdentity {
|
||||
pub fn new(pid: u32, creation_token: u64, build_hash: impl Into<String>) -> Self {
|
||||
Self {
|
||||
pid,
|
||||
creation_token,
|
||||
build_hash: build_hash.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChallengeMacVerification {
|
||||
Verified,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PipePeerError {
|
||||
NotAuthenticated,
|
||||
PidMismatch { expected: u32, actual: u32 },
|
||||
CreationTokenMismatch,
|
||||
BuildHashMismatch,
|
||||
ChallengeMacRejected,
|
||||
}
|
||||
|
||||
impl fmt::Display for PipePeerError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotAuthenticated => formatter.write_str("pipe peer is not authenticated"),
|
||||
Self::PidMismatch { expected, actual } => {
|
||||
write!(
|
||||
formatter,
|
||||
"pipe peer PID mismatch: expected {expected}, got {actual}"
|
||||
)
|
||||
}
|
||||
Self::CreationTokenMismatch => {
|
||||
formatter.write_str("pipe peer process creation token mismatch")
|
||||
}
|
||||
Self::BuildHashMismatch => formatter.write_str("pipe peer build hash mismatch"),
|
||||
Self::ChallengeMacRejected => {
|
||||
formatter.write_str("pipe challenge MAC was not verified")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PipePeerError {}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct SessionMaterial(Vec<u8>);
|
||||
|
||||
impl fmt::Debug for SessionMaterial {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("SessionMaterial")
|
||||
.field("bytes", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionMaterial {
|
||||
pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
|
||||
Self(bytes.into())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expose_to_verified_peer<'a>(&'a self, _peer: &VerifiedPipePeer) -> &'a [u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PipePeerVerifier {
|
||||
expected: HelperProcessIdentity,
|
||||
verified_peer: Option<VerifiedPipePeer>,
|
||||
}
|
||||
|
||||
impl PipePeerVerifier {
|
||||
#[must_use]
|
||||
pub fn new(expected: HelperProcessIdentity) -> Self {
|
||||
Self {
|
||||
expected,
|
||||
verified_peer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticates the connected pipe peer against process and challenge evidence.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a [`PipePeerError`] when any expected identity field differs or
|
||||
/// the challenge MAC was not verified. A failed attempt clears prior peer
|
||||
/// authentication state.
|
||||
pub fn authenticate(
|
||||
&mut self,
|
||||
actual: HelperProcessIdentity,
|
||||
challenge_mac: ChallengeMacVerification,
|
||||
) -> Result<&VerifiedPipePeer, PipePeerError> {
|
||||
self.verified_peer = None;
|
||||
|
||||
if actual.pid != self.expected.pid {
|
||||
return Err(PipePeerError::PidMismatch {
|
||||
expected: self.expected.pid,
|
||||
actual: actual.pid,
|
||||
});
|
||||
}
|
||||
if actual.creation_token != self.expected.creation_token {
|
||||
return Err(PipePeerError::CreationTokenMismatch);
|
||||
}
|
||||
if actual.build_hash != self.expected.build_hash {
|
||||
return Err(PipePeerError::BuildHashMismatch);
|
||||
}
|
||||
if challenge_mac != ChallengeMacVerification::Verified {
|
||||
return Err(PipePeerError::ChallengeMacRejected);
|
||||
}
|
||||
|
||||
self.verified_peer = Some(VerifiedPipePeer { identity: actual });
|
||||
self.verified_peer()
|
||||
}
|
||||
|
||||
/// Returns the authenticated peer for this verifier.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PipePeerError::NotAuthenticated`] until authentication has
|
||||
/// completed successfully, and after any failed reauthentication attempt.
|
||||
pub fn verified_peer(&self) -> Result<&VerifiedPipePeer, PipePeerError> {
|
||||
self.verified_peer
|
||||
.as_ref()
|
||||
.ok_or(PipePeerError::NotAuthenticated)
|
||||
}
|
||||
|
||||
/// Exposes session material only after successful pipe peer authentication.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PipePeerError::NotAuthenticated`] when no authenticated peer
|
||||
/// is currently bound to the verifier.
|
||||
pub fn session_material<'a>(
|
||||
&self,
|
||||
material: &'a SessionMaterial,
|
||||
) -> Result<&'a [u8], PipePeerError> {
|
||||
let peer = self.verified_peer()?;
|
||||
Ok(material.expose_to_verified_peer(peer))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VerifiedPipePeer {
|
||||
identity: HelperProcessIdentity,
|
||||
}
|
||||
|
||||
impl VerifiedPipePeer {
|
||||
#[must_use]
|
||||
pub fn identity(&self) -> &HelperProcessIdentity {
|
||||
&self.identity
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identity() -> HelperProcessIdentity {
|
||||
HelperProcessIdentity::new(41, 9001, "build-a")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_material_is_unavailable_before_authentication() {
|
||||
let verifier = PipePeerVerifier::new(identity());
|
||||
let material = SessionMaterial::new(b"secret".to_vec());
|
||||
assert_eq!(
|
||||
verifier.session_material(&material),
|
||||
Err(PipePeerError::NotAuthenticated)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_identity_fields_and_mac_must_match() {
|
||||
let variants = [
|
||||
(
|
||||
HelperProcessIdentity::new(42, 9001, "build-a"),
|
||||
ChallengeMacVerification::Verified,
|
||||
),
|
||||
(
|
||||
HelperProcessIdentity::new(41, 9002, "build-a"),
|
||||
ChallengeMacVerification::Verified,
|
||||
),
|
||||
(
|
||||
HelperProcessIdentity::new(41, 9001, "build-b"),
|
||||
ChallengeMacVerification::Verified,
|
||||
),
|
||||
(identity(), ChallengeMacVerification::Rejected),
|
||||
];
|
||||
|
||||
for (actual, mac) in variants {
|
||||
let mut verifier = PipePeerVerifier::new(identity());
|
||||
assert!(verifier.authenticate(actual, mac).is_err());
|
||||
assert_eq!(
|
||||
verifier.verified_peer(),
|
||||
Err(PipePeerError::NotAuthenticated)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_peer_can_receive_session_material() {
|
||||
let mut verifier = PipePeerVerifier::new(identity());
|
||||
verifier
|
||||
.authenticate(identity(), ChallengeMacVerification::Verified)
|
||||
.unwrap();
|
||||
let material = SessionMaterial::new(b"session-ticket".to_vec());
|
||||
assert_eq!(
|
||||
verifier.session_material(&material).unwrap(),
|
||||
b"session-ticket"
|
||||
);
|
||||
assert!(!format!("{material:?}").contains("session-ticket"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
use std::fmt;
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Default Windows Remote Desktop service port.
|
||||
pub const DEFAULT_RDP_PORT: u16 = 3389;
|
||||
|
||||
const MAX_HOST_LENGTH: usize = 253;
|
||||
|
||||
/// A validated network endpoint for an RDP reachability probe.
|
||||
///
|
||||
/// The type contains no credentials. Its [`Debug`] and [`fmt::Display`]
|
||||
/// implementations redact the host so a diagnostic log cannot disclose a
|
||||
/// user-configured address accidentally. Network adapters must opt in to host
|
||||
/// access through [`Self::host`].
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct RdpEndpoint {
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl RdpEndpoint {
|
||||
/// Parses a hostname or IP endpoint, using port 3389 when omitted.
|
||||
///
|
||||
/// Accepted forms include `host`, `host:port`, `192.0.2.1`, bare IPv6 with
|
||||
/// the default port, and `[2001:db8::1]:port`. An IPv6 address with an
|
||||
/// explicit port must use brackets.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RdpEndpointError`] for URI schemes, paths, whitespace,
|
||||
/// controls, empty/invalid hosts, malformed bracketed IPv6, or a missing,
|
||||
/// zero, non-numeric, or out-of-range port.
|
||||
pub fn parse(target: &str) -> Result<Self, RdpEndpointError> {
|
||||
validate_target_text(target)?;
|
||||
|
||||
let (host, port) = if target.starts_with('[') {
|
||||
parse_bracketed_ipv6(target)?
|
||||
} else {
|
||||
parse_unbracketed_target(target)?
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
host: host.to_owned(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the validated hostname or unbracketed IP literal.
|
||||
#[must_use]
|
||||
pub fn host(&self) -> &str {
|
||||
&self.host
|
||||
}
|
||||
|
||||
/// Returns the explicit or defaulted TCP port.
|
||||
#[must_use]
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RdpEndpoint {
|
||||
type Err = RdpEndpointError;
|
||||
|
||||
fn from_str(target: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(target)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RdpEndpoint {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RdpEndpoint")
|
||||
.field("host", &"[redacted]")
|
||||
.field("port", &self.port)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RdpEndpoint {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "[redacted]:{}", self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail-close reason for an untrusted RDP endpoint string.
|
||||
///
|
||||
/// Variants intentionally contain no part of the rejected input, making both
|
||||
/// derived [`Debug`] and [`fmt::Display`] safe for diagnostics.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RdpEndpointError {
|
||||
EmptyTarget,
|
||||
ControlCharacter,
|
||||
Whitespace,
|
||||
SchemeNotAllowed,
|
||||
PathOrQueryNotAllowed,
|
||||
EmptyHost,
|
||||
HostTooLong,
|
||||
InvalidHost,
|
||||
MalformedBracketedIpv6,
|
||||
InvalidIpv6Literal,
|
||||
InvalidPort,
|
||||
}
|
||||
|
||||
impl fmt::Display for RdpEndpointError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let message = match self {
|
||||
Self::EmptyTarget => "RDP target is empty",
|
||||
Self::ControlCharacter => "RDP target contains a control character",
|
||||
Self::Whitespace => "RDP target contains whitespace",
|
||||
Self::SchemeNotAllowed => "RDP target must not contain a URI scheme",
|
||||
Self::PathOrQueryNotAllowed => "RDP target must not contain a path, query, or fragment",
|
||||
Self::EmptyHost => "RDP target host is empty",
|
||||
Self::HostTooLong => "RDP target host is too long",
|
||||
Self::InvalidHost => "RDP target host is invalid",
|
||||
Self::MalformedBracketedIpv6 => "bracketed IPv6 RDP target is malformed",
|
||||
Self::InvalidIpv6Literal => "bracketed RDP target is not a valid IPv6 address",
|
||||
Self::InvalidPort => "RDP target port is invalid",
|
||||
};
|
||||
formatter.write_str(message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RdpEndpointError {}
|
||||
|
||||
fn validate_target_text(target: &str) -> Result<(), RdpEndpointError> {
|
||||
if target.is_empty() {
|
||||
return Err(RdpEndpointError::EmptyTarget);
|
||||
}
|
||||
if target.chars().any(char::is_control) {
|
||||
return Err(RdpEndpointError::ControlCharacter);
|
||||
}
|
||||
if target.chars().any(char::is_whitespace) {
|
||||
return Err(RdpEndpointError::Whitespace);
|
||||
}
|
||||
if target.contains("://") {
|
||||
return Err(RdpEndpointError::SchemeNotAllowed);
|
||||
}
|
||||
if target.contains(['/', '\\', '?', '#']) {
|
||||
return Err(RdpEndpointError::PathOrQueryNotAllowed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_bracketed_ipv6(target: &str) -> Result<(&str, u16), RdpEndpointError> {
|
||||
let close = target
|
||||
.find(']')
|
||||
.ok_or(RdpEndpointError::MalformedBracketedIpv6)?;
|
||||
let host = &target[1..close];
|
||||
if host.is_empty() {
|
||||
return Err(RdpEndpointError::EmptyHost);
|
||||
}
|
||||
if host.len() > MAX_HOST_LENGTH {
|
||||
return Err(RdpEndpointError::HostTooLong);
|
||||
}
|
||||
host.parse::<Ipv6Addr>()
|
||||
.map_err(|_| RdpEndpointError::InvalidIpv6Literal)?;
|
||||
|
||||
let suffix = &target[close + 1..];
|
||||
let port = if suffix.is_empty() {
|
||||
DEFAULT_RDP_PORT
|
||||
} else {
|
||||
let port = suffix
|
||||
.strip_prefix(':')
|
||||
.ok_or(RdpEndpointError::MalformedBracketedIpv6)?;
|
||||
parse_port(port)?
|
||||
};
|
||||
Ok((host, port))
|
||||
}
|
||||
|
||||
fn parse_unbracketed_target(target: &str) -> Result<(&str, u16), RdpEndpointError> {
|
||||
match target.matches(':').count() {
|
||||
0 => {
|
||||
validate_host(target)?;
|
||||
Ok((target, DEFAULT_RDP_PORT))
|
||||
}
|
||||
1 => {
|
||||
let (host, port) = target
|
||||
.split_once(':')
|
||||
.ok_or(RdpEndpointError::InvalidHost)?;
|
||||
validate_host(host)?;
|
||||
Ok((host, parse_port(port)?))
|
||||
}
|
||||
_ => {
|
||||
target
|
||||
.parse::<Ipv6Addr>()
|
||||
.map_err(|_| RdpEndpointError::InvalidIpv6Literal)?;
|
||||
Ok((target, DEFAULT_RDP_PORT))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_host(host: &str) -> Result<(), RdpEndpointError> {
|
||||
if host.is_empty() {
|
||||
return Err(RdpEndpointError::EmptyHost);
|
||||
}
|
||||
if host.len() > MAX_HOST_LENGTH {
|
||||
return Err(RdpEndpointError::HostTooLong);
|
||||
}
|
||||
if host.parse::<IpAddr>().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if !host.is_ascii() {
|
||||
return Err(RdpEndpointError::InvalidHost);
|
||||
}
|
||||
|
||||
let hostname = host.strip_suffix('.').unwrap_or(host);
|
||||
if hostname.is_empty() {
|
||||
return Err(RdpEndpointError::EmptyHost);
|
||||
}
|
||||
for label in hostname.split('.') {
|
||||
if label.is_empty()
|
||||
|| label.len() > 63
|
||||
|| !label
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
|| !label
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(u8::is_ascii_alphanumeric)
|
||||
|| !label
|
||||
.as_bytes()
|
||||
.last()
|
||||
.is_some_and(u8::is_ascii_alphanumeric)
|
||||
{
|
||||
return Err(RdpEndpointError::InvalidHost);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_port(port: &str) -> Result<u16, RdpEndpointError> {
|
||||
if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(RdpEndpointError::InvalidPort);
|
||||
}
|
||||
let port = port
|
||||
.parse::<u16>()
|
||||
.map_err(|_| RdpEndpointError::InvalidPort)?;
|
||||
if port == 0 {
|
||||
return Err(RdpEndpointError::InvalidPort);
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostname_and_ipv4_use_default_port() {
|
||||
let hostname = RdpEndpoint::parse("desktop.example.test").unwrap();
|
||||
assert_eq!(hostname.host(), "desktop.example.test");
|
||||
assert_eq!(hostname.port(), DEFAULT_RDP_PORT);
|
||||
|
||||
let ipv4 = RdpEndpoint::parse("192.0.2.15").unwrap();
|
||||
assert_eq!(ipv4.host(), "192.0.2.15");
|
||||
assert_eq!(ipv4.port(), DEFAULT_RDP_PORT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_and_ipv4_accept_explicit_port() {
|
||||
let hostname: RdpEndpoint = "rdp-host:3390".parse().unwrap();
|
||||
assert_eq!(hostname.host(), "rdp-host");
|
||||
assert_eq!(hostname.port(), 3390);
|
||||
|
||||
let ipv4 = RdpEndpoint::parse("192.0.2.15:65535").unwrap();
|
||||
assert_eq!(ipv4.host(), "192.0.2.15");
|
||||
assert_eq!(ipv4.port(), u16::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_supports_default_and_bracketed_explicit_port() {
|
||||
let bare = RdpEndpoint::parse("2001:db8::5").unwrap();
|
||||
assert_eq!(bare.host(), "2001:db8::5");
|
||||
assert_eq!(bare.port(), DEFAULT_RDP_PORT);
|
||||
|
||||
let bracketed = RdpEndpoint::parse("[2001:db8::5]:3391").unwrap();
|
||||
assert_eq!(bracketed.host(), "2001:db8::5");
|
||||
assert_eq!(bracketed.port(), 3391);
|
||||
|
||||
let bracketed_default = RdpEndpoint::parse("[::1]").unwrap();
|
||||
assert_eq!(bracketed_default.host(), "::1");
|
||||
assert_eq!(bracketed_default.port(), DEFAULT_RDP_PORT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_control_whitespace_scheme_and_paths_are_rejected() {
|
||||
let cases = [
|
||||
("", RdpEndpointError::EmptyTarget),
|
||||
("host\nname", RdpEndpointError::ControlCharacter),
|
||||
(" host", RdpEndpointError::Whitespace),
|
||||
("rdp://host", RdpEndpointError::SchemeNotAllowed),
|
||||
("host/path", RdpEndpointError::PathOrQueryNotAllowed),
|
||||
("host\\path", RdpEndpointError::PathOrQueryNotAllowed),
|
||||
("host?query", RdpEndpointError::PathOrQueryNotAllowed),
|
||||
("host#fragment", RdpEndpointError::PathOrQueryNotAllowed),
|
||||
];
|
||||
|
||||
for (target, expected) in cases {
|
||||
assert_eq!(
|
||||
RdpEndpoint::parse(target),
|
||||
Err(expected),
|
||||
"target {target:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_invalid_hosts_are_rejected() {
|
||||
let cases = [
|
||||
(":3389", RdpEndpointError::EmptyHost),
|
||||
(".", RdpEndpointError::EmptyHost),
|
||||
("bad..host", RdpEndpointError::InvalidHost),
|
||||
("-host", RdpEndpointError::InvalidHost),
|
||||
("host-", RdpEndpointError::InvalidHost),
|
||||
("host_name", RdpEndpointError::InvalidHost),
|
||||
("user@host:3389", RdpEndpointError::InvalidHost),
|
||||
("h\u{00f6}st", RdpEndpointError::InvalidHost),
|
||||
];
|
||||
|
||||
for (target, expected) in cases {
|
||||
assert_eq!(
|
||||
RdpEndpoint::parse(target),
|
||||
Err(expected),
|
||||
"target {target:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_brackets_and_invalid_ipv6_are_rejected() {
|
||||
let cases = [
|
||||
("[::1", RdpEndpointError::MalformedBracketedIpv6),
|
||||
("[]:3389", RdpEndpointError::EmptyHost),
|
||||
("[hostname]:3389", RdpEndpointError::InvalidIpv6Literal),
|
||||
("[::1]extra", RdpEndpointError::MalformedBracketedIpv6),
|
||||
("[::1]:3389:1", RdpEndpointError::InvalidPort),
|
||||
("2001:db8::invalid", RdpEndpointError::InvalidIpv6Literal),
|
||||
];
|
||||
|
||||
for (target, expected) in cases {
|
||||
assert_eq!(
|
||||
RdpEndpoint::parse(target),
|
||||
Err(expected),
|
||||
"target {target:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_zero_non_numeric_and_out_of_range_ports_are_rejected() {
|
||||
for target in [
|
||||
"host:",
|
||||
"host:0",
|
||||
"host:-1",
|
||||
"host:+3389",
|
||||
"host:abc",
|
||||
"host:65536",
|
||||
] {
|
||||
assert_eq!(
|
||||
RdpEndpoint::parse(target),
|
||||
Err(RdpEndpointError::InvalidPort),
|
||||
"target {target:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_host_and_dns_label_are_rejected() {
|
||||
let overlong_label = format!("{}.test", "a".repeat(64));
|
||||
assert_eq!(
|
||||
RdpEndpoint::parse(&overlong_label),
|
||||
Err(RdpEndpointError::InvalidHost)
|
||||
);
|
||||
|
||||
let overlong_host = format!(
|
||||
"{}.{}.{}.{}",
|
||||
"a".repeat(63),
|
||||
"b".repeat(63),
|
||||
"c".repeat(63),
|
||||
"d".repeat(63)
|
||||
);
|
||||
assert!(overlong_host.len() > MAX_HOST_LENGTH);
|
||||
assert_eq!(
|
||||
RdpEndpoint::parse(&overlong_host),
|
||||
Err(RdpEndpointError::HostTooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_display_and_errors_do_not_include_target() {
|
||||
let marker = "sensitive-host-marker.example";
|
||||
let endpoint = RdpEndpoint::parse(&format!("{marker}:3390")).unwrap();
|
||||
let debug = format!("{endpoint:?}");
|
||||
let display = endpoint.to_string();
|
||||
assert!(!debug.contains(marker));
|
||||
assert!(!display.contains(marker));
|
||||
assert!(debug.contains("3390"));
|
||||
assert!(display.contains("3390"));
|
||||
|
||||
let error = RdpEndpoint::parse("secret/path").unwrap_err();
|
||||
assert!(!format!("{error:?}").contains("secret"));
|
||||
assert!(!error.to_string().contains("secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::VerifiedPipePeer;
|
||||
use remotedesk_protocol::{
|
||||
ClientRenderPathReport, MemoryPathStatus, MemorySurfaceType, PathReportBinding,
|
||||
VerificationSource,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenderReportPayload {
|
||||
pub status: MemoryPathStatus,
|
||||
pub adapter_identity: String,
|
||||
pub render_device_identity: String,
|
||||
pub surface_type: MemorySurfaceType,
|
||||
pub cpu_map_count: u64,
|
||||
pub cross_adapter_copy_count: u64,
|
||||
pub verification_source: VerificationSource,
|
||||
}
|
||||
|
||||
/// An ingress handle constructed from the authenticated local helper API.
|
||||
///
|
||||
/// The wire payload contains no endpoint field. Consequently, a remote payload
|
||||
/// cannot claim to be a local render report through this API.
|
||||
#[derive(Debug)]
|
||||
pub struct ClientRenderReportIngress<'a> {
|
||||
_peer: &'a VerifiedPipePeer,
|
||||
binding: PathReportBinding,
|
||||
}
|
||||
|
||||
impl<'a> ClientRenderReportIngress<'a> {
|
||||
#[must_use]
|
||||
pub fn from_native_video_helper(
|
||||
peer: &'a VerifiedPipePeer,
|
||||
binding: PathReportBinding,
|
||||
) -> Self {
|
||||
Self {
|
||||
_peer: peer,
|
||||
binding,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bind(&self, payload: RenderReportPayload) -> ClientRenderPathReport {
|
||||
ClientRenderPathReport {
|
||||
binding: self.binding.clone(),
|
||||
status: payload.status,
|
||||
adapter_identity: payload.adapter_identity,
|
||||
render_device_identity: payload.render_device_identity,
|
||||
surface_type: payload.surface_type,
|
||||
cpu_map_count: payload.cpu_map_count,
|
||||
cross_adapter_copy_count: payload.cross_adapter_copy_count,
|
||||
verification_source: payload.verification_source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ChallengeMacVerification, HelperProcessIdentity, PipePeerVerifier};
|
||||
use remotedesk_protocol::{
|
||||
StrictPathCoordinator, StrictPathState, ValidationContext, ZeroCopyPolicy,
|
||||
};
|
||||
|
||||
fn binding() -> PathReportBinding {
|
||||
PathReportBinding {
|
||||
session_instance_id: [1; 16],
|
||||
helper_instance_id: [2; 16],
|
||||
pipeline_epoch: 7,
|
||||
selected_config_hash: [3; 32],
|
||||
verifier_challenge: [4; 32],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_source_binds_report_to_local_client() {
|
||||
let identity = HelperProcessIdentity::new(7, 11, "build");
|
||||
let mut verifier = PipePeerVerifier::new(identity.clone());
|
||||
let peer = verifier
|
||||
.authenticate(identity, ChallengeMacVerification::Verified)
|
||||
.unwrap();
|
||||
let expected_binding = binding();
|
||||
let ingress =
|
||||
ClientRenderReportIngress::from_native_video_helper(peer, expected_binding.clone());
|
||||
let report = ingress.bind(RenderReportPayload {
|
||||
status: MemoryPathStatus::CpuCopyFree,
|
||||
adapter_identity: "dxgi-luid:100".into(),
|
||||
render_device_identity: "d3d11-device:7".into(),
|
||||
surface_type: MemorySurfaceType::D3d11,
|
||||
cpu_map_count: 0,
|
||||
cross_adapter_copy_count: 0,
|
||||
verification_source: VerificationSource::PipelineTracing,
|
||||
});
|
||||
|
||||
assert_eq!(report.binding, expected_binding);
|
||||
assert_eq!(report.status, MemoryPathStatus::CpuCopyFree);
|
||||
assert_eq!(report.adapter_identity, "dxgi-luid:100");
|
||||
assert_eq!(report.render_device_identity, "d3d11-device:7");
|
||||
|
||||
let mut coordinator = StrictPathCoordinator::new(
|
||||
ZeroCopyPolicy::RequiredEndToEnd,
|
||||
ValidationContext {
|
||||
session_instance_id: [1; 16],
|
||||
agent_helper_instance_id: [9; 16],
|
||||
client_helper_instance_id: [2; 16],
|
||||
pipeline_epoch: 7,
|
||||
selected_config_hash: [3; 32],
|
||||
verifier_challenge: [4; 32],
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
coordinator.accept_client_report(report),
|
||||
Ok(StrictPathState::Negotiating)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SessionState {
|
||||
Idle,
|
||||
Negotiating,
|
||||
Revalidating,
|
||||
Connected,
|
||||
Failed { reason: String },
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Idle => "idle",
|
||||
Self::Negotiating => "negotiating",
|
||||
Self::Revalidating => "revalidating",
|
||||
Self::Connected => "connected",
|
||||
Self::Failed { .. } => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionError {
|
||||
from: &'static str,
|
||||
operation: &'static str,
|
||||
}
|
||||
|
||||
impl SessionError {
|
||||
#[must_use]
|
||||
pub fn from(&self) -> &'static str {
|
||||
self.from
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn operation(&self) -> &'static str {
|
||||
self.operation
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SessionError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"cannot {} while session is {}",
|
||||
self.operation, self.from
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SessionError {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionMachine {
|
||||
state: SessionState,
|
||||
}
|
||||
|
||||
impl Default for SessionMachine {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: SessionState::Idle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionMachine {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn state(&self) -> &SessionState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Starts negotiation for an idle session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SessionError`] unless the current state is [`SessionState::Idle`].
|
||||
pub fn begin_negotiation(&mut self) -> Result<(), SessionError> {
|
||||
self.transition(
|
||||
"begin negotiation",
|
||||
matches!(self.state, SessionState::Idle),
|
||||
SessionState::Negotiating,
|
||||
)
|
||||
}
|
||||
|
||||
/// Marks a negotiated or revalidated session as connected.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SessionError`] unless the current state is
|
||||
/// [`SessionState::Negotiating`] or [`SessionState::Revalidating`].
|
||||
pub fn connect(&mut self) -> Result<(), SessionError> {
|
||||
self.transition(
|
||||
"connect",
|
||||
matches!(
|
||||
self.state,
|
||||
SessionState::Negotiating | SessionState::Revalidating
|
||||
),
|
||||
SessionState::Connected,
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts path revalidation for a connected session.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SessionError`] unless the current state is
|
||||
/// [`SessionState::Connected`].
|
||||
pub fn begin_revalidation(&mut self) -> Result<(), SessionError> {
|
||||
self.transition(
|
||||
"begin revalidation",
|
||||
matches!(self.state, SessionState::Connected),
|
||||
SessionState::Revalidating,
|
||||
)
|
||||
}
|
||||
|
||||
/// Moves an active session into its failed state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SessionError`] when the current state is idle or already failed.
|
||||
pub fn fail(&mut self, reason: impl Into<String>) -> Result<(), SessionError> {
|
||||
let allowed = !matches!(self.state, SessionState::Idle | SessionState::Failed { .. });
|
||||
self.transition(
|
||||
"fail",
|
||||
allowed,
|
||||
SessionState::Failed {
|
||||
reason: reason.into(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Resets a failed session so it can negotiate again.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SessionError`] unless the current state is [`SessionState::Failed`].
|
||||
pub fn reset(&mut self) -> Result<(), SessionError> {
|
||||
self.transition(
|
||||
"reset",
|
||||
matches!(self.state, SessionState::Failed { .. }),
|
||||
SessionState::Idle,
|
||||
)
|
||||
}
|
||||
|
||||
fn transition(
|
||||
&mut self,
|
||||
operation: &'static str,
|
||||
allowed: bool,
|
||||
next: SessionState,
|
||||
) -> Result<(), SessionError> {
|
||||
if !allowed {
|
||||
return Err(SessionError {
|
||||
from: self.state.name(),
|
||||
operation,
|
||||
});
|
||||
}
|
||||
self.state = next;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normal_session_and_revalidation_flow() {
|
||||
let mut session = SessionMachine::new();
|
||||
session.begin_negotiation().unwrap();
|
||||
session.connect().unwrap();
|
||||
session.begin_revalidation().unwrap();
|
||||
assert_eq!(session.state(), &SessionState::Revalidating);
|
||||
session.connect().unwrap();
|
||||
assert_eq!(session.state(), &SessionState::Connected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_transition_does_not_change_state() {
|
||||
let mut session = SessionMachine::new();
|
||||
let error = session.connect().unwrap_err();
|
||||
assert_eq!(error.from(), "idle");
|
||||
assert_eq!(session.state(), &SessionState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_session_must_reset_before_retrying() {
|
||||
let mut session = SessionMachine::new();
|
||||
session.begin_negotiation().unwrap();
|
||||
session.fail("peer rejected").unwrap();
|
||||
assert!(session.begin_negotiation().is_err());
|
||||
session.reset().unwrap();
|
||||
session.begin_negotiation().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "remotedesk-control-service"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
ed25519-dalek = "2.2"
|
||||
remotedesk-client-core = { path = "../../crates/client-core" }
|
||||
remotedesk-credential-store = { path = "../credential-store" }
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
semver = "1.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
sha2 = "0.10"
|
||||
tiny_http = "0.12.0"
|
||||
url = "2.5.4"
|
||||
zeroize = "1.8"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const UPDATE_PRODUCT: &str = "remotedesk";
|
||||
const MAX_MANIFEST_SIZE: u64 = 64 * 1024;
|
||||
const MAX_PAYLOAD_SIZE: usize = 48 * 1024;
|
||||
const MAX_RELEASE_NOTES: usize = 4 * 1024;
|
||||
const MAX_UPDATE_URL_LENGTH: usize = 2_048;
|
||||
const MAX_INSTALLER_SIZE: u64 = 1024 * 1024 * 1024;
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UpdateConfig {
|
||||
manifest_url: String,
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
impl UpdateConfig {
|
||||
pub fn new(manifest_url: &str, public_key: &str) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
manifest_url: normalize_manifest_url(manifest_url)?,
|
||||
public_key: normalize_public_key(public_key)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UpdateStatus {
|
||||
UpToDate,
|
||||
Available,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct UpdateReleaseSummary {
|
||||
pub version: String,
|
||||
pub channel: String,
|
||||
pub published_at: String,
|
||||
pub notes: Option<String>,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct UpdateCheckResponse {
|
||||
pub current_version: String,
|
||||
pub status: UpdateStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub release: Option<UpdateReleaseSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct UpdateInstallResponse {
|
||||
pub accepted: bool,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PreparedUpdate {
|
||||
pub version: String,
|
||||
pub installer_path: PathBuf,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
pub struct UpdateClient {
|
||||
client: Client,
|
||||
download_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl UpdateClient {
|
||||
pub fn new(download_dir: PathBuf) -> Result<Self, String> {
|
||||
let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
|
||||
if attempt.previous().len() >= 3 {
|
||||
attempt.error("update download exceeded the redirect limit")
|
||||
} else if attempt.url().scheme() != "https" {
|
||||
attempt.error("update redirects must use HTTPS")
|
||||
} else {
|
||||
attempt.follow()
|
||||
}
|
||||
});
|
||||
let client = Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.redirect(redirect_policy)
|
||||
.user_agent(concat!("RemoteDesk/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|error| format!("unable to initialize the update client: {error}"))?;
|
||||
Ok(Self {
|
||||
client,
|
||||
download_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn check(&self, config: &UpdateConfig) -> Result<UpdateCheckResponse, String> {
|
||||
let manifest = self.fetch_verified_manifest(config)?;
|
||||
Ok(evaluate_manifest(manifest))
|
||||
}
|
||||
|
||||
pub fn prepare_install(
|
||||
&self,
|
||||
config: &UpdateConfig,
|
||||
expected_version: &str,
|
||||
) -> Result<PreparedUpdate, String> {
|
||||
let expected = parse_release_version(expected_version)?;
|
||||
let current = current_version();
|
||||
if expected <= current {
|
||||
return Err("the requested update version is not newer than this build".to_owned());
|
||||
}
|
||||
let manifest = self.fetch_verified_manifest(config)?;
|
||||
let manifest_version = parse_release_version(&manifest.version)?;
|
||||
if manifest_version != expected {
|
||||
return Err("the signed update manifest changed; check for updates again".to_owned());
|
||||
}
|
||||
let installer_path = self.download_installer(&manifest)?;
|
||||
Ok(PreparedUpdate {
|
||||
version: manifest.version,
|
||||
installer_path,
|
||||
sha256: manifest.installer.sha256,
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_verified_manifest(&self, config: &UpdateConfig) -> Result<UpdateManifest, String> {
|
||||
let response = self
|
||||
.client
|
||||
.get(&config.manifest_url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.map_err(|error| format!("unable to download the update manifest: {error}"))?;
|
||||
let response = checked_response(response, "update manifest")?;
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_MANIFEST_SIZE)
|
||||
{
|
||||
return Err("update manifest is too large".to_owned());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.take(MAX_MANIFEST_SIZE + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| format!("unable to read the update manifest: {error}"))?;
|
||||
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_MANIFEST_SIZE {
|
||||
return Err("update manifest is too large".to_owned());
|
||||
}
|
||||
verify_manifest_envelope(&bytes, &config.public_key)
|
||||
}
|
||||
|
||||
fn download_installer(&self, manifest: &UpdateManifest) -> Result<PathBuf, String> {
|
||||
fs::create_dir_all(&self.download_dir)
|
||||
.map_err(|error| format!("unable to create the update directory: {error}"))?;
|
||||
let file_name = format!(
|
||||
"RemoteDesk-{}-{}.msi",
|
||||
manifest.version,
|
||||
current_update_target()
|
||||
);
|
||||
let installer_path = self.download_dir.join(file_name);
|
||||
if installer_path.is_file()
|
||||
&& verify_installer_file(
|
||||
&installer_path,
|
||||
manifest.installer.size_bytes,
|
||||
&manifest.installer.sha256,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(installer_path);
|
||||
}
|
||||
if installer_path.exists() {
|
||||
fs::remove_file(&installer_path)
|
||||
.map_err(|error| format!("unable to replace the cached update: {error}"))?;
|
||||
}
|
||||
|
||||
let partial_path = self.download_dir.join(format!(
|
||||
".RemoteDesk-{}-{}.part-{}",
|
||||
manifest.version,
|
||||
current_update_target(),
|
||||
std::process::id()
|
||||
));
|
||||
if partial_path.exists() {
|
||||
fs::remove_file(&partial_path)
|
||||
.map_err(|error| format!("unable to clear an incomplete update: {error}"))?;
|
||||
}
|
||||
let result = self.download_to_path(manifest, &partial_path);
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&partial_path);
|
||||
return Err(error);
|
||||
}
|
||||
fs::rename(&partial_path, &installer_path)
|
||||
.map_err(|error| format!("unable to finalize the downloaded update: {error}"))?;
|
||||
Ok(installer_path)
|
||||
}
|
||||
|
||||
fn download_to_path(&self, manifest: &UpdateManifest, path: &Path) -> Result<(), String> {
|
||||
let response = self
|
||||
.client
|
||||
.get(&manifest.installer.url)
|
||||
.header(reqwest::header::ACCEPT, "application/octet-stream")
|
||||
.send()
|
||||
.map_err(|error| format!("unable to download the update installer: {error}"))?;
|
||||
let mut response = checked_response(response, "update installer")?;
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length != manifest.installer.size_bytes)
|
||||
{
|
||||
return Err("update installer size does not match the signed manifest".to_owned());
|
||||
}
|
||||
|
||||
let mut file = File::create(path)
|
||||
.map_err(|error| format!("unable to create the update installer: {error}"))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut total = 0_u64;
|
||||
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
|
||||
loop {
|
||||
let read = response
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| format!("unable to read the update installer: {error}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
total = total
|
||||
.checked_add(u64::try_from(read).unwrap_or(u64::MAX))
|
||||
.ok_or_else(|| "update installer is too large".to_owned())?;
|
||||
if total > manifest.installer.size_bytes || total > MAX_INSTALLER_SIZE {
|
||||
return Err("update installer is larger than the signed manifest".to_owned());
|
||||
}
|
||||
file.write_all(&buffer[..read])
|
||||
.map_err(|error| format!("unable to save the update installer: {error}"))?;
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
file.sync_all()
|
||||
.map_err(|error| format!("unable to flush the update installer: {error}"))?;
|
||||
if total != manifest.installer.size_bytes {
|
||||
return Err("update installer size does not match the signed manifest".to_owned());
|
||||
}
|
||||
let actual_hash = format!("{:x}", hasher.finalize());
|
||||
if !actual_hash.eq_ignore_ascii_case(&manifest.installer.sha256) {
|
||||
return Err("update installer SHA-256 does not match the signed manifest".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_manifest_url(value: &str) -> Result<String, String> {
|
||||
normalize_https_url(value, "update manifest")
|
||||
}
|
||||
|
||||
pub fn normalize_public_key(value: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
|
||||
return Err("update public key is invalid".to_owned());
|
||||
}
|
||||
let bytes = STANDARD
|
||||
.decode(value)
|
||||
.map_err(|_| "update public key must be Base64".to_owned())?;
|
||||
let key: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| "update public key must contain 32 bytes".to_owned())?;
|
||||
VerifyingKey::from_bytes(&key).map_err(|_| "update public key is invalid".to_owned())?;
|
||||
Ok(STANDARD.encode(key))
|
||||
}
|
||||
|
||||
pub fn default_update_directory() -> Option<PathBuf> {
|
||||
std::env::var_os("LOCALAPPDATA")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.map(|root| root.join("RemoteDesk").join("updates"))
|
||||
}
|
||||
|
||||
fn verify_manifest_envelope(bytes: &[u8], public_key: &str) -> Result<UpdateManifest, String> {
|
||||
let envelope: SignedUpdateEnvelope = serde_json::from_slice(bytes)
|
||||
.map_err(|_| "update manifest envelope is not valid JSON".to_owned())?;
|
||||
if envelope.schema != 1 {
|
||||
return Err("update manifest schema is not supported".to_owned());
|
||||
}
|
||||
let payload = STANDARD
|
||||
.decode(envelope.payload)
|
||||
.map_err(|_| "update manifest payload must be Base64".to_owned())?;
|
||||
if payload.len() > MAX_PAYLOAD_SIZE {
|
||||
return Err("update manifest payload is too large".to_owned());
|
||||
}
|
||||
let signature = STANDARD
|
||||
.decode(envelope.signature)
|
||||
.map_err(|_| "update manifest signature must be Base64".to_owned())?;
|
||||
let signature: [u8; 64] = signature
|
||||
.try_into()
|
||||
.map_err(|_| "update manifest signature must contain 64 bytes".to_owned())?;
|
||||
let signature = Signature::from_bytes(&signature);
|
||||
let public_key = STANDARD
|
||||
.decode(public_key)
|
||||
.map_err(|_| "update public key must be Base64".to_owned())?;
|
||||
let public_key: [u8; 32] = public_key
|
||||
.try_into()
|
||||
.map_err(|_| "update public key must contain 32 bytes".to_owned())?;
|
||||
let public_key = VerifyingKey::from_bytes(&public_key)
|
||||
.map_err(|_| "update public key is invalid".to_owned())?;
|
||||
public_key
|
||||
.verify_strict(&payload, &signature)
|
||||
.map_err(|_| "update manifest signature is invalid".to_owned())?;
|
||||
|
||||
let manifest: UpdateManifest = serde_json::from_slice(&payload)
|
||||
.map_err(|_| "signed update manifest payload is not valid JSON".to_owned())?;
|
||||
validate_manifest(&manifest)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn validate_manifest(manifest: &UpdateManifest) -> Result<(), String> {
|
||||
if manifest.product != UPDATE_PRODUCT {
|
||||
return Err("update manifest product does not match RemoteDesk".to_owned());
|
||||
}
|
||||
if manifest.target != current_update_target() {
|
||||
return Err("update manifest target does not match this build".to_owned());
|
||||
}
|
||||
parse_release_version(&manifest.version)?;
|
||||
validate_short_text(&manifest.channel, 32, "update channel")?;
|
||||
validate_short_text(&manifest.published_at, 64, "update publication time")?;
|
||||
if let Some(notes) = manifest.notes.as_deref()
|
||||
&& (notes.len() > MAX_RELEASE_NOTES
|
||||
|| notes.chars().any(|character| {
|
||||
character.is_control() && !matches!(character, '\r' | '\n' | '\t')
|
||||
}))
|
||||
{
|
||||
return Err("update release notes are invalid".to_owned());
|
||||
}
|
||||
normalize_https_url(&manifest.installer.url, "update installer")?;
|
||||
let installer_url = url::Url::parse(&manifest.installer.url)
|
||||
.map_err(|_| "update installer URL is invalid".to_owned())?;
|
||||
if !installer_url.path().to_ascii_lowercase().ends_with(".msi") {
|
||||
return Err("update installer URL must identify an MSI package".to_owned());
|
||||
}
|
||||
if manifest.installer.size_bytes == 0 || manifest.installer.size_bytes > MAX_INSTALLER_SIZE {
|
||||
return Err("update installer size is invalid".to_owned());
|
||||
}
|
||||
if manifest.installer.sha256.len() != 64
|
||||
|| !manifest
|
||||
.installer
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
return Err("update installer SHA-256 is invalid".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn evaluate_manifest(manifest: UpdateManifest) -> UpdateCheckResponse {
|
||||
let current = current_version();
|
||||
let available = parse_release_version(&manifest.version).is_ok_and(|version| version > current);
|
||||
let release = available.then_some(UpdateReleaseSummary {
|
||||
version: manifest.version,
|
||||
channel: manifest.channel,
|
||||
published_at: manifest.published_at,
|
||||
notes: manifest.notes,
|
||||
size_bytes: manifest.installer.size_bytes,
|
||||
});
|
||||
UpdateCheckResponse {
|
||||
current_version: current.to_string(),
|
||||
status: if available {
|
||||
UpdateStatus::Available
|
||||
} else {
|
||||
UpdateStatus::UpToDate
|
||||
},
|
||||
release,
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_installer_file(
|
||||
path: &Path,
|
||||
expected_size: u64,
|
||||
expected_hash: &str,
|
||||
) -> Result<(), String> {
|
||||
let metadata = fs::metadata(path)
|
||||
.map_err(|error| format!("unable to inspect the cached update: {error}"))?;
|
||||
if metadata.len() != expected_size {
|
||||
return Err("cached update size does not match".to_owned());
|
||||
}
|
||||
let mut file =
|
||||
File::open(path).map_err(|error| format!("unable to open the cached update: {error}"))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| format!("unable to read the cached update: {error}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
let actual_hash = format!("{:x}", hasher.finalize());
|
||||
if !actual_hash.eq_ignore_ascii_case(expected_hash) {
|
||||
return Err("cached update SHA-256 does not match".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checked_response(response: Response, name: &str) -> Result<Response, String> {
|
||||
if response.url().scheme() != "https" {
|
||||
return Err(format!("{name} response did not use HTTPS"));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("{name} returned HTTP {}", response.status()));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn normalize_https_url(value: &str, name: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_UPDATE_URL_LENGTH
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
return Err(format!("{name} URL is invalid"));
|
||||
}
|
||||
let url = url::Url::parse(value).map_err(|_| format!("{name} URL is invalid"))?;
|
||||
if url.scheme() != "https" || url.host_str().is_none() {
|
||||
return Err(format!("{name} URL must use HTTPS"));
|
||||
}
|
||||
if !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"{name} URL must not contain credentials, query, or fragment"
|
||||
));
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn validate_short_text(value: &str, maximum: usize, name: &str) -> Result<(), String> {
|
||||
if value.is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
|
||||
return Err(format!("{name} is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_release_version(value: &str) -> Result<Version, String> {
|
||||
let version = Version::parse(value).map_err(|_| "update version is invalid".to_owned())?;
|
||||
if !version.pre.is_empty() || !version.build.is_empty() {
|
||||
return Err("update version must be a three-part release version".to_owned());
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn current_version() -> Version {
|
||||
Version::parse(env!("CARGO_PKG_VERSION")).expect("workspace package version is valid semver")
|
||||
}
|
||||
|
||||
fn current_update_target() -> &'static str {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("windows", "x86_64") => "windows-x64",
|
||||
("windows", "aarch64") => "windows-arm64",
|
||||
_ => "unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SignedUpdateEnvelope {
|
||||
schema: u8,
|
||||
payload: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UpdateManifest {
|
||||
product: String,
|
||||
channel: String,
|
||||
version: String,
|
||||
published_at: String,
|
||||
target: String,
|
||||
installer: UpdateInstaller,
|
||||
notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UpdateInstaller {
|
||||
url: String,
|
||||
sha256: String,
|
||||
size_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer as _, SigningKey};
|
||||
use serde_json::json;
|
||||
|
||||
fn signed_envelope(version: &str) -> (Vec<u8>, String) {
|
||||
let signing_key = SigningKey::from_bytes(&[7_u8; 32]);
|
||||
let payload = serde_json::to_vec(&json!({
|
||||
"product": UPDATE_PRODUCT,
|
||||
"channel": "stable",
|
||||
"version": version,
|
||||
"published_at": "2026-08-10T00:00:00Z",
|
||||
"target": current_update_target(),
|
||||
"installer": {
|
||||
"url": format!("https://updates.example.test/RemoteDesk-{version}.msi"),
|
||||
"sha256": "a".repeat(64),
|
||||
"size_bytes": 123_456
|
||||
},
|
||||
"notes": "Security and reliability fixes"
|
||||
}))
|
||||
.unwrap();
|
||||
let signature = signing_key.sign(&payload);
|
||||
let envelope = serde_json::to_vec(&json!({
|
||||
"schema": 1,
|
||||
"payload": STANDARD.encode(&payload),
|
||||
"signature": STANDARD.encode(signature.to_bytes())
|
||||
}))
|
||||
.unwrap();
|
||||
(
|
||||
envelope,
|
||||
STANDARD.encode(signing_key.verifying_key().to_bytes()),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_manifest_is_verified_before_version_evaluation() {
|
||||
let (envelope, public_key) = signed_envelope("99.0.0");
|
||||
let manifest = verify_manifest_envelope(&envelope, &public_key).unwrap();
|
||||
let response = evaluate_manifest(manifest);
|
||||
assert_eq!(response.status, UpdateStatus::Available);
|
||||
assert_eq!(response.release.unwrap().version, "99.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_manifest_payload_is_rejected() {
|
||||
let (envelope, public_key) = signed_envelope("99.0.0");
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&envelope).unwrap();
|
||||
let mut payload = STANDARD.decode(value["payload"].as_str().unwrap()).unwrap();
|
||||
payload[0] ^= 1;
|
||||
value["payload"] = serde_json::Value::String(STANDARD.encode(payload));
|
||||
assert!(
|
||||
verify_manifest_envelope(&serde_json::to_vec(&value).unwrap(), &public_key).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_configuration_requires_https_and_a_valid_ed25519_key() {
|
||||
let (_, public_key) = signed_envelope("99.0.0");
|
||||
assert!(UpdateConfig::new("https://updates.example.test/stable.json", &public_key).is_ok());
|
||||
assert!(UpdateConfig::new("http://updates.example.test/stable.json", &public_key).is_err());
|
||||
assert!(
|
||||
UpdateConfig::new(
|
||||
"https://updates.example.test/stable.json?token=x",
|
||||
&public_key
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(UpdateConfig::new("https://updates.example.test/stable.json", "invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_wrong_target_and_non_msi_artifact() {
|
||||
let signing_key = SigningKey::from_bytes(&[9_u8; 32]);
|
||||
let payload = serde_json::to_vec(&json!({
|
||||
"product": UPDATE_PRODUCT,
|
||||
"channel": "stable",
|
||||
"version": "99.0.0",
|
||||
"published_at": "2026-08-10T00:00:00Z",
|
||||
"target": "wrong-target",
|
||||
"installer": {
|
||||
"url": "https://updates.example.test/RemoteDesk.exe",
|
||||
"sha256": "a".repeat(64),
|
||||
"size_bytes": 123
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let envelope = serde_json::to_vec(&json!({
|
||||
"schema": 1,
|
||||
"payload": STANDARD.encode(&payload),
|
||||
"signature": STANDARD.encode(signing_key.sign(&payload).to_bytes())
|
||||
}))
|
||||
.unwrap();
|
||||
let public_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||
assert!(verify_manifest_envelope(&envelope, &public_key).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "remotedesk-credential-store"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
inquire = "0.9.4"
|
||||
remotedesk-client-core = { path = "../../crates/client-core" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
zeroize = { version = "1.8", features = ["derive"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
keyring = { version = "3.6.3", default-features = false, features = ["windows-native"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,687 @@
|
||||
use base64::{
|
||||
Engine as _,
|
||||
engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD},
|
||||
};
|
||||
use remotedesk_client_core::CredentialRef;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
|
||||
|
||||
const SERVICE_NAME: &str = "RemoteDesk RDP";
|
||||
const LINUX_AGENT_IDENTITY_SERVICE_NAME: &str = "RemoteDesk Linux Agent Identity";
|
||||
const LINUX_DESKTOP_RESUME_SERVICE_NAME: &str = "RemoteDesk Linux Desktop Resume";
|
||||
const ENTRY_OWNER: &str = "RemoteDesk";
|
||||
const MAX_ACCOUNT_LENGTH: usize = 256;
|
||||
const LINUX_AGENT_IDENTITY_PREFIX: &str = "RemoteDesk/Linux/agent/";
|
||||
const LINUX_DESKTOP_RESUME_PREFIX: &str = "RemoteDesk/Linux/desktop-resume/";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CredentialStatus {
|
||||
Missing,
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Zeroize, ZeroizeOnDrop)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RdpCredential {
|
||||
account: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl RdpCredential {
|
||||
/// Creates a validated RDP credential.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CredentialStoreError::InvalidRecord`] for an invalid account or empty password.
|
||||
pub fn new(account: String, password: String) -> Result<Self, CredentialStoreError> {
|
||||
validate_account(&account)?;
|
||||
if password.is_empty() {
|
||||
return Err(CredentialStoreError::InvalidRecord);
|
||||
}
|
||||
Ok(Self { account, password })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn account(&self) -> &str {
|
||||
&self.account
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn password(&self) -> &str {
|
||||
&self.password
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RdpCredential {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("RdpCredential([redacted])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct LinuxAgentIdentityRef(String);
|
||||
|
||||
impl LinuxAgentIdentityRef {
|
||||
/// Creates the Credential Manager reference bound to a TLS certificate fingerprint.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error unless `certificate_sha256` is exactly 64 lowercase hexadecimal digits.
|
||||
pub fn new(certificate_sha256: &str) -> Result<Self, CredentialStoreError> {
|
||||
if certificate_sha256.len() != 64
|
||||
|| !certificate_sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
|
||||
{
|
||||
return Err(CredentialStoreError::InvalidRecord);
|
||||
}
|
||||
Ok(Self(format!(
|
||||
"{LINUX_AGENT_IDENTITY_PREFIX}{certificate_sha256}"
|
||||
)))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn target(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct LinuxDesktopResumeRef(String);
|
||||
|
||||
impl LinuxDesktopResumeRef {
|
||||
/// Creates a Credential Manager reference bound to an Agent certificate and Linux user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error unless the certificate fingerprint and user name are canonical.
|
||||
pub fn new(certificate_sha256: &str, user: &str) -> Result<Self, CredentialStoreError> {
|
||||
if certificate_sha256.len() != 64
|
||||
|| !certificate_sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
|
||||
|| user.is_empty()
|
||||
|| user.len() > 32
|
||||
|| !user
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(CredentialStoreError::InvalidRecord);
|
||||
}
|
||||
Ok(Self(format!(
|
||||
"{LINUX_DESKTOP_RESUME_PREFIX}{certificate_sha256}/{user}"
|
||||
)))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn target(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Zeroize, ZeroizeOnDrop)]
|
||||
pub struct LinuxDesktopResumeToken(String);
|
||||
|
||||
impl LinuxDesktopResumeToken {
|
||||
/// Creates a validated 32-byte URL-safe Base64 resume token.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error unless the token is the canonical unpadded 43-character form.
|
||||
pub fn new(token: String) -> Result<Self, CredentialStoreError> {
|
||||
validate_linux_desktop_resume_token(&token)?;
|
||||
Ok(Self(token))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn token(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for LinuxDesktopResumeToken {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("LinuxDesktopResumeToken([redacted])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CredentialStoreError {
|
||||
UnsupportedPlatform,
|
||||
Missing,
|
||||
InvalidRecord,
|
||||
StoreUnavailable,
|
||||
}
|
||||
|
||||
impl fmt::Display for CredentialStoreError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UnsupportedPlatform => {
|
||||
formatter.write_str("Windows Credential Manager is available only on Windows")
|
||||
}
|
||||
Self::Missing => formatter.write_str("the RDP credential is not configured"),
|
||||
Self::InvalidRecord => formatter.write_str("the stored RDP credential is invalid"),
|
||||
Self::StoreUnavailable => {
|
||||
formatter.write_str("Windows Credential Manager is unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CredentialStoreError {}
|
||||
|
||||
/// Reports whether the referenced credential is available and valid.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the credential store is unavailable or the stored record is invalid.
|
||||
pub fn status(reference: &CredentialRef) -> Result<CredentialStatus, CredentialStoreError> {
|
||||
match load_secret(reference) {
|
||||
Ok(secret) => {
|
||||
let result = decode_record(&secret).map(|_| CredentialStatus::Ready);
|
||||
drop(secret);
|
||||
result
|
||||
}
|
||||
Err(CredentialStoreError::Missing) => Ok(CredentialStatus::Missing),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads and validates an RDP credential from the platform credential store.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the credential is missing, invalid, or cannot be read.
|
||||
pub fn load(reference: &CredentialRef) -> Result<RdpCredential, CredentialStoreError> {
|
||||
let secret = load_secret(reference)?;
|
||||
let result = decode_record(&secret);
|
||||
drop(secret);
|
||||
result
|
||||
}
|
||||
|
||||
/// Persists a validated RDP credential in the platform credential store.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the credential is invalid or the platform store cannot write it.
|
||||
pub fn save(
|
||||
reference: &CredentialRef,
|
||||
credential: &RdpCredential,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
validate_account(credential.account())?;
|
||||
if credential.password().is_empty() {
|
||||
return Err(CredentialStoreError::InvalidRecord);
|
||||
}
|
||||
let encoded = Zeroizing::new(
|
||||
serde_json::to_string(credential).map_err(|_| CredentialStoreError::InvalidRecord)?,
|
||||
);
|
||||
save_secret(reference, &encoded)
|
||||
}
|
||||
|
||||
/// Deletes the referenced credential. Missing credentials are treated as deleted.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the platform credential store cannot perform the deletion.
|
||||
pub fn delete(reference: &CredentialRef) -> Result<CredentialStatus, CredentialStoreError> {
|
||||
match delete_secret(reference) {
|
||||
Ok(()) | Err(CredentialStoreError::Missing) => Ok(CredentialStatus::Missing),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether a certificate-bound Linux Agent public key is stored and valid.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when Credential Manager is unavailable or the stored key is malformed.
|
||||
pub fn linux_agent_identity_status(
|
||||
reference: &LinuxAgentIdentityRef,
|
||||
) -> Result<CredentialStatus, CredentialStoreError> {
|
||||
match linux_agent_identity_load(reference) {
|
||||
Ok(_) => Ok(CredentialStatus::Ready),
|
||||
Err(CredentialStoreError::Missing) => Ok(CredentialStatus::Missing),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a certificate-bound Linux Agent Ed25519 public key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the mapping is missing, unavailable, or malformed.
|
||||
pub fn linux_agent_identity_load(
|
||||
reference: &LinuxAgentIdentityRef,
|
||||
) -> Result<String, CredentialStoreError> {
|
||||
let encoded = load_linux_agent_identity(reference)?;
|
||||
validate_linux_agent_public_key(&encoded)?;
|
||||
Ok(encoded.to_string())
|
||||
}
|
||||
|
||||
/// Stores a validated Linux Agent Ed25519 public key under its certificate fingerprint.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the public key is malformed or Credential Manager cannot write it.
|
||||
pub fn linux_agent_identity_save(
|
||||
reference: &LinuxAgentIdentityRef,
|
||||
public_key: &str,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
validate_linux_agent_public_key(public_key)?;
|
||||
save_linux_agent_identity(reference, public_key)
|
||||
}
|
||||
|
||||
/// Deletes a Linux Agent identity mapping. A missing mapping is treated as deleted.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when Credential Manager cannot perform the deletion.
|
||||
pub fn linux_agent_identity_delete(
|
||||
reference: &LinuxAgentIdentityRef,
|
||||
) -> Result<CredentialStatus, CredentialStoreError> {
|
||||
match delete_linux_agent_identity(reference) {
|
||||
Ok(()) | Err(CredentialStoreError::Missing) => Ok(CredentialStatus::Missing),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a certificate-and-user-bound Linux desktop resume token.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the token is missing, unavailable, or malformed.
|
||||
pub fn linux_desktop_resume_load(
|
||||
reference: &LinuxDesktopResumeRef,
|
||||
) -> Result<LinuxDesktopResumeToken, CredentialStoreError> {
|
||||
let token = load_linux_desktop_resume(reference)?;
|
||||
LinuxDesktopResumeToken::new(token.to_string())
|
||||
}
|
||||
|
||||
/// Stores a validated Linux desktop resume token in Windows Credential Manager.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the token is malformed or Credential Manager cannot write it.
|
||||
pub fn linux_desktop_resume_save(
|
||||
reference: &LinuxDesktopResumeRef,
|
||||
token: &str,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
validate_linux_desktop_resume_token(token)?;
|
||||
save_linux_desktop_resume(reference, token)
|
||||
}
|
||||
|
||||
/// Deletes a Linux desktop resume token. A missing token is treated as deleted.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when Credential Manager cannot perform the deletion.
|
||||
pub fn linux_desktop_resume_delete(
|
||||
reference: &LinuxDesktopResumeRef,
|
||||
) -> Result<CredentialStatus, CredentialStoreError> {
|
||||
match delete_linux_desktop_resume(reference) {
|
||||
Ok(()) | Err(CredentialStoreError::Missing) => Ok(CredentialStatus::Missing),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_account(account: &str) -> Result<(), CredentialStoreError> {
|
||||
if account.trim().is_empty()
|
||||
|| account.len() > MAX_ACCOUNT_LENGTH
|
||||
|| account.chars().any(char::is_control)
|
||||
{
|
||||
return Err(CredentialStoreError::InvalidRecord);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_record(encoded: &str) -> Result<RdpCredential, CredentialStoreError> {
|
||||
let credential: RdpCredential =
|
||||
serde_json::from_str(encoded).map_err(|_| CredentialStoreError::InvalidRecord)?;
|
||||
validate_account(credential.account())?;
|
||||
if credential.password().is_empty() {
|
||||
return Err(CredentialStoreError::InvalidRecord);
|
||||
}
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
fn validate_linux_agent_public_key(public_key: &str) -> Result<(), CredentialStoreError> {
|
||||
let decoded = STANDARD_NO_PAD
|
||||
.decode(public_key)
|
||||
.map_err(|_| CredentialStoreError::InvalidRecord)?;
|
||||
if decoded.len() != 32 || STANDARD_NO_PAD.encode(decoded) != public_key {
|
||||
return Err(CredentialStoreError::InvalidRecord);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_linux_desktop_resume_token(token: &str) -> Result<(), CredentialStoreError> {
|
||||
let decoded = URL_SAFE_NO_PAD
|
||||
.decode(token)
|
||||
.map_err(|_| CredentialStoreError::InvalidRecord)?;
|
||||
if decoded.len() != 32 || URL_SAFE_NO_PAD.encode(decoded) != token {
|
||||
return Err(CredentialStoreError::InvalidRecord);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn entry(reference: &CredentialRef) -> Result<keyring::Entry, CredentialStoreError> {
|
||||
keyring::Entry::new_with_target(reference.target(), SERVICE_NAME, ENTRY_OWNER)
|
||||
.map_err(|_| CredentialStoreError::StoreUnavailable)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn load_secret(reference: &CredentialRef) -> Result<Zeroizing<String>, CredentialStoreError> {
|
||||
entry(reference)?
|
||||
.get_password()
|
||||
.map(Zeroizing::new)
|
||||
.map_err(|error| {
|
||||
if matches!(error, keyring::Error::NoEntry) {
|
||||
CredentialStoreError::Missing
|
||||
} else {
|
||||
CredentialStoreError::StoreUnavailable
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn load_secret(_reference: &CredentialRef) -> Result<Zeroizing<String>, CredentialStoreError> {
|
||||
Err(CredentialStoreError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn save_secret(reference: &CredentialRef, secret: &str) -> Result<(), CredentialStoreError> {
|
||||
entry(reference)?
|
||||
.set_password(secret)
|
||||
.map_err(|_| CredentialStoreError::StoreUnavailable)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn save_secret(_reference: &CredentialRef, _secret: &str) -> Result<(), CredentialStoreError> {
|
||||
Err(CredentialStoreError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn delete_secret(reference: &CredentialRef) -> Result<(), CredentialStoreError> {
|
||||
entry(reference)?.delete_credential().map_err(|error| {
|
||||
if matches!(error, keyring::Error::NoEntry) {
|
||||
CredentialStoreError::Missing
|
||||
} else {
|
||||
CredentialStoreError::StoreUnavailable
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn delete_secret(_reference: &CredentialRef) -> Result<(), CredentialStoreError> {
|
||||
Err(CredentialStoreError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn linux_agent_identity_entry(
|
||||
reference: &LinuxAgentIdentityRef,
|
||||
) -> Result<keyring::Entry, CredentialStoreError> {
|
||||
keyring::Entry::new_with_target(
|
||||
reference.target(),
|
||||
LINUX_AGENT_IDENTITY_SERVICE_NAME,
|
||||
ENTRY_OWNER,
|
||||
)
|
||||
.map_err(|_| CredentialStoreError::StoreUnavailable)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn load_linux_agent_identity(
|
||||
reference: &LinuxAgentIdentityRef,
|
||||
) -> Result<Zeroizing<String>, CredentialStoreError> {
|
||||
linux_agent_identity_entry(reference)?
|
||||
.get_password()
|
||||
.map(Zeroizing::new)
|
||||
.map_err(|error| {
|
||||
if matches!(error, keyring::Error::NoEntry) {
|
||||
CredentialStoreError::Missing
|
||||
} else {
|
||||
CredentialStoreError::StoreUnavailable
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn load_linux_agent_identity(
|
||||
_reference: &LinuxAgentIdentityRef,
|
||||
) -> Result<Zeroizing<String>, CredentialStoreError> {
|
||||
Err(CredentialStoreError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn save_linux_agent_identity(
|
||||
reference: &LinuxAgentIdentityRef,
|
||||
public_key: &str,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
linux_agent_identity_entry(reference)?
|
||||
.set_password(public_key)
|
||||
.map_err(|_| CredentialStoreError::StoreUnavailable)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn save_linux_agent_identity(
|
||||
_reference: &LinuxAgentIdentityRef,
|
||||
_public_key: &str,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
Err(CredentialStoreError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn delete_linux_agent_identity(
|
||||
reference: &LinuxAgentIdentityRef,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
linux_agent_identity_entry(reference)?
|
||||
.delete_credential()
|
||||
.map_err(|error| {
|
||||
if matches!(error, keyring::Error::NoEntry) {
|
||||
CredentialStoreError::Missing
|
||||
} else {
|
||||
CredentialStoreError::StoreUnavailable
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn linux_desktop_resume_entry(
|
||||
reference: &LinuxDesktopResumeRef,
|
||||
) -> Result<keyring::Entry, CredentialStoreError> {
|
||||
keyring::Entry::new_with_target(
|
||||
reference.target(),
|
||||
LINUX_DESKTOP_RESUME_SERVICE_NAME,
|
||||
ENTRY_OWNER,
|
||||
)
|
||||
.map_err(|_| CredentialStoreError::StoreUnavailable)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn load_linux_desktop_resume(
|
||||
reference: &LinuxDesktopResumeRef,
|
||||
) -> Result<Zeroizing<String>, CredentialStoreError> {
|
||||
linux_desktop_resume_entry(reference)?
|
||||
.get_password()
|
||||
.map(Zeroizing::new)
|
||||
.map_err(|error| {
|
||||
if matches!(error, keyring::Error::NoEntry) {
|
||||
CredentialStoreError::Missing
|
||||
} else {
|
||||
CredentialStoreError::StoreUnavailable
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn load_linux_desktop_resume(
|
||||
_reference: &LinuxDesktopResumeRef,
|
||||
) -> Result<Zeroizing<String>, CredentialStoreError> {
|
||||
Err(CredentialStoreError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn save_linux_desktop_resume(
|
||||
reference: &LinuxDesktopResumeRef,
|
||||
token: &str,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
linux_desktop_resume_entry(reference)?
|
||||
.set_password(token)
|
||||
.map_err(|_| CredentialStoreError::StoreUnavailable)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn save_linux_desktop_resume(
|
||||
_reference: &LinuxDesktopResumeRef,
|
||||
_token: &str,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
Err(CredentialStoreError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn delete_linux_desktop_resume(
|
||||
reference: &LinuxDesktopResumeRef,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
linux_desktop_resume_entry(reference)?
|
||||
.delete_credential()
|
||||
.map_err(|error| {
|
||||
if matches!(error, keyring::Error::NoEntry) {
|
||||
CredentialStoreError::Missing
|
||||
} else {
|
||||
CredentialStoreError::StoreUnavailable
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn delete_linux_desktop_resume(
|
||||
_reference: &LinuxDesktopResumeRef,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
Err(CredentialStoreError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn delete_linux_agent_identity(
|
||||
_reference: &LinuxAgentIdentityRef,
|
||||
) -> Result<(), CredentialStoreError> {
|
||||
Err(CredentialStoreError::UnsupportedPlatform)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linux_agent_identity_reference_and_key_are_canonical() {
|
||||
let fingerprint = "ab".repeat(32);
|
||||
let reference = LinuxAgentIdentityRef::new(&fingerprint).unwrap();
|
||||
assert_eq!(
|
||||
reference.target(),
|
||||
format!("{LINUX_AGENT_IDENTITY_PREFIX}{}", "ab".repeat(32))
|
||||
);
|
||||
assert!(LinuxAgentIdentityRef::new(&"AB".repeat(32)).is_err());
|
||||
assert!(LinuxAgentIdentityRef::new(&"ab".repeat(31)).is_err());
|
||||
assert!(validate_linux_agent_public_key(&STANDARD_NO_PAD.encode([7_u8; 32])).is_ok());
|
||||
assert!(validate_linux_agent_public_key("not-a-public-key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_desktop_resume_reference_and_token_are_canonical_and_secret_free() {
|
||||
let fingerprint = "ab".repeat(32);
|
||||
let reference = LinuxDesktopResumeRef::new(&fingerprint, "alice-1").unwrap();
|
||||
assert_eq!(
|
||||
reference.target(),
|
||||
format!("{LINUX_DESKTOP_RESUME_PREFIX}{fingerprint}/alice-1")
|
||||
);
|
||||
assert!(LinuxDesktopResumeRef::new(&"AB".repeat(32), "alice").is_err());
|
||||
assert!(LinuxDesktopResumeRef::new(&fingerprint, "../alice").is_err());
|
||||
|
||||
let marker = URL_SAFE_NO_PAD.encode([7_u8; 32]);
|
||||
let token = LinuxDesktopResumeToken::new(marker.clone()).unwrap();
|
||||
assert_eq!(token.token(), marker);
|
||||
assert!(!format!("{token:?}").contains(&marker));
|
||||
assert!(LinuxDesktopResumeToken::new("short".into()).is_err());
|
||||
assert!(LinuxDesktopResumeToken::new(format!("{}=", "a".repeat(42))).is_err());
|
||||
assert!(LinuxDesktopResumeToken::new("a".repeat(43)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn linux_desktop_resume_round_trips_through_windows_credential_manager() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let fingerprint = format!("{unique:064x}");
|
||||
let reference = LinuxDesktopResumeRef::new(&fingerprint, "resume-test").unwrap();
|
||||
let token = URL_SAFE_NO_PAD.encode([11_u8; 32]);
|
||||
let _ = linux_desktop_resume_delete(&reference);
|
||||
|
||||
linux_desktop_resume_save(&reference, &token).unwrap();
|
||||
let loaded = linux_desktop_resume_load(&reference).unwrap();
|
||||
assert_eq!(loaded.token(), token);
|
||||
assert_eq!(
|
||||
linux_desktop_resume_delete(&reference),
|
||||
Ok(CredentialStatus::Missing)
|
||||
);
|
||||
assert_eq!(
|
||||
linux_desktop_resume_load(&reference).unwrap_err(),
|
||||
CredentialStoreError::Missing
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn linux_agent_identity_round_trips_through_windows_credential_manager() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let fingerprint = format!("{unique:064x}");
|
||||
let reference = LinuxAgentIdentityRef::new(&fingerprint).unwrap();
|
||||
let public_key = STANDARD_NO_PAD.encode([19_u8; 32]);
|
||||
let _ = linux_agent_identity_delete(&reference);
|
||||
|
||||
linux_agent_identity_save(&reference, &public_key).unwrap();
|
||||
assert_eq!(
|
||||
linux_agent_identity_status(&reference),
|
||||
Ok(CredentialStatus::Ready)
|
||||
);
|
||||
assert_eq!(linux_agent_identity_load(&reference), Ok(public_key));
|
||||
assert_eq!(
|
||||
linux_agent_identity_delete(&reference),
|
||||
Ok(CredentialStatus::Missing)
|
||||
);
|
||||
assert_eq!(
|
||||
linux_agent_identity_status(&reference),
|
||||
Ok(CredentialStatus::Missing)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_round_trip_and_debug_are_secret_free() {
|
||||
let marker = "do-not-log-this-password";
|
||||
let credential = RdpCredential::new("DOMAIN\\user".to_owned(), marker.to_owned()).unwrap();
|
||||
let encoded = serde_json::to_string(&credential).unwrap();
|
||||
let decoded = decode_record(&encoded).unwrap();
|
||||
|
||||
assert_eq!(decoded.account(), "DOMAIN\\user");
|
||||
assert_eq!(decoded.password(), marker);
|
||||
assert!(!format!("{decoded:?}").contains(marker));
|
||||
assert!(!format!("{decoded:?}").contains("DOMAIN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_account_and_empty_password_fail_closed() {
|
||||
assert!(RdpCredential::new(String::new(), "secret".to_owned()).is_err());
|
||||
assert!(RdpCredential::new("user\nname".to_owned(), "secret".to_owned()).is_err());
|
||||
assert!(RdpCredential::new("user".to_owned(), String::new()).is_err());
|
||||
assert!(decode_record(r#"{"account":"user","password":"secret","extra":1}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use inquire::{Password, Text};
|
||||
use remotedesk_client_core::CredentialRef;
|
||||
use remotedesk_credential_store::{CredentialStatus, RdpCredential};
|
||||
use serde::Serialize;
|
||||
use std::env;
|
||||
use std::io::{self, Read};
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Operation {
|
||||
Set,
|
||||
Delete,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CredentialKind {
|
||||
Rdp,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Reference {
|
||||
Rdp(CredentialRef),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Args {
|
||||
operation: Operation,
|
||||
reference: Reference,
|
||||
stdin_input: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusOutput {
|
||||
status: CredentialStatus,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("RemoteDesk credential operation failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args = parse_args(env::args().skip(1))?;
|
||||
let stdin_input = args.stdin_input;
|
||||
let status = match (args.operation, args.reference) {
|
||||
(Operation::Set, Reference::Rdp(reference)) => {
|
||||
let (account, password) = if stdin_input {
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_to_string(&mut input)
|
||||
.map_err(|_| "credential input failed".to_owned())?;
|
||||
let mut lines = input.lines();
|
||||
let account = lines.next().unwrap_or_default().to_owned();
|
||||
let password = lines.next().unwrap_or_default().to_owned();
|
||||
(account, password)
|
||||
} else {
|
||||
let account = Text::new("Windows account (DOMAIN\\user or user@domain):")
|
||||
.prompt()
|
||||
.map_err(|_| "account input was cancelled".to_owned())?;
|
||||
let password = Password::new("Password:")
|
||||
.without_confirmation()
|
||||
.prompt()
|
||||
.map_err(|_| "password input was cancelled".to_owned())?;
|
||||
(account, password)
|
||||
};
|
||||
let credential =
|
||||
RdpCredential::new(account, password).map_err(|error| error.to_string())?;
|
||||
remotedesk_credential_store::save(&reference, &credential)
|
||||
.map_err(|error| error.to_string())?;
|
||||
CredentialStatus::Ready
|
||||
}
|
||||
(Operation::Delete, Reference::Rdp(reference)) => {
|
||||
remotedesk_credential_store::delete(&reference).map_err(|error| error.to_string())?
|
||||
}
|
||||
(Operation::Status, Reference::Rdp(reference)) => {
|
||||
remotedesk_credential_store::status(&reference).map_err(|error| error.to_string())?
|
||||
}
|
||||
};
|
||||
let output = serde_json::to_string(&StatusOutput { status })
|
||||
.map_err(|_| "unable to encode credential status".to_owned())?;
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
let (operation, credential_kind) = match args.next().as_deref() {
|
||||
Some("set") => (Operation::Set, CredentialKind::Rdp),
|
||||
Some("delete") => (Operation::Delete, CredentialKind::Rdp),
|
||||
Some("status") => (Operation::Status, CredentialKind::Rdp),
|
||||
_ => return Err(usage().to_owned()),
|
||||
};
|
||||
if args.next().as_deref() != Some("--credential-ref") {
|
||||
return Err(usage().to_owned());
|
||||
}
|
||||
let reference = args
|
||||
.next()
|
||||
.ok_or_else(|| "--credential-ref requires a value".to_owned())?;
|
||||
let stdin_input = match args.next().as_deref() {
|
||||
None => false,
|
||||
Some("--stdin")
|
||||
if operation == Operation::Set && credential_kind == CredentialKind::Rdp =>
|
||||
{
|
||||
true
|
||||
}
|
||||
Some(_) => return Err("unexpected credential helper argument".to_owned()),
|
||||
};
|
||||
let reference = match credential_kind {
|
||||
CredentialKind::Rdp => {
|
||||
Reference::Rdp(CredentialRef::new(reference).map_err(|error| error.to_string())?)
|
||||
}
|
||||
};
|
||||
Ok(Args {
|
||||
operation,
|
||||
reference,
|
||||
stdin_input,
|
||||
})
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"usage: remotedesk-credential-store <set|delete|status> --credential-ref <application-reference> [--stdin]"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn strings<'a>(values: &'a [&'a str]) -> impl Iterator<Item = String> + 'a {
|
||||
values.iter().map(|value| (*value).to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_only_application_owned_references() {
|
||||
let args = parse_args(strings(&[
|
||||
"status",
|
||||
"--credential-ref",
|
||||
"RemoteDesk/RDP/profile-1",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(args.operation, Operation::Status);
|
||||
assert!(
|
||||
matches!(args.reference, Reference::Rdp(reference) if reference.target() == "RemoteDesk/RDP/profile-1")
|
||||
);
|
||||
|
||||
assert!(parse_args(strings(&["status", "--credential-ref", "TERMSRV/host"])).is_err());
|
||||
assert!(parse_args(strings(&["set", "--password", "secret"])).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
[package]
|
||||
name = "remotedesk-linux-terminal"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22"
|
||||
crossterm = "0.29"
|
||||
ed25519-dalek = "2.2"
|
||||
futures-util = "0.3"
|
||||
inquire = "0.9.4"
|
||||
rand = "0.9"
|
||||
remotedesk-agent-runtime = { path = "../../../agent/agent-runtime", features = ["wayland-eis"] }
|
||||
remotedesk-credential-store = { path = "../credential-store" }
|
||||
remotedesk-protocol = { path = "../../../protocol" }
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["json", "rustls-tls"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["logging", "ring", "std", "tls12"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.10"
|
||||
softbuffer = "0.4.8"
|
||||
tokio = { version = "1.47", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
url = "2.5"
|
||||
winit = "0.30.13"
|
||||
zeroize = "1.8"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
cpal = "0.17.3"
|
||||
ironrdp-rdpsnd = "0.9"
|
||||
ironrdp-rdpsnd-native = { version = "0.7", default-features = false, features = ["opus"] }
|
||||
keyring = { version = "3.6.3", default-features = false, features = ["windows-native"] }
|
||||
raw-window-handle = "0.6"
|
||||
windows = { version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Media_MediaFoundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_DataExchange",
|
||||
"Win32_System_Memory",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
remotedesk-edge-service = { path = "../../../edge/edge-service" }
|
||||
rustls-pemfile = "2.2"
|
||||
tiny_http = "0.12"
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] }
|
||||
|
||||
[lints.rust]
|
||||
# Windows Media Foundation, D3D11, Opus, and WASAPI are isolated behind cfg(windows).
|
||||
unsafe_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
use std::sync::mpsc::{SyncSender, TrySendError, sync_channel};
|
||||
|
||||
use cpal::traits::StreamTrait as _;
|
||||
use ironrdp_rdpsnd::pdu::{AudioFormat, WaveFormat};
|
||||
use ironrdp_rdpsnd_native::cpal::DecodeStream;
|
||||
use remotedesk_agent_runtime::RemoteOpusPacket;
|
||||
|
||||
const OPUS_SAMPLE_RATE: u32 = 48_000;
|
||||
const OPUS_CHANNELS: u16 = 2;
|
||||
const OPUS_BITRATE_BYTES_PER_SECOND: u32 = 12_000;
|
||||
const AUDIO_PACKET_QUEUE: usize = 8;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AudioPushResult {
|
||||
Queued,
|
||||
Dropped,
|
||||
}
|
||||
|
||||
pub struct NativeOpusPlayer {
|
||||
sender: SyncSender<Vec<u8>>,
|
||||
_stream: DecodeStream,
|
||||
}
|
||||
|
||||
impl NativeOpusPlayer {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let (sender, receiver) = sync_channel(AUDIO_PACKET_QUEUE);
|
||||
let format = AudioFormat {
|
||||
format: WaveFormat::OPUS,
|
||||
n_channels: OPUS_CHANNELS,
|
||||
n_samples_per_sec: OPUS_SAMPLE_RATE,
|
||||
n_avg_bytes_per_sec: OPUS_BITRATE_BYTES_PER_SECOND,
|
||||
n_block_align: 1,
|
||||
bits_per_sample: 16,
|
||||
data: None,
|
||||
};
|
||||
let stream = DecodeStream::new(&format, receiver)
|
||||
.map_err(|error| format!("unable to initialize Opus/WASAPI playback: {error}"))?;
|
||||
stream
|
||||
.stream()
|
||||
.play()
|
||||
.map_err(|error| format!("unable to start Opus/WASAPI playback: {error}"))?;
|
||||
Ok(Self {
|
||||
sender,
|
||||
_stream: stream,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push(&self, packet: RemoteOpusPacket) -> Result<AudioPushResult, String> {
|
||||
match self.sender.try_send(packet.data.to_vec()) {
|
||||
Ok(()) => Ok(AudioPushResult::Queued),
|
||||
Err(TrySendError::Full(_)) => Ok(AudioPushResult::Dropped),
|
||||
Err(TrySendError::Disconnected(_)) => Err("Opus/WASAPI playback stopped".to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError};
|
||||
use std::time::Duration;
|
||||
|
||||
use remotedesk_agent_runtime::{DESKTOP_MAX_CLIPBOARD_BYTES, normalize_clipboard_text};
|
||||
use tokio::sync::mpsc as tokio_mpsc;
|
||||
use windows::Win32::Foundation::{HANDLE, HGLOBAL};
|
||||
use windows::Win32::System::DataExchange::{
|
||||
CloseClipboard, EmptyClipboard, GetClipboardData, GetClipboardSequenceNumber,
|
||||
IsClipboardFormatAvailable, OpenClipboard, SetClipboardData,
|
||||
};
|
||||
use windows::Win32::System::Memory::{
|
||||
GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock,
|
||||
};
|
||||
use windows::core::Free as _;
|
||||
|
||||
const CF_UNICODETEXT: u32 = 13;
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const OPEN_RETRIES: usize = 5;
|
||||
|
||||
enum ClipboardCommand {
|
||||
SetText(String),
|
||||
}
|
||||
|
||||
pub struct NativeClipboard {
|
||||
commands: SyncSender<ClipboardCommand>,
|
||||
events: tokio_mpsc::Receiver<String>,
|
||||
}
|
||||
|
||||
impl NativeClipboard {
|
||||
pub fn start(read_local: bool) -> Self {
|
||||
let (commands, command_receiver) = mpsc::sync_channel(4);
|
||||
let (event_sender, events) = tokio_mpsc::channel(4);
|
||||
let _ = std::thread::spawn(move || {
|
||||
clipboard_worker(command_receiver, event_sender, read_local)
|
||||
});
|
||||
Self { commands, events }
|
||||
}
|
||||
|
||||
pub async fn next_text(&mut self) -> Option<String> {
|
||||
self.events.recv().await
|
||||
}
|
||||
|
||||
pub fn set_text(&self, text: String) -> Result<(), String> {
|
||||
self.commands
|
||||
.try_send(ClipboardCommand::SetText(text))
|
||||
.map_err(|error| match error {
|
||||
TrySendError::Full(_) => "local clipboard worker is busy".to_owned(),
|
||||
TrySendError::Disconnected(_) => "local clipboard worker stopped".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn clipboard_worker(
|
||||
commands: Receiver<ClipboardCommand>,
|
||||
events: tokio_mpsc::Sender<String>,
|
||||
read_local: bool,
|
||||
) {
|
||||
let mut last_sequence = 0;
|
||||
loop {
|
||||
match commands.recv_timeout(POLL_INTERVAL) {
|
||||
Ok(ClipboardCommand::SetText(text)) => match write_text(&text) {
|
||||
Ok(()) => last_sequence = unsafe { GetClipboardSequenceNumber() },
|
||||
Err(error) => {
|
||||
eprintln!("unable to update the Windows clipboard: {error}");
|
||||
}
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if !read_local {
|
||||
continue;
|
||||
}
|
||||
let sequence = unsafe { GetClipboardSequenceNumber() };
|
||||
if sequence == 0 || sequence == last_sequence {
|
||||
continue;
|
||||
}
|
||||
match read_text() {
|
||||
Ok(Some(text)) => {
|
||||
last_sequence = sequence;
|
||||
if events.blocking_send(text).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => last_sequence = sequence,
|
||||
Err(error) => eprintln!("unable to read the Windows clipboard: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ClipboardGuard;
|
||||
|
||||
impl ClipboardGuard {
|
||||
fn open() -> Result<Self, String> {
|
||||
for attempt in 0..OPEN_RETRIES {
|
||||
if unsafe { OpenClipboard(None) }.is_ok() {
|
||||
return Ok(Self);
|
||||
}
|
||||
if attempt + 1 < OPEN_RETRIES {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
Err("clipboard is locked by another process".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ClipboardGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = unsafe { CloseClipboard() };
|
||||
}
|
||||
}
|
||||
|
||||
fn read_text() -> Result<Option<String>, String> {
|
||||
let _clipboard = ClipboardGuard::open()?;
|
||||
if unsafe { IsClipboardFormatAvailable(CF_UNICODETEXT) }.is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
let handle = unsafe { GetClipboardData(CF_UNICODETEXT) }
|
||||
.map_err(|error| format!("GetClipboardData failed: {error}"))?;
|
||||
let global = HGLOBAL(handle.0);
|
||||
let byte_len = unsafe { GlobalSize(global) };
|
||||
let max_byte_len = (DESKTOP_MAX_CLIPBOARD_BYTES * 2 + 1) * size_of::<u16>();
|
||||
if byte_len < size_of::<u16>() || byte_len > max_byte_len || byte_len % 2 != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let pointer = unsafe { GlobalLock(global) }.cast::<u16>();
|
||||
if pointer.is_null() {
|
||||
return Err("GlobalLock failed".to_owned());
|
||||
}
|
||||
let units = unsafe { std::slice::from_raw_parts(pointer, byte_len / size_of::<u16>()) };
|
||||
let Some(length) = units.iter().position(|unit| *unit == 0) else {
|
||||
let _ = unsafe { GlobalUnlock(global) };
|
||||
return Ok(None);
|
||||
};
|
||||
let text = String::from_utf16(&units[..length])
|
||||
.map_err(|_| "clipboard contains invalid UTF-16".to_owned());
|
||||
let _ = unsafe { GlobalUnlock(global) };
|
||||
let text = normalize_clipboard_text(&text?).map_err(|error| error.to_string())?;
|
||||
if text.len() > DESKTOP_MAX_CLIPBOARD_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(text))
|
||||
}
|
||||
|
||||
fn write_text(text: &str) -> Result<(), String> {
|
||||
let normalized = normalize_clipboard_text(text).map_err(|error| error.to_string())?;
|
||||
if normalized.len() > DESKTOP_MAX_CLIPBOARD_BYTES {
|
||||
return Err("clipboard text exceeds the size limit".to_owned());
|
||||
}
|
||||
let windows_text = normalized.replace('\n', "\r\n");
|
||||
let mut units = windows_text.encode_utf16().collect::<Vec<_>>();
|
||||
units.push(0);
|
||||
let byte_len = units
|
||||
.len()
|
||||
.checked_mul(size_of::<u16>())
|
||||
.ok_or_else(|| "clipboard allocation size overflowed".to_owned())?;
|
||||
let mut global = unsafe { GlobalAlloc(GMEM_MOVEABLE, byte_len) }
|
||||
.map_err(|error| format!("GlobalAlloc failed: {error}"))?;
|
||||
let pointer = unsafe { GlobalLock(global) }.cast::<u16>();
|
||||
if pointer.is_null() {
|
||||
unsafe { global.free() };
|
||||
return Err("GlobalLock failed".to_owned());
|
||||
}
|
||||
unsafe { std::ptr::copy_nonoverlapping(units.as_ptr(), pointer, units.len()) };
|
||||
let _ = unsafe { GlobalUnlock(global) };
|
||||
|
||||
let result: Result<(), String> = (|| {
|
||||
let _clipboard = ClipboardGuard::open()?;
|
||||
unsafe { EmptyClipboard() }.map_err(|error| format!("EmptyClipboard failed: {error}"))?;
|
||||
unsafe { SetClipboardData(CF_UNICODETEXT, Some(HANDLE(global.0))) }
|
||||
.map_err(|error| format!("SetClipboardData failed: {error}"))?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
unsafe { global.free() };
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
use core::mem::ManuallyDrop;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
use remotedesk_agent_runtime::RemoteH264AccessUnit;
|
||||
use windows::Win32::Foundation::{HWND, RECT};
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION,
|
||||
D3D11_TEX2D_VPIV, D3D11_TEX2D_VPOV, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE,
|
||||
D3D11_VIDEO_PROCESSOR_COLOR_SPACE, D3D11_VIDEO_PROCESSOR_CONTENT_DESC,
|
||||
D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_INPUT, D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_OUTPUT,
|
||||
D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0,
|
||||
D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0,
|
||||
D3D11_VIDEO_PROCESSOR_STREAM, D3D11_VIDEO_USAGE_PLAYBACK_NORMAL,
|
||||
D3D11_VPIV_DIMENSION_TEXTURE2D, D3D11_VPOV_DIMENSION_TEXTURE2D, D3D11CreateDevice,
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView, ID3D11Texture2D, ID3D11VideoContext,
|
||||
ID3D11VideoDevice, ID3D11VideoProcessor, ID3D11VideoProcessorEnumerator,
|
||||
ID3D11VideoProcessorOutputView,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_UNKNOWN, DXGI_MODE_DESC,
|
||||
DXGI_RATIONAL, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
DXGI_MWA_NO_ALT_ENTER, DXGI_PRESENT, DXGI_SWAP_CHAIN_DESC, DXGI_SWAP_CHAIN_FLAG,
|
||||
DXGI_SWAP_EFFECT_DISCARD, DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIAdapter, IDXGIDevice,
|
||||
IDXGIFactory, IDXGISwapChain,
|
||||
};
|
||||
use windows::Win32::Media::MediaFoundation::{
|
||||
CLSID_MSH264DecoderMFT, IMFAttributes, IMFDXGIBuffer, IMFDXGIDeviceManager, IMFMediaType,
|
||||
IMFSample, IMFTransform, MF_E_NO_MORE_TYPES, MF_E_NOTACCEPTING, MF_E_TRANSFORM_NEED_MORE_INPUT,
|
||||
MF_E_TRANSFORM_STREAM_CHANGE, MF_MT_FRAME_SIZE, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE,
|
||||
MF_MT_VIDEO_NOMINAL_RANGE, MF_MT_YUV_MATRIX, MF_SA_D3D11_AWARE, MF_VERSION,
|
||||
MFCreateDXGIDeviceManager, MFCreateMediaType, MFCreateMemoryBuffer, MFCreateSample,
|
||||
MFMediaType_Video, MFNominalRange_0_255, MFSTARTUP_FULL, MFStartup,
|
||||
MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM,
|
||||
MFT_MESSAGE_SET_D3D_MANAGER, MFT_OUTPUT_DATA_BUFFER, MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES,
|
||||
MFT_OUTPUT_STREAM_PROVIDES_SAMPLES, MFVideoFormat_H264, MFVideoFormat_H264_ES,
|
||||
MFVideoFormat_NV12, MFVideoTransferMatrix_BT709,
|
||||
};
|
||||
use windows::Win32::System::Com::{
|
||||
CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx,
|
||||
};
|
||||
use windows::core::Interface as _;
|
||||
use winit::window::Window;
|
||||
|
||||
const MAX_OUTPUT_TYPES: u32 = 64;
|
||||
const MAX_OUTPUTS_PER_INPUT: usize = 8;
|
||||
|
||||
pub struct NativeH264Renderer {
|
||||
decoder: StreamingDecoder,
|
||||
renderer: VideoRenderer,
|
||||
}
|
||||
|
||||
pub struct NativeVideoPresentation {
|
||||
pub decode_latency_us: u64,
|
||||
pub presentation_latency_us: u64,
|
||||
pub frames: usize,
|
||||
}
|
||||
|
||||
impl NativeH264Renderer {
|
||||
pub fn new(window: Arc<Window>) -> Result<Self, String> {
|
||||
let decoder = StreamingDecoder::new()?;
|
||||
let renderer = VideoRenderer::new(window, decoder.device.clone(), decoder.context.clone())?;
|
||||
Ok(Self { decoder, renderer })
|
||||
}
|
||||
|
||||
pub fn decode_and_present(
|
||||
&mut self,
|
||||
access_unit: &RemoteH264AccessUnit,
|
||||
) -> Result<Option<NativeVideoPresentation>, String> {
|
||||
let decode_started = Instant::now();
|
||||
let frames = self.decoder.decode(access_unit)?;
|
||||
let decode_latency_us = elapsed_microseconds(decode_started);
|
||||
if frames.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let presentation_started = Instant::now();
|
||||
let frame_count = frames.len();
|
||||
for frame in frames {
|
||||
self.renderer.present(&frame)?;
|
||||
}
|
||||
Ok(Some(NativeVideoPresentation {
|
||||
decode_latency_us,
|
||||
presentation_latency_us: elapsed_microseconds(presentation_started),
|
||||
frames: frame_count,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
struct ComGuard;
|
||||
|
||||
impl Drop for ComGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe { windows::Win32::System::Com::CoUninitialize() };
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaFoundationGuard;
|
||||
|
||||
impl Drop for MediaFoundationGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = unsafe { windows::Win32::Media::MediaFoundation::MFShutdown() };
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingDecoder {
|
||||
transform: IMFTransform,
|
||||
_input_type: IMFMediaType,
|
||||
_manager: IMFDXGIDeviceManager,
|
||||
_attributes: IMFAttributes,
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
next_timestamp_100ns: i64,
|
||||
_media_foundation_guard: MediaFoundationGuard,
|
||||
_com_guard: ComGuard,
|
||||
}
|
||||
|
||||
impl StreamingDecoder {
|
||||
fn new() -> Result<Self, String> {
|
||||
unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }
|
||||
.ok()
|
||||
.map_err(|error| format!("unable to initialize COM for remote H.264: {error}"))?;
|
||||
let com_guard = ComGuard;
|
||||
unsafe { MFStartup(MF_VERSION, MFSTARTUP_FULL) }
|
||||
.map_err(|error| format!("unable to start Media Foundation: {error}"))?;
|
||||
let media_foundation_guard = MediaFoundationGuard;
|
||||
let (device, context) = create_hardware_d3d11_device()?;
|
||||
let mut reset_token = 0_u32;
|
||||
let mut manager = None;
|
||||
unsafe { MFCreateDXGIDeviceManager(&raw mut reset_token, &raw mut manager) }
|
||||
.map_err(|error| format!("unable to create remote-video DXGI manager: {error}"))?;
|
||||
let manager =
|
||||
manager.ok_or_else(|| "Media Foundation returned no DXGI manager".to_owned())?;
|
||||
unsafe { manager.ResetDevice(&device, reset_token) }
|
||||
.map_err(|error| format!("unable to bind the remote-video D3D11 device: {error}"))?;
|
||||
|
||||
let transform: IMFTransform =
|
||||
unsafe { CoCreateInstance(&CLSID_MSH264DecoderMFT, None, CLSCTX_INPROC_SERVER) }
|
||||
.map_err(|error| {
|
||||
format!("unable to create the Windows H.264 decoder MFT: {error}")
|
||||
})?;
|
||||
let attributes = unsafe { transform.GetAttributes() }
|
||||
.map_err(|error| format!("unable to read H.264 decoder attributes: {error}"))?;
|
||||
let d3d11_aware_key = MF_SA_D3D11_AWARE;
|
||||
if unsafe { attributes.GetUINT32(&d3d11_aware_key) }.unwrap_or_default() != 1 {
|
||||
return Err("the Windows H.264 decoder is not D3D11-aware".to_owned());
|
||||
}
|
||||
unsafe { transform.ProcessMessage(MFT_MESSAGE_SET_D3D_MANAGER, manager.as_raw() as usize) }
|
||||
.map_err(|error| format!("unable to attach D3D11 to the H.264 decoder: {error}"))?;
|
||||
|
||||
let input_type = select_h264_input(&transform)?;
|
||||
select_nv12_output(&transform)?;
|
||||
let output_info = unsafe { transform.GetOutputStreamInfo(0) }.map_err(|error| {
|
||||
format!("unable to read H.264 decoder output requirements: {error}")
|
||||
})?;
|
||||
let sample_flags = u32::try_from(
|
||||
(MFT_OUTPUT_STREAM_PROVIDES_SAMPLES.0 | MFT_OUTPUT_STREAM_CAN_PROVIDE_SAMPLES.0).max(0),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
if output_info.dwFlags & sample_flags == 0 {
|
||||
return Err("the D3D11 H.264 decoder requires CPU-allocated output samples".to_owned());
|
||||
}
|
||||
unsafe { transform.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0) }
|
||||
.map_err(|error| format!("unable to begin H.264 streaming: {error}"))?;
|
||||
unsafe { transform.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0) }
|
||||
.map_err(|error| format!("unable to start H.264 input: {error}"))?;
|
||||
Ok(Self {
|
||||
transform,
|
||||
_input_type: input_type,
|
||||
_manager: manager,
|
||||
_attributes: attributes,
|
||||
device,
|
||||
context,
|
||||
next_timestamp_100ns: 0,
|
||||
_media_foundation_guard: media_foundation_guard,
|
||||
_com_guard: com_guard,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(&mut self, access_unit: &RemoteH264AccessUnit) -> Result<Vec<DecodedFrame>, String> {
|
||||
if access_unit.data.is_empty() || access_unit.data.len() > 16 * 1024 * 1024 {
|
||||
return Err("remote H.264 access unit is outside the decoder bounds".to_owned());
|
||||
}
|
||||
let mut frames = self.drain_output()?;
|
||||
let input = create_input_sample(
|
||||
&access_unit.data,
|
||||
self.next_timestamp_100ns,
|
||||
access_unit.duration,
|
||||
)?;
|
||||
let duration_100ns = i64::try_from(access_unit.duration.as_nanos() / 100)
|
||||
.map_err(|_| "remote H.264 duration overflow".to_owned())?
|
||||
.max(1);
|
||||
self.next_timestamp_100ns = self.next_timestamp_100ns.saturating_add(duration_100ns);
|
||||
match unsafe { self.transform.ProcessInput(0, &input, 0) } {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.code() == MF_E_NOTACCEPTING => {
|
||||
frames.extend(self.drain_output()?);
|
||||
unsafe { self.transform.ProcessInput(0, &input, 0) }
|
||||
.map_err(|retry| format!("H.264 decoder still rejects input: {retry}"))?;
|
||||
}
|
||||
Err(error) => return Err(format!("H.264 decoder rejected input: {error}")),
|
||||
}
|
||||
frames.extend(self.drain_output()?);
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn drain_output(&mut self) -> Result<Vec<DecodedFrame>, String> {
|
||||
let mut frames = Vec::new();
|
||||
for _ in 0..MAX_OUTPUTS_PER_INPUT {
|
||||
let mut output = MFT_OUTPUT_DATA_BUFFER::default();
|
||||
output.pSample = ManuallyDrop::new(None);
|
||||
let mut status = 0_u32;
|
||||
let result = unsafe {
|
||||
self.transform
|
||||
.ProcessOutput(0, std::slice::from_mut(&mut output), &raw mut status)
|
||||
};
|
||||
let sample = unsafe { ManuallyDrop::take(&mut output.pSample) };
|
||||
let _events = unsafe { ManuallyDrop::take(&mut output.pEvents) };
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let sample = sample.ok_or_else(|| {
|
||||
"H.264 decoder returned success without a DXGI sample".to_owned()
|
||||
})?;
|
||||
frames.push(decoded_frame(sample, &self.transform, &self.device)?);
|
||||
}
|
||||
Err(error) if error.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => break,
|
||||
Err(error) if error.code() == MF_E_TRANSFORM_STREAM_CHANGE => {
|
||||
select_nv12_output(&self.transform)?;
|
||||
}
|
||||
Err(error) => return Err(format!("H.264 decoder output failed: {error}")),
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
}
|
||||
|
||||
fn select_h264_input(transform: &IMFTransform) -> Result<IMFMediaType, String> {
|
||||
let mut last_error = None;
|
||||
for subtype in [MFVideoFormat_H264_ES, MFVideoFormat_H264] {
|
||||
let media_type = unsafe { MFCreateMediaType() }
|
||||
.map_err(|error| format!("unable to create the H.264 input media type: {error}"))?;
|
||||
let major_type_key = MF_MT_MAJOR_TYPE;
|
||||
let video_type = MFMediaType_Video;
|
||||
unsafe { media_type.SetGUID(&major_type_key, &video_type) }
|
||||
.map_err(|error| format!("unable to set the H.264 input major type: {error}"))?;
|
||||
let subtype_key = MF_MT_SUBTYPE;
|
||||
unsafe { media_type.SetGUID(&subtype_key, &subtype) }
|
||||
.map_err(|error| format!("unable to set the H.264 input subtype: {error}"))?;
|
||||
match unsafe { transform.SetInputType(0, &media_type, 0) } {
|
||||
Ok(()) => return Ok(media_type),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"Windows H.264 decoder rejected Annex-B input: {}",
|
||||
last_error
|
||||
.map(|error| error.to_string())
|
||||
.unwrap_or_else(|| "no supported input subtype".to_owned())
|
||||
))
|
||||
}
|
||||
|
||||
fn create_input_sample(
|
||||
data: &[u8],
|
||||
timestamp_100ns: i64,
|
||||
duration: std::time::Duration,
|
||||
) -> Result<IMFSample, String> {
|
||||
let length = u32::try_from(data.len()).map_err(|_| "H.264 input is too large".to_owned())?;
|
||||
let buffer = unsafe { MFCreateMemoryBuffer(length) }
|
||||
.map_err(|error| format!("unable to allocate H.264 input buffer: {error}"))?;
|
||||
let mut destination = core::ptr::null_mut();
|
||||
unsafe { buffer.Lock(&raw mut destination, None, None) }
|
||||
.map_err(|error| format!("unable to lock H.264 input buffer: {error}"))?;
|
||||
if destination.is_null() {
|
||||
let _ = unsafe { buffer.Unlock() };
|
||||
return Err("Media Foundation returned a null H.264 input buffer".to_owned());
|
||||
}
|
||||
unsafe { core::ptr::copy_nonoverlapping(data.as_ptr(), destination, data.len()) };
|
||||
unsafe { buffer.Unlock() }
|
||||
.map_err(|error| format!("unable to unlock H.264 input buffer: {error}"))?;
|
||||
unsafe { buffer.SetCurrentLength(length) }
|
||||
.map_err(|error| format!("unable to commit H.264 input bytes: {error}"))?;
|
||||
let sample = unsafe { MFCreateSample() }
|
||||
.map_err(|error| format!("unable to create H.264 input sample: {error}"))?;
|
||||
unsafe { sample.AddBuffer(&buffer) }
|
||||
.map_err(|error| format!("unable to attach H.264 input bytes: {error}"))?;
|
||||
unsafe { sample.SetSampleTime(timestamp_100ns) }
|
||||
.map_err(|error| format!("unable to set H.264 sample time: {error}"))?;
|
||||
let duration_100ns = i64::try_from(duration.as_nanos() / 100)
|
||||
.map_err(|_| "H.264 sample duration overflow".to_owned())?
|
||||
.max(1);
|
||||
unsafe { sample.SetSampleDuration(duration_100ns) }
|
||||
.map_err(|error| format!("unable to set H.264 sample duration: {error}"))?;
|
||||
Ok(sample)
|
||||
}
|
||||
|
||||
fn select_nv12_output(transform: &IMFTransform) -> Result<(), String> {
|
||||
for index in 0..MAX_OUTPUT_TYPES {
|
||||
let media_type = match unsafe { transform.GetOutputAvailableType(0, index) } {
|
||||
Ok(media_type) => media_type,
|
||||
Err(error) if error.code() == MF_E_NO_MORE_TYPES => break,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"unable to enumerate decoder output {index}: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
let subtype_key = MF_MT_SUBTYPE;
|
||||
let subtype = unsafe { media_type.GetGUID(&subtype_key) };
|
||||
if subtype.is_ok_and(|value| value == MFVideoFormat_NV12)
|
||||
&& unsafe { transform.SetOutputType(0, &media_type, 0) }.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("the Windows H.264 decoder exposes no NV12 output".to_owned())
|
||||
}
|
||||
|
||||
struct DecodedFrame {
|
||||
_sample: IMFSample,
|
||||
texture: ID3D11Texture2D,
|
||||
subresource: u32,
|
||||
mip_levels: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
input_color_space: D3D11_VIDEO_PROCESSOR_COLOR_SPACE,
|
||||
}
|
||||
|
||||
fn decoded_frame(
|
||||
sample: IMFSample,
|
||||
transform: &IMFTransform,
|
||||
expected_device: &ID3D11Device,
|
||||
) -> Result<DecodedFrame, String> {
|
||||
let buffer_count = unsafe { sample.GetBufferCount() }
|
||||
.map_err(|error| format!("unable to count decoded H.264 buffers: {error}"))?;
|
||||
if buffer_count != 1 {
|
||||
return Err("decoded H.264 frame must contain one DXGI buffer".to_owned());
|
||||
}
|
||||
let buffer = unsafe { sample.GetBufferByIndex(0) }
|
||||
.map_err(|error| format!("unable to read decoded H.264 buffer: {error}"))?;
|
||||
let dxgi_buffer = buffer
|
||||
.cast::<IMFDXGIBuffer>()
|
||||
.map_err(|_| "H.264 decoder returned a CPU media buffer".to_owned())?;
|
||||
let mut raw_texture = core::ptr::null_mut();
|
||||
unsafe { dxgi_buffer.GetResource(&ID3D11Texture2D::IID, &raw mut raw_texture) }
|
||||
.map_err(|error| format!("decoded H.264 buffer has no D3D11 texture: {error}"))?;
|
||||
if raw_texture.is_null() {
|
||||
return Err("decoded H.264 buffer returned a null texture".to_owned());
|
||||
}
|
||||
let texture = unsafe { ID3D11Texture2D::from_raw(raw_texture) };
|
||||
let mut descriptor = windows::Win32::Graphics::Direct3D11::D3D11_TEXTURE2D_DESC::default();
|
||||
unsafe { texture.GetDesc(&raw mut descriptor) };
|
||||
if descriptor.Format != DXGI_FORMAT_NV12 || descriptor.Width == 0 || descriptor.Height == 0 {
|
||||
return Err("decoded H.264 texture is not NV12".to_owned());
|
||||
}
|
||||
let texture_device = unsafe { texture.GetDevice() }
|
||||
.map_err(|error| format!("unable to read decoded texture device: {error}"))?;
|
||||
if texture_device != *expected_device {
|
||||
return Err("decoded H.264 texture belongs to another D3D11 device".to_owned());
|
||||
}
|
||||
let subresource = unsafe { dxgi_buffer.GetSubresourceIndex() }
|
||||
.map_err(|error| format!("unable to read decoded H.264 subresource: {error}"))?;
|
||||
let subresources = descriptor.MipLevels.saturating_mul(descriptor.ArraySize);
|
||||
if descriptor.MipLevels == 0 || subresources == 0 || subresource >= subresources {
|
||||
return Err("decoded H.264 subresource is outside its texture".to_owned());
|
||||
}
|
||||
let media_type = unsafe { transform.GetOutputCurrentType(0) }
|
||||
.map_err(|error| format!("unable to read current H.264 output type: {error}"))?;
|
||||
let (width, height) =
|
||||
media_type_frame_size(&media_type).unwrap_or((descriptor.Width, descriptor.Height));
|
||||
if width > descriptor.Width || height > descriptor.Height {
|
||||
return Err("visible H.264 frame exceeds its NV12 texture".to_owned());
|
||||
}
|
||||
Ok(DecodedFrame {
|
||||
_sample: sample,
|
||||
texture,
|
||||
subresource,
|
||||
mip_levels: descriptor.MipLevels,
|
||||
width,
|
||||
height,
|
||||
input_color_space: media_type_color_space(&media_type, height),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_hardware_d3d11_device() -> Result<(ID3D11Device, ID3D11DeviceContext), String> {
|
||||
let levels = [D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0];
|
||||
let mut device = None;
|
||||
let mut context = None;
|
||||
unsafe {
|
||||
D3D11CreateDevice(
|
||||
None::<&IDXGIAdapter>,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
windows::Win32::Foundation::HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT,
|
||||
Some(&levels),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&raw mut device),
|
||||
None,
|
||||
Some(&raw mut context),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to create remote-video D3D11 device: {error}"))?;
|
||||
Ok((
|
||||
device.ok_or_else(|| "D3D11 returned no remote-video device".to_owned())?,
|
||||
context.ok_or_else(|| "D3D11 returned no remote-video context".to_owned())?,
|
||||
))
|
||||
}
|
||||
|
||||
struct VideoRenderer {
|
||||
window: Arc<Window>,
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
video_device: ID3D11VideoDevice,
|
||||
video_context: ID3D11VideoContext,
|
||||
swap_chain: IDXGISwapChain,
|
||||
pipeline: Option<ProcessorPipeline>,
|
||||
output_size: (u32, u32),
|
||||
output_frame: u32,
|
||||
}
|
||||
|
||||
struct ProcessorPipeline {
|
||||
input_size: (u32, u32),
|
||||
output_size: (u32, u32),
|
||||
enumerator: ID3D11VideoProcessorEnumerator,
|
||||
processor: ID3D11VideoProcessor,
|
||||
output_view: ID3D11VideoProcessorOutputView,
|
||||
render_target: ID3D11RenderTargetView,
|
||||
}
|
||||
|
||||
impl VideoRenderer {
|
||||
fn new(
|
||||
window: Arc<Window>,
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
) -> Result<Self, String> {
|
||||
let RawWindowHandle::Win32(handle) = window
|
||||
.window_handle()
|
||||
.map_err(|error| format!("unable to read remote-video window handle: {error}"))?
|
||||
.as_raw()
|
||||
else {
|
||||
return Err("remote H.264 presentation requires a Win32 window".to_owned());
|
||||
};
|
||||
let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void);
|
||||
let size = window.inner_size();
|
||||
let width = size.width.max(1);
|
||||
let height = size.height.max(1);
|
||||
let descriptor = swap_chain_descriptor(hwnd, width, height);
|
||||
let dxgi_device: IDXGIDevice = device
|
||||
.cast()
|
||||
.map_err(|error| format!("remote-video device has no DXGI interface: {error}"))?;
|
||||
let adapter = unsafe { dxgi_device.GetAdapter() }
|
||||
.map_err(|error| format!("unable to read remote-video adapter: {error}"))?;
|
||||
let factory: IDXGIFactory = unsafe { adapter.GetParent() }
|
||||
.map_err(|error| format!("unable to read remote-video DXGI factory: {error}"))?;
|
||||
unsafe { factory.MakeWindowAssociation(hwnd, DXGI_MWA_NO_ALT_ENTER) }
|
||||
.map_err(|error| format!("unable to configure remote-video window: {error}"))?;
|
||||
let mut swap_chain = None;
|
||||
unsafe { factory.CreateSwapChain(&device, &raw const descriptor, &raw mut swap_chain) }
|
||||
.ok()
|
||||
.map_err(|error| format!("unable to create remote-video swap chain: {error}"))?;
|
||||
Ok(Self {
|
||||
window,
|
||||
video_device: device
|
||||
.cast()
|
||||
.map_err(|error| format!("D3D11 device has no video interface: {error}"))?,
|
||||
video_context: context
|
||||
.cast()
|
||||
.map_err(|error| format!("D3D11 context has no video interface: {error}"))?,
|
||||
device,
|
||||
context,
|
||||
swap_chain: swap_chain.ok_or_else(|| "DXGI returned no swap chain".to_owned())?,
|
||||
pipeline: None,
|
||||
output_size: (width, height),
|
||||
output_frame: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn present(&mut self, frame: &DecodedFrame) -> Result<(), String> {
|
||||
let size = self.window.inner_size();
|
||||
if size.width == 0 || size.height == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.resize(size.width, size.height)?;
|
||||
self.ensure_pipeline(frame.width, frame.height)?;
|
||||
let pipeline = self
|
||||
.pipeline
|
||||
.as_ref()
|
||||
.ok_or_else(|| "video processor is missing".to_owned())?;
|
||||
let input_descriptor = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC {
|
||||
FourCC: 0,
|
||||
ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0 {
|
||||
Texture2D: D3D11_TEX2D_VPIV {
|
||||
MipSlice: frame.subresource % frame.mip_levels,
|
||||
ArraySlice: frame.subresource / frame.mip_levels,
|
||||
},
|
||||
},
|
||||
};
|
||||
let mut input_view = None;
|
||||
unsafe {
|
||||
self.video_device.CreateVideoProcessorInputView(
|
||||
&frame.texture,
|
||||
&pipeline.enumerator,
|
||||
&raw const input_descriptor,
|
||||
Some(&raw mut input_view),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to create NV12 input view: {error}"))?;
|
||||
let source = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: i32::try_from(frame.width).map_err(|_| "video width overflow")?,
|
||||
bottom: i32::try_from(frame.height).map_err(|_| "video height overflow")?,
|
||||
};
|
||||
let destination = aspect_fit_rect(frame.width, frame.height, size.width, size.height)?;
|
||||
let target = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: i32::try_from(size.width).map_err(|_| "window width overflow")?,
|
||||
bottom: i32::try_from(size.height).map_err(|_| "window height overflow")?,
|
||||
};
|
||||
unsafe {
|
||||
self.context
|
||||
.ClearRenderTargetView(&pipeline.render_target, &[0.0, 0.0, 0.0, 1.0]);
|
||||
self.video_context.VideoProcessorSetOutputTargetRect(
|
||||
&pipeline.processor,
|
||||
true,
|
||||
Some(&raw const target),
|
||||
);
|
||||
self.video_context.VideoProcessorSetStreamSourceRect(
|
||||
&pipeline.processor,
|
||||
0,
|
||||
true,
|
||||
Some(&raw const source),
|
||||
);
|
||||
self.video_context.VideoProcessorSetStreamDestRect(
|
||||
&pipeline.processor,
|
||||
0,
|
||||
true,
|
||||
Some(&raw const destination),
|
||||
);
|
||||
self.video_context.VideoProcessorSetStreamColorSpace(
|
||||
&pipeline.processor,
|
||||
0,
|
||||
&raw const frame.input_color_space,
|
||||
);
|
||||
}
|
||||
let mut stream = D3D11_VIDEO_PROCESSOR_STREAM {
|
||||
Enable: true.into(),
|
||||
pInputSurface: ManuallyDrop::new(input_view),
|
||||
..Default::default()
|
||||
};
|
||||
let result = unsafe {
|
||||
self.video_context.VideoProcessorBlt(
|
||||
&pipeline.processor,
|
||||
&pipeline.output_view,
|
||||
self.output_frame,
|
||||
std::slice::from_ref(&stream),
|
||||
)
|
||||
};
|
||||
let _input_view = unsafe { ManuallyDrop::take(&mut stream.pInputSurface) };
|
||||
result.map_err(|error| format!("unable to process remote NV12 frame: {error}"))?;
|
||||
unsafe { self.swap_chain.Present(1, DXGI_PRESENT(0)) }
|
||||
.ok()
|
||||
.map_err(|error| format!("unable to present remote H.264 frame: {error}"))?;
|
||||
self.output_frame = self.output_frame.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resize(&mut self, width: u32, height: u32) -> Result<(), String> {
|
||||
if self.output_size == (width, height) {
|
||||
return Ok(());
|
||||
}
|
||||
self.pipeline = None;
|
||||
unsafe {
|
||||
self.swap_chain.ResizeBuffers(
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
DXGI_FORMAT_UNKNOWN,
|
||||
DXGI_SWAP_CHAIN_FLAG(0),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to resize remote-video swap chain: {error}"))?;
|
||||
self.output_size = (width, height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_pipeline(&mut self, input_width: u32, input_height: u32) -> Result<(), String> {
|
||||
if self.pipeline.as_ref().is_some_and(|pipeline| {
|
||||
pipeline.input_size == (input_width, input_height)
|
||||
&& pipeline.output_size == self.output_size
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
let content = D3D11_VIDEO_PROCESSOR_CONTENT_DESC {
|
||||
InputFrameFormat: D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE,
|
||||
InputFrameRate: DXGI_RATIONAL {
|
||||
Numerator: 60,
|
||||
Denominator: 1,
|
||||
},
|
||||
InputWidth: input_width,
|
||||
InputHeight: input_height,
|
||||
OutputFrameRate: DXGI_RATIONAL {
|
||||
Numerator: 60,
|
||||
Denominator: 1,
|
||||
},
|
||||
OutputWidth: self.output_size.0,
|
||||
OutputHeight: self.output_size.1,
|
||||
Usage: D3D11_VIDEO_USAGE_PLAYBACK_NORMAL,
|
||||
};
|
||||
let enumerator = unsafe {
|
||||
self.video_device
|
||||
.CreateVideoProcessorEnumerator(&raw const content)
|
||||
}
|
||||
.map_err(|error| format!("unable to create video processor enumerator: {error}"))?;
|
||||
let input_support = unsafe { enumerator.CheckVideoProcessorFormat(DXGI_FORMAT_NV12) }
|
||||
.map_err(|error| format!("unable to verify NV12 processor input: {error}"))?;
|
||||
let output_support =
|
||||
unsafe { enumerator.CheckVideoProcessorFormat(DXGI_FORMAT_B8G8R8A8_UNORM) }
|
||||
.map_err(|error| format!("unable to verify BGRA processor output: {error}"))?;
|
||||
let required_input = u32::try_from(D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_INPUT.0)
|
||||
.map_err(|_| "invalid D3D11 input support flag".to_owned())?;
|
||||
let required_output = u32::try_from(D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_OUTPUT.0)
|
||||
.map_err(|_| "invalid D3D11 output support flag".to_owned())?;
|
||||
if input_support & required_input == 0 || output_support & required_output == 0 {
|
||||
return Err("D3D11 VideoProcessor lacks NV12/BGRA support".to_owned());
|
||||
}
|
||||
let processor = unsafe { self.video_device.CreateVideoProcessor(&enumerator, 0) }
|
||||
.map_err(|error| format!("unable to create D3D11 video processor: {error}"))?;
|
||||
let back_buffer: ID3D11Texture2D = unsafe { self.swap_chain.GetBuffer(0) }
|
||||
.map_err(|error| format!("unable to read remote-video back buffer: {error}"))?;
|
||||
let mut render_target = None;
|
||||
unsafe {
|
||||
self.device
|
||||
.CreateRenderTargetView(&back_buffer, None, Some(&raw mut render_target))
|
||||
}
|
||||
.map_err(|error| format!("unable to create remote-video render target: {error}"))?;
|
||||
let render_target =
|
||||
render_target.ok_or_else(|| "D3D11 returned no render target view".to_owned())?;
|
||||
let output_descriptor = D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC {
|
||||
ViewDimension: D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0 {
|
||||
Texture2D: D3D11_TEX2D_VPOV { MipSlice: 0 },
|
||||
},
|
||||
};
|
||||
let mut output_view = None;
|
||||
unsafe {
|
||||
self.video_device.CreateVideoProcessorOutputView(
|
||||
&back_buffer,
|
||||
&enumerator,
|
||||
&raw const output_descriptor,
|
||||
Some(&raw mut output_view),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to create processor output view: {error}"))?;
|
||||
self.pipeline = Some(ProcessorPipeline {
|
||||
input_size: (input_width, input_height),
|
||||
output_size: self.output_size,
|
||||
enumerator,
|
||||
processor,
|
||||
output_view: output_view
|
||||
.ok_or_else(|| "D3D11 returned no processor output view".to_owned())?,
|
||||
render_target,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn swap_chain_descriptor(hwnd: HWND, width: u32, height: u32) -> DXGI_SWAP_CHAIN_DESC {
|
||||
DXGI_SWAP_CHAIN_DESC {
|
||||
BufferDesc: DXGI_MODE_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
RefreshRate: DXGI_RATIONAL {
|
||||
Numerator: 0,
|
||||
Denominator: 1,
|
||||
},
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
..Default::default()
|
||||
},
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 2,
|
||||
OutputWindow: hwnd,
|
||||
Windowed: true.into(),
|
||||
SwapEffect: DXGI_SWAP_EFFECT_DISCARD,
|
||||
Flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn aspect_fit_rect(
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
target_width: u32,
|
||||
target_height: u32,
|
||||
) -> Result<RECT, String> {
|
||||
if source_width == 0 || source_height == 0 || target_width == 0 || target_height == 0 {
|
||||
return Err("video dimensions must be non-zero".to_owned());
|
||||
}
|
||||
let source_aspect = f64::from(source_width) / f64::from(source_height);
|
||||
let target_aspect = f64::from(target_width) / f64::from(target_height);
|
||||
let (width, height) = if source_aspect > target_aspect {
|
||||
(
|
||||
target_width,
|
||||
(f64::from(target_width) / source_aspect).round() as u32,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
(f64::from(target_height) * source_aspect).round() as u32,
|
||||
target_height,
|
||||
)
|
||||
};
|
||||
let left = (target_width - width) / 2;
|
||||
let top = (target_height - height) / 2;
|
||||
Ok(RECT {
|
||||
left: i32::try_from(left).map_err(|_| "video left overflow")?,
|
||||
top: i32::try_from(top).map_err(|_| "video top overflow")?,
|
||||
right: i32::try_from(left + width).map_err(|_| "video right overflow")?,
|
||||
bottom: i32::try_from(top + height).map_err(|_| "video bottom overflow")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn media_type_frame_size(media_type: &IMFMediaType) -> Option<(u32, u32)> {
|
||||
let frame_size_key = MF_MT_FRAME_SIZE;
|
||||
let packed = unsafe { media_type.GetUINT64(&frame_size_key) }.ok()?;
|
||||
let width = u32::try_from(packed >> 32).ok()?;
|
||||
let height = u32::try_from(packed & u64::from(u32::MAX)).ok()?;
|
||||
(width > 0 && height > 0).then_some((width, height))
|
||||
}
|
||||
|
||||
fn media_type_color_space(
|
||||
media_type: &IMFMediaType,
|
||||
visible_height: u32,
|
||||
) -> D3D11_VIDEO_PROCESSOR_COLOR_SPACE {
|
||||
let bt709_value = u32::try_from(MFVideoTransferMatrix_BT709.0).unwrap_or(1);
|
||||
let full_range_value = u32::try_from(MFNominalRange_0_255.0).unwrap_or(1);
|
||||
let yuv_matrix_key = MF_MT_YUV_MATRIX;
|
||||
let bt709 = unsafe { media_type.GetUINT32(&yuv_matrix_key) }
|
||||
.map_or(visible_height >= 720, |value| value == bt709_value);
|
||||
let nominal_range_key = MF_MT_VIDEO_NOMINAL_RANGE;
|
||||
let full_range = unsafe { media_type.GetUINT32(&nominal_range_key) }
|
||||
.is_ok_and(|value| value == full_range_value);
|
||||
D3D11_VIDEO_PROCESSOR_COLOR_SPACE {
|
||||
_bitfield: (u32::from(bt709) << 2) | ((if full_range { 2 } else { 1 }) << 4),
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_microseconds(started: Instant) -> u64 {
|
||||
started.elapsed().as_micros().try_into().unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aspect_fit_centers_letterbox_and_pillarbox_output() {
|
||||
assert_eq!(
|
||||
aspect_fit_rect(1_920, 1_080, 1_000, 1_000).unwrap(),
|
||||
RECT {
|
||||
left: 0,
|
||||
top: 218,
|
||||
right: 1_000,
|
||||
bottom: 781
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
aspect_fit_rect(1_000, 1_000, 1_920, 1_080).unwrap(),
|
||||
RECT {
|
||||
left: 420,
|
||||
top: 0,
|
||||
right: 1_500,
|
||||
bottom: 1_080
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "remotedesk-native-video"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
remotedesk-client-core = { path = "../../crates/client-core" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
winit = "0.30"
|
||||
windows = { version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Media_MediaFoundation",
|
||||
"Win32_System_Com",
|
||||
] }
|
||||
|
||||
[lints.rust]
|
||||
# The Windows adapter is the only FFI boundary in this crate. All unsafe calls
|
||||
# are kept in one function with explicit initialized out-parameters.
|
||||
unsafe_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
@@ -0,0 +1,950 @@
|
||||
use remotedesk_client_core::{
|
||||
AdapterId, D3d11PipelinePlan, GpuPolicy, GpuSelection, PipelineRequest, ZeroCopyPolicy,
|
||||
plan_d3d11_pipeline,
|
||||
};
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
#[cfg(windows)]
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod player;
|
||||
|
||||
#[cfg(windows)]
|
||||
const MAX_DECODE_PROBE_FILE_SIZE: u64 = 512 * 1024 * 1024;
|
||||
#[cfg(windows)]
|
||||
const MAX_DECODE_PROBE_READS: usize = 512;
|
||||
#[cfg(windows)]
|
||||
const MAX_NATIVE_MEDIA_TYPES: u32 = 64;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct DryRunArgs {
|
||||
window_adapter: AdapterId,
|
||||
decode_adapter: AdapterId,
|
||||
manual_adapter: Option<AdapterId>,
|
||||
zero_copy_policy: ZeroCopyPolicy,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run(env::args().skip(1)) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(message) => {
|
||||
eprintln!("error: {message}");
|
||||
eprintln!("{}", usage());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
|
||||
let values = args.collect::<Vec<_>>();
|
||||
if values == ["--capabilities"] {
|
||||
return print_helper_capabilities();
|
||||
}
|
||||
if values == ["--probe-d3d11"] {
|
||||
return probe_d3d11();
|
||||
}
|
||||
if values == ["--probe-h264-decoder"] {
|
||||
return probe_h264_decoder();
|
||||
}
|
||||
if values.len() == 2 && values[0] == "--probe-h264-file" {
|
||||
return probe_h264_file(Path::new(&values[1]));
|
||||
}
|
||||
if values.len() == 2 && values[0] == "--play-h264-file" {
|
||||
return play_h264_file(Path::new(&values[1]));
|
||||
}
|
||||
let plan = build_plan(parse_args(values.into_iter())?)?;
|
||||
|
||||
println!("native-video D3D11 pipeline dry-run");
|
||||
println!("decode_adapter={}", plan.decode_adapter);
|
||||
println!("render_adapter={}", plan.render_adapter);
|
||||
println!("display_adapter={}", plan.display_adapter);
|
||||
println!("zero_copy_policy={:?}", plan.zero_copy_policy);
|
||||
println!("planned_memory_path={:?}", plan.planned_memory_path);
|
||||
println!("zero_copy_candidate={}", plan.is_zero_copy_candidate());
|
||||
println!("d3d11_created=false");
|
||||
println!("note=plan only; no D3D11 device or decoder was created");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_plan(args: DryRunArgs) -> Result<D3d11PipelinePlan, String> {
|
||||
let render_candidate = args
|
||||
.manual_adapter
|
||||
.clone()
|
||||
.unwrap_or_else(|| args.window_adapter.clone());
|
||||
let mut available_adapters = vec![args.window_adapter.clone(), args.decode_adapter.clone()];
|
||||
if !available_adapters.contains(&render_candidate) {
|
||||
available_adapters.push(render_candidate.clone());
|
||||
}
|
||||
|
||||
plan_d3d11_pipeline(PipelineRequest {
|
||||
policy: GpuPolicy {
|
||||
selection: args
|
||||
.manual_adapter
|
||||
.map_or(GpuSelection::WindowDisplayAdapter, GpuSelection::Manual),
|
||||
zero_copy_policy: args.zero_copy_policy,
|
||||
},
|
||||
window_display_adapter: args.window_adapter,
|
||||
decoder_adapter: args.decode_adapter,
|
||||
available_adapters,
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn parse_args(mut args: impl Iterator<Item = String>) -> Result<DryRunArgs, String> {
|
||||
let mut dry_run = false;
|
||||
let mut window_adapter = None;
|
||||
let mut decode_adapter = None;
|
||||
let mut manual_adapter = None;
|
||||
let mut zero_copy_policy = ZeroCopyPolicy::RequiredEndToEnd;
|
||||
|
||||
while let Some(argument) = args.next() {
|
||||
match argument.as_str() {
|
||||
"--dry-run" => dry_run = true,
|
||||
"--window-adapter" => {
|
||||
window_adapter = Some(AdapterId::new(required_value(&mut args, &argument)?));
|
||||
}
|
||||
"--decode-adapter" => {
|
||||
decode_adapter = Some(AdapterId::new(required_value(&mut args, &argument)?));
|
||||
}
|
||||
"--manual-adapter" => {
|
||||
manual_adapter = Some(AdapterId::new(required_value(&mut args, &argument)?));
|
||||
}
|
||||
"--required-end-to-end" => zero_copy_policy = ZeroCopyPolicy::RequiredEndToEnd,
|
||||
"--compatibility" => zero_copy_policy = ZeroCopyPolicy::Compatibility,
|
||||
"--help" | "-h" => return Err(usage().to_owned()),
|
||||
other => return Err(format!("unknown argument {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
if !dry_run {
|
||||
return Err("only --dry-run is implemented".into());
|
||||
}
|
||||
|
||||
let window_adapter = window_adapter.ok_or_else(|| "--window-adapter is required".to_owned())?;
|
||||
let selected_adapter = manual_adapter.as_ref().unwrap_or(&window_adapter).clone();
|
||||
let decode_adapter = decode_adapter.unwrap_or(selected_adapter);
|
||||
|
||||
Ok(DryRunArgs {
|
||||
window_adapter,
|
||||
decode_adapter,
|
||||
manual_adapter,
|
||||
zero_copy_policy,
|
||||
})
|
||||
}
|
||||
|
||||
fn required_value(args: &mut impl Iterator<Item = String>, option: &str) -> Result<String, String> {
|
||||
args.next()
|
||||
.filter(|value| !value.is_empty() && !value.starts_with("--"))
|
||||
.ok_or_else(|| format!("{option} requires a value"))
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"usage: remotedesk-native-video --capabilities | --probe-d3d11 | --probe-h264-decoder | --probe-h264-file <local.mp4> | --play-h264-file <local.mp4> | --dry-run --window-adapter <id> [--decode-adapter <id>] [--manual-adapter <id>] [--required-end-to-end|--compatibility]"
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct HelperCapabilities {
|
||||
schema_version: u8,
|
||||
version: &'static str,
|
||||
platform: &'static str,
|
||||
h264_file_probe: bool,
|
||||
h264_file_player: bool,
|
||||
d3d11_video_processor: bool,
|
||||
cpu_frame_mapping: bool,
|
||||
}
|
||||
|
||||
fn print_helper_capabilities() -> Result<(), String> {
|
||||
let response = HelperCapabilities {
|
||||
schema_version: 1,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
platform: env::consts::OS,
|
||||
h264_file_probe: cfg!(windows),
|
||||
h264_file_player: cfg!(windows),
|
||||
d3d11_video_processor: cfg!(windows),
|
||||
cpu_frame_mapping: false,
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&response)
|
||||
.map_err(|error| format!("unable to serialize helper capabilities: {error}"))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn supported_h264_container(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
["mp4", "m4v", "mov"]
|
||||
.iter()
|
||||
.any(|candidate| extension.eq_ignore_ascii_case(candidate))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn validated_h264_probe_path(path: &Path) -> Result<(PathBuf, u64), String> {
|
||||
if !supported_h264_container(path) {
|
||||
return Err("decode probe input must be a local MP4, M4V, or MOV file".to_owned());
|
||||
}
|
||||
let canonical = std::fs::canonicalize(path)
|
||||
.map_err(|error| format!("unable to resolve decode probe input: {error}"))?;
|
||||
let is_unc = canonical.components().next().is_some_and(|component| {
|
||||
matches!(
|
||||
component,
|
||||
std::path::Component::Prefix(prefix)
|
||||
if matches!(
|
||||
prefix.kind(),
|
||||
std::path::Prefix::UNC(_, _) | std::path::Prefix::VerbatimUNC(_, _)
|
||||
)
|
||||
)
|
||||
});
|
||||
if is_unc {
|
||||
return Err("decode probe input must not be a UNC path".to_owned());
|
||||
}
|
||||
let metadata = std::fs::metadata(&canonical)
|
||||
.map_err(|error| format!("unable to inspect decode probe input: {error}"))?;
|
||||
if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_DECODE_PROBE_FILE_SIZE {
|
||||
return Err(
|
||||
"decode probe input must be a non-empty regular file no larger than 512 MiB".to_owned(),
|
||||
);
|
||||
}
|
||||
Ok((canonical, metadata.len()))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn probe_d3d11() -> Result<(), String> {
|
||||
Err("D3D11 probing is available only on Windows".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn probe_h264_decoder() -> Result<(), String> {
|
||||
Err("D3D11 H.264 decoder probing is available only on Windows".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn probe_h264_file(_path: &Path) -> Result<(), String> {
|
||||
Err("Media Foundation H.264 file probing is available only on Windows".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn play_h264_file(_path: &Path) -> Result<(), String> {
|
||||
Err("Media Foundation H.264 playback is available only on Windows".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn play_h264_file(path: &Path) -> Result<(), String> {
|
||||
player::play_h264_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn create_hardware_d3d11_device() -> Result<
|
||||
(
|
||||
windows::Win32::Graphics::Direct3D11::ID3D11Device,
|
||||
windows::Win32::Graphics::Direct3D11::ID3D11DeviceContext,
|
||||
windows::Win32::Graphics::Direct3D::D3D_FEATURE_LEVEL,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
use windows::Win32::Foundation::HMODULE;
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL, D3D_FEATURE_LEVEL_11_0,
|
||||
D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_12_0, D3D_FEATURE_LEVEL_12_1,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_SDK_VERSION,
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::IDXGIAdapter;
|
||||
|
||||
let requested_levels = [
|
||||
D3D_FEATURE_LEVEL_12_1,
|
||||
D3D_FEATURE_LEVEL_12_0,
|
||||
D3D_FEATURE_LEVEL_11_1,
|
||||
D3D_FEATURE_LEVEL_11_0,
|
||||
];
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
let mut selected_level = D3D_FEATURE_LEVEL(0);
|
||||
|
||||
// SAFETY: all output pointers refer to initialized `Option`/value storage
|
||||
// that remains alive for the call. A null adapter with HARDWARE requests
|
||||
// the system default adapter, and the feature-level slice is immutable.
|
||||
unsafe {
|
||||
D3D11CreateDevice(
|
||||
None::<&IDXGIAdapter>,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT,
|
||||
Some(&requested_levels),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&raw mut device),
|
||||
Some(&raw mut selected_level),
|
||||
Some(&raw mut context),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to create a hardware D3D11 device: {error}"))?;
|
||||
let device = device.ok_or_else(|| "D3D11 returned success without a device".to_owned())?;
|
||||
let context =
|
||||
context.ok_or_else(|| "D3D11 returned success without an immediate context".to_owned())?;
|
||||
Ok((device, context, selected_level))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn probe_d3d11() -> Result<(), String> {
|
||||
let (_device, _context, selected_level) = create_hardware_d3d11_device()?;
|
||||
println!("native-video D3D11 device probe");
|
||||
println!("d3d11_created=true");
|
||||
println!("driver_type=hardware");
|
||||
println!("feature_level=0x{:x}", selected_level.0);
|
||||
println!("bgra_support=true");
|
||||
println!("video_support=true");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct ComGuard;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for ComGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe { windows::Win32::System::Com::CoUninitialize() };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct MediaFoundationGuard;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for MediaFoundationGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = unsafe { windows::Win32::Media::MediaFoundation::MFShutdown() };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn source_reader_index(value: i32) -> u32 {
|
||||
u32::from_ne_bytes(value.to_ne_bytes())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn source_reader_flag(value: i32) -> Result<u32, String> {
|
||||
u32::try_from(value).map_err(|_| "Media Foundation returned an invalid reader flag".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_h264_subtype(subtype: windows::core::GUID) -> bool {
|
||||
use windows::Win32::Media::MediaFoundation::{MFVideoFormat_H264, MFVideoFormat_H264_ES};
|
||||
|
||||
subtype == MFVideoFormat_H264 || subtype == MFVideoFormat_H264_ES
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn find_h264_native_media_type(
|
||||
reader: &windows::Win32::Media::MediaFoundation::IMFSourceReader,
|
||||
video_stream: u32,
|
||||
) -> Result<(windows::Win32::Media::MediaFoundation::IMFMediaType, u32), String> {
|
||||
use windows::Win32::Media::MediaFoundation::{IMFMediaType, MF_E_NO_MORE_TYPES, MF_MT_SUBTYPE};
|
||||
|
||||
for media_type_index in 0..MAX_NATIVE_MEDIA_TYPES {
|
||||
let native_type: IMFMediaType =
|
||||
match unsafe { reader.GetNativeMediaType(video_stream, media_type_index) } {
|
||||
Ok(native_type) => native_type,
|
||||
Err(error) if error.code() == MF_E_NO_MORE_TYPES => break,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"unable to query native video media type {media_type_index}: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
let subtype_key = MF_MT_SUBTYPE;
|
||||
let native_subtype = unsafe { native_type.GetGUID(&subtype_key) }.map_err(|error| {
|
||||
format!("native video media type {media_type_index} has no subtype: {error}")
|
||||
})?;
|
||||
if is_h264_subtype(native_subtype) {
|
||||
return Ok((native_type, media_type_index));
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"decode probe input exposes no H.264 native video type within the first {MAX_NATIVE_MEDIA_TYPES} entries"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn valid_texture_subresource(mip_levels: u32, array_size: u32, subresource: u32) -> bool {
|
||||
mip_levels
|
||||
.checked_mul(array_size)
|
||||
.is_some_and(|count| count > 0 && subresource < count)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn probe_h264_file(path: &Path) -> Result<(), String> {
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
|
||||
use windows::Win32::Graphics::Direct3D11::{D3D11_TEXTURE2D_DESC, ID3D11Texture2D};
|
||||
use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_NV12;
|
||||
use windows::Win32::Media::MediaFoundation::{
|
||||
IMFAttributes, IMFDXGIBuffer, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE,
|
||||
MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, MF_SOURCE_READER_ALL_STREAMS,
|
||||
MF_SOURCE_READER_D3D_MANAGER, MF_SOURCE_READER_DISABLE_DXVA,
|
||||
MF_SOURCE_READER_FIRST_VIDEO_STREAM, MF_SOURCE_READERF_ENDOFSTREAM,
|
||||
MF_SOURCE_READERF_ERROR, MF_VERSION, MFCreateAttributes, MFCreateDXGIDeviceManager,
|
||||
MFCreateMediaType, MFCreateSourceReaderFromURL, MFMediaType_Video, MFSTARTUP_FULL,
|
||||
MFStartup, MFVideoFormat_NV12,
|
||||
};
|
||||
use windows::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx};
|
||||
use windows::core::{Interface as _, PCWSTR};
|
||||
|
||||
let (path, file_size) = validated_h264_probe_path(path)?;
|
||||
unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }
|
||||
.ok()
|
||||
.map_err(|error| format!("unable to initialize COM for H.264 decode: {error}"))?;
|
||||
let _com_guard = ComGuard;
|
||||
unsafe { MFStartup(MF_VERSION, MFSTARTUP_FULL) }
|
||||
.map_err(|error| format!("unable to start Media Foundation: {error}"))?;
|
||||
let _media_foundation_guard = MediaFoundationGuard;
|
||||
|
||||
let (device, _context, selected_level) = create_hardware_d3d11_device()?;
|
||||
let mut reset_token = 0_u32;
|
||||
let mut manager = None;
|
||||
unsafe { MFCreateDXGIDeviceManager(&raw mut reset_token, &raw mut manager) }
|
||||
.map_err(|error| format!("unable to create the DXGI device manager: {error}"))?;
|
||||
let manager =
|
||||
manager.ok_or_else(|| "Media Foundation returned no DXGI device manager".to_owned())?;
|
||||
unsafe { manager.ResetDevice(&device, reset_token) }
|
||||
.map_err(|error| format!("unable to bind D3D11 to the DXGI device manager: {error}"))?;
|
||||
|
||||
let mut attributes: Option<IMFAttributes> = None;
|
||||
unsafe { MFCreateAttributes(&raw mut attributes, 4) }
|
||||
.map_err(|error| format!("unable to create source reader attributes: {error}"))?;
|
||||
let attributes = attributes
|
||||
.ok_or_else(|| "Media Foundation returned no source reader attributes".to_owned())?;
|
||||
let d3d_manager_key = MF_SOURCE_READER_D3D_MANAGER;
|
||||
let hardware_transforms_key = MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS;
|
||||
let disable_dxva_key = MF_SOURCE_READER_DISABLE_DXVA;
|
||||
unsafe { attributes.SetUnknown(&d3d_manager_key, &manager) }
|
||||
.map_err(|error| format!("unable to attach the DXGI device manager: {error}"))?;
|
||||
unsafe { attributes.SetUINT32(&hardware_transforms_key, 1) }
|
||||
.map_err(|error| format!("unable to enable hardware transforms: {error}"))?;
|
||||
unsafe { attributes.SetUINT32(&disable_dxva_key, 0) }
|
||||
.map_err(|error| format!("unable to enable DXVA decoding: {error}"))?;
|
||||
|
||||
let mut wide_path = path.as_os_str().encode_wide().collect::<Vec<_>>();
|
||||
if wide_path.contains(&0) {
|
||||
return Err("decode probe input path contains a NUL character".to_owned());
|
||||
}
|
||||
wide_path.push(0);
|
||||
let reader =
|
||||
unsafe { MFCreateSourceReaderFromURL(PCWSTR::from_raw(wide_path.as_ptr()), &attributes) }
|
||||
.map_err(|error| format!("unable to open the H.264 media source: {error}"))?;
|
||||
let all_streams = source_reader_index(MF_SOURCE_READER_ALL_STREAMS.0);
|
||||
let video_stream = source_reader_index(MF_SOURCE_READER_FIRST_VIDEO_STREAM.0);
|
||||
unsafe { reader.SetStreamSelection(all_streams, false) }
|
||||
.map_err(|error| format!("unable to disable non-video media streams: {error}"))?;
|
||||
unsafe { reader.SetStreamSelection(video_stream, true) }
|
||||
.map_err(|error| format!("unable to select the first video stream: {error}"))?;
|
||||
let (native_type, native_media_type_index) =
|
||||
find_h264_native_media_type(&reader, video_stream)?;
|
||||
unsafe { reader.SetCurrentMediaType(video_stream, None, &native_type) }
|
||||
.map_err(|error| format!("unable to select the H.264 native video media type: {error}"))?;
|
||||
|
||||
let output_type = unsafe { MFCreateMediaType() }
|
||||
.map_err(|error| format!("unable to create the NV12 output media type: {error}"))?;
|
||||
let major_type_key = MF_MT_MAJOR_TYPE;
|
||||
let video_type = MFMediaType_Video;
|
||||
unsafe { output_type.SetGUID(&major_type_key, &video_type) }
|
||||
.map_err(|error| format!("unable to set the output major type: {error}"))?;
|
||||
let subtype_key = MF_MT_SUBTYPE;
|
||||
let nv12_type = MFVideoFormat_NV12;
|
||||
unsafe { output_type.SetGUID(&subtype_key, &nv12_type) }
|
||||
.map_err(|error| format!("unable to request NV12 output: {error}"))?;
|
||||
unsafe { reader.SetCurrentMediaType(video_stream, None, &output_type) }
|
||||
.map_err(|error| format!("unable to activate H.264 to NV12 decoding: {error}"))?;
|
||||
let actual_type = unsafe { reader.GetCurrentMediaType(video_stream) }
|
||||
.map_err(|error| format!("unable to read the selected output media type: {error}"))?;
|
||||
let subtype_key = MF_MT_SUBTYPE;
|
||||
let actual_subtype = unsafe { actual_type.GetGUID(&subtype_key) }
|
||||
.map_err(|error| format!("selected output media type has no subtype: {error}"))?;
|
||||
if actual_subtype != MFVideoFormat_NV12 {
|
||||
return Err("Media Foundation did not select NV12 output".to_owned());
|
||||
}
|
||||
|
||||
let end_of_stream_flag = source_reader_flag(MF_SOURCE_READERF_ENDOFSTREAM.0)?;
|
||||
let error_flag = source_reader_flag(MF_SOURCE_READERF_ERROR.0)?;
|
||||
for read_index in 0..MAX_DECODE_PROBE_READS {
|
||||
let mut actual_stream = 0_u32;
|
||||
let mut stream_flags = 0_u32;
|
||||
let mut timestamp = 0_i64;
|
||||
let mut sample = None;
|
||||
unsafe {
|
||||
reader.ReadSample(
|
||||
video_stream,
|
||||
0,
|
||||
Some(&raw mut actual_stream),
|
||||
Some(&raw mut stream_flags),
|
||||
Some(&raw mut timestamp),
|
||||
Some(&raw mut sample),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("H.264 source reader failed: {error}"))?;
|
||||
if stream_flags & error_flag != 0 {
|
||||
return Err("H.264 source reader reported a stream error".to_owned());
|
||||
}
|
||||
if let Some(sample) = sample {
|
||||
if actual_stream != video_stream {
|
||||
return Err(
|
||||
"H.264 source reader returned a sample from the wrong stream".to_owned(),
|
||||
);
|
||||
}
|
||||
let buffer_count = unsafe { sample.GetBufferCount() }
|
||||
.map_err(|error| format!("unable to count decoded sample buffers: {error}"))?;
|
||||
for buffer_index in 0..buffer_count {
|
||||
let buffer = unsafe { sample.GetBufferByIndex(buffer_index) }
|
||||
.map_err(|error| format!("unable to read decoded sample buffer: {error}"))?;
|
||||
let dxgi_buffer = buffer.cast::<IMFDXGIBuffer>().map_err(|_| {
|
||||
"decoded sample was delivered through a CPU media buffer".to_owned()
|
||||
})?;
|
||||
let mut raw_texture = core::ptr::null_mut();
|
||||
unsafe { dxgi_buffer.GetResource(&ID3D11Texture2D::IID, &raw mut raw_texture) }
|
||||
.map_err(|error| {
|
||||
format!("decoded DXGI buffer has no D3D11 texture: {error}")
|
||||
})?;
|
||||
if raw_texture.is_null() {
|
||||
return Err("decoded DXGI buffer returned a null D3D11 texture".to_owned());
|
||||
}
|
||||
// GetResource returns an owned COM reference. Transfer that reference
|
||||
// into the windows-rs wrapper so it is released exactly once.
|
||||
let texture = unsafe { ID3D11Texture2D::from_raw(raw_texture) };
|
||||
let mut descriptor = D3D11_TEXTURE2D_DESC::default();
|
||||
unsafe { texture.GetDesc(&raw mut descriptor) };
|
||||
if descriptor.Format != DXGI_FORMAT_NV12
|
||||
|| descriptor.Width == 0
|
||||
|| descriptor.Height == 0
|
||||
{
|
||||
return Err("decoded D3D11 texture is not a valid NV12 surface".to_owned());
|
||||
}
|
||||
let subresource =
|
||||
unsafe { dxgi_buffer.GetSubresourceIndex() }.map_err(|error| {
|
||||
format!("unable to read decoded subresource index: {error}")
|
||||
})?;
|
||||
if !valid_texture_subresource(
|
||||
descriptor.MipLevels,
|
||||
descriptor.ArraySize,
|
||||
subresource,
|
||||
) {
|
||||
return Err("decoded subresource index exceeds the texture layout".to_owned());
|
||||
}
|
||||
let texture_device = unsafe { texture.GetDevice() }
|
||||
.map_err(|error| format!("unable to read decoded texture device: {error}"))?;
|
||||
if texture_device != device {
|
||||
return Err("decoded texture belongs to a different D3D11 device".to_owned());
|
||||
}
|
||||
|
||||
println!("native-video Media Foundation H.264 sample decode probe");
|
||||
println!("input_size={file_size}");
|
||||
println!("native_media_type_index={native_media_type_index}");
|
||||
println!("hardware_transform_requested=true");
|
||||
println!("sample_decoded=true");
|
||||
println!("dxgi_surface=true");
|
||||
println!("output_format=NV12");
|
||||
println!("output_width={}", descriptor.Width);
|
||||
println!("output_height={}", descriptor.Height);
|
||||
println!("output_array_size={}", descriptor.ArraySize);
|
||||
println!("output_subresource={subresource}");
|
||||
println!("same_d3d11_device=true");
|
||||
println!("sample_timestamp_100ns={timestamp}");
|
||||
println!("source_reader_reads={}", read_index + 1);
|
||||
println!("feature_level=0x{:x}", selected_level.0);
|
||||
println!("cpu_buffer_observed=false");
|
||||
println!("hardware_decode_verified=false");
|
||||
println!("zero_copy_verified=false");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if stream_flags & end_of_stream_flag != 0 {
|
||||
return Err("H.264 input reached end-of-stream before producing a frame".to_owned());
|
||||
}
|
||||
}
|
||||
Err("H.264 input produced no decoded frame within the bounded read limit".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn select_h264_profile(profiles: &[windows::core::GUID]) -> Option<windows::core::GUID> {
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_DECODER_PROFILE_H264_VLD_FGT, D3D11_DECODER_PROFILE_H264_VLD_NOFGT,
|
||||
D3D11_DECODER_PROFILE_H264_VLD_WITHFMOASO_NOFGT,
|
||||
};
|
||||
|
||||
[
|
||||
D3D11_DECODER_PROFILE_H264_VLD_NOFGT,
|
||||
D3D11_DECODER_PROFILE_H264_VLD_WITHFMOASO_NOFGT,
|
||||
D3D11_DECODER_PROFILE_H264_VLD_FGT,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|candidate| profiles.contains(candidate))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn select_decoder_config(
|
||||
configs: &[windows::Win32::Graphics::Direct3D11::D3D11_VIDEO_DECODER_CONFIG],
|
||||
) -> Option<windows::Win32::Graphics::Direct3D11::D3D11_VIDEO_DECODER_CONFIG> {
|
||||
let unencrypted = windows::core::GUID::default();
|
||||
configs
|
||||
.iter()
|
||||
.filter(|config| {
|
||||
config.guidConfigBitstreamEncryption == unencrypted
|
||||
&& config.guidConfigMBcontrolEncryption == unencrypted
|
||||
&& config.guidConfigResidDiffEncryption == unencrypted
|
||||
})
|
||||
.max_by_key(|config| match config.ConfigBitstreamRaw {
|
||||
2 => 2,
|
||||
1 => 1,
|
||||
_ => 0,
|
||||
})
|
||||
.filter(|config| matches!(config.ConfigBitstreamRaw, 1 | 2))
|
||||
.copied()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn probe_h264_decoder() -> Result<(), String> {
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_BIND_DECODER, D3D11_TEX2D_VDOV, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||
D3D11_VDOV_DIMENSION_TEXTURE2D, D3D11_VIDEO_DECODER_CONFIG, D3D11_VIDEO_DECODER_DESC,
|
||||
D3D11_VIDEO_DECODER_OUTPUT_VIEW_DESC, D3D11_VIDEO_DECODER_OUTPUT_VIEW_DESC_0,
|
||||
ID3D11Texture2D, ID3D11VideoDecoderOutputView, ID3D11VideoDevice,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC};
|
||||
use windows::core::Interface as _;
|
||||
|
||||
const SAMPLE_WIDTH: u32 = 1_920;
|
||||
const SAMPLE_HEIGHT: u32 = 1_088;
|
||||
|
||||
let (device, _context, selected_level) = create_hardware_d3d11_device()?;
|
||||
let video_device: ID3D11VideoDevice = device
|
||||
.cast()
|
||||
.map_err(|error| format!("D3D11 device does not expose ID3D11VideoDevice: {error}"))?;
|
||||
let profile_count = unsafe { video_device.GetVideoDecoderProfileCount() };
|
||||
let mut profiles = Vec::with_capacity(usize::try_from(profile_count).unwrap_or(0));
|
||||
for index in 0..profile_count {
|
||||
profiles.push(
|
||||
unsafe { video_device.GetVideoDecoderProfile(index) }
|
||||
.map_err(|error| format!("unable to enumerate D3D11 decoder profile: {error}"))?,
|
||||
);
|
||||
}
|
||||
let profile = select_h264_profile(&profiles).ok_or_else(|| {
|
||||
"the selected D3D11 adapter exposes no supported H.264 VLD profile".to_owned()
|
||||
})?;
|
||||
let nv12_supported =
|
||||
unsafe { video_device.CheckVideoDecoderFormat(&raw const profile, DXGI_FORMAT_NV12) }
|
||||
.map_err(|error| format!("unable to query H.264 NV12 output support: {error}"))?
|
||||
.as_bool();
|
||||
if !nv12_supported {
|
||||
return Err("the selected H.264 decoder profile does not support NV12 output".to_owned());
|
||||
}
|
||||
|
||||
let decoder_descriptor = D3D11_VIDEO_DECODER_DESC {
|
||||
Guid: profile,
|
||||
SampleWidth: SAMPLE_WIDTH,
|
||||
SampleHeight: SAMPLE_HEIGHT,
|
||||
OutputFormat: DXGI_FORMAT_NV12,
|
||||
};
|
||||
let config_count =
|
||||
unsafe { video_device.GetVideoDecoderConfigCount(&raw const decoder_descriptor) }
|
||||
.map_err(|error| format!("unable to query H.264 decoder configurations: {error}"))?;
|
||||
let mut configs = Vec::with_capacity(usize::try_from(config_count).unwrap_or(0));
|
||||
for index in 0..config_count {
|
||||
let mut config = D3D11_VIDEO_DECODER_CONFIG::default();
|
||||
unsafe {
|
||||
video_device.GetVideoDecoderConfig(
|
||||
&raw const decoder_descriptor,
|
||||
index,
|
||||
&raw mut config,
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to read H.264 decoder configuration: {error}"))?;
|
||||
configs.push(config);
|
||||
}
|
||||
let config = select_decoder_config(&configs).ok_or_else(|| {
|
||||
"no unencrypted H.264 raw bitstream decoder configuration is available".to_owned()
|
||||
})?;
|
||||
let decoder = unsafe {
|
||||
video_device.CreateVideoDecoder(&raw const decoder_descriptor, &raw const config)
|
||||
}
|
||||
.map_err(|error| format!("unable to create the H.264 video decoder: {error}"))?;
|
||||
|
||||
let render_target_count = u32::from(config.ConfigMinRenderTargetBuffCount).max(1);
|
||||
let texture_descriptor = D3D11_TEXTURE2D_DESC {
|
||||
Width: SAMPLE_WIDTH,
|
||||
Height: SAMPLE_HEIGHT,
|
||||
MipLevels: 1,
|
||||
ArraySize: render_target_count,
|
||||
Format: DXGI_FORMAT_NV12,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: u32::try_from(D3D11_BIND_DECODER.0)
|
||||
.map_err(|_| "D3D11 decoder bind flag is invalid".to_owned())?,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut texture: Option<ID3D11Texture2D> = None;
|
||||
unsafe { device.CreateTexture2D(&raw const texture_descriptor, None, Some(&raw mut texture)) }
|
||||
.map_err(|error| format!("unable to allocate the NV12 decoder texture array: {error}"))?;
|
||||
let texture = texture.ok_or_else(|| "D3D11 returned no decoder texture".to_owned())?;
|
||||
let output_descriptor = D3D11_VIDEO_DECODER_OUTPUT_VIEW_DESC {
|
||||
DecodeProfile: profile,
|
||||
ViewDimension: D3D11_VDOV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_VIDEO_DECODER_OUTPUT_VIEW_DESC_0 {
|
||||
Texture2D: D3D11_TEX2D_VDOV { ArraySlice: 0 },
|
||||
},
|
||||
};
|
||||
let mut output_view: Option<ID3D11VideoDecoderOutputView> = None;
|
||||
unsafe {
|
||||
video_device.CreateVideoDecoderOutputView(
|
||||
&texture,
|
||||
&raw const output_descriptor,
|
||||
Some(&raw mut output_view),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to create the H.264 decoder output view: {error}"))?;
|
||||
let _output_view =
|
||||
output_view.ok_or_else(|| "D3D11 returned no decoder output view".to_owned())?;
|
||||
|
||||
let mut actual_descriptor = D3D11_VIDEO_DECODER_DESC::default();
|
||||
let mut actual_config = D3D11_VIDEO_DECODER_CONFIG::default();
|
||||
unsafe { decoder.GetCreationParameters(&raw mut actual_descriptor, &raw mut actual_config) }
|
||||
.map_err(|error| {
|
||||
format!("unable to verify the H.264 decoder creation parameters: {error}")
|
||||
})?;
|
||||
if actual_descriptor != decoder_descriptor || actual_config != config {
|
||||
return Err("D3D11 decoder creation parameters changed unexpectedly".to_owned());
|
||||
}
|
||||
|
||||
println!("native-video D3D11 H.264 decoder allocation probe");
|
||||
println!("d3d11_created=true");
|
||||
println!("video_support=true");
|
||||
println!("h264_profile={profile:?}");
|
||||
println!("h264_nv12_supported=true");
|
||||
println!("h264_decoder_created=true");
|
||||
println!("decoder_output_surface_created=true");
|
||||
println!("decoder_width={SAMPLE_WIDTH}");
|
||||
println!("decoder_height={SAMPLE_HEIGHT}");
|
||||
println!("decoder_surface_count={render_target_count}");
|
||||
println!("decoder_bitstream_raw={}", config.ConfigBitstreamRaw);
|
||||
println!("feature_level=0x{:x}", selected_level.0);
|
||||
println!("sample_decoded=false");
|
||||
println!("zero_copy_verified=false");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn strings<'a>(values: &'a [&'a str]) -> impl Iterator<Item = String> + 'a {
|
||||
values.iter().map(|value| (*value).to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_decoder_to_window_adapter() {
|
||||
let args = parse_args(strings(&[
|
||||
"--dry-run",
|
||||
"--window-adapter",
|
||||
"gpu-0",
|
||||
"--required-end-to-end",
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(args.window_adapter, AdapterId::new("gpu-0"));
|
||||
assert_eq!(args.decode_adapter, AdapterId::new("gpu-0"));
|
||||
assert_eq!(args.zero_copy_policy, ZeroCopyPolicy::RequiredEndToEnd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_is_mandatory() {
|
||||
let error = parse_args(strings(&["--window-adapter", "gpu-0"])).unwrap_err();
|
||||
assert_eq!(error, "only --dry-run is implemented");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_adapter_defaults_decoder_to_manual_gpu_but_strict_checks_display() {
|
||||
let args = parse_args(strings(&[
|
||||
"--dry-run",
|
||||
"--window-adapter",
|
||||
"gpu-0",
|
||||
"--manual-adapter",
|
||||
"gpu-1",
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(args.manual_adapter, Some(AdapterId::new("gpu-1")));
|
||||
assert_eq!(args.decode_adapter, AdapterId::new("gpu-1"));
|
||||
|
||||
let error = build_plan(args).unwrap_err();
|
||||
assert!(
|
||||
error.contains("decode adapter gpu-1 -> render adapter gpu-1 -> display adapter gpu-0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_cross_adapter_decode_is_rejected_in_required_end_to_end() {
|
||||
let args = parse_args(strings(&[
|
||||
"--dry-run",
|
||||
"--window-adapter",
|
||||
"gpu-0",
|
||||
"--manual-adapter",
|
||||
"gpu-1",
|
||||
"--decode-adapter",
|
||||
"gpu-0",
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let error = build_plan(args).unwrap_err();
|
||||
assert!(
|
||||
error.contains("decode adapter gpu-0 -> render adapter gpu-1 -> display adapter gpu-0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_reports_manual_to_display_cross_adapter_copy() {
|
||||
let args = parse_args(strings(&[
|
||||
"--dry-run",
|
||||
"--window-adapter",
|
||||
"gpu-0",
|
||||
"--manual-adapter",
|
||||
"gpu-1",
|
||||
"--compatibility",
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let plan = build_plan(args).unwrap();
|
||||
assert_eq!(plan.decode_adapter, AdapterId::new("gpu-1"));
|
||||
assert_eq!(plan.render_adapter, AdapterId::new("gpu-1"));
|
||||
assert_eq!(plan.display_adapter, AdapterId::new("gpu-0"));
|
||||
assert_eq!(
|
||||
plan.planned_memory_path,
|
||||
remotedesk_client_core::MemoryPathStatus::CrossAdapterCopy
|
||||
);
|
||||
assert!(!plan.is_zero_copy_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_end_to_end_is_default_and_compatibility_is_explicit() {
|
||||
let required = parse_args(strings(&["--dry-run", "--window-adapter", "gpu-0"])).unwrap();
|
||||
assert_eq!(required.zero_copy_policy, ZeroCopyPolicy::RequiredEndToEnd);
|
||||
|
||||
let compatibility = parse_args(strings(&[
|
||||
"--dry-run",
|
||||
"--window-adapter",
|
||||
"gpu-0",
|
||||
"--compatibility",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
compatibility.zero_copy_policy,
|
||||
ZeroCopyPolicy::Compatibility
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_exposes_the_h264_decoder_allocation_probe() {
|
||||
assert!(usage().contains("--capabilities"));
|
||||
assert!(usage().contains("--probe-h264-decoder"));
|
||||
assert!(usage().contains("--probe-h264-file"));
|
||||
assert!(usage().contains("--play-h264-file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_capability_schema_keeps_cpu_frame_mapping_disabled() {
|
||||
let response = HelperCapabilities {
|
||||
schema_version: 1,
|
||||
version: "test",
|
||||
platform: "test",
|
||||
h264_file_probe: true,
|
||||
h264_file_player: true,
|
||||
d3d11_video_processor: true,
|
||||
cpu_frame_mapping: false,
|
||||
};
|
||||
let json = serde_json::to_value(response).unwrap();
|
||||
assert_eq!(json["schema_version"], 1);
|
||||
assert_eq!(json["cpu_frame_mapping"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_file_probe_accepts_only_supported_local_container_extensions() {
|
||||
assert!(supported_h264_container(Path::new("sample.mp4")));
|
||||
assert!(supported_h264_container(Path::new("sample.M4V")));
|
||||
assert!(supported_h264_container(Path::new("sample.mov")));
|
||||
assert!(!supported_h264_container(Path::new("sample.h264")));
|
||||
assert!(!supported_h264_container(Path::new("sample.webm")));
|
||||
assert!(!supported_h264_container(Path::new("sample")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_subresource_validation_covers_mips_and_array_slices() {
|
||||
assert!(valid_texture_subresource(1, 4, 3));
|
||||
assert!(valid_texture_subresource(2, 4, 7));
|
||||
assert!(!valid_texture_subresource(2, 4, 8));
|
||||
assert!(!valid_texture_subresource(0, 4, 0));
|
||||
assert!(!valid_texture_subresource(4, 0, 0));
|
||||
assert!(!valid_texture_subresource(u32::MAX, 2, 0));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn native_media_type_filter_accepts_only_h264_subtypes() {
|
||||
use windows::Win32::Media::MediaFoundation::{
|
||||
MFVideoFormat_H264, MFVideoFormat_H264_ES, MFVideoFormat_NV12,
|
||||
};
|
||||
|
||||
assert!(is_h264_subtype(MFVideoFormat_H264));
|
||||
assert!(is_h264_subtype(MFVideoFormat_H264_ES));
|
||||
assert!(!is_h264_subtype(MFVideoFormat_NV12));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn h264_profile_selection_prefers_the_standard_vld_profile() {
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_DECODER_PROFILE_H264_VLD_FGT, D3D11_DECODER_PROFILE_H264_VLD_NOFGT,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
select_h264_profile(&[
|
||||
D3D11_DECODER_PROFILE_H264_VLD_FGT,
|
||||
D3D11_DECODER_PROFILE_H264_VLD_NOFGT,
|
||||
]),
|
||||
Some(D3D11_DECODER_PROFILE_H264_VLD_NOFGT)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn decoder_config_selection_requires_unencrypted_raw_bitstream_input() {
|
||||
use windows::Win32::Graphics::Direct3D11::D3D11_VIDEO_DECODER_CONFIG;
|
||||
|
||||
let short_slice = D3D11_VIDEO_DECODER_CONFIG {
|
||||
ConfigBitstreamRaw: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let long_slice = D3D11_VIDEO_DECODER_CONFIG {
|
||||
ConfigBitstreamRaw: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let encrypted = D3D11_VIDEO_DECODER_CONFIG {
|
||||
guidConfigBitstreamEncryption: windows::core::GUID::from_u128(1),
|
||||
ConfigBitstreamRaw: 2,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
select_decoder_config(&[short_slice, encrypted, long_slice]),
|
||||
Some(long_slice)
|
||||
);
|
||||
assert_eq!(select_decoder_config(&[encrypted]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
use core::mem::ManuallyDrop;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
use windows::Win32::Foundation::{HWND, RECT};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_TEX2D_VPIV, D3D11_TEX2D_VPOV, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE,
|
||||
D3D11_VIDEO_PROCESSOR_COLOR_SPACE, D3D11_VIDEO_PROCESSOR_CONTENT_DESC,
|
||||
D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_INPUT, D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_OUTPUT,
|
||||
D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0,
|
||||
D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0,
|
||||
D3D11_VIDEO_PROCESSOR_STREAM, D3D11_VIDEO_USAGE_PLAYBACK_NORMAL,
|
||||
D3D11_VPIV_DIMENSION_TEXTURE2D, D3D11_VPOV_DIMENSION_TEXTURE2D, ID3D11Device,
|
||||
ID3D11DeviceContext, ID3D11RenderTargetView, ID3D11Texture2D, ID3D11VideoContext,
|
||||
ID3D11VideoDevice, ID3D11VideoProcessor, ID3D11VideoProcessorEnumerator,
|
||||
ID3D11VideoProcessorOutputView,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_UNKNOWN, DXGI_MODE_DESC,
|
||||
DXGI_RATIONAL, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
DXGI_MWA_NO_ALT_ENTER, DXGI_PRESENT, DXGI_SWAP_CHAIN_DESC, DXGI_SWAP_CHAIN_FLAG,
|
||||
DXGI_SWAP_EFFECT_DISCARD, DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIDevice, IDXGIFactory,
|
||||
IDXGISwapChain,
|
||||
};
|
||||
use windows::Win32::Media::MediaFoundation::{
|
||||
IMFAttributes, IMFDXGIBuffer, IMFDXGIDeviceManager, IMFSample, IMFSourceReader,
|
||||
MF_MT_FRAME_SIZE, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE, MF_MT_VIDEO_NOMINAL_RANGE, MF_MT_YUV_MATRIX,
|
||||
MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, MF_SOURCE_READER_ALL_STREAMS,
|
||||
MF_SOURCE_READER_D3D_MANAGER, MF_SOURCE_READER_DISABLE_DXVA,
|
||||
MF_SOURCE_READER_FIRST_VIDEO_STREAM, MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED,
|
||||
MF_SOURCE_READERF_ENDOFSTREAM, MF_SOURCE_READERF_ERROR, MF_VERSION, MFCreateAttributes,
|
||||
MFCreateDXGIDeviceManager, MFCreateMediaType, MFCreateSourceReaderFromURL, MFMediaType_Video,
|
||||
MFNominalRange_0_255, MFSTARTUP_FULL, MFStartup, MFVideoFormat_NV12,
|
||||
MFVideoTransferMatrix_BT709,
|
||||
};
|
||||
use windows::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx};
|
||||
use windows::core::{Interface as _, PCWSTR};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::{LogicalSize, PhysicalSize};
|
||||
use winit::event::{ElementState, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::keyboard::{Key, ModifiersState, NamedKey};
|
||||
use winit::window::{Fullscreen, Window, WindowAttributes, WindowId};
|
||||
|
||||
use super::{
|
||||
ComGuard, MediaFoundationGuard, create_hardware_d3d11_device, find_h264_native_media_type,
|
||||
source_reader_flag, source_reader_index, valid_texture_subresource, validated_h264_probe_path,
|
||||
};
|
||||
|
||||
const MAX_PLAYBACK_TIMELINE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const MAX_PLAYBACK_READS: usize = 10_000_000;
|
||||
|
||||
pub fn play_h264_file(path: &Path) -> Result<(), String> {
|
||||
let (mut decoder, file_size, native_media_type_index, feature_level) =
|
||||
DecoderSession::open(path)?;
|
||||
let first_frame = decoder
|
||||
.read_frame()?
|
||||
.ok_or_else(|| "H.264 input contains no decoded video frame".to_owned())?;
|
||||
let initial_size = fit_initial_window(first_frame.width, first_frame.height);
|
||||
let event_loop = EventLoop::new()
|
||||
.map_err(|error| format!("unable to create native-video event loop: {error}"))?;
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
let mut app = PlaybackApp::new(decoder, first_frame, initial_size);
|
||||
event_loop
|
||||
.run_app(&mut app)
|
||||
.map_err(|error| format!("native-video event loop failed: {error}"))?;
|
||||
if let Some(error) = app.error.take() {
|
||||
return Err(error);
|
||||
}
|
||||
if app.presented_frames == 0 {
|
||||
return Err("native-video window closed before presenting a frame".to_owned());
|
||||
}
|
||||
|
||||
println!("native-video Media Foundation H.264 window playback");
|
||||
println!("input_size={file_size}");
|
||||
println!("native_media_type_index={native_media_type_index}");
|
||||
println!("feature_level=0x{:x}", feature_level.0);
|
||||
println!("sample_decoded=true");
|
||||
println!("dxgi_surface=true");
|
||||
println!("same_d3d11_device=true");
|
||||
println!("d3d11_video_processor_presented=true");
|
||||
println!("cpu_frame_mapping=false");
|
||||
println!("presented_frames={}", app.presented_frames);
|
||||
println!("hardware_decode_verified=false");
|
||||
println!("zero_copy_verified=false");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct DecoderSession {
|
||||
reader: IMFSourceReader,
|
||||
_attributes: IMFAttributes,
|
||||
_manager: IMFDXGIDeviceManager,
|
||||
context: ID3D11DeviceContext,
|
||||
device: ID3D11Device,
|
||||
video_stream: u32,
|
||||
end_of_stream_flag: u32,
|
||||
error_flag: u32,
|
||||
media_type_changed_flag: u32,
|
||||
visible_size: (u32, u32),
|
||||
input_color_space: D3D11_VIDEO_PROCESSOR_COLOR_SPACE,
|
||||
reads: usize,
|
||||
_media_foundation_guard: MediaFoundationGuard,
|
||||
_com_guard: ComGuard,
|
||||
}
|
||||
|
||||
impl DecoderSession {
|
||||
fn open(
|
||||
path: &Path,
|
||||
) -> Result<
|
||||
(
|
||||
Self,
|
||||
u64,
|
||||
u32,
|
||||
windows::Win32::Graphics::Direct3D::D3D_FEATURE_LEVEL,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
|
||||
let (path, file_size) = validated_h264_probe_path(path)?;
|
||||
unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }
|
||||
.ok()
|
||||
.map_err(|error| format!("unable to initialize COM for H.264 playback: {error}"))?;
|
||||
let com_guard = ComGuard;
|
||||
unsafe { MFStartup(MF_VERSION, MFSTARTUP_FULL) }
|
||||
.map_err(|error| format!("unable to start Media Foundation: {error}"))?;
|
||||
let media_foundation_guard = MediaFoundationGuard;
|
||||
|
||||
let (device, context, feature_level) = create_hardware_d3d11_device()?;
|
||||
let mut reset_token = 0_u32;
|
||||
let mut manager = None;
|
||||
unsafe { MFCreateDXGIDeviceManager(&raw mut reset_token, &raw mut manager) }
|
||||
.map_err(|error| format!("unable to create the DXGI device manager: {error}"))?;
|
||||
let manager =
|
||||
manager.ok_or_else(|| "Media Foundation returned no DXGI device manager".to_owned())?;
|
||||
unsafe { manager.ResetDevice(&device, reset_token) }
|
||||
.map_err(|error| format!("unable to bind D3D11 to the DXGI device manager: {error}"))?;
|
||||
|
||||
let mut attributes: Option<IMFAttributes> = None;
|
||||
unsafe { MFCreateAttributes(&raw mut attributes, 4) }
|
||||
.map_err(|error| format!("unable to create source reader attributes: {error}"))?;
|
||||
let attributes = attributes
|
||||
.ok_or_else(|| "Media Foundation returned no source reader attributes".to_owned())?;
|
||||
let d3d_manager_key = MF_SOURCE_READER_D3D_MANAGER;
|
||||
let hardware_transforms_key = MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS;
|
||||
let disable_dxva_key = MF_SOURCE_READER_DISABLE_DXVA;
|
||||
unsafe { attributes.SetUnknown(&d3d_manager_key, &manager) }
|
||||
.map_err(|error| format!("unable to attach the DXGI device manager: {error}"))?;
|
||||
unsafe { attributes.SetUINT32(&hardware_transforms_key, 1) }
|
||||
.map_err(|error| format!("unable to enable hardware transforms: {error}"))?;
|
||||
unsafe { attributes.SetUINT32(&disable_dxva_key, 0) }
|
||||
.map_err(|error| format!("unable to enable DXVA decoding: {error}"))?;
|
||||
|
||||
let mut wide_path = path.as_os_str().encode_wide().collect::<Vec<_>>();
|
||||
if wide_path.contains(&0) {
|
||||
return Err("H.264 playback path contains a NUL character".to_owned());
|
||||
}
|
||||
wide_path.push(0);
|
||||
let reader = unsafe {
|
||||
MFCreateSourceReaderFromURL(PCWSTR::from_raw(wide_path.as_ptr()), &attributes)
|
||||
}
|
||||
.map_err(|error| format!("unable to open the H.264 media source: {error}"))?;
|
||||
let all_streams = source_reader_index(MF_SOURCE_READER_ALL_STREAMS.0);
|
||||
let video_stream = source_reader_index(MF_SOURCE_READER_FIRST_VIDEO_STREAM.0);
|
||||
unsafe { reader.SetStreamSelection(all_streams, false) }
|
||||
.map_err(|error| format!("unable to disable non-video media streams: {error}"))?;
|
||||
unsafe { reader.SetStreamSelection(video_stream, true) }
|
||||
.map_err(|error| format!("unable to select the first video stream: {error}"))?;
|
||||
let (native_type, native_media_type_index) =
|
||||
find_h264_native_media_type(&reader, video_stream)?;
|
||||
unsafe { reader.SetCurrentMediaType(video_stream, None, &native_type) }.map_err(
|
||||
|error| format!("unable to select the H.264 native video media type: {error}"),
|
||||
)?;
|
||||
|
||||
let output_type = unsafe { MFCreateMediaType() }
|
||||
.map_err(|error| format!("unable to create the NV12 output media type: {error}"))?;
|
||||
let major_type_key = MF_MT_MAJOR_TYPE;
|
||||
let video_type = MFMediaType_Video;
|
||||
unsafe { output_type.SetGUID(&major_type_key, &video_type) }
|
||||
.map_err(|error| format!("unable to set the output major type: {error}"))?;
|
||||
let subtype_key = MF_MT_SUBTYPE;
|
||||
let nv12_type = MFVideoFormat_NV12;
|
||||
unsafe { output_type.SetGUID(&subtype_key, &nv12_type) }
|
||||
.map_err(|error| format!("unable to request NV12 output: {error}"))?;
|
||||
unsafe { reader.SetCurrentMediaType(video_stream, None, &output_type) }
|
||||
.map_err(|error| format!("unable to activate H.264 to NV12 decoding: {error}"))?;
|
||||
let actual_type = unsafe { reader.GetCurrentMediaType(video_stream) }
|
||||
.map_err(|error| format!("unable to read the selected output media type: {error}"))?;
|
||||
let subtype_key = MF_MT_SUBTYPE;
|
||||
let actual_subtype = unsafe { actual_type.GetGUID(&subtype_key) }
|
||||
.map_err(|error| format!("selected output media type has no subtype: {error}"))?;
|
||||
if actual_subtype != MFVideoFormat_NV12 {
|
||||
return Err("Media Foundation did not select NV12 output".to_owned());
|
||||
}
|
||||
let visible_size = media_type_frame_size(&actual_type)?;
|
||||
let input_color_space = media_type_color_space(&actual_type, visible_size.1);
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
reader,
|
||||
_attributes: attributes,
|
||||
_manager: manager,
|
||||
context,
|
||||
device,
|
||||
video_stream,
|
||||
end_of_stream_flag: source_reader_flag(MF_SOURCE_READERF_ENDOFSTREAM.0)?,
|
||||
error_flag: source_reader_flag(MF_SOURCE_READERF_ERROR.0)?,
|
||||
media_type_changed_flag: source_reader_flag(
|
||||
MF_SOURCE_READERF_CURRENTMEDIATYPECHANGED.0,
|
||||
)?,
|
||||
visible_size,
|
||||
input_color_space,
|
||||
reads: 0,
|
||||
_media_foundation_guard: media_foundation_guard,
|
||||
_com_guard: com_guard,
|
||||
},
|
||||
file_size,
|
||||
native_media_type_index,
|
||||
feature_level,
|
||||
))
|
||||
}
|
||||
|
||||
fn read_frame(&mut self) -> Result<Option<DecodedFrame>, String> {
|
||||
while self.reads < MAX_PLAYBACK_READS {
|
||||
self.reads += 1;
|
||||
let mut actual_stream = 0_u32;
|
||||
let mut stream_flags = 0_u32;
|
||||
let mut timestamp = 0_i64;
|
||||
let mut sample = None;
|
||||
unsafe {
|
||||
self.reader.ReadSample(
|
||||
self.video_stream,
|
||||
0,
|
||||
Some(&raw mut actual_stream),
|
||||
Some(&raw mut stream_flags),
|
||||
Some(&raw mut timestamp),
|
||||
Some(&raw mut sample),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("H.264 source reader failed: {error}"))?;
|
||||
if stream_flags & self.error_flag != 0 {
|
||||
return Err("H.264 source reader reported a stream error".to_owned());
|
||||
}
|
||||
if stream_flags & self.media_type_changed_flag != 0 {
|
||||
let media_type = unsafe { self.reader.GetCurrentMediaType(self.video_stream) }
|
||||
.map_err(|error| {
|
||||
format!("unable to read changed playback media type: {error}")
|
||||
})?;
|
||||
let subtype_key = MF_MT_SUBTYPE;
|
||||
let subtype = unsafe { media_type.GetGUID(&subtype_key) }
|
||||
.map_err(|error| format!("changed media type has no subtype: {error}"))?;
|
||||
if subtype != MFVideoFormat_NV12 {
|
||||
return Err("playback media type changed away from NV12".to_owned());
|
||||
}
|
||||
self.visible_size = media_type_frame_size(&media_type)?;
|
||||
self.input_color_space = media_type_color_space(&media_type, self.visible_size.1);
|
||||
}
|
||||
if let Some(sample) = sample {
|
||||
if actual_stream != self.video_stream {
|
||||
return Err("H.264 source reader returned the wrong stream".to_owned());
|
||||
}
|
||||
let buffer_count = unsafe { sample.GetBufferCount() }
|
||||
.map_err(|error| format!("unable to count decoded buffers: {error}"))?;
|
||||
if buffer_count != 1 {
|
||||
return Err("decoded frame must contain exactly one DXGI buffer".to_owned());
|
||||
}
|
||||
let buffer = unsafe { sample.GetBufferByIndex(0) }
|
||||
.map_err(|error| format!("unable to read decoded buffer: {error}"))?;
|
||||
let dxgi_buffer = buffer.cast::<IMFDXGIBuffer>().map_err(|_| {
|
||||
"decoded frame was delivered through a CPU media buffer".to_owned()
|
||||
})?;
|
||||
let mut raw_texture = core::ptr::null_mut();
|
||||
unsafe { dxgi_buffer.GetResource(&ID3D11Texture2D::IID, &raw mut raw_texture) }
|
||||
.map_err(|error| format!("decoded buffer has no D3D11 texture: {error}"))?;
|
||||
if raw_texture.is_null() {
|
||||
return Err("decoded buffer returned a null D3D11 texture".to_owned());
|
||||
}
|
||||
let texture = unsafe { ID3D11Texture2D::from_raw(raw_texture) };
|
||||
let mut descriptor =
|
||||
windows::Win32::Graphics::Direct3D11::D3D11_TEXTURE2D_DESC::default();
|
||||
unsafe { texture.GetDesc(&raw mut descriptor) };
|
||||
if descriptor.Format != DXGI_FORMAT_NV12
|
||||
|| descriptor.Width == 0
|
||||
|| descriptor.Height == 0
|
||||
{
|
||||
return Err("decoded texture is not a valid NV12 surface".to_owned());
|
||||
}
|
||||
if self.visible_size.0 > descriptor.Width || self.visible_size.1 > descriptor.Height
|
||||
{
|
||||
return Err("visible video frame exceeds its NV12 texture".to_owned());
|
||||
}
|
||||
let subresource = unsafe { dxgi_buffer.GetSubresourceIndex() }
|
||||
.map_err(|error| format!("unable to read decoded subresource: {error}"))?;
|
||||
if !valid_texture_subresource(
|
||||
descriptor.MipLevels,
|
||||
descriptor.ArraySize,
|
||||
subresource,
|
||||
) {
|
||||
return Err("decoded subresource exceeds the texture layout".to_owned());
|
||||
}
|
||||
let texture_device = unsafe { texture.GetDevice() }
|
||||
.map_err(|error| format!("unable to read decoded texture device: {error}"))?;
|
||||
if texture_device != self.device {
|
||||
return Err("decoded texture belongs to a different D3D11 device".to_owned());
|
||||
}
|
||||
return Ok(Some(DecodedFrame {
|
||||
_sample: sample,
|
||||
texture,
|
||||
subresource,
|
||||
mip_levels: descriptor.MipLevels,
|
||||
width: self.visible_size.0,
|
||||
height: self.visible_size.1,
|
||||
input_color_space: self.input_color_space,
|
||||
timestamp_100ns: timestamp,
|
||||
}));
|
||||
}
|
||||
if stream_flags & self.end_of_stream_flag != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Err("H.264 playback exceeded the bounded ten-million-read limit".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
struct DecodedFrame {
|
||||
// Retaining the sample keeps Media Foundation from recycling the decoder
|
||||
// surface before VideoProcessorBlt consumes its texture subresource.
|
||||
_sample: IMFSample,
|
||||
texture: ID3D11Texture2D,
|
||||
subresource: u32,
|
||||
mip_levels: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
input_color_space: D3D11_VIDEO_PROCESSOR_COLOR_SPACE,
|
||||
timestamp_100ns: i64,
|
||||
}
|
||||
|
||||
struct PlaybackApp {
|
||||
decoder: DecoderSession,
|
||||
pending_frame: Option<DecodedFrame>,
|
||||
last_frame: Option<DecodedFrame>,
|
||||
renderer: Option<VideoRenderer>,
|
||||
window: Option<Arc<Window>>,
|
||||
initial_size: PhysicalSize<u32>,
|
||||
first_timestamp: i64,
|
||||
playback_started: Instant,
|
||||
modifiers: ModifiersState,
|
||||
fullscreen: bool,
|
||||
ended: bool,
|
||||
error: Option<String>,
|
||||
presented_frames: u64,
|
||||
}
|
||||
|
||||
impl PlaybackApp {
|
||||
fn new(
|
||||
decoder: DecoderSession,
|
||||
first_frame: DecodedFrame,
|
||||
initial_size: PhysicalSize<u32>,
|
||||
) -> Self {
|
||||
Self {
|
||||
decoder,
|
||||
first_timestamp: first_frame.timestamp_100ns,
|
||||
pending_frame: Some(first_frame),
|
||||
last_frame: None,
|
||||
renderer: None,
|
||||
window: None,
|
||||
initial_size,
|
||||
playback_started: Instant::now(),
|
||||
modifiers: ModifiersState::empty(),
|
||||
fullscreen: false,
|
||||
ended: false,
|
||||
error: None,
|
||||
presented_frames: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&mut self, event_loop: &ActiveEventLoop, error: String) {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
}
|
||||
|
||||
fn set_fullscreen(&mut self, fullscreen: bool) {
|
||||
if let Some(window) = self.window.as_ref() {
|
||||
window.set_fullscreen(
|
||||
fullscreen.then(|| Fullscreen::Borderless(window.current_monitor())),
|
||||
);
|
||||
self.fullscreen = fullscreen;
|
||||
}
|
||||
}
|
||||
|
||||
fn present_pending(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let Some(frame) = self.pending_frame.take() else {
|
||||
return;
|
||||
};
|
||||
let Some(renderer) = self.renderer.as_mut() else {
|
||||
self.pending_frame = Some(frame);
|
||||
return;
|
||||
};
|
||||
if let Err(error) = renderer.present(&frame) {
|
||||
self.fail(event_loop, error);
|
||||
return;
|
||||
}
|
||||
self.presented_frames = self.presented_frames.saturating_add(1);
|
||||
self.last_frame = Some(frame);
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for PlaybackApp {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
let attributes = WindowAttributes::default()
|
||||
.with_title("RemoteDesk Native Video")
|
||||
.with_inner_size(LogicalSize::new(
|
||||
f64::from(self.initial_size.width),
|
||||
f64::from(self.initial_size.height),
|
||||
))
|
||||
.with_min_inner_size(LogicalSize::new(320.0, 200.0));
|
||||
let window = match event_loop.create_window(attributes) {
|
||||
Ok(window) => Arc::new(window),
|
||||
Err(error) => {
|
||||
self.fail(
|
||||
event_loop,
|
||||
format!("unable to create playback window: {error}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let renderer = match VideoRenderer::new(
|
||||
Arc::clone(&window),
|
||||
self.decoder.device.clone(),
|
||||
self.decoder.context.clone(),
|
||||
) {
|
||||
Ok(renderer) => renderer,
|
||||
Err(error) => {
|
||||
self.fail(event_loop, error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.renderer = Some(renderer);
|
||||
self.window = Some(window);
|
||||
self.playback_started = Instant::now();
|
||||
}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
window_id: WindowId,
|
||||
event: WindowEvent,
|
||||
) {
|
||||
if self
|
||||
.window
|
||||
.as_ref()
|
||||
.is_none_or(|window| window.id() != window_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => {
|
||||
if let Some(renderer) = self.renderer.as_mut()
|
||||
&& let Err(error) = renderer.resize(size.width, size.height)
|
||||
{
|
||||
self.fail(event_loop, error);
|
||||
}
|
||||
}
|
||||
WindowEvent::ModifiersChanged(modifiers) => self.modifiers = modifiers.state(),
|
||||
WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
|
||||
let toggle_fullscreen = event.logical_key == Key::Named(NamedKey::F11)
|
||||
|| (event.logical_key == Key::Named(NamedKey::Enter)
|
||||
&& self.modifiers.alt_key());
|
||||
let exit_session = matches!(&event.logical_key, Key::Character(value) if value.eq_ignore_ascii_case("q"))
|
||||
&& self.modifiers.control_key()
|
||||
&& self.modifiers.shift_key();
|
||||
if exit_session {
|
||||
event_loop.exit();
|
||||
} else if toggle_fullscreen {
|
||||
self.set_fullscreen(!self.fullscreen);
|
||||
} else if event.logical_key == Key::Named(NamedKey::Escape) && self.fullscreen {
|
||||
self.set_fullscreen(false);
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
if self.pending_frame.is_none()
|
||||
&& let (Some(renderer), Some(frame)) =
|
||||
(self.renderer.as_mut(), self.last_frame.as_ref())
|
||||
&& let Err(error) = renderer.present(frame)
|
||||
{
|
||||
self.fail(event_loop, error);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_none() || self.error.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(frame) = self.pending_frame.as_ref() {
|
||||
let offset = match frame_time_offset(self.first_timestamp, frame.timestamp_100ns) {
|
||||
Ok(offset) => offset,
|
||||
Err(error) => {
|
||||
self.fail(event_loop, error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let deadline = self.playback_started + offset;
|
||||
if Instant::now() < deadline {
|
||||
event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
|
||||
return;
|
||||
}
|
||||
self.present_pending(event_loop);
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
return;
|
||||
}
|
||||
if self.ended {
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
return;
|
||||
}
|
||||
match self.decoder.read_frame() {
|
||||
Ok(Some(frame)) => {
|
||||
self.pending_frame = Some(frame);
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
}
|
||||
Ok(None) => {
|
||||
self.ended = true;
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
}
|
||||
Err(error) => self.fail(event_loop, error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoRenderer {
|
||||
window: Arc<Window>,
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
video_device: ID3D11VideoDevice,
|
||||
video_context: ID3D11VideoContext,
|
||||
swap_chain: IDXGISwapChain,
|
||||
pipeline: Option<ProcessorPipeline>,
|
||||
output_size: (u32, u32),
|
||||
output_frame: u32,
|
||||
}
|
||||
|
||||
struct ProcessorPipeline {
|
||||
input_size: (u32, u32),
|
||||
output_size: (u32, u32),
|
||||
enumerator: ID3D11VideoProcessorEnumerator,
|
||||
processor: ID3D11VideoProcessor,
|
||||
output_view: ID3D11VideoProcessorOutputView,
|
||||
render_target: ID3D11RenderTargetView,
|
||||
}
|
||||
|
||||
impl VideoRenderer {
|
||||
fn new(
|
||||
window: Arc<Window>,
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
) -> Result<Self, String> {
|
||||
let RawWindowHandle::Win32(handle) = window
|
||||
.window_handle()
|
||||
.map_err(|error| format!("unable to read playback window handle: {error}"))?
|
||||
.as_raw()
|
||||
else {
|
||||
return Err("native-video playback requires a Win32 window".to_owned());
|
||||
};
|
||||
let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void);
|
||||
let size = window.inner_size();
|
||||
let width = size.width.max(1);
|
||||
let height = size.height.max(1);
|
||||
let descriptor = swap_chain_descriptor(hwnd, width, height);
|
||||
let dxgi_device: IDXGIDevice = device
|
||||
.cast()
|
||||
.map_err(|error| format!("D3D11 device has no DXGI interface: {error}"))?;
|
||||
let adapter = unsafe { dxgi_device.GetAdapter() }
|
||||
.map_err(|error| format!("unable to read D3D11 adapter: {error}"))?;
|
||||
let factory: IDXGIFactory = unsafe { adapter.GetParent() }
|
||||
.map_err(|error| format!("unable to read DXGI factory: {error}"))?;
|
||||
unsafe { factory.MakeWindowAssociation(hwnd, DXGI_MWA_NO_ALT_ENTER) }
|
||||
.map_err(|error| format!("unable to disable DXGI Alt+Enter handling: {error}"))?;
|
||||
let mut swap_chain = None;
|
||||
unsafe { factory.CreateSwapChain(&device, &raw const descriptor, &raw mut swap_chain) }
|
||||
.ok()
|
||||
.map_err(|error| format!("unable to create native-video swap chain: {error}"))?;
|
||||
let swap_chain =
|
||||
swap_chain.ok_or_else(|| "DXGI returned no native-video swap chain".to_owned())?;
|
||||
let video_device: ID3D11VideoDevice = device
|
||||
.cast()
|
||||
.map_err(|error| format!("D3D11 device has no video interface: {error}"))?;
|
||||
let video_context: ID3D11VideoContext = context
|
||||
.cast()
|
||||
.map_err(|error| format!("D3D11 context has no video interface: {error}"))?;
|
||||
Ok(Self {
|
||||
window,
|
||||
device,
|
||||
context,
|
||||
video_device,
|
||||
video_context,
|
||||
swap_chain,
|
||||
pipeline: None,
|
||||
output_size: (width, height),
|
||||
output_frame: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn resize(&mut self, width: u32, height: u32) -> Result<(), String> {
|
||||
if width == 0 || height == 0 || self.output_size == (width, height) {
|
||||
return Ok(());
|
||||
}
|
||||
self.pipeline = None;
|
||||
unsafe {
|
||||
self.swap_chain.ResizeBuffers(
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
DXGI_FORMAT_UNKNOWN,
|
||||
DXGI_SWAP_CHAIN_FLAG(0),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to resize native-video swap chain: {error}"))?;
|
||||
self.output_size = (width, height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn present(&mut self, frame: &DecodedFrame) -> Result<(), String> {
|
||||
let size = self.window.inner_size();
|
||||
if size.width == 0 || size.height == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.resize(size.width, size.height)?;
|
||||
self.ensure_pipeline(frame.width, frame.height)?;
|
||||
let pipeline = self
|
||||
.pipeline
|
||||
.as_ref()
|
||||
.ok_or_else(|| "native-video processor pipeline is unavailable".to_owned())?;
|
||||
let input_descriptor = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC {
|
||||
FourCC: 0,
|
||||
ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0 {
|
||||
Texture2D: D3D11_TEX2D_VPIV {
|
||||
MipSlice: frame.subresource % frame.mip_levels,
|
||||
ArraySlice: frame.subresource / frame.mip_levels,
|
||||
},
|
||||
},
|
||||
};
|
||||
let mut input_view = None;
|
||||
unsafe {
|
||||
self.video_device.CreateVideoProcessorInputView(
|
||||
&frame.texture,
|
||||
&pipeline.enumerator,
|
||||
&raw const input_descriptor,
|
||||
Some(&raw mut input_view),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to create NV12 processor input view: {error}"))?;
|
||||
let input_view =
|
||||
input_view.ok_or_else(|| "D3D11 returned no processor input view".to_owned())?;
|
||||
|
||||
let source = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: i32::try_from(frame.width)
|
||||
.map_err(|_| "video width exceeds Win32 coordinates".to_owned())?,
|
||||
bottom: i32::try_from(frame.height)
|
||||
.map_err(|_| "video height exceeds Win32 coordinates".to_owned())?,
|
||||
};
|
||||
let destination = aspect_fit_rect(frame.width, frame.height, size.width, size.height)?;
|
||||
let target = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: i32::try_from(size.width)
|
||||
.map_err(|_| "window width exceeds Win32 coordinates".to_owned())?,
|
||||
bottom: i32::try_from(size.height)
|
||||
.map_err(|_| "window height exceeds Win32 coordinates".to_owned())?,
|
||||
};
|
||||
unsafe {
|
||||
self.context
|
||||
.ClearRenderTargetView(&pipeline.render_target, &[0.0, 0.0, 0.0, 1.0]);
|
||||
self.video_context.VideoProcessorSetOutputTargetRect(
|
||||
&pipeline.processor,
|
||||
true,
|
||||
Some(&raw const target),
|
||||
);
|
||||
self.video_context.VideoProcessorSetStreamSourceRect(
|
||||
&pipeline.processor,
|
||||
0,
|
||||
true,
|
||||
Some(&raw const source),
|
||||
);
|
||||
self.video_context.VideoProcessorSetStreamDestRect(
|
||||
&pipeline.processor,
|
||||
0,
|
||||
true,
|
||||
Some(&raw const destination),
|
||||
);
|
||||
self.video_context.VideoProcessorSetStreamColorSpace(
|
||||
&pipeline.processor,
|
||||
0,
|
||||
&raw const frame.input_color_space,
|
||||
);
|
||||
}
|
||||
let mut stream = D3D11_VIDEO_PROCESSOR_STREAM {
|
||||
Enable: true.into(),
|
||||
pInputSurface: ManuallyDrop::new(Some(input_view)),
|
||||
..Default::default()
|
||||
};
|
||||
let blit_result = unsafe {
|
||||
self.video_context.VideoProcessorBlt(
|
||||
&pipeline.processor,
|
||||
&pipeline.output_view,
|
||||
self.output_frame,
|
||||
std::slice::from_ref(&stream),
|
||||
)
|
||||
};
|
||||
let _input_view = unsafe { ManuallyDrop::take(&mut stream.pInputSurface) };
|
||||
blit_result.map_err(|error| format!("unable to process NV12 video frame: {error}"))?;
|
||||
unsafe { self.swap_chain.Present(1, DXGI_PRESENT(0)) }
|
||||
.ok()
|
||||
.map_err(|error| format!("unable to present native-video frame: {error}"))?;
|
||||
self.output_frame = self.output_frame.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_pipeline(&mut self, input_width: u32, input_height: u32) -> Result<(), String> {
|
||||
if self.pipeline.as_ref().is_some_and(|pipeline| {
|
||||
pipeline.input_size == (input_width, input_height)
|
||||
&& pipeline.output_size == self.output_size
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
let content = D3D11_VIDEO_PROCESSOR_CONTENT_DESC {
|
||||
InputFrameFormat: D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE,
|
||||
InputFrameRate: DXGI_RATIONAL {
|
||||
Numerator: 60,
|
||||
Denominator: 1,
|
||||
},
|
||||
InputWidth: input_width,
|
||||
InputHeight: input_height,
|
||||
OutputFrameRate: DXGI_RATIONAL {
|
||||
Numerator: 60,
|
||||
Denominator: 1,
|
||||
},
|
||||
OutputWidth: self.output_size.0,
|
||||
OutputHeight: self.output_size.1,
|
||||
Usage: D3D11_VIDEO_USAGE_PLAYBACK_NORMAL,
|
||||
};
|
||||
let enumerator = unsafe {
|
||||
self.video_device
|
||||
.CreateVideoProcessorEnumerator(&raw const content)
|
||||
}
|
||||
.map_err(|error| format!("unable to create video processor enumerator: {error}"))?;
|
||||
let input_support = unsafe { enumerator.CheckVideoProcessorFormat(DXGI_FORMAT_NV12) }
|
||||
.map_err(|error| format!("unable to query NV12 processor support: {error}"))?;
|
||||
let output_support =
|
||||
unsafe { enumerator.CheckVideoProcessorFormat(DXGI_FORMAT_B8G8R8A8_UNORM) }
|
||||
.map_err(|error| format!("unable to query BGRA processor support: {error}"))?;
|
||||
let required_input = u32::try_from(D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_INPUT.0)
|
||||
.map_err(|_| "invalid D3D11 input support flag".to_owned())?;
|
||||
let required_output = u32::try_from(D3D11_VIDEO_PROCESSOR_FORMAT_SUPPORT_OUTPUT.0)
|
||||
.map_err(|_| "invalid D3D11 output support flag".to_owned())?;
|
||||
if input_support & required_input == 0 || output_support & required_output == 0 {
|
||||
return Err("D3D11 video processor cannot convert NV12 to BGRA".to_owned());
|
||||
}
|
||||
let processor = unsafe { self.video_device.CreateVideoProcessor(&enumerator, 0) }
|
||||
.map_err(|error| format!("unable to create D3D11 video processor: {error}"))?;
|
||||
let back_buffer: ID3D11Texture2D = unsafe { self.swap_chain.GetBuffer(0) }
|
||||
.map_err(|error| format!("unable to acquire swap-chain back buffer: {error}"))?;
|
||||
let mut render_target = None;
|
||||
unsafe {
|
||||
self.device
|
||||
.CreateRenderTargetView(&back_buffer, None, Some(&raw mut render_target))
|
||||
}
|
||||
.map_err(|error| format!("unable to create swap-chain render target: {error}"))?;
|
||||
let render_target =
|
||||
render_target.ok_or_else(|| "D3D11 returned no render target view".to_owned())?;
|
||||
let output_descriptor = D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC {
|
||||
ViewDimension: D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||
Anonymous: D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0 {
|
||||
Texture2D: D3D11_TEX2D_VPOV { MipSlice: 0 },
|
||||
},
|
||||
};
|
||||
let mut output_view = None;
|
||||
unsafe {
|
||||
self.video_device.CreateVideoProcessorOutputView(
|
||||
&back_buffer,
|
||||
&enumerator,
|
||||
&raw const output_descriptor,
|
||||
Some(&raw mut output_view),
|
||||
)
|
||||
}
|
||||
.map_err(|error| format!("unable to create processor output view: {error}"))?;
|
||||
let output_view =
|
||||
output_view.ok_or_else(|| "D3D11 returned no processor output view".to_owned())?;
|
||||
self.pipeline = Some(ProcessorPipeline {
|
||||
input_size: (input_width, input_height),
|
||||
output_size: self.output_size,
|
||||
enumerator,
|
||||
processor,
|
||||
output_view,
|
||||
render_target,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn swap_chain_descriptor(hwnd: HWND, width: u32, height: u32) -> DXGI_SWAP_CHAIN_DESC {
|
||||
DXGI_SWAP_CHAIN_DESC {
|
||||
BufferDesc: DXGI_MODE_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
RefreshRate: DXGI_RATIONAL {
|
||||
Numerator: 0,
|
||||
Denominator: 1,
|
||||
},
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
..Default::default()
|
||||
},
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 2,
|
||||
OutputWindow: hwnd,
|
||||
Windowed: true.into(),
|
||||
SwapEffect: DXGI_SWAP_EFFECT_DISCARD,
|
||||
Flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_initial_window(width: u32, height: u32) -> PhysicalSize<u32> {
|
||||
let scale = (1_600_f64 / f64::from(width))
|
||||
.min(900_f64 / f64::from(height))
|
||||
.min(1.0);
|
||||
PhysicalSize::new(
|
||||
(f64::from(width) * scale).round().max(320.0) as u32,
|
||||
(f64::from(height) * scale).round().max(200.0) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
fn aspect_fit_rect(
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
target_width: u32,
|
||||
target_height: u32,
|
||||
) -> Result<RECT, String> {
|
||||
if source_width == 0 || source_height == 0 || target_width == 0 || target_height == 0 {
|
||||
return Err("video and window dimensions must be non-zero".to_owned());
|
||||
}
|
||||
let source_aspect = f64::from(source_width) / f64::from(source_height);
|
||||
let target_aspect = f64::from(target_width) / f64::from(target_height);
|
||||
let (width, height) = if source_aspect > target_aspect {
|
||||
(
|
||||
target_width,
|
||||
(f64::from(target_width) / source_aspect).round() as u32,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
(f64::from(target_height) * source_aspect).round() as u32,
|
||||
target_height,
|
||||
)
|
||||
};
|
||||
let left = (target_width - width) / 2;
|
||||
let top = (target_height - height) / 2;
|
||||
Ok(RECT {
|
||||
left: i32::try_from(left).map_err(|_| "video destination left overflow".to_owned())?,
|
||||
top: i32::try_from(top).map_err(|_| "video destination top overflow".to_owned())?,
|
||||
right: i32::try_from(left + width)
|
||||
.map_err(|_| "video destination right overflow".to_owned())?,
|
||||
bottom: i32::try_from(top + height)
|
||||
.map_err(|_| "video destination bottom overflow".to_owned())?,
|
||||
})
|
||||
}
|
||||
|
||||
fn frame_time_offset(first_timestamp: i64, timestamp: i64) -> Result<Duration, String> {
|
||||
let delta_100ns = timestamp.saturating_sub(first_timestamp).max(0);
|
||||
let nanoseconds = u64::try_from(delta_100ns)
|
||||
.ok()
|
||||
.and_then(|value| value.checked_mul(100))
|
||||
.ok_or_else(|| "video timestamp overflow".to_owned())?;
|
||||
let offset = Duration::from_nanos(nanoseconds);
|
||||
if offset > MAX_PLAYBACK_TIMELINE {
|
||||
return Err("video timestamp exceeds the 24-hour playback limit".to_owned());
|
||||
}
|
||||
Ok(offset)
|
||||
}
|
||||
|
||||
fn media_type_frame_size(
|
||||
media_type: &windows::Win32::Media::MediaFoundation::IMFMediaType,
|
||||
) -> Result<(u32, u32), String> {
|
||||
let frame_size_key = MF_MT_FRAME_SIZE;
|
||||
let packed = unsafe { media_type.GetUINT64(&frame_size_key) }
|
||||
.map_err(|error| format!("NV12 media type has no visible frame size: {error}"))?;
|
||||
let width =
|
||||
u32::try_from(packed >> 32).map_err(|_| "visible video width is invalid".to_owned())?;
|
||||
let height = u32::try_from(packed & u64::from(u32::MAX))
|
||||
.map_err(|_| "visible video height is invalid".to_owned())?;
|
||||
if width == 0 || height == 0 {
|
||||
return Err("visible video frame size must be non-zero".to_owned());
|
||||
}
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
fn media_type_color_space(
|
||||
media_type: &windows::Win32::Media::MediaFoundation::IMFMediaType,
|
||||
visible_height: u32,
|
||||
) -> D3D11_VIDEO_PROCESSOR_COLOR_SPACE {
|
||||
let bt709_value = u32::try_from(MFVideoTransferMatrix_BT709.0).unwrap_or(1);
|
||||
let full_range_value = u32::try_from(MFNominalRange_0_255.0).unwrap_or(1);
|
||||
let yuv_matrix_key = MF_MT_YUV_MATRIX;
|
||||
let bt709 = unsafe { media_type.GetUINT32(&yuv_matrix_key) }
|
||||
.map_or(visible_height >= 720, |value| value == bt709_value);
|
||||
let nominal_range_key = MF_MT_VIDEO_NOMINAL_RANGE;
|
||||
let full_range = unsafe { media_type.GetUINT32(&nominal_range_key) }
|
||||
.is_ok_and(|value| value == full_range_value);
|
||||
|
||||
// D3D11_VIDEO_PROCESSOR_COLOR_SPACE packs YCbCr_Matrix at bit 2 and
|
||||
// Nominal_Range at bits 4..5 (1 = limited, 2 = full).
|
||||
D3D11_VIDEO_PROCESSOR_COLOR_SPACE {
|
||||
_bitfield: (u32::from(bt709) << 2) | ((if full_range { 2 } else { 1 }) << 4),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aspect_fit_preserves_video_and_centers_black_bars() {
|
||||
assert_eq!(
|
||||
aspect_fit_rect(1_920, 1_080, 1_000, 1_000).unwrap(),
|
||||
RECT {
|
||||
left: 0,
|
||||
top: 218,
|
||||
right: 1_000,
|
||||
bottom: 781,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
aspect_fit_rect(1_000, 1_000, 1_920, 1_080).unwrap(),
|
||||
RECT {
|
||||
left: 420,
|
||||
top: 0,
|
||||
right: 1_500,
|
||||
bottom: 1_080,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_timestamps_are_relative_bounded_and_nonnegative() {
|
||||
assert_eq!(frame_time_offset(1_000, 900).unwrap(), Duration::ZERO);
|
||||
assert_eq!(
|
||||
frame_time_offset(1_000, 11_000).unwrap(),
|
||||
Duration::from_millis(1)
|
||||
);
|
||||
assert!(frame_time_offset(0, 24 * 60 * 60 * 10_000_000 + 1).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "remotedesk-rdp-session"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ironrdp-connector = "0.10.0"
|
||||
ironrdp-pdu = "0.9.0"
|
||||
ironrdp-tokio = "0.10.0"
|
||||
ironrdp-tls = { version = "0.2.2", features = ["rustls"] }
|
||||
remotedesk-client-core = { path = "../../crates/client-core" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.0", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,28 @@
|
||||
# RemoteDesk RDP Session Spike
|
||||
|
||||
This standalone helper verifies that a target is reachable through DNS and TCP and that IronRDP can decode its X.224/RDP security negotiation response.
|
||||
|
||||
It intentionally stops in IronRDP's `EnhancedSecurityUpgrade` state. It does not start TLS, CredSSP/NLA, authenticate, create a desktop session, receive graphics, or render frames. A successful result proves an NLA-capable RDP endpoint responded to IronRDP; it does not prove that any account can log in.
|
||||
|
||||
The process accepts one bounded JSON object on stdin:
|
||||
|
||||
```json
|
||||
{"target":"rdp-host.example:3389","timeout_ms":3000}
|
||||
```
|
||||
|
||||
`target` and `timeout_ms` are the only accepted fields. In particular, `username` and `password` are rejected. The target never appears in JSON output, `Debug`, error messages, or command-line arguments.
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```powershell
|
||||
'{"target":"127.0.0.1:3389","timeout_ms":3000}' |
|
||||
cargo run --manifest-path client/helpers/rdp-session/Cargo.toml --quiet
|
||||
```
|
||||
|
||||
Example success shape:
|
||||
|
||||
```json
|
||||
{"ok":true,"stage":"rdp_negotiation","security_protocol":"hybrid_extended","tcp_latency_ms":1,"total_latency_ms":8}
|
||||
```
|
||||
|
||||
The helper is part of the root Cargo workspace and is packaged beside the control service.
|
||||
@@ -0,0 +1,466 @@
|
||||
//! Minimal direct RDP reachability spike.
|
||||
//!
|
||||
//! This crate deliberately stops after `IronRDP` validates the server's X.224
|
||||
//! connection confirm, RDP security negotiation response, and TLS certificate
|
||||
//! exchange. It does not perform CredSSP/NLA, login, licensing, graphics, or input.
|
||||
|
||||
use ironrdp_connector::{ClientConnector, ClientConnectorState, Config, Credentials, DesktopSize};
|
||||
use ironrdp_pdu::gcc::KeyboardType;
|
||||
use ironrdp_pdu::nego::SecurityProtocol;
|
||||
use ironrdp_pdu::rdp::capability_sets::MajorPlatformType;
|
||||
use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo};
|
||||
use ironrdp_tokio::TokioFramed;
|
||||
use remotedesk_client_core::RdpEndpoint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::io::{self, Read};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::net::{TcpStream, lookup_host};
|
||||
use tokio::time::{Instant, timeout_at};
|
||||
|
||||
pub const DEFAULT_TIMEOUT_MS: u64 = 3_000;
|
||||
pub const MIN_TIMEOUT_MS: u64 = 250;
|
||||
pub const MAX_TIMEOUT_MS: u64 = 10_000;
|
||||
pub const MAX_STDIN_BYTES: u64 = 8 * 1_024;
|
||||
const MAX_RESOLVED_ADDRESSES: usize = 16;
|
||||
|
||||
fn default_timeout_ms() -> u64 {
|
||||
DEFAULT_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/// Secret-free request accepted from stdin.
|
||||
///
|
||||
/// Unknown JSON fields are rejected, so this process cannot accidentally
|
||||
/// accept a password intended for a future full-session helper.
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProbeRequest {
|
||||
pub target: String,
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProbeRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProbeRequest")
|
||||
.field("target", &"[redacted]")
|
||||
.field("timeout_ms", &self.timeout_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProbeStage {
|
||||
Input,
|
||||
Dns,
|
||||
Tcp,
|
||||
RdpNegotiation,
|
||||
Tls,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NegotiatedSecurityProtocol {
|
||||
Hybrid,
|
||||
HybridExtended,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProbeErrorCode {
|
||||
InvalidRequest,
|
||||
RequestTooLarge,
|
||||
InvalidTarget,
|
||||
TimeoutOutOfRange,
|
||||
DnsFailed,
|
||||
DnsReturnedNoAddresses,
|
||||
TcpConnectFailed,
|
||||
Timeout,
|
||||
LocalAddressUnavailable,
|
||||
RdpNegotiationFailed,
|
||||
UnexpectedIronRdpState,
|
||||
TlsHandshakeFailed,
|
||||
CertificateEncodingFailed,
|
||||
}
|
||||
|
||||
/// Machine-readable result that never includes the target or library errors.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ProbeResponse {
|
||||
pub ok: bool,
|
||||
pub stage: ProbeStage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub security_protocol: Option<NegotiatedSecurityProtocol>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tcp_latency_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_latency_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub certificate_sha256: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<ProbeErrorCode>,
|
||||
}
|
||||
|
||||
impl ProbeResponse {
|
||||
#[must_use]
|
||||
pub const fn failure(stage: ProbeStage, error_code: ProbeErrorCode) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
stage,
|
||||
security_protocol: None,
|
||||
tcp_latency_ms: None,
|
||||
total_latency_ms: None,
|
||||
certificate_sha256: None,
|
||||
error_code: Some(error_code),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads exactly one bounded JSON request.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable, input-free error response when stdin is oversized or is
|
||||
/// not a valid [`ProbeRequest`].
|
||||
pub fn read_request(reader: impl Read) -> Result<ProbeRequest, ProbeResponse> {
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.take(MAX_STDIN_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| ProbeResponse::failure(ProbeStage::Input, ProbeErrorCode::InvalidRequest))?;
|
||||
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_STDIN_BYTES {
|
||||
return Err(ProbeResponse::failure(
|
||||
ProbeStage::Input,
|
||||
ProbeErrorCode::RequestTooLarge,
|
||||
));
|
||||
}
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|_| ProbeResponse::failure(ProbeStage::Input, ProbeErrorCode::InvalidRequest))
|
||||
}
|
||||
|
||||
/// Performs DNS, TCP, `IronRDP` protocol negotiation, and a secret-free TLS handshake.
|
||||
pub async fn probe(request: ProbeRequest) -> ProbeResponse {
|
||||
if !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&request.timeout_ms) {
|
||||
return ProbeResponse::failure(ProbeStage::Input, ProbeErrorCode::TimeoutOutOfRange);
|
||||
}
|
||||
let Ok(endpoint) = RdpEndpoint::parse(&request.target) else {
|
||||
return ProbeResponse::failure(ProbeStage::Input, ProbeErrorCode::InvalidTarget);
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
let deadline = started + Duration::from_millis(request.timeout_ms);
|
||||
let addresses = match resolve(&endpoint, deadline).await {
|
||||
Ok(addresses) => addresses,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let tcp_started = Instant::now();
|
||||
let stream = match timeout_at(deadline, TcpStream::connect(addresses.as_slice())).await {
|
||||
Ok(Ok(stream)) => stream,
|
||||
Ok(Err(_)) => {
|
||||
return ProbeResponse::failure(ProbeStage::Tcp, ProbeErrorCode::TcpConnectFailed);
|
||||
}
|
||||
Err(_) => return ProbeResponse::failure(ProbeStage::Tcp, ProbeErrorCode::Timeout),
|
||||
};
|
||||
let tcp_latency_ms = elapsed_ms(tcp_started);
|
||||
let Ok(client_addr) = stream.local_addr() else {
|
||||
return ProbeResponse::failure(ProbeStage::Tcp, ProbeErrorCode::LocalAddressUnavailable);
|
||||
};
|
||||
|
||||
let mut framed = TokioFramed::new(stream);
|
||||
let mut connector = ClientConnector::new(pre_nla_config(), client_addr);
|
||||
match timeout_at(
|
||||
deadline,
|
||||
ironrdp_tokio::connect_begin(&mut framed, &mut connector),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(_)) => {
|
||||
return ProbeResponse::failure(
|
||||
ProbeStage::RdpNegotiation,
|
||||
ProbeErrorCode::RdpNegotiationFailed,
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return ProbeResponse::failure(ProbeStage::RdpNegotiation, ProbeErrorCode::Timeout);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(security_protocol) = negotiated_security_protocol(&connector) else {
|
||||
return ProbeResponse::failure(
|
||||
ProbeStage::RdpNegotiation,
|
||||
ProbeErrorCode::UnexpectedIronRdpState,
|
||||
);
|
||||
};
|
||||
let (stream, _) = framed.into_inner();
|
||||
let certificate =
|
||||
match timeout_at(deadline, ironrdp_tls::upgrade(stream, endpoint.host())).await {
|
||||
Ok(Ok((_, certificate))) => certificate,
|
||||
Ok(Err(_)) => {
|
||||
return ProbeResponse::failure(ProbeStage::Tls, ProbeErrorCode::TlsHandshakeFailed);
|
||||
}
|
||||
Err(_) => return ProbeResponse::failure(ProbeStage::Tls, ProbeErrorCode::Timeout),
|
||||
};
|
||||
let Some(certificate_sha256) = ironrdp_tls::certificate_sha256(&certificate) else {
|
||||
return ProbeResponse::failure(ProbeStage::Tls, ProbeErrorCode::CertificateEncodingFailed);
|
||||
};
|
||||
ProbeResponse {
|
||||
ok: true,
|
||||
stage: ProbeStage::Tls,
|
||||
security_protocol: Some(security_protocol),
|
||||
tcp_latency_ms: Some(tcp_latency_ms),
|
||||
total_latency_ms: Some(elapsed_ms(started)),
|
||||
certificate_sha256: Some(hex_sha256(certificate_sha256)),
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_sha256(value: [u8; 32]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let mut output = String::with_capacity(64);
|
||||
for byte in value {
|
||||
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
endpoint: &RdpEndpoint,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<SocketAddr>, ProbeResponse> {
|
||||
let resolved = match timeout_at(deadline, lookup_host((endpoint.host(), endpoint.port()))).await
|
||||
{
|
||||
Ok(Ok(resolved)) => resolved,
|
||||
Ok(Err(_)) => {
|
||||
return Err(ProbeResponse::failure(
|
||||
ProbeStage::Dns,
|
||||
ProbeErrorCode::DnsFailed,
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(ProbeResponse::failure(
|
||||
ProbeStage::Dns,
|
||||
ProbeErrorCode::Timeout,
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut addresses = Vec::new();
|
||||
for address in resolved.take(MAX_RESOLVED_ADDRESSES) {
|
||||
if !addresses.contains(&address) {
|
||||
addresses.push(address);
|
||||
}
|
||||
}
|
||||
if addresses.is_empty() {
|
||||
return Err(ProbeResponse::failure(
|
||||
ProbeStage::Dns,
|
||||
ProbeErrorCode::DnsReturnedNoAddresses,
|
||||
));
|
||||
}
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
fn pre_nla_config() -> Config {
|
||||
Config {
|
||||
credentials: Credentials::SmartCard {
|
||||
pin: String::new(),
|
||||
config: None,
|
||||
},
|
||||
domain: None,
|
||||
enable_tls: false,
|
||||
enable_credssp: true,
|
||||
keyboard_type: KeyboardType::IbmEnhanced,
|
||||
keyboard_subtype: 0,
|
||||
keyboard_layout: 0,
|
||||
keyboard_functional_keys_count: 12,
|
||||
ime_file_name: String::new(),
|
||||
dig_product_id: String::new(),
|
||||
desktop_size: DesktopSize {
|
||||
width: 1_280,
|
||||
height: 720,
|
||||
},
|
||||
bitmap: None,
|
||||
client_build: 0,
|
||||
client_name: "RemoteDeskProbe".to_owned(),
|
||||
client_dir: String::new(),
|
||||
platform: MajorPlatformType::WINDOWS,
|
||||
enable_server_pointer: false,
|
||||
request_data: None,
|
||||
autologon: false,
|
||||
enable_audio_playback: false,
|
||||
compression_type: None,
|
||||
pointer_software_rendering: false,
|
||||
multitransport_flags: None,
|
||||
performance_flags: PerformanceFlags::default(),
|
||||
desktop_scale_factor: 100,
|
||||
hardware_id: None,
|
||||
license_cache: None,
|
||||
timezone_info: TimezoneInfo::default(),
|
||||
alternate_shell: String::new(),
|
||||
work_dir: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn negotiated_security_protocol(connector: &ClientConnector) -> Option<NegotiatedSecurityProtocol> {
|
||||
let ClientConnectorState::EnhancedSecurityUpgrade { selected_protocol } = &connector.state
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if selected_protocol.contains(SecurityProtocol::HYBRID_EX) {
|
||||
Some(NegotiatedSecurityProtocol::HybridExtended)
|
||||
} else if selected_protocol.contains(SecurityProtocol::HYBRID) {
|
||||
Some(NegotiatedSecurityProtocol::Hybrid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(started: Instant) -> u64 {
|
||||
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Writes one JSON response without logging request or library error details.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an I/O or serialization error when stdout cannot accept the result.
|
||||
pub fn write_response(writer: impl io::Write, response: &ProbeResponse) -> io::Result<()> {
|
||||
let mut writer = io::BufWriter::new(writer);
|
||||
serde_json::to_writer(&mut writer, response).map_err(io::Error::other)?;
|
||||
io::Write::write_all(&mut writer, b"\n")?;
|
||||
io::Write::flush(&mut writer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stdin_request_defaults_timeout_and_redacts_debug() {
|
||||
let marker = "sensitive-rdp-host.example";
|
||||
let input = format!(r#"{{"target":"{marker}"}}"#);
|
||||
let request = read_request(input.as_bytes()).unwrap();
|
||||
|
||||
assert_eq!(request.target, marker);
|
||||
assert_eq!(request.timeout_ms, DEFAULT_TIMEOUT_MS);
|
||||
assert!(!format!("{request:?}").contains(marker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_and_other_unknown_fields_are_rejected() {
|
||||
for input in [
|
||||
r#"{"target":"host","password":"secret-marker"}"#,
|
||||
r#"{"target":"host","username":"user"}"#,
|
||||
r#"{"target":"host","extra":true}"#,
|
||||
] {
|
||||
let response = read_request(input.as_bytes()).unwrap_err();
|
||||
assert_eq!(
|
||||
response,
|
||||
ProbeResponse::failure(ProbeStage::Input, ProbeErrorCode::InvalidRequest)
|
||||
);
|
||||
let serialized = serde_json::to_string(&response).unwrap();
|
||||
assert!(!serialized.contains("secret-marker"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_stdin_is_rejected_without_parsing() {
|
||||
let input = vec![b'a'; usize::try_from(MAX_STDIN_BYTES + 1).unwrap()];
|
||||
assert_eq!(
|
||||
read_request(input.as_slice()),
|
||||
Err(ProbeResponse::failure(
|
||||
ProbeStage::Input,
|
||||
ProbeErrorCode::RequestTooLarge
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_contains_no_target_or_library_error_text() {
|
||||
let marker = "sensitive-rdp-host.example";
|
||||
let response = ProbeResponse {
|
||||
ok: true,
|
||||
stage: ProbeStage::Tls,
|
||||
security_protocol: Some(NegotiatedSecurityProtocol::HybridExtended),
|
||||
tcp_latency_ms: Some(12),
|
||||
total_latency_ms: Some(24),
|
||||
certificate_sha256: Some("a".repeat(64)),
|
||||
error_code: None,
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
write_response(&mut output, &response).unwrap();
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
|
||||
assert!(!output.contains(marker));
|
||||
assert!(!output.to_ascii_lowercase().contains("password"));
|
||||
assert!(output.contains("tls"));
|
||||
assert!(output.contains("hybrid_extended"));
|
||||
assert!(output.contains(&"a".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_fingerprint_is_lowercase_fixed_width_hex() {
|
||||
assert_eq!(hex_sha256([0xab; 32]), "ab".repeat(32));
|
||||
assert_eq!(hex_sha256([0; 32]).len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_nla_config_contains_no_account_secret_and_requires_nla() {
|
||||
let config = pre_nla_config();
|
||||
assert!(!config.enable_tls);
|
||||
assert!(config.enable_credssp);
|
||||
assert!(config.request_data.is_none());
|
||||
match config.credentials {
|
||||
Credentials::SmartCard { pin, config } => {
|
||||
assert!(pin.is_empty());
|
||||
assert!(config.is_none());
|
||||
}
|
||||
Credentials::UsernamePassword { .. } => panic!("probe must not hold a password"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_target_and_timeout_fail_before_network_access() {
|
||||
let invalid_target = probe(ProbeRequest {
|
||||
target: "rdp://secret-host".into(),
|
||||
timeout_ms: DEFAULT_TIMEOUT_MS,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
invalid_target,
|
||||
ProbeResponse::failure(ProbeStage::Input, ProbeErrorCode::InvalidTarget)
|
||||
);
|
||||
|
||||
let invalid_timeout = probe(ProbeRequest {
|
||||
target: "localhost".into(),
|
||||
timeout_ms: 1,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
invalid_timeout,
|
||||
ProbeResponse::failure(ProbeStage::Input, ProbeErrorCode::TimeoutOutOfRange)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_mapping_accepts_only_nla_security_modes() {
|
||||
let client_addr = "127.0.0.1:40000".parse().unwrap();
|
||||
let mut connector = ClientConnector::new(pre_nla_config(), client_addr);
|
||||
connector.state = ClientConnectorState::EnhancedSecurityUpgrade {
|
||||
selected_protocol: SecurityProtocol::HYBRID,
|
||||
};
|
||||
assert_eq!(
|
||||
negotiated_security_protocol(&connector),
|
||||
Some(NegotiatedSecurityProtocol::Hybrid)
|
||||
);
|
||||
connector.state = ClientConnectorState::EnhancedSecurityUpgrade {
|
||||
selected_protocol: SecurityProtocol::HYBRID_EX,
|
||||
};
|
||||
assert_eq!(
|
||||
negotiated_security_protocol(&connector),
|
||||
Some(NegotiatedSecurityProtocol::HybridExtended)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use remotedesk_rdp_session::{
|
||||
ProbeErrorCode, ProbeResponse, ProbeStage, probe, read_request, write_response,
|
||||
};
|
||||
use std::io;
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
if std::env::args_os().len() != 1 {
|
||||
let response = ProbeResponse::failure(ProbeStage::Input, ProbeErrorCode::InvalidRequest);
|
||||
let _ = write_response(io::stdout().lock(), &response);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let request = match read_request(io::stdin().lock()) {
|
||||
Ok(request) => request,
|
||||
Err(response) => {
|
||||
let _ = write_response(io::stdout().lock(), &response);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let response = probe(request).await;
|
||||
let success = response.ok;
|
||||
if write_response(io::stdout().lock(), &response).is_err() {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
if success {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "remotedesk-rdp-viewer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
ironrdp = { version = "0.17.0", features = ["client", "client-all", "cliprdr", "connector", "input", "pdu", "rustls"] }
|
||||
ironrdp-viewer = { version = "0.1.0", default-features = false, features = ["rustls"] }
|
||||
ironrdp-tls = { version = "0.2.2", features = ["rustls"] }
|
||||
remotedesk-client-core = { path = "../../crates/client-core" }
|
||||
remotedesk-credential-store = { path = "../credential-store" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
smallvec = "1.15"
|
||||
softbuffer = "0.4"
|
||||
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] }
|
||||
tracing = "0.1"
|
||||
winit = "0.30"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
windows = { version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
] }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,437 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionState {
|
||||
WaitingForCredentials,
|
||||
Connecting,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
Failed,
|
||||
Terminated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResizeState {
|
||||
Idle,
|
||||
Pending,
|
||||
ReconnectRequired,
|
||||
Reconnecting,
|
||||
Confirmed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SessionSnapshot<'a> {
|
||||
schema_version: u8,
|
||||
session_id: &'a str,
|
||||
state: SessionState,
|
||||
started_at_unix_ms: u64,
|
||||
updated_at_unix_ms: u64,
|
||||
frame_count: u64,
|
||||
frames_per_second: Option<f64>,
|
||||
desktop_width: u16,
|
||||
desktop_height: u16,
|
||||
monitor_count: u8,
|
||||
multi_monitor: bool,
|
||||
network_latency_ms: Option<u32>,
|
||||
base_network_latency_ms: Option<u32>,
|
||||
bandwidth_kbps: Option<u32>,
|
||||
decode_latency_ms: Option<f64>,
|
||||
presentation_latency_ms: Option<f64>,
|
||||
renderer: &'static str,
|
||||
frame_conversion_pixels: u64,
|
||||
frame_upload_mode: &'static str,
|
||||
frame_upload_pixels: u64,
|
||||
reconnect_attempt: u8,
|
||||
resize_generation: u64,
|
||||
resize_state: ResizeState,
|
||||
resize_requested_width: Option<u16>,
|
||||
resize_requested_height: Option<u16>,
|
||||
resize_failure: Option<&'static str>,
|
||||
error_code: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub struct SessionDiagnostics {
|
||||
path: PathBuf,
|
||||
session_id: String,
|
||||
state: SessionState,
|
||||
started_at_unix_ms: u64,
|
||||
frame_count: u64,
|
||||
frames_per_second: Option<f64>,
|
||||
frame_rate_started: Instant,
|
||||
frame_rate_frames: u32,
|
||||
desktop_size: (u16, u16),
|
||||
monitor_count: u8,
|
||||
multi_monitor: bool,
|
||||
network_latency_ms: Option<u32>,
|
||||
base_network_latency_ms: Option<u32>,
|
||||
bandwidth_kbps: Option<u32>,
|
||||
decode_latency_ms: Option<f64>,
|
||||
presentation_latency_ms: Option<f64>,
|
||||
renderer: &'static str,
|
||||
frame_conversion_pixels: u64,
|
||||
frame_upload_mode: &'static str,
|
||||
frame_upload_pixels: u64,
|
||||
reconnect_attempt: u8,
|
||||
resize_generation: u64,
|
||||
resize_state: ResizeState,
|
||||
resize_requested_size: Option<(u16, u16)>,
|
||||
resize_failure: Option<&'static str>,
|
||||
error_code: Option<&'static str>,
|
||||
last_write: Instant,
|
||||
}
|
||||
|
||||
impl SessionDiagnostics {
|
||||
pub fn new(session_id: String) -> Result<Self, String> {
|
||||
validate_session_id(&session_id)?;
|
||||
let directory = env::temp_dir().join("RemoteDesk").join("sessions");
|
||||
fs::create_dir_all(&directory).map_err(|error| {
|
||||
format!("unable to create the session diagnostics directory: {error}")
|
||||
})?;
|
||||
let diagnostics = Self {
|
||||
path: directory.join(format!("{session_id}.json")),
|
||||
session_id,
|
||||
state: SessionState::WaitingForCredentials,
|
||||
started_at_unix_ms: unix_millis(),
|
||||
frame_count: 0,
|
||||
frames_per_second: None,
|
||||
frame_rate_started: Instant::now(),
|
||||
frame_rate_frames: 0,
|
||||
desktop_size: (0, 0),
|
||||
monitor_count: 1,
|
||||
multi_monitor: false,
|
||||
network_latency_ms: None,
|
||||
base_network_latency_ms: None,
|
||||
bandwidth_kbps: None,
|
||||
decode_latency_ms: None,
|
||||
presentation_latency_ms: None,
|
||||
renderer: "pending",
|
||||
frame_conversion_pixels: 0,
|
||||
frame_upload_mode: "pending",
|
||||
frame_upload_pixels: 0,
|
||||
reconnect_attempt: 0,
|
||||
resize_generation: 0,
|
||||
resize_state: ResizeState::Idle,
|
||||
resize_requested_size: None,
|
||||
resize_failure: None,
|
||||
error_code: None,
|
||||
last_write: Instant::now(),
|
||||
};
|
||||
diagnostics.write()?;
|
||||
Ok(diagnostics)
|
||||
}
|
||||
|
||||
pub fn set_state(&mut self, state: SessionState) {
|
||||
self.state = state;
|
||||
self.error_code = None;
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn connected_frame(&mut self, width: u16, height: u16) {
|
||||
self.state = SessionState::Connected;
|
||||
self.frame_count = self.frame_count.saturating_add(1);
|
||||
self.frame_rate_frames = self.frame_rate_frames.saturating_add(1);
|
||||
let elapsed = self.frame_rate_started.elapsed();
|
||||
if elapsed >= Duration::from_secs(1) {
|
||||
self.frames_per_second = frame_rate(self.frame_rate_frames, elapsed);
|
||||
self.frame_rate_frames = 0;
|
||||
self.frame_rate_started = Instant::now();
|
||||
}
|
||||
self.desktop_size = (width, height);
|
||||
self.error_code = None;
|
||||
self.write_throttled();
|
||||
}
|
||||
|
||||
pub fn reconnecting(&mut self, attempt: u8) {
|
||||
self.state = SessionState::Reconnecting;
|
||||
self.reconnect_attempt = attempt;
|
||||
self.network_latency_ms = None;
|
||||
self.base_network_latency_ms = None;
|
||||
self.bandwidth_kbps = None;
|
||||
self.decode_latency_ms = None;
|
||||
self.error_code = None;
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn network_metrics(
|
||||
&mut self,
|
||||
base_rtt_ms: Option<u32>,
|
||||
average_rtt_ms: u32,
|
||||
bandwidth_kbps: Option<u32>,
|
||||
) {
|
||||
self.network_latency_ms = Some(average_rtt_ms);
|
||||
self.base_network_latency_ms = base_rtt_ms;
|
||||
self.bandwidth_kbps = bandwidth_kbps;
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn monitor_layout(&mut self, monitor_count: usize) {
|
||||
self.monitor_count = u8::try_from(monitor_count).unwrap_or(u8::MAX);
|
||||
self.multi_monitor = monitor_count > 1;
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn presented(&mut self, latency_ms: f64) {
|
||||
self.presentation_latency_ms = Some(latency_ms.max(0.0));
|
||||
self.write_throttled();
|
||||
}
|
||||
|
||||
pub fn decoded(&mut self, latency_ms: f64) {
|
||||
self.decode_latency_ms = Some(latency_ms.max(0.0));
|
||||
self.write_throttled();
|
||||
}
|
||||
|
||||
pub fn renderer(&mut self, renderer: &'static str) {
|
||||
self.renderer = renderer;
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn frame_upload(&mut self, mode: &'static str, uploaded_pixels: u64) {
|
||||
self.frame_upload_mode = mode;
|
||||
self.frame_upload_pixels = uploaded_pixels;
|
||||
self.write_throttled();
|
||||
}
|
||||
|
||||
pub fn converted_pixels(&mut self, converted_pixels: u64) {
|
||||
self.frame_conversion_pixels = converted_pixels;
|
||||
self.write_throttled();
|
||||
}
|
||||
|
||||
pub fn resize_pending(&mut self, generation: u64, width: u16, height: u16) {
|
||||
self.resize_generation = generation;
|
||||
self.resize_state = ResizeState::Pending;
|
||||
self.resize_requested_size = Some((width, height));
|
||||
self.resize_failure = None;
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn resize_reconnect_required(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
width: u16,
|
||||
height: u16,
|
||||
failure: &'static str,
|
||||
) {
|
||||
self.resize_generation = generation;
|
||||
self.resize_state = ResizeState::ReconnectRequired;
|
||||
self.resize_requested_size = Some((width, height));
|
||||
self.resize_failure = Some(failure);
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn resize_reconnecting(&mut self, generation: u64, width: u16, height: u16) {
|
||||
self.resize_generation = generation;
|
||||
self.resize_state = ResizeState::Reconnecting;
|
||||
self.resize_requested_size = Some((width, height));
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn resize_confirmed(&mut self, generation: u64, width: u16, height: u16) {
|
||||
self.resize_generation = generation;
|
||||
self.resize_state = ResizeState::Confirmed;
|
||||
self.resize_requested_size = Some((width, height));
|
||||
self.resize_failure = None;
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn resize_cancelled(&mut self, generation: u64, width: u16, height: u16) {
|
||||
self.resize_generation = generation;
|
||||
self.resize_state = ResizeState::Cancelled;
|
||||
self.resize_requested_size = Some((width, height));
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn failed(&mut self, error_code: &'static str) {
|
||||
self.state = SessionState::Failed;
|
||||
self.error_code = Some(error_code);
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
pub fn terminated(&mut self) {
|
||||
self.state = SessionState::Terminated;
|
||||
self.error_code = None;
|
||||
self.write_best_effort();
|
||||
}
|
||||
|
||||
fn write_throttled(&mut self) {
|
||||
if self.last_write.elapsed() >= Duration::from_millis(250) {
|
||||
self.write_best_effort();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_best_effort(&mut self) {
|
||||
if let Err(error) = self.write() {
|
||||
eprintln!("warning: unable to update session diagnostics: {error}");
|
||||
} else {
|
||||
self.last_write = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self) -> Result<(), String> {
|
||||
let snapshot = SessionSnapshot {
|
||||
schema_version: 1,
|
||||
session_id: &self.session_id,
|
||||
state: self.state,
|
||||
started_at_unix_ms: self.started_at_unix_ms,
|
||||
updated_at_unix_ms: unix_millis(),
|
||||
frame_count: self.frame_count,
|
||||
frames_per_second: self.frames_per_second,
|
||||
desktop_width: self.desktop_size.0,
|
||||
desktop_height: self.desktop_size.1,
|
||||
monitor_count: self.monitor_count,
|
||||
multi_monitor: self.multi_monitor,
|
||||
network_latency_ms: self.network_latency_ms,
|
||||
base_network_latency_ms: self.base_network_latency_ms,
|
||||
bandwidth_kbps: self.bandwidth_kbps,
|
||||
decode_latency_ms: self.decode_latency_ms,
|
||||
presentation_latency_ms: self.presentation_latency_ms,
|
||||
renderer: self.renderer,
|
||||
frame_conversion_pixels: self.frame_conversion_pixels,
|
||||
frame_upload_mode: self.frame_upload_mode,
|
||||
frame_upload_pixels: self.frame_upload_pixels,
|
||||
reconnect_attempt: self.reconnect_attempt,
|
||||
resize_generation: self.resize_generation,
|
||||
resize_state: self.resize_state,
|
||||
resize_requested_width: self.resize_requested_size.map(|size| size.0),
|
||||
resize_requested_height: self.resize_requested_size.map(|size| size.1),
|
||||
resize_failure: self.resize_failure,
|
||||
error_code: self.error_code,
|
||||
};
|
||||
let data = serde_json::to_vec(&snapshot)
|
||||
.map_err(|error| format!("unable to encode session diagnostics: {error}"))?;
|
||||
fs::write(&self.path, data)
|
||||
.map_err(|error| format!("unable to write session diagnostics: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_session_id(value: &str) -> Result<(), String> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
{
|
||||
return Err("invalid session diagnostics identifier".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unix_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn frame_rate(frames: u32, elapsed: Duration) -> Option<f64> {
|
||||
let seconds = elapsed.as_secs_f64();
|
||||
(frames > 0 && seconds > 0.0).then(|| f64::from(frames) / seconds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_identifiers_are_bounded_and_path_safe() {
|
||||
assert!(validate_session_id("42-123456-7").is_ok());
|
||||
assert!(validate_session_id("").is_err());
|
||||
assert!(validate_session_id("../secret").is_err());
|
||||
assert!(validate_session_id(&"a".repeat(65)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_rate_uses_observed_frames_and_elapsed_time() {
|
||||
assert_eq!(frame_rate(60, Duration::from_secs(2)), Some(30.0));
|
||||
assert_eq!(frame_rate(0, Duration::from_secs(1)), None);
|
||||
assert_eq!(frame_rate(1, Duration::ZERO), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_network_metrics_are_persisted_without_fabrication() {
|
||||
let session_id = format!("network-{}-{}", std::process::id(), unix_millis());
|
||||
let mut diagnostics = SessionDiagnostics::new(session_id).unwrap();
|
||||
diagnostics.network_metrics(Some(12), 18, Some(50_000));
|
||||
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&diagnostics.path).unwrap()).unwrap();
|
||||
assert_eq!(snapshot["network_latency_ms"], 18);
|
||||
assert_eq!(snapshot["base_network_latency_ms"], 12);
|
||||
assert_eq!(snapshot["bandwidth_kbps"], 50_000);
|
||||
fs::remove_file(&diagnostics.path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_decode_latency_is_persisted() {
|
||||
let session_id = format!("decode-{}-{}", std::process::id(), unix_millis());
|
||||
let mut diagnostics = SessionDiagnostics::new(session_id).unwrap();
|
||||
diagnostics.decoded(2.75);
|
||||
diagnostics.terminated();
|
||||
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&diagnostics.path).unwrap()).unwrap();
|
||||
assert_eq!(snapshot["decode_latency_ms"], 2.75);
|
||||
fs::remove_file(&diagnostics.path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_monitor_layout_is_persisted() {
|
||||
let session_id = format!("monitors-{}-{}", std::process::id(), unix_millis());
|
||||
let mut diagnostics = SessionDiagnostics::new(session_id).unwrap();
|
||||
diagnostics.monitor_layout(3);
|
||||
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&diagnostics.path).unwrap()).unwrap();
|
||||
assert_eq!(snapshot["monitor_count"], 3);
|
||||
assert_eq!(snapshot["multi_monitor"], true);
|
||||
fs::remove_file(&diagnostics.path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_upload_work_is_persisted() {
|
||||
let session_id = format!("upload-{}-{}", std::process::id(), unix_millis());
|
||||
let mut diagnostics = SessionDiagnostics::new(session_id).unwrap();
|
||||
diagnostics.frame_upload("dirty_rect", 4_096);
|
||||
diagnostics.converted_pixels(2_048);
|
||||
diagnostics.terminated();
|
||||
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&diagnostics.path).unwrap()).unwrap();
|
||||
assert_eq!(snapshot["frame_upload_mode"], "dirty_rect");
|
||||
assert_eq!(snapshot["frame_upload_pixels"], 4_096);
|
||||
assert_eq!(snapshot["frame_conversion_pixels"], 2_048);
|
||||
fs::remove_file(&diagnostics.path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_lifecycle_is_structured_and_contains_no_connection_data() {
|
||||
let session_id = format!("resize-{}-{}", std::process::id(), unix_millis());
|
||||
let mut diagnostics = SessionDiagnostics::new(session_id).unwrap();
|
||||
diagnostics.resize_pending(7, 2_560, 1_440);
|
||||
diagnostics.resize_reconnect_required(7, 2_560, 1_440, "unsupported");
|
||||
diagnostics.resize_reconnecting(7, 2_560, 1_440);
|
||||
diagnostics.resize_confirmed(7, 2_560, 1_440);
|
||||
|
||||
let data = fs::read(&diagnostics.path).unwrap();
|
||||
let snapshot: serde_json::Value = serde_json::from_slice(&data).unwrap();
|
||||
assert_eq!(snapshot["resize_generation"], 7);
|
||||
assert_eq!(snapshot["resize_state"], "confirmed");
|
||||
assert_eq!(snapshot["resize_requested_width"], 2_560);
|
||||
assert_eq!(snapshot["resize_requested_height"], 1_440);
|
||||
assert_eq!(snapshot["resize_failure"], serde_json::Value::Null);
|
||||
let serialized = String::from_utf8(data).unwrap();
|
||||
assert!(!serialized.contains("target"));
|
||||
assert!(!serialized.contains("username"));
|
||||
assert!(!serialized.contains("password"));
|
||||
fs::remove_file(&diagnostics.path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
use anyhow::Context as _;
|
||||
use ironrdp::client::rdp::{RdpClient, RdpOutputEvent};
|
||||
use ironrdp_viewer::cli::ViewerConfig;
|
||||
use remotedesk_client_core::{
|
||||
CredentialRef, DEFAULT_RDP_PORT, RdpEndpoint, RdpViewerLaunchConfig,
|
||||
receive_secure_pipe_session,
|
||||
};
|
||||
use remotedesk_credential_store::RdpCredential;
|
||||
use std::env;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
use tokio::runtime;
|
||||
use tokio::sync::mpsc;
|
||||
use winit::dpi::PhysicalSize;
|
||||
use winit::event_loop::EventLoop;
|
||||
|
||||
mod app;
|
||||
mod diagnostics;
|
||||
mod monitor_layout;
|
||||
mod renderer;
|
||||
#[cfg(windows)]
|
||||
mod windows_keyboard_hook;
|
||||
|
||||
use app::{MultiMonitorPlacement, SessionInput, ViewerApp, ViewerEvent};
|
||||
use diagnostics::{SessionDiagnostics, SessionState};
|
||||
use monitor_layout::{enumerate_local_monitors, select_monitors};
|
||||
|
||||
const DEFAULT_WIDTH: u16 = 1_600;
|
||||
const DEFAULT_HEIGHT: u16 = 900;
|
||||
const MAX_RECONNECT_ATTEMPTS: u8 = 3;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ReconnectPolicy {
|
||||
attempts: u8,
|
||||
}
|
||||
|
||||
impl ReconnectPolicy {
|
||||
fn next_delay(&mut self) -> Option<Duration> {
|
||||
if self.attempts >= MAX_RECONNECT_ATTEMPTS {
|
||||
return None;
|
||||
}
|
||||
let delay = Duration::from_secs(1_u64 << self.attempts);
|
||||
self.attempts += 1;
|
||||
Some(delay)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Args {
|
||||
target: String,
|
||||
username: String,
|
||||
width: u16,
|
||||
height: u16,
|
||||
use_multimon: bool,
|
||||
monitor_indices: Vec<u8>,
|
||||
fullscreen: bool,
|
||||
redirect_clipboard: bool,
|
||||
session_id: Option<String>,
|
||||
certificate_sha256: Option<[u8; 32]>,
|
||||
credential_ref: Option<CredentialRef>,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("RemoteDesk native RDP session failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> anyhow::Result<()> {
|
||||
// The patched IronRDP TLS backend disables rustls' implicit provider.
|
||||
tokio_rustls::rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.map_err(|_| anyhow::anyhow!("unable to install the rustls AWS-LC crypto provider"))?;
|
||||
let args = load_args(env::args().skip(1).collect()).map_err(anyhow::Error::msg)?;
|
||||
let mut diagnostics = args
|
||||
.session_id
|
||||
.clone()
|
||||
.map(SessionDiagnostics::new)
|
||||
.transpose()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let endpoint =
|
||||
RdpEndpoint::parse(&args.target).map_err(|_| anyhow::anyhow!("invalid RDP target"))?;
|
||||
let credential = match args.credential_ref.as_ref() {
|
||||
Some(reference) => match remotedesk_credential_store::load(reference) {
|
||||
Ok(credential) => Some(credential),
|
||||
Err(error) => {
|
||||
if let Some(diagnostics) = diagnostics.as_mut() {
|
||||
diagnostics.failed("credential_load_failed");
|
||||
}
|
||||
return Err(anyhow::Error::msg(error));
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let viewer_args = viewer_args(&endpoint, &args, credential.as_ref());
|
||||
let viewer_config =
|
||||
ViewerConfig::parse_from(viewer_args).context("invalid session configuration")?;
|
||||
drop(credential);
|
||||
|
||||
// `into_config` prompts locally for any missing username/password. The
|
||||
// password is never accepted by this process on argv or through the UI.
|
||||
let mut config = match viewer_config.into_config() {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
if let Some(diagnostics) = diagnostics.as_mut() {
|
||||
diagnostics.failed("configuration_failed");
|
||||
}
|
||||
return Err(error).context("credential prompt or session configuration failed");
|
||||
}
|
||||
};
|
||||
if let Some(diagnostics) = diagnostics.as_mut() {
|
||||
diagnostics.set_state(SessionState::Connecting);
|
||||
}
|
||||
|
||||
let event_loop = EventLoop::<ViewerEvent>::with_user_event()
|
||||
.build()
|
||||
.context("unable to create the RDP window event loop")?;
|
||||
let multi_monitor = if args.use_multimon {
|
||||
let layout = match enumerate_local_monitors()
|
||||
.and_then(|layout| select_monitors(layout, &args.monitor_indices))
|
||||
{
|
||||
Ok(layout) => layout,
|
||||
Err(error) => {
|
||||
if let Some(diagnostics) = diagnostics.as_mut() {
|
||||
diagnostics.failed("multi_monitor_layout_failed");
|
||||
}
|
||||
return Err(anyhow::Error::msg(error));
|
||||
}
|
||||
};
|
||||
config = match config.with_monitor_layout(layout.monitors.clone()) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
if let Some(diagnostics) = diagnostics.as_mut() {
|
||||
diagnostics.failed("multi_monitor_layout_failed");
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(diagnostics) = diagnostics.as_mut() {
|
||||
diagnostics.monitor_layout(layout.monitors.len());
|
||||
}
|
||||
Some(MultiMonitorPlacement {
|
||||
origin: layout.window_origin,
|
||||
size: layout.window_size,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let session_event_proxy = event_loop.create_proxy();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows_keyboard_hook::register_proxy(session_event_proxy.clone());
|
||||
windows_keyboard_hook::install();
|
||||
}
|
||||
let initial_window_size = PhysicalSize::new(
|
||||
u32::from(config.connector().desktop_size.width),
|
||||
u32::from(config.connector().desktop_size.height),
|
||||
);
|
||||
|
||||
let (placeholder_sender, _placeholder_receiver) = mpsc::unbounded_channel();
|
||||
let session_input = SessionInput::new(placeholder_sender);
|
||||
let mut app = ViewerApp::new(
|
||||
&event_loop,
|
||||
session_input.clone(),
|
||||
event_loop.create_proxy(),
|
||||
initial_window_size,
|
||||
args.fullscreen || args.use_multimon,
|
||||
multi_monitor,
|
||||
diagnostics,
|
||||
)
|
||||
.context("unable to initialize the RDP renderer")?;
|
||||
let runtime = runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("unable to create the RDP async runtime")?;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
runtime.block_on(supervise_session(
|
||||
config,
|
||||
session_input,
|
||||
session_event_proxy,
|
||||
));
|
||||
});
|
||||
event_loop
|
||||
.run_app(&mut app)
|
||||
.context("RDP window stopped unexpectedly")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_args(arguments: Vec<String>) -> Result<Args, String> {
|
||||
if let [option, pipe_name] = arguments.as_slice()
|
||||
&& option == "--control-pipe"
|
||||
{
|
||||
let session = receive_secure_pipe_session(pipe_name)
|
||||
.map_err(|error| format!("secure control pipe failed: {error}"))?;
|
||||
let config: RdpViewerLaunchConfig = serde_json::from_slice(session.payload())
|
||||
.map_err(|_| "secure control pipe payload does not match the RDP schema".to_owned())?;
|
||||
let args = args_from_pipe_config(config)?;
|
||||
session
|
||||
.acknowledge()
|
||||
.map_err(|error| format!("secure control pipe acknowledgement failed: {error}"))?;
|
||||
return Ok(args);
|
||||
}
|
||||
parse_args(arguments.into_iter())
|
||||
}
|
||||
|
||||
fn args_from_pipe_config(config: RdpViewerLaunchConfig) -> Result<Args, String> {
|
||||
if config.schema_version != 2 {
|
||||
return Err("unsupported RDP control pipe schema".to_owned());
|
||||
}
|
||||
RdpEndpoint::parse(&config.target).map_err(|_| "invalid RDP target".to_owned())?;
|
||||
if config.username.len() > 256 || config.username.chars().any(char::is_control) {
|
||||
return Err("invalid Windows username".to_owned());
|
||||
}
|
||||
if config.session_id.is_empty()
|
||||
|| config.session_id.len() > 64
|
||||
|| !config
|
||||
.session_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
{
|
||||
return Err("invalid RDP diagnostics session identifier".to_owned());
|
||||
}
|
||||
let width = parse_dimension(&config.width.to_string())?;
|
||||
let height = parse_dimension(&config.height.to_string())?;
|
||||
let certificate_sha256 = (!config.certificate_sha256.trim().is_empty())
|
||||
.then(|| parse_certificate_sha256(&config.certificate_sha256))
|
||||
.transpose()?;
|
||||
let credential_ref = config
|
||||
.credential_ref
|
||||
.map(CredentialRef::new)
|
||||
.transpose()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Args {
|
||||
target: config.target,
|
||||
username: config.username,
|
||||
width,
|
||||
height,
|
||||
use_multimon: config.use_multimon,
|
||||
monitor_indices: config.monitor_indices,
|
||||
fullscreen: config.fullscreen,
|
||||
redirect_clipboard: config.redirect_clipboard,
|
||||
session_id: Some(config.session_id),
|
||||
certificate_sha256,
|
||||
credential_ref,
|
||||
})
|
||||
}
|
||||
|
||||
async fn supervise_session(
|
||||
mut config: ironrdp::client::config::Config,
|
||||
session_input: SessionInput,
|
||||
event_loop_proxy: winit::event_loop::EventLoopProxy<ViewerEvent>,
|
||||
) {
|
||||
let mut reconnect = ReconnectPolicy::default();
|
||||
loop {
|
||||
let (output_sender, mut output_receiver) = mpsc::channel::<RdpOutputEvent>(64);
|
||||
let client = RdpClient::new(config.clone(), output_sender);
|
||||
session_input.replace(client.input_sender());
|
||||
let client_run = client.run();
|
||||
tokio::pin!(client_run);
|
||||
|
||||
let mut failure = None;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
event = output_receiver.recv() => {
|
||||
let Some(event) = event else {
|
||||
break;
|
||||
};
|
||||
match event {
|
||||
RdpOutputEvent::ConnectionFailure(_) | RdpOutputEvent::Terminated(Err(_)) => {
|
||||
failure = Some(event);
|
||||
break;
|
||||
}
|
||||
RdpOutputEvent::Terminated(Ok(_)) => {
|
||||
let _ = event_loop_proxy.send_event(ViewerEvent::Rdp(event));
|
||||
return;
|
||||
}
|
||||
RdpOutputEvent::ResizeReconnectStarted { width, height, .. } => {
|
||||
config.set_desktop_size(width, height);
|
||||
if event_loop_proxy.send_event(ViewerEvent::Rdp(event)).is_err() {
|
||||
session_input.send_close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if event_loop_proxy.send_event(ViewerEvent::Rdp(event)).is_err() {
|
||||
session_input.send_close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
() = &mut client_run => break,
|
||||
}
|
||||
}
|
||||
|
||||
let Some(delay) = reconnect.next_delay() else {
|
||||
if let Some(event) = failure {
|
||||
let _ = event_loop_proxy.send_event(ViewerEvent::Rdp(event));
|
||||
} else {
|
||||
let _ = event_loop_proxy.send_event(ViewerEvent::ReconnectExhausted);
|
||||
}
|
||||
return;
|
||||
};
|
||||
if event_loop_proxy
|
||||
.send_event(ViewerEvent::ReconnectScheduled {
|
||||
attempt: reconnect.attempts,
|
||||
max_attempts: MAX_RECONNECT_ATTEMPTS,
|
||||
delay_seconds: delay.as_secs(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
let mut target = None;
|
||||
let mut username = String::new();
|
||||
let mut width = DEFAULT_WIDTH;
|
||||
let mut height = DEFAULT_HEIGHT;
|
||||
let mut use_multimon = false;
|
||||
let mut monitor_indices = Vec::new();
|
||||
let mut fullscreen = false;
|
||||
let mut redirect_clipboard = true;
|
||||
let mut session_id = None;
|
||||
let mut certificate_sha256 = None;
|
||||
let mut credential_ref = None;
|
||||
|
||||
while let Some(argument) = args.next() {
|
||||
match argument.as_str() {
|
||||
"--target" => target = Some(required_value(&mut args, "--target")?),
|
||||
"--username" => username = required_value(&mut args, "--username")?,
|
||||
"--width" => width = parse_dimension(&required_value(&mut args, "--width")?)?,
|
||||
"--height" => height = parse_dimension(&required_value(&mut args, "--height")?)?,
|
||||
"--multimon" => use_multimon = true,
|
||||
"--monitor-index" => {
|
||||
let value = required_value(&mut args, "--monitor-index")?;
|
||||
let index = value
|
||||
.parse::<u8>()
|
||||
.map_err(|_| "monitor index is invalid")?;
|
||||
monitor_indices.push(index);
|
||||
use_multimon = true;
|
||||
}
|
||||
"--fullscreen" => fullscreen = true,
|
||||
"--disable-clipboard" => redirect_clipboard = false,
|
||||
"--session-id" => session_id = Some(required_value(&mut args, "--session-id")?),
|
||||
"--credential-ref" => {
|
||||
let value = required_value(&mut args, "--credential-ref")?;
|
||||
credential_ref =
|
||||
Some(CredentialRef::new(value).map_err(|error| error.to_string())?);
|
||||
}
|
||||
"--certificate-sha256" => {
|
||||
certificate_sha256 = Some(parse_certificate_sha256(&required_value(
|
||||
&mut args,
|
||||
"--certificate-sha256",
|
||||
)?)?);
|
||||
}
|
||||
"--help" | "-h" => return Err(usage().to_owned()),
|
||||
_ => return Err("unknown RDP viewer argument".to_owned()),
|
||||
}
|
||||
}
|
||||
if username.len() > 256 || username.chars().any(char::is_control) {
|
||||
return Err("invalid Windows username".to_owned());
|
||||
}
|
||||
Ok(Args {
|
||||
target: target.ok_or_else(|| "--target is required".to_owned())?,
|
||||
username,
|
||||
width,
|
||||
height,
|
||||
use_multimon,
|
||||
monitor_indices,
|
||||
fullscreen,
|
||||
redirect_clipboard,
|
||||
session_id,
|
||||
certificate_sha256,
|
||||
credential_ref,
|
||||
})
|
||||
}
|
||||
|
||||
fn required_value(args: &mut impl Iterator<Item = String>, option: &str) -> Result<String, String> {
|
||||
args.next()
|
||||
.filter(|value| !value.is_empty() && !value.starts_with("--"))
|
||||
.ok_or_else(|| format!("{option} requires a value"))
|
||||
}
|
||||
|
||||
fn parse_dimension(value: &str) -> Result<u16, String> {
|
||||
value
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|value| (200..=8_192).contains(value))
|
||||
.ok_or_else(|| "RDP dimensions must be between 200 and 8192".to_owned())
|
||||
}
|
||||
|
||||
fn parse_certificate_sha256(value: &str) -> Result<[u8; 32], String> {
|
||||
let compact = value
|
||||
.chars()
|
||||
.filter(|character| *character != ':')
|
||||
.collect::<String>();
|
||||
if compact.len() != 64 || !compact.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err("certificate SHA-256 must contain exactly 64 hexadecimal digits".to_owned());
|
||||
}
|
||||
let mut fingerprint = [0_u8; 32];
|
||||
for (index, byte) in fingerprint.iter_mut().enumerate() {
|
||||
let offset = index * 2;
|
||||
*byte = u8::from_str_radix(&compact[offset..offset + 2], 16)
|
||||
.map_err(|_| "certificate SHA-256 is invalid".to_owned())?;
|
||||
}
|
||||
Ok(fingerprint)
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"usage: remotedesk-rdp-viewer --control-pipe <\\\\.\\pipe\\RemoteDesk\\rdp-session>"
|
||||
}
|
||||
|
||||
fn viewer_args(
|
||||
endpoint: &RdpEndpoint,
|
||||
args: &Args,
|
||||
credential: Option<&RdpCredential>,
|
||||
) -> Vec<String> {
|
||||
let mut values = vec![
|
||||
"remotedesk-rdp-viewer".to_owned(),
|
||||
rdp_authority(endpoint),
|
||||
"--desktop-width".to_owned(),
|
||||
args.width.to_string(),
|
||||
"--desktop-height".to_owned(),
|
||||
args.height.to_string(),
|
||||
"--clipboard-type".to_owned(),
|
||||
if args.redirect_clipboard {
|
||||
"enable"
|
||||
} else {
|
||||
"disable"
|
||||
}
|
||||
.to_owned(),
|
||||
];
|
||||
let account = credential.map_or(args.username.as_str(), RdpCredential::account);
|
||||
if !account.is_empty() {
|
||||
let (domain, username) = split_domain_username(account);
|
||||
values.extend(["--username".to_owned(), username.to_owned()]);
|
||||
if let Some(domain) = domain {
|
||||
values.extend(["--domain".to_owned(), domain.to_owned()]);
|
||||
}
|
||||
}
|
||||
if let Some(credential) = credential {
|
||||
values.extend(["--password".to_owned(), credential.password().to_owned()]);
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn rdp_authority(endpoint: &RdpEndpoint) -> String {
|
||||
let host = if endpoint.host().contains(':') {
|
||||
format!("[{}]", endpoint.host())
|
||||
} else {
|
||||
endpoint.host().to_owned()
|
||||
};
|
||||
if endpoint.port() == DEFAULT_RDP_PORT {
|
||||
host
|
||||
} else {
|
||||
format!("{host}:{}", endpoint.port())
|
||||
}
|
||||
}
|
||||
|
||||
fn split_domain_username(username: &str) -> (Option<&str>, &str) {
|
||||
username
|
||||
.split_once('\\')
|
||||
.map_or((None, username), |(domain, name)| (Some(domain), name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
use remotedesk_client_core::{
|
||||
PIPE_BOOTSTRAP_KEY_ENV, SecurePipeServer, current_process_identity,
|
||||
};
|
||||
|
||||
fn strings<'a>(values: &'a [&'a str]) -> impl Iterator<Item = String> + 'a {
|
||||
values.iter().map(|value| (*value).to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_only_non_secret_session_options() {
|
||||
let args = parse_args(strings(&[
|
||||
"--target",
|
||||
"desktop.example.test:3390",
|
||||
"--username",
|
||||
"DOMAIN\\zoe",
|
||||
"--width",
|
||||
"1920",
|
||||
"--height",
|
||||
"1080",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(args.target, "desktop.example.test:3390");
|
||||
assert_eq!(args.username, "DOMAIN\\zoe");
|
||||
assert_eq!((args.width, args.height), (1920, 1080));
|
||||
assert!(!args.use_multimon);
|
||||
assert!(!args.fullscreen);
|
||||
assert!(args.redirect_clipboard);
|
||||
assert!(args.credential_ref.is_none());
|
||||
|
||||
assert!(parse_args(strings(&["--target", "host", "--password", "secret"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_pipe_config_is_strictly_validated() {
|
||||
let config = RdpViewerLaunchConfig {
|
||||
schema_version: 2,
|
||||
target: "desktop.example.test:3390".to_owned(),
|
||||
username: "DOMAIN\\zoe".to_owned(),
|
||||
width: 1_920,
|
||||
height: 1_080,
|
||||
use_multimon: true,
|
||||
monitor_indices: vec![],
|
||||
fullscreen: true,
|
||||
redirect_clipboard: false,
|
||||
session_id: "10-20-30".to_owned(),
|
||||
certificate_sha256: "ab".repeat(32),
|
||||
credential_ref: Some("RemoteDesk/RDP/profile-42".to_owned()),
|
||||
};
|
||||
let args = args_from_pipe_config(config).unwrap();
|
||||
assert_eq!(args.target, "desktop.example.test:3390");
|
||||
assert_eq!(args.certificate_sha256, Some([0xab; 32]));
|
||||
assert_eq!(
|
||||
args.credential_ref.as_ref().map(CredentialRef::target),
|
||||
Some("RemoteDesk/RDP/profile-42")
|
||||
);
|
||||
assert!(args.fullscreen);
|
||||
assert!(args.use_multimon);
|
||||
assert!(!args.redirect_clipboard);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn invalid_secure_pipe_config_is_not_acknowledged() {
|
||||
let session_id = format!("viewer-test-{}", std::process::id());
|
||||
let (server, bootstrap) = SecurePipeServer::create(&session_id).unwrap();
|
||||
let pipe_name = bootstrap.pipe_name().to_owned();
|
||||
let bootstrap_key = bootstrap.encoded_key().to_owned();
|
||||
let expected = current_process_identity().unwrap();
|
||||
let client = std::thread::spawn(move || {
|
||||
// SAFETY: this test sets the one-time environment value before the
|
||||
// helper code reads and removes it, and creates no other worker.
|
||||
unsafe { std::env::set_var(PIPE_BOOTSTRAP_KEY_ENV, bootstrap_key) };
|
||||
load_args(vec!["--control-pipe".to_owned(), pipe_name])
|
||||
});
|
||||
|
||||
let payload = br#"{"schema_version":1,"unexpected":true}"#;
|
||||
let error = server
|
||||
.authenticate_and_send(expected, payload, Duration::from_secs(2))
|
||||
.unwrap_err();
|
||||
assert!(client.join().unwrap().is_err());
|
||||
assert!(matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::UnexpectedEof
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::TimedOut
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_arguments_never_contain_a_password_option() {
|
||||
let endpoint = RdpEndpoint::parse("[2001:db8::5]:3390").unwrap();
|
||||
let args = Args {
|
||||
target: "ignored".to_owned(),
|
||||
username: "DOMAIN\\zoe".to_owned(),
|
||||
width: 2_560,
|
||||
height: 1_440,
|
||||
use_multimon: false,
|
||||
monitor_indices: vec![],
|
||||
fullscreen: false,
|
||||
redirect_clipboard: true,
|
||||
session_id: None,
|
||||
certificate_sha256: None,
|
||||
credential_ref: None,
|
||||
};
|
||||
let values = viewer_args(&endpoint, &args, None);
|
||||
|
||||
assert_eq!(values[1], "[2001:db8::5]:3390");
|
||||
assert!(values.windows(2).any(|pair| pair == ["--domain", "DOMAIN"]));
|
||||
assert!(values.windows(2).any(|pair| pair == ["--username", "zoe"]));
|
||||
assert!(
|
||||
values
|
||||
.windows(2)
|
||||
.any(|pair| pair == ["--clipboard-type", "enable"])
|
||||
);
|
||||
assert!(!values.iter().any(|value| value.contains("password")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_are_bounded() {
|
||||
assert_eq!(parse_dimension("200"), Ok(200));
|
||||
assert_eq!(parse_dimension("8192"), Ok(8192));
|
||||
assert!(parse_dimension("199").is_err());
|
||||
assert!(parse_dimension("8193").is_err());
|
||||
assert!(parse_dimension("full").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_is_an_explicit_non_secret_flag() {
|
||||
let args = parse_args(strings(&["--target", "host", "--fullscreen"])).unwrap();
|
||||
assert!(args.fullscreen);
|
||||
assert!(args.session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_redirection_can_be_disabled_explicitly() {
|
||||
let args = parse_args(strings(&["--target", "host", "--disable-clipboard"])).unwrap();
|
||||
assert!(!args.redirect_clipboard);
|
||||
let endpoint = RdpEndpoint::parse("host").unwrap();
|
||||
let values = viewer_args(&endpoint, &args, None);
|
||||
assert!(
|
||||
values
|
||||
.windows(2)
|
||||
.any(|pair| pair == ["--clipboard-type", "disable"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_identifier_is_an_internal_non_secret_option() {
|
||||
let args = parse_args(strings(&["--target", "host", "--session-id", "10-20-30"])).unwrap();
|
||||
assert_eq!(args.session_id.as_deref(), Some("10-20-30"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_reference_is_validated_and_remains_non_secret() {
|
||||
let args = parse_args(strings(&[
|
||||
"--target",
|
||||
"host",
|
||||
"--credential-ref",
|
||||
"RemoteDesk/RDP/profile-42",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
args.credential_ref.as_ref().map(CredentialRef::target),
|
||||
Some("RemoteDesk/RDP/profile-42")
|
||||
);
|
||||
assert!(
|
||||
parse_args(strings(&[
|
||||
"--target",
|
||||
"host",
|
||||
"--credential-ref",
|
||||
"TERMSRV/host"
|
||||
]))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_fingerprint_accepts_plain_or_colon_hex() {
|
||||
assert_eq!(parse_certificate_sha256(&"ab".repeat(32)), Ok([0xab; 32]));
|
||||
assert_eq!(
|
||||
parse_certificate_sha256(&["ab"; 32].join(":")),
|
||||
Ok([0xab; 32])
|
||||
);
|
||||
assert!(parse_certificate_sha256("ab").is_err());
|
||||
assert!(parse_certificate_sha256(&"zz".repeat(32)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_policy_is_bounded_and_exponential() {
|
||||
let mut policy = ReconnectPolicy::default();
|
||||
assert_eq!(policy.next_delay(), Some(Duration::from_secs(1)));
|
||||
assert_eq!(policy.next_delay(), Some(Duration::from_secs(2)));
|
||||
assert_eq!(policy.next_delay(), Some(Duration::from_secs(4)));
|
||||
assert_eq!(policy.next_delay(), None);
|
||||
assert_eq!(policy.attempts, MAX_RECONNECT_ATTEMPTS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
use ironrdp::client::config::MonitorLayoutEntry;
|
||||
use winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||
|
||||
const MAX_RDP_MONITORS: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct RawMonitor {
|
||||
left: i32,
|
||||
top: i32,
|
||||
right: i32,
|
||||
bottom: i32,
|
||||
primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalMonitorLayout {
|
||||
pub monitors: Vec<MonitorLayoutEntry>,
|
||||
pub window_origin: PhysicalPosition<i32>,
|
||||
pub window_size: PhysicalSize<u32>,
|
||||
}
|
||||
|
||||
pub fn enumerate_local_monitors() -> Result<LocalMonitorLayout, String> {
|
||||
enumerate_platform_monitors().and_then(normalize_monitor_layout)
|
||||
}
|
||||
|
||||
pub fn select_monitors(
|
||||
layout: LocalMonitorLayout,
|
||||
indices: &[u8],
|
||||
) -> Result<LocalMonitorLayout, String> {
|
||||
if indices.is_empty() {
|
||||
return Ok(layout);
|
||||
}
|
||||
if indices.len() < 2 || indices.len() > MAX_RDP_MONITORS {
|
||||
return Err("custom RDP monitor selection requires between 2 and 16 displays".to_owned());
|
||||
}
|
||||
let mut selected = Vec::with_capacity(indices.len());
|
||||
for &index in indices {
|
||||
let monitor = layout
|
||||
.monitors
|
||||
.get(usize::from(index))
|
||||
.ok_or_else(|| "custom RDP monitor index is out of range".to_owned())?;
|
||||
if selected
|
||||
.iter()
|
||||
.any(|entry: &MonitorLayoutEntry| entry == monitor)
|
||||
{
|
||||
return Err("custom RDP monitor selection contains duplicates".to_owned());
|
||||
}
|
||||
selected.push(monitor.clone());
|
||||
}
|
||||
if !selected.iter().any(|monitor| monitor.is_primary) {
|
||||
return Err("custom RDP monitor selection must include the primary display".to_owned());
|
||||
}
|
||||
let left = selected
|
||||
.iter()
|
||||
.map(|monitor| monitor.left)
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
let top = selected
|
||||
.iter()
|
||||
.map(|monitor| monitor.top)
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
let original_left = layout
|
||||
.monitors
|
||||
.iter()
|
||||
.map(|monitor| monitor.left)
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
let original_top = layout
|
||||
.monitors
|
||||
.iter()
|
||||
.map(|monitor| monitor.top)
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
let right = selected
|
||||
.iter()
|
||||
.map(|monitor| {
|
||||
monitor
|
||||
.left
|
||||
.saturating_add(i32::try_from(monitor.width).unwrap_or(i32::MAX))
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let bottom = selected
|
||||
.iter()
|
||||
.map(|monitor| {
|
||||
monitor
|
||||
.top
|
||||
.saturating_add(i32::try_from(monitor.height).unwrap_or(i32::MAX))
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let width = u32::try_from(i64::from(right) - i64::from(left))
|
||||
.map_err(|_| "custom RDP monitor bounds are invalid".to_owned())?;
|
||||
let height = u32::try_from(i64::from(bottom) - i64::from(top))
|
||||
.map_err(|_| "custom RDP monitor bounds are invalid".to_owned())?;
|
||||
Ok(LocalMonitorLayout {
|
||||
monitors: selected,
|
||||
window_origin: PhysicalPosition::new(
|
||||
layout
|
||||
.window_origin
|
||||
.x
|
||||
.saturating_add(left)
|
||||
.saturating_sub(original_left),
|
||||
layout
|
||||
.window_origin
|
||||
.y
|
||||
.saturating_add(top)
|
||||
.saturating_sub(original_top),
|
||||
),
|
||||
window_size: PhysicalSize::new(width, height),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_monitor_layout(raw: Vec<RawMonitor>) -> Result<LocalMonitorLayout, String> {
|
||||
if !(2..=MAX_RDP_MONITORS).contains(&raw.len()) {
|
||||
return Err(
|
||||
"native RDP multi-monitor requires between 2 and 16 active displays".to_owned(),
|
||||
);
|
||||
}
|
||||
let mut primaries = raw.iter().filter(|monitor| monitor.primary);
|
||||
let primary = primaries
|
||||
.next()
|
||||
.ok_or_else(|| "Windows did not report a primary display".to_owned())?;
|
||||
if primaries.next().is_some() {
|
||||
return Err("Windows reported more than one primary display".to_owned());
|
||||
}
|
||||
let primary_left = primary.left;
|
||||
let primary_top = primary.top;
|
||||
|
||||
let window_left = raw.iter().map(|monitor| monitor.left).min().unwrap_or(0);
|
||||
let window_top = raw.iter().map(|monitor| monitor.top).min().unwrap_or(0);
|
||||
let window_right = raw.iter().map(|monitor| monitor.right).max().unwrap_or(0);
|
||||
let window_bottom = raw.iter().map(|monitor| monitor.bottom).max().unwrap_or(0);
|
||||
let window_width = u32::try_from(i64::from(window_right) - i64::from(window_left))
|
||||
.map_err(|_| "the Windows virtual desktop width is invalid".to_owned())?;
|
||||
let window_height = u32::try_from(i64::from(window_bottom) - i64::from(window_top))
|
||||
.map_err(|_| "the Windows virtual desktop height is invalid".to_owned())?;
|
||||
|
||||
let mut monitors = Vec::with_capacity(raw.len());
|
||||
for monitor in raw {
|
||||
let raw_width = u32::try_from(i64::from(monitor.right) - i64::from(monitor.left))
|
||||
.map_err(|_| "Windows reported an invalid monitor width".to_owned())?;
|
||||
let height = u32::try_from(i64::from(monitor.bottom) - i64::from(monitor.top))
|
||||
.map_err(|_| "Windows reported an invalid monitor height".to_owned())?;
|
||||
let width = raw_width & !1;
|
||||
if !(200..=8_192).contains(&width) || !(200..=8_192).contains(&height) {
|
||||
return Err("each RDP monitor must be between 200 and 8192 pixels".to_owned());
|
||||
}
|
||||
let left = i32::try_from(i64::from(monitor.left) - i64::from(primary_left))
|
||||
.map_err(|_| "monitor X coordinate exceeds the RDP layout limit".to_owned())?;
|
||||
let top = i32::try_from(i64::from(monitor.top) - i64::from(primary_top))
|
||||
.map_err(|_| "monitor Y coordinate exceeds the RDP layout limit".to_owned())?;
|
||||
monitors.push(MonitorLayoutEntry {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
scale_factor: 100,
|
||||
is_primary: monitor.primary,
|
||||
});
|
||||
}
|
||||
monitors.sort_by_key(|monitor| !monitor.is_primary);
|
||||
|
||||
Ok(LocalMonitorLayout {
|
||||
monitors,
|
||||
window_origin: PhysicalPosition::new(window_left, window_top),
|
||||
window_size: PhysicalSize::new(window_width, window_height),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn enumerate_platform_monitors() -> Result<Vec<RawMonitor>, String> {
|
||||
use std::mem;
|
||||
use windows::Win32::Foundation::{LPARAM, RECT};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFO,
|
||||
};
|
||||
use windows::core::BOOL;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Enumeration {
|
||||
monitors: Vec<RawMonitor>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn callback(
|
||||
monitor: HMONITOR,
|
||||
_device_context: HDC,
|
||||
_rect: *mut RECT,
|
||||
data: LPARAM,
|
||||
) -> BOOL {
|
||||
// SAFETY: EnumDisplayMonitors invokes the callback synchronously while
|
||||
// `data` points to the live Enumeration value below.
|
||||
let enumeration = unsafe { &mut *(data.0 as *mut Enumeration) };
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: u32::try_from(mem::size_of::<MONITORINFO>())
|
||||
.expect("MONITORINFO size fits u32"),
|
||||
..MONITORINFO::default()
|
||||
};
|
||||
// SAFETY: `info` is initialized with the required cbSize and remains
|
||||
// valid for the duration of this Win32 call.
|
||||
if !unsafe { GetMonitorInfoW(monitor, &mut info) }.as_bool() {
|
||||
enumeration.error = Some(format!(
|
||||
"unable to read Windows monitor geometry: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
return false.into();
|
||||
}
|
||||
enumeration.monitors.push(RawMonitor {
|
||||
left: info.rcMonitor.left,
|
||||
top: info.rcMonitor.top,
|
||||
right: info.rcMonitor.right,
|
||||
bottom: info.rcMonitor.bottom,
|
||||
primary: info.dwFlags & 1 != 0,
|
||||
});
|
||||
true.into()
|
||||
}
|
||||
|
||||
let mut enumeration = Enumeration::default();
|
||||
// SAFETY: the callback is synchronous and receives an exclusive pointer
|
||||
// to `enumeration`; no device context or clipping rectangle is required.
|
||||
let completed = unsafe {
|
||||
EnumDisplayMonitors(
|
||||
None,
|
||||
None,
|
||||
Some(callback),
|
||||
LPARAM((&mut enumeration as *mut Enumeration) as isize),
|
||||
)
|
||||
};
|
||||
if let Some(error) = enumeration.error {
|
||||
return Err(error);
|
||||
}
|
||||
if !completed.as_bool() {
|
||||
return Err(format!(
|
||||
"unable to enumerate Windows monitors: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
Ok(enumeration.monitors)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn enumerate_platform_monitors() -> Result<Vec<RawMonitor>, String> {
|
||||
Err("native RDP multi-monitor is only available on Windows".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_geometry_is_normalized_relative_to_the_primary_monitor() {
|
||||
let layout = normalize_monitor_layout(vec![
|
||||
RawMonitor {
|
||||
left: -2_560,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 1_440,
|
||||
primary: false,
|
||||
},
|
||||
RawMonitor {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1_920,
|
||||
bottom: 1_080,
|
||||
primary: true,
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert!(layout.monitors[0].is_primary);
|
||||
assert_eq!((layout.monitors[0].left, layout.monitors[0].top), (0, 0));
|
||||
assert_eq!(
|
||||
(layout.monitors[1].left, layout.monitors[1].top),
|
||||
(-2_560, 0)
|
||||
);
|
||||
assert_eq!(layout.window_origin, PhysicalPosition::new(-2_560, 0));
|
||||
assert_eq!(layout.window_size, PhysicalSize::new(4_480, 1_440));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_primary_and_monitor_counts_are_rejected() {
|
||||
let only = RawMonitor {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1_920,
|
||||
bottom: 1_080,
|
||||
primary: true,
|
||||
};
|
||||
assert!(normalize_monitor_layout(vec![only]).is_err());
|
||||
assert!(
|
||||
normalize_monitor_layout(vec![
|
||||
only,
|
||||
RawMonitor {
|
||||
primary: true,
|
||||
..only
|
||||
}
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_selection_keeps_primary_and_recomputes_bounds() {
|
||||
let layout = normalize_monitor_layout(vec![
|
||||
RawMonitor {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1_920,
|
||||
bottom: 1_080,
|
||||
primary: true,
|
||||
},
|
||||
RawMonitor {
|
||||
left: 1_920,
|
||||
top: 0,
|
||||
right: 3_840,
|
||||
bottom: 1_080,
|
||||
primary: false,
|
||||
},
|
||||
RawMonitor {
|
||||
left: -1_280,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 1_024,
|
||||
primary: false,
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
let selected = select_monitors(layout, &[0, 1]).unwrap();
|
||||
assert_eq!(selected.monitors.len(), 2);
|
||||
assert_eq!(selected.window_origin, PhysicalPosition::new(0, 0));
|
||||
assert_eq!(selected.window_size, PhysicalSize::new(3_840, 1_080));
|
||||
assert!(select_monitors(selected.clone(), &[1, 2]).is_err());
|
||||
assert!(select_monitors(selected, &[0, 0]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
use core::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ironrdp::client::rdp::DirtyRegion;
|
||||
use winit::event_loop::{EventLoop, OwnedDisplayHandle};
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::app::ViewerEvent;
|
||||
|
||||
pub struct RendererContext {
|
||||
software: softbuffer::Context<OwnedDisplayHandle>,
|
||||
}
|
||||
|
||||
impl RendererContext {
|
||||
pub fn new(event_loop: &EventLoop<ViewerEvent>) -> anyhow::Result<Self> {
|
||||
let software = softbuffer::Context::new(event_loop.owned_display_handle())
|
||||
.map_err(|error| anyhow::anyhow!("unable to initialize software renderer: {error}"))?;
|
||||
Ok(Self { software })
|
||||
}
|
||||
|
||||
pub fn create_surface(&self, window: Arc<Window>) -> anyhow::Result<WindowSurface> {
|
||||
#[cfg(windows)]
|
||||
if let Ok(renderer) = D3d11Renderer::new(&window) {
|
||||
return Ok(WindowSurface {
|
||||
window,
|
||||
renderer: Renderer::D3d11(renderer),
|
||||
});
|
||||
}
|
||||
|
||||
let surface = softbuffer::Surface::new(&self.software, Arc::clone(&window))
|
||||
.map_err(|error| anyhow::anyhow!("unable to create software surface: {error}"))?;
|
||||
Ok(WindowSurface {
|
||||
window,
|
||||
renderer: Renderer::Software(surface),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WindowSurface {
|
||||
pub window: Arc<Window>,
|
||||
renderer: Renderer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PresentStats {
|
||||
pub upload_mode: &'static str,
|
||||
pub uploaded_pixels: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct UploadRegion {
|
||||
left: u32,
|
||||
top: u32,
|
||||
right: u32,
|
||||
bottom: u32,
|
||||
}
|
||||
|
||||
impl UploadRegion {
|
||||
fn full(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel_count(self) -> u64 {
|
||||
u64::from(self.right - self.left) * u64::from(self.bottom - self.top)
|
||||
}
|
||||
|
||||
fn is_full(self, width: u32, height: u32) -> bool {
|
||||
self == Self::full(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_region(dirty: DirtyRegion, width: u32, height: u32) -> UploadRegion {
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| u32::from(dirty.left) >= width
|
||||
|| u32::from(dirty.top) >= height
|
||||
|| dirty.right < dirty.left
|
||||
|| dirty.bottom < dirty.top
|
||||
{
|
||||
return UploadRegion::full(width, height);
|
||||
}
|
||||
UploadRegion {
|
||||
left: u32::from(dirty.left),
|
||||
top: u32::from(dirty.top),
|
||||
right: u32::from(dirty.right).min(width - 1) + 1,
|
||||
bottom: u32::from(dirty.bottom).min(height - 1) + 1,
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowSurface {
|
||||
pub fn resize(&mut self, width: u16, height: u16) -> anyhow::Result<()> {
|
||||
match &mut self.renderer {
|
||||
Renderer::Software(surface) => surface
|
||||
.resize(
|
||||
NonZeroU32::new(u32::from(width)).expect("RDP width is non-zero"),
|
||||
NonZeroU32::new(u32::from(height)).expect("RDP height is non-zero"),
|
||||
)
|
||||
.map_err(|error| anyhow::anyhow!("unable to resize software surface: {error}")),
|
||||
#[cfg(windows)]
|
||||
Renderer::D3d11(renderer) => renderer.resize(u32::from(width), u32::from(height)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn present(
|
||||
&mut self,
|
||||
pixels: &[u32],
|
||||
width: u16,
|
||||
height: u16,
|
||||
dirty_region: Option<DirtyRegion>,
|
||||
) -> anyhow::Result<PresentStats> {
|
||||
match &mut self.renderer {
|
||||
Renderer::Software(surface) => {
|
||||
let mut buffer = surface.buffer_mut().map_err(|error| {
|
||||
anyhow::anyhow!("unable to acquire software buffer: {error}")
|
||||
})?;
|
||||
buffer.copy_from_slice(pixels);
|
||||
buffer.present().map_err(|error| {
|
||||
anyhow::anyhow!("unable to present software frame: {error}")
|
||||
})?;
|
||||
Ok(PresentStats {
|
||||
upload_mode: "full_frame",
|
||||
uploaded_pixels: u64::from(width) * u64::from(height),
|
||||
})
|
||||
}
|
||||
#[cfg(windows)]
|
||||
Renderer::D3d11(renderer) => renderer.present(pixels, width, height, dirty_region),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self.renderer {
|
||||
Renderer::Software(_) => "software framebuffer",
|
||||
#[cfg(windows)]
|
||||
Renderer::D3d11(_) => "D3D11 CPU upload",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Renderer {
|
||||
Software(softbuffer::Surface<OwnedDisplayHandle, Arc<Window>>),
|
||||
#[cfg(windows)]
|
||||
D3d11(D3d11Renderer),
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct D3d11Renderer {
|
||||
device: windows::Win32::Graphics::Direct3D11::ID3D11Device,
|
||||
context: windows::Win32::Graphics::Direct3D11::ID3D11DeviceContext,
|
||||
swap_chain: windows::Win32::Graphics::Dxgi::IDXGISwapChain,
|
||||
back_buffer: Option<windows::Win32::Graphics::Direct3D11::ID3D11Texture2D>,
|
||||
frame_texture: Option<windows::Win32::Graphics::Direct3D11::ID3D11Texture2D>,
|
||||
size: (u32, u32),
|
||||
needs_full_upload: bool,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl D3d11Renderer {
|
||||
fn new(window: &Window) -> anyhow::Result<Self> {
|
||||
use raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
use windows::Win32::Foundation::{HMODULE, HWND};
|
||||
use windows::Win32::Graphics::Direct3D::{
|
||||
D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1,
|
||||
D3D_FEATURE_LEVEL_12_0, D3D_FEATURE_LEVEL_12_1,
|
||||
};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDeviceAndSwapChain,
|
||||
ID3D11Device, ID3D11DeviceContext,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_MODE_DESC, DXGI_RATIONAL, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
DXGI_SWAP_CHAIN_DESC, DXGI_SWAP_EFFECT_DISCARD, DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
IDXGISwapChain,
|
||||
};
|
||||
|
||||
let RawWindowHandle::Win32(handle) = window
|
||||
.window_handle()
|
||||
.map_err(|error| anyhow::anyhow!("unable to obtain window handle: {error}"))?
|
||||
.as_raw()
|
||||
else {
|
||||
anyhow::bail!("D3D11 rendering requires a Win32 window");
|
||||
};
|
||||
let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void);
|
||||
let size = window.inner_size();
|
||||
let width = size.width.max(1);
|
||||
let height = size.height.max(1);
|
||||
let descriptor = DXGI_SWAP_CHAIN_DESC {
|
||||
BufferDesc: DXGI_MODE_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
RefreshRate: DXGI_RATIONAL {
|
||||
Numerator: 0,
|
||||
Denominator: 1,
|
||||
},
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
..Default::default()
|
||||
},
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
|
||||
BufferCount: 2,
|
||||
OutputWindow: hwnd,
|
||||
Windowed: true.into(),
|
||||
SwapEffect: DXGI_SWAP_EFFECT_DISCARD,
|
||||
Flags: 0,
|
||||
};
|
||||
let levels = [
|
||||
D3D_FEATURE_LEVEL_12_1,
|
||||
D3D_FEATURE_LEVEL_12_0,
|
||||
D3D_FEATURE_LEVEL_11_1,
|
||||
D3D_FEATURE_LEVEL_11_0,
|
||||
];
|
||||
let mut swap_chain: Option<IDXGISwapChain> = None;
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
unsafe {
|
||||
D3D11CreateDeviceAndSwapChain(
|
||||
None,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&levels),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&raw const descriptor),
|
||||
Some(&raw mut swap_chain),
|
||||
Some(&raw mut device),
|
||||
None,
|
||||
Some(&raw mut context),
|
||||
)
|
||||
}
|
||||
.map_err(|error| anyhow::anyhow!("unable to create D3D11 swap chain: {error}"))?;
|
||||
let swap_chain =
|
||||
swap_chain.ok_or_else(|| anyhow::anyhow!("D3D11 returned no swap chain"))?;
|
||||
let device = device.ok_or_else(|| anyhow::anyhow!("D3D11 returned no device"))?;
|
||||
let context = context.ok_or_else(|| anyhow::anyhow!("D3D11 returned no context"))?;
|
||||
let back_buffer = Some(unsafe { swap_chain.GetBuffer(0)? });
|
||||
let frame_texture = Some(Self::create_frame_texture(&device, width, height)?);
|
||||
Ok(Self {
|
||||
device,
|
||||
context,
|
||||
swap_chain,
|
||||
back_buffer,
|
||||
frame_texture,
|
||||
size: (width, height),
|
||||
needs_full_upload: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_frame_texture(
|
||||
device: &windows::Win32::Graphics::Direct3D11::ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> anyhow::Result<windows::Win32::Graphics::Direct3D11::ID3D11Texture2D> {
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, ID3D11Texture2D,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
|
||||
let descriptor = D3D11_TEXTURE2D_DESC {
|
||||
Width: width,
|
||||
Height: height,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let mut texture: Option<ID3D11Texture2D> = None;
|
||||
unsafe {
|
||||
device.CreateTexture2D(&raw const descriptor, None, Some(&raw mut texture))?;
|
||||
}
|
||||
texture.ok_or_else(|| anyhow::anyhow!("D3D11 returned no frame texture"))
|
||||
}
|
||||
|
||||
fn resize(&mut self, width: u32, height: u32) -> anyhow::Result<()> {
|
||||
use windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_UNKNOWN;
|
||||
use windows::Win32::Graphics::Dxgi::DXGI_SWAP_CHAIN_FLAG;
|
||||
|
||||
if self.size == (width, height) {
|
||||
return Ok(());
|
||||
}
|
||||
self.back_buffer = None;
|
||||
self.frame_texture = None;
|
||||
unsafe {
|
||||
self.swap_chain.ResizeBuffers(
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
DXGI_FORMAT_UNKNOWN,
|
||||
DXGI_SWAP_CHAIN_FLAG(0),
|
||||
)?;
|
||||
self.back_buffer = Some(self.swap_chain.GetBuffer(0)?);
|
||||
}
|
||||
self.frame_texture = Some(Self::create_frame_texture(&self.device, width, height)?);
|
||||
self.size = (width, height);
|
||||
self.needs_full_upload = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn present(
|
||||
&mut self,
|
||||
pixels: &[u32],
|
||||
width: u16,
|
||||
height: u16,
|
||||
dirty_region: Option<DirtyRegion>,
|
||||
) -> anyhow::Result<PresentStats> {
|
||||
use windows::Win32::Graphics::Direct3D11::D3D11_BOX;
|
||||
use windows::Win32::Graphics::Dxgi::DXGI_PRESENT;
|
||||
|
||||
let width = u32::from(width);
|
||||
let height = u32::from(height);
|
||||
self.resize(width, height)?;
|
||||
let expected = usize::try_from(width)
|
||||
.ok()
|
||||
.and_then(|width| {
|
||||
usize::try_from(height)
|
||||
.ok()
|
||||
.and_then(|height| width.checked_mul(height))
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("D3D11 frame dimensions overflow"))?;
|
||||
if pixels.len() != expected {
|
||||
anyhow::bail!("D3D11 frame length does not match its dimensions");
|
||||
}
|
||||
let back_buffer = self
|
||||
.back_buffer
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("D3D11 back buffer is unavailable"))?;
|
||||
let frame_texture = self
|
||||
.frame_texture
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("D3D11 frame texture is unavailable"))?;
|
||||
let region = if self.needs_full_upload {
|
||||
Some(UploadRegion::full(width, height))
|
||||
} else {
|
||||
dirty_region.map(|dirty| upload_region(dirty, width, height))
|
||||
};
|
||||
unsafe {
|
||||
if let Some(region) = region {
|
||||
let source_offset = usize::try_from(region.top)
|
||||
.ok()
|
||||
.and_then(|top| {
|
||||
usize::try_from(width)
|
||||
.ok()
|
||||
.and_then(|width| top.checked_mul(width))
|
||||
})
|
||||
.and_then(|offset| {
|
||||
usize::try_from(region.left)
|
||||
.ok()
|
||||
.and_then(|left| offset.checked_add(left))
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("D3D11 dirty region offset overflow"))?;
|
||||
let upload_box = D3D11_BOX {
|
||||
left: region.left,
|
||||
top: region.top,
|
||||
front: 0,
|
||||
right: region.right,
|
||||
bottom: region.bottom,
|
||||
back: 1,
|
||||
};
|
||||
self.context.UpdateSubresource(
|
||||
frame_texture,
|
||||
0,
|
||||
Some(&raw const upload_box),
|
||||
pixels.as_ptr().add(source_offset).cast(),
|
||||
width * 4,
|
||||
0,
|
||||
);
|
||||
}
|
||||
self.context.CopyResource(back_buffer, frame_texture);
|
||||
self.swap_chain
|
||||
.Present(1, DXGI_PRESENT(0))
|
||||
.ok()
|
||||
.map_err(|error| anyhow::anyhow!("unable to present D3D11 frame: {error}"))?;
|
||||
}
|
||||
self.needs_full_upload = false;
|
||||
let (upload_mode, uploaded_pixels) = match region {
|
||||
Some(region) if region.is_full(width, height) => ("full_frame", region.pixel_count()),
|
||||
Some(region) => ("dirty_rect", region.pixel_count()),
|
||||
None => ("cached_frame", 0),
|
||||
};
|
||||
Ok(PresentStats {
|
||||
upload_mode,
|
||||
uploaded_pixels,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inclusive_dirty_region_becomes_exclusive_upload_box() {
|
||||
assert_eq!(
|
||||
upload_region(
|
||||
DirtyRegion {
|
||||
left: 10,
|
||||
top: 20,
|
||||
right: 19,
|
||||
bottom: 29,
|
||||
},
|
||||
100,
|
||||
80,
|
||||
),
|
||||
UploadRegion {
|
||||
left: 10,
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 30,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_region_is_clipped_and_invalid_input_falls_back_to_full_frame() {
|
||||
assert_eq!(
|
||||
upload_region(
|
||||
DirtyRegion {
|
||||
left: 90,
|
||||
top: 70,
|
||||
right: 200,
|
||||
bottom: 200,
|
||||
},
|
||||
100,
|
||||
80,
|
||||
),
|
||||
UploadRegion {
|
||||
left: 90,
|
||||
top: 70,
|
||||
right: 100,
|
||||
bottom: 80,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
upload_region(
|
||||
DirtyRegion {
|
||||
left: 20,
|
||||
top: 10,
|
||||
right: 19,
|
||||
bottom: 11,
|
||||
},
|
||||
100,
|
||||
80,
|
||||
),
|
||||
UploadRegion::full(100, 80)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::thread;
|
||||
|
||||
use ironrdp::input::Scancode;
|
||||
use tracing::warn;
|
||||
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowThreadProcessId,
|
||||
KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MSG, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
|
||||
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||
};
|
||||
use winit::event_loop::EventLoopProxy;
|
||||
|
||||
use crate::app::ViewerEvent;
|
||||
|
||||
static PROCESS_ID: OnceLock<u32> = OnceLock::new();
|
||||
|
||||
pub fn install() {
|
||||
let _ = PROCESS_ID.set(std::process::id());
|
||||
thread::spawn(move || unsafe {
|
||||
let hook = match SetWindowsHookExW(WH_KEYBOARD_LL, Some(callback), None, 0) {
|
||||
Ok(hook) => hook,
|
||||
Err(error) => {
|
||||
warn!(?error, "unable to install Windows keyboard hook");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut message = MSG::default();
|
||||
while GetMessageW(&mut message, None, 0, 0).as_bool() {
|
||||
let _ = TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
let _ = UnhookWindowsHookEx(hook);
|
||||
});
|
||||
|
||||
unsafe extern "system" fn callback(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code >= 0 && should_handle() {
|
||||
let data = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) };
|
||||
let message = wparam.0 as u32;
|
||||
let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN);
|
||||
let released = matches!(message, WM_KEYUP | WM_SYSKEYUP);
|
||||
if pressed || released {
|
||||
if let Some(event) = HookState::event(data, pressed) {
|
||||
if let Some(proxy) = HOOK_PROXY.get() {
|
||||
let _ = proxy.send_event(event);
|
||||
}
|
||||
return LRESULT(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||
}
|
||||
}
|
||||
|
||||
static HOOK_PROXY: OnceLock<EventLoopProxy<ViewerEvent>> = OnceLock::new();
|
||||
|
||||
fn should_handle() -> bool {
|
||||
let Some(process_id) = PROCESS_ID.get().copied() else {
|
||||
return false;
|
||||
};
|
||||
let foreground = unsafe { GetForegroundWindow() };
|
||||
if foreground.0.is_null() {
|
||||
return false;
|
||||
}
|
||||
let mut foreground_process = 0;
|
||||
unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process)) };
|
||||
foreground_process == process_id
|
||||
}
|
||||
|
||||
struct HookState;
|
||||
|
||||
impl HookState {
|
||||
fn event(data: &KBDLLHOOKSTRUCT, pressed: bool) -> Option<ViewerEvent> {
|
||||
static LOGO_DOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
let is_logo = matches!(data.vkCode, 0x5B | 0x5C);
|
||||
let active = LOGO_DOWN.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if !is_logo && !active {
|
||||
return None;
|
||||
}
|
||||
if is_logo {
|
||||
LOGO_DOWN.store(pressed, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
let mut scancode = data.scanCode as u16;
|
||||
if data.flags.contains(LLKHF_EXTENDED) {
|
||||
scancode |= 0xE000;
|
||||
}
|
||||
Some(ViewerEvent::GlobalKeyboard {
|
||||
scancode: Scancode::from_u16(scancode),
|
||||
pressed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_proxy(proxy: EventLoopProxy<ViewerEvent>) {
|
||||
let _ = HOOK_PROXY.set(proxy);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "remotedesk-windows-agent-viewer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
flate2 = "1.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
softbuffer = "0.4"
|
||||
winit = "0.30"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
@@ -0,0 +1,735 @@
|
||||
use flate2::read::ZlibDecoder;
|
||||
use serde::Serialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{BufRead as _, BufReader, Read as _, Write as _};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::PhysicalSize;
|
||||
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, OwnedDisplayHandle};
|
||||
use winit::keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey};
|
||||
use winit::window::{Fullscreen, Window, WindowAttributes, WindowId};
|
||||
#[cfg(windows)]
|
||||
mod windows_keyboard_hook;
|
||||
|
||||
const DEFAULT_PORT: u16 = 39_501;
|
||||
const MAX_DIMENSION: u32 = 16_384;
|
||||
const MAX_RAW_BYTES: usize = 256 * 1024 * 1024;
|
||||
const MAX_COMPRESSED_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
target: String,
|
||||
session_id: String,
|
||||
fps: u8,
|
||||
fullscreen: bool,
|
||||
mode: String,
|
||||
application: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Frame {
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixels: Vec<u32>,
|
||||
capture_latency_ms: Option<f64>,
|
||||
encode_latency_ms: Option<f64>,
|
||||
processing_latency_ms: Option<f64>,
|
||||
decode_latency_ms: f64,
|
||||
}
|
||||
|
||||
enum ViewerEvent {
|
||||
Connected,
|
||||
Frame(Frame),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("Windows Agent viewer failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args = parse_args(env::args().skip(1))?;
|
||||
let diagnostics = Diagnostics::new(&args.session_id)?;
|
||||
let event_loop = EventLoop::<ViewerEvent>::with_user_event()
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let proxy = event_loop.create_proxy();
|
||||
let target = normalize_target(&args.target)?;
|
||||
#[cfg(windows)]
|
||||
windows_keyboard_hook::install(target.clone());
|
||||
let fps = args.fps;
|
||||
let mode = args.mode.clone();
|
||||
let application = args.application.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("windows-agent-rdwf".to_owned())
|
||||
.spawn(move || receive_frames(&target, fps, &mode, application.as_deref(), &proxy))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut app = ViewerApp::new(&event_loop, args.fullscreen, diagnostics, args.target.clone())?;
|
||||
event_loop
|
||||
.run_app(&mut app)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
let mut target = None;
|
||||
let mut session_id = None;
|
||||
let mut fps = 10_u8;
|
||||
let mut fullscreen = false;
|
||||
let mut mode = "desktop".to_owned();
|
||||
let mut application = None;
|
||||
while let Some(option) = args.next() {
|
||||
match option.as_str() {
|
||||
"--target" => target = args.next(),
|
||||
"--session-id" => session_id = args.next(),
|
||||
"--fps" => {
|
||||
fps = args
|
||||
.next()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.filter(|value| (1..=15).contains(value))
|
||||
.ok_or_else(|| "--fps must be between 1 and 15".to_owned())?;
|
||||
}
|
||||
"--fullscreen" => fullscreen = true,
|
||||
"--mode" => mode = args.next().ok_or_else(|| "--mode is required".to_owned())?,
|
||||
"--application" => application = args.next(),
|
||||
_ => return Err(format!("unknown option {option}")),
|
||||
}
|
||||
}
|
||||
let target = target.ok_or_else(|| "--target is required".to_owned())?;
|
||||
let session_id = session_id.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
});
|
||||
Ok(Args {
|
||||
target,
|
||||
session_id: session_id.ok_or_else(|| "--session-id is invalid".to_owned())?,
|
||||
fps,
|
||||
fullscreen,
|
||||
mode: match mode.as_str() {
|
||||
"desktop" | "application" => mode,
|
||||
_ => return Err("--mode must be desktop or application".to_owned()),
|
||||
},
|
||||
application,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_target(target: &str) -> Result<String, String> {
|
||||
if target.is_empty() || target.len() > 512 || target.contains(char::is_whitespace) {
|
||||
return Err("Windows Agent target is invalid".to_owned());
|
||||
}
|
||||
if target.starts_with('[') {
|
||||
return Ok(if target.contains("]: ") || target.contains("]:") {
|
||||
target.to_owned()
|
||||
} else {
|
||||
format!("{target}:{DEFAULT_PORT}")
|
||||
});
|
||||
}
|
||||
if target.matches(':').count() > 1 {
|
||||
return Ok(format!("[{target}]:{DEFAULT_PORT}"));
|
||||
}
|
||||
if target.contains(':') {
|
||||
Ok(target.to_owned())
|
||||
} else {
|
||||
Ok(format!("{target}:{DEFAULT_PORT}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_frames(
|
||||
target: &str,
|
||||
fps: u8,
|
||||
mode: &str,
|
||||
application: Option<&str>,
|
||||
proxy: &winit::event_loop::EventLoopProxy<ViewerEvent>,
|
||||
) {
|
||||
let result = receive_frames_inner(target, fps, mode, application, proxy);
|
||||
if let Err(error) = result {
|
||||
let _ = proxy.send_event(ViewerEvent::Failed(error));
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_frames_inner(
|
||||
target: &str,
|
||||
fps: u8,
|
||||
mode: &str,
|
||||
application: Option<&str>,
|
||||
proxy: &winit::event_loop::EventLoopProxy<ViewerEvent>,
|
||||
) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
let stream = TcpStream::connect(target).map_err(|error| format!("连接失败:{error}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(15)))
|
||||
.map_err(|error| error.to_string())?;
|
||||
stream
|
||||
.set_nodelay(true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
// Keep JSON control messages and the binary RDWF stream on one buffered
|
||||
// reader. This permits read-ahead without losing frame bytes.
|
||||
let mut reader = BufReader::with_capacity(64 * 1024, stream);
|
||||
let hello = read_json_line(&mut reader)?;
|
||||
if hello.get("kind").and_then(serde_json::Value::as_str) != Some("windows_agent_hello") {
|
||||
return Err("目标不是 RemoteDesk Windows Agent".to_owned());
|
||||
}
|
||||
let command = if mode == "application" {
|
||||
let id = application.ok_or_else(|| "应用模式未选择应用".to_owned())?;
|
||||
serde_json::json!({ "kind": "open_application", "application": id, "allow_software_fallback": true, "frames_per_second": fps })
|
||||
} else {
|
||||
serde_json::json!({ "kind": "open_desktop", "allow_software_fallback": true, "frames_per_second": fps })
|
||||
};
|
||||
writeln!(
|
||||
reader.get_mut(),
|
||||
"{}",
|
||||
serde_json::to_string(&command).map_err(|error| error.to_string())?
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let opened = read_json_line(&mut reader)?;
|
||||
if opened.get("kind").and_then(serde_json::Value::as_str) != Some("desktop_opened") {
|
||||
return Err(opened
|
||||
.get("error")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("Windows Agent 拒绝桌面会话")
|
||||
.to_owned());
|
||||
}
|
||||
let _network_latency = started.elapsed();
|
||||
proxy
|
||||
.send_event(ViewerEvent::Connected)
|
||||
.map_err(|_| "viewer window closed".to_owned())?;
|
||||
loop {
|
||||
let frame = read_frame(&mut reader)?;
|
||||
proxy
|
||||
.send_event(ViewerEvent::Frame(frame))
|
||||
.map_err(|_| "viewer window closed".to_owned())?;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json_line(reader: &mut BufReader<TcpStream>) -> Result<serde_json::Value, String> {
|
||||
let mut line = String::new();
|
||||
reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if line.len() > 16 * 1024 || !line.ends_with('\n') {
|
||||
return Err("Windows Agent JSON response is invalid".to_owned());
|
||||
}
|
||||
serde_json::from_str(&line).map_err(|_| "Windows Agent JSON response is invalid".to_owned())
|
||||
}
|
||||
|
||||
fn read_frame(reader: &mut BufReader<TcpStream>) -> Result<Frame, String> {
|
||||
let mut magic = [0_u8; 4];
|
||||
reader
|
||||
.read_exact(&mut magic)
|
||||
.map_err(|error| format!("远端在 RDWF 帧头处断开:{error}"))?;
|
||||
if &magic == b"RDWE" {
|
||||
let mut length = [0_u8; 4];
|
||||
reader
|
||||
.read_exact(&mut length)
|
||||
.map_err(|error| format!("Agent 错误帧不完整:{error}"))?;
|
||||
let length = u32::from_le_bytes(length) as usize;
|
||||
if length == 0 || length > 16 * 1024 {
|
||||
return Err("Agent 错误帧长度无效".to_owned());
|
||||
}
|
||||
let mut message = vec![0_u8; length];
|
||||
reader
|
||||
.read_exact(&mut message)
|
||||
.map_err(|error| format!("Agent 错误帧不完整:{error}"))?;
|
||||
return Err(format!(
|
||||
"Windows Agent 桌面采集失败:{}",
|
||||
String::from_utf8_lossy(&message)
|
||||
));
|
||||
}
|
||||
if &magic != b"RDWF" {
|
||||
return Err("RDWF frame header or codec is unsupported".to_owned());
|
||||
}
|
||||
let mut rest = [0_u8; 20];
|
||||
reader
|
||||
.read_exact(&mut rest)
|
||||
.map_err(|error| format!("远端 RDWF 帧头不完整:{error}"))?;
|
||||
let mut base = [0_u8; 24];
|
||||
base[..4].copy_from_slice(&magic);
|
||||
base[4..].copy_from_slice(&rest);
|
||||
if !matches!(base[4], 1 | 2) || base[5] != 1 {
|
||||
return Err("RDWF frame header or codec is unsupported".to_owned());
|
||||
}
|
||||
let version = base[4];
|
||||
let width = u32::from_le_bytes(base[8..12].try_into().expect("fixed slice"));
|
||||
let height = u32::from_le_bytes(base[12..16].try_into().expect("fixed slice"));
|
||||
let raw_len = u32::from_le_bytes(base[16..20].try_into().expect("fixed slice")) as usize;
|
||||
let compressed_len = u32::from_le_bytes(base[20..24].try_into().expect("fixed slice")) as usize;
|
||||
validate_frame_lengths(width, height, raw_len, compressed_len)?;
|
||||
let (capture, encode) = if version == 2 {
|
||||
let mut timing = [0_u8; 16];
|
||||
reader
|
||||
.read_exact(&mut timing)
|
||||
.map_err(|error| format!("远端 RDWF 时序字段不完整:{error}"))?;
|
||||
(
|
||||
Some(micros_ms(u64::from_le_bytes(
|
||||
timing[0..8].try_into().expect("fixed slice"),
|
||||
))),
|
||||
Some(micros_ms(u64::from_le_bytes(
|
||||
timing[8..16].try_into().expect("fixed slice"),
|
||||
))),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let processing = capture
|
||||
.zip(encode)
|
||||
.map(|(capture, encode)| capture + encode);
|
||||
let mut compressed = vec![0; compressed_len];
|
||||
reader
|
||||
.read_exact(&mut compressed)
|
||||
.map_err(|error| format!("远端 RDWF 压缩帧不完整:{error}"))?;
|
||||
let decode_started = Instant::now();
|
||||
let mut decoder = ZlibDecoder::new(compressed.as_slice());
|
||||
let mut bgra = Vec::with_capacity(raw_len);
|
||||
decoder
|
||||
.read_to_end(&mut bgra)
|
||||
.map_err(|error| format!("RDWF zlib 解码失败:{error}"))?;
|
||||
if bgra.len() != raw_len {
|
||||
return Err("RDWF decompressed length mismatch".to_owned());
|
||||
}
|
||||
let pixels = bgra
|
||||
.chunks_exact(4)
|
||||
.map(|pixel| u32::from_le_bytes([pixel[0], pixel[1], pixel[2], 0]))
|
||||
.collect();
|
||||
Ok(Frame {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
capture_latency_ms: capture,
|
||||
encode_latency_ms: encode,
|
||||
processing_latency_ms: processing,
|
||||
decode_latency_ms: decode_started.elapsed().as_secs_f64() * 1_000.0,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_frame_lengths(
|
||||
width: u32,
|
||||
height: u32,
|
||||
raw_len: usize,
|
||||
compressed_len: usize,
|
||||
) -> Result<(), String> {
|
||||
if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION {
|
||||
return Err("RDWF dimensions are invalid".to_owned());
|
||||
}
|
||||
let expected = usize::try_from(width)
|
||||
.ok()
|
||||
.and_then(|width| {
|
||||
usize::try_from(height)
|
||||
.ok()
|
||||
.and_then(|height| width.checked_mul(height))
|
||||
})
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.ok_or_else(|| "RDWF frame length overflow".to_owned())?;
|
||||
if raw_len != expected
|
||||
|| raw_len > MAX_RAW_BYTES
|
||||
|| compressed_len == 0
|
||||
|| compressed_len > MAX_COMPRESSED_BYTES
|
||||
{
|
||||
return Err("RDWF frame lengths are invalid".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn micros_ms(value: u64) -> f64 {
|
||||
Duration::from_micros(value).as_secs_f64() * 1_000.0
|
||||
}
|
||||
|
||||
struct ViewerApp {
|
||||
context: softbuffer::Context<OwnedDisplayHandle>,
|
||||
window: Option<Arc<Window>>,
|
||||
surface: Option<softbuffer::Surface<OwnedDisplayHandle, Arc<Window>>>,
|
||||
frame: Option<Frame>,
|
||||
fullscreen: bool,
|
||||
target: String,
|
||||
cursor_position: (i32, i32),
|
||||
modifiers: ModifiersState,
|
||||
control_down: bool,
|
||||
alt_down: bool,
|
||||
diagnostics: Diagnostics,
|
||||
}
|
||||
|
||||
impl ViewerApp {
|
||||
fn new(
|
||||
event_loop: &EventLoop<ViewerEvent>,
|
||||
fullscreen: bool,
|
||||
diagnostics: Diagnostics,
|
||||
target: String,
|
||||
) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
context: softbuffer::Context::new(event_loop.owned_display_handle())
|
||||
.map_err(|error| error.to_string())?,
|
||||
window: None,
|
||||
surface: None,
|
||||
frame: None,
|
||||
fullscreen,
|
||||
target,
|
||||
cursor_position: (0, 0),
|
||||
modifiers: ModifiersState::empty(),
|
||||
control_down: false,
|
||||
alt_down: false,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
fn redraw(&mut self) {
|
||||
let (Some(window), Some(surface), Some(frame)) =
|
||||
(&self.window, &mut self.surface, &self.frame)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let local = window.inner_size();
|
||||
let (Some(width), Some(height)) =
|
||||
(NonZeroU32::new(local.width), NonZeroU32::new(local.height))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if surface.resize(width, height).is_err() {
|
||||
return;
|
||||
}
|
||||
let started = Instant::now();
|
||||
if let Ok(mut output) = surface.buffer_mut() {
|
||||
output.fill(0);
|
||||
let viewport = (0, 0, local.width.max(1), local.height.max(1));
|
||||
for y in 0..viewport.3 {
|
||||
let source_y = u64::from(y) * u64::from(frame.height) / u64::from(viewport.3);
|
||||
for x in 0..viewport.2 {
|
||||
let source_x = u64::from(x) * u64::from(frame.width) / u64::from(viewport.2);
|
||||
let source = usize::try_from(source_y * u64::from(frame.width) + source_x).ok();
|
||||
let target = usize::try_from(
|
||||
u64::from(viewport.1 + y) * u64::from(local.width)
|
||||
+ u64::from(viewport.0 + x),
|
||||
)
|
||||
.ok();
|
||||
if let (Some(source), Some(target)) = (source, target)
|
||||
&& let (Some(pixel), Some(destination)) =
|
||||
(frame.pixels.get(source), output.get_mut(target))
|
||||
{
|
||||
*destination = *pixel;
|
||||
}
|
||||
}
|
||||
}
|
||||
if output.present().is_ok() {
|
||||
self.diagnostics.presented(started.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler<ViewerEvent> for ViewerApp {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let attributes = WindowAttributes::default()
|
||||
.with_title("RemoteDesk Windows Agent")
|
||||
.with_inner_size(PhysicalSize::new(1600, 900))
|
||||
.with_fullscreen(
|
||||
self.fullscreen
|
||||
.then(|| Fullscreen::Borderless(event_loop.primary_monitor())),
|
||||
);
|
||||
match event_loop.create_window(attributes) {
|
||||
Ok(window) => {
|
||||
let window = Arc::new(window);
|
||||
self.surface = softbuffer::Surface::new(&self.context, Arc::clone(&window)).ok();
|
||||
self.window = Some(window);
|
||||
}
|
||||
Err(_) => event_loop.exit(),
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
if self.window.as_ref().is_none_or(|window| window.id() != id) {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
self.diagnostics.terminated();
|
||||
event_loop.exit();
|
||||
}
|
||||
WindowEvent::Resized(_) => {
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.redraw(),
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
if let Some(window) = &self.window {
|
||||
let size = window.inner_size();
|
||||
let x = (position.x.max(0.0).min(size.width as f64) * 65535.0 / size.width.max(1) as f64) as i32;
|
||||
let y = (position.y.max(0.0).min(size.height as f64) * 65535.0 / size.height.max(1) as f64) as i32;
|
||||
self.cursor_position = (x, y);
|
||||
send_input(&self.target, serde_json::json!({"kind":"input","input_type":"mouse","x":x,"y":y}));
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let action = match (button, state) {
|
||||
(MouseButton::Left, ElementState::Pressed) => "left_down",
|
||||
(MouseButton::Left, ElementState::Released) => "left_up",
|
||||
(MouseButton::Right, ElementState::Pressed) => "right_down",
|
||||
(MouseButton::Right, ElementState::Released) => "right_up",
|
||||
(MouseButton::Middle, ElementState::Pressed) => "middle_down",
|
||||
(MouseButton::Middle, ElementState::Released) => "middle_up",
|
||||
_ => return,
|
||||
};
|
||||
send_input(&self.target, serde_json::json!({"kind":"input","input_type":"mouse","mouse_action":action,"x":self.cursor_position.0,"y":self.cursor_position.1}));
|
||||
}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let value = match delta { MouseScrollDelta::LineDelta(_, y) => (y * 120.0) as i32, MouseScrollDelta::PixelDelta(p) => p.y as i32 };
|
||||
send_input(&self.target, serde_json::json!({"kind":"input","input_type":"mouse","mouse_action":"wheel","buttons":value,"x":self.cursor_position.0,"y":self.cursor_position.1}));
|
||||
}
|
||||
WindowEvent::ModifiersChanged(modifiers) => self.modifiers = modifiers.state(),
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
if matches!(event.logical_key, Key::Named(NamedKey::Super)) {
|
||||
let vk = if matches!(event.physical_key, PhysicalKey::Code(KeyCode::SuperRight)) { 0x5C } else { 0x5B };
|
||||
send_input(&self.target, serde_json::json!({"kind":"input","input_type":"key","code":vk,"down":event.state == ElementState::Pressed}));
|
||||
return;
|
||||
}
|
||||
let control = matches!(event.physical_key, PhysicalKey::Code(KeyCode::ControlLeft | KeyCode::ControlRight));
|
||||
let alt = matches!(event.physical_key, PhysicalKey::Code(KeyCode::AltLeft | KeyCode::AltRight));
|
||||
if event.state == ElementState::Pressed {
|
||||
self.control_down |= control;
|
||||
self.alt_down |= alt;
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
let vk = virtual_key(code);
|
||||
if vk != 0 { send_input(&self.target, serde_json::json!({"kind":"input","input_type":"key","code":vk,"down":true})); }
|
||||
}
|
||||
if matches!(event.physical_key, PhysicalKey::Code(KeyCode::KeyF))
|
||||
&& (self.control_down || self.modifiers.control_key())
|
||||
&& (self.alt_down || self.modifiers.alt_key())
|
||||
{
|
||||
self.fullscreen = !self.fullscreen;
|
||||
if let Some(window) = &self.window {
|
||||
window.set_fullscreen(self.fullscreen.then(|| Fullscreen::Borderless(window.current_monitor())));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
let vk = virtual_key(code);
|
||||
if vk != 0 { send_input(&self.target, serde_json::json!({"kind":"input","input_type":"key","code":vk,"down":false})); }
|
||||
}
|
||||
if control { self.control_down = false; }
|
||||
if alt { self.alt_down = false; }
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: ViewerEvent) {
|
||||
match event {
|
||||
ViewerEvent::Connected => self.diagnostics.connected(),
|
||||
ViewerEvent::Frame(frame) => {
|
||||
self.diagnostics.frame(&frame);
|
||||
self.frame = Some(frame);
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
ViewerEvent::Failed(error) => {
|
||||
eprintln!("{error}");
|
||||
self.diagnostics.failed(&error);
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
}
|
||||
}
|
||||
|
||||
fn virtual_key(code: KeyCode) -> u16 {
|
||||
match code {
|
||||
KeyCode::KeyA => 0x41, KeyCode::KeyB => 0x42, KeyCode::KeyC => 0x43, KeyCode::KeyD => 0x44,
|
||||
KeyCode::KeyE => 0x45, KeyCode::KeyF => 0x46, KeyCode::KeyG => 0x47, KeyCode::KeyH => 0x48,
|
||||
KeyCode::KeyI => 0x49, KeyCode::KeyJ => 0x4A, KeyCode::KeyK => 0x4B, KeyCode::KeyL => 0x4C,
|
||||
KeyCode::KeyM => 0x4D, KeyCode::KeyN => 0x4E, KeyCode::KeyO => 0x4F, KeyCode::KeyP => 0x50,
|
||||
KeyCode::KeyQ => 0x51, KeyCode::KeyR => 0x52, KeyCode::KeyS => 0x53, KeyCode::KeyT => 0x54,
|
||||
KeyCode::KeyU => 0x55, KeyCode::KeyV => 0x56, KeyCode::KeyW => 0x57, KeyCode::KeyX => 0x58,
|
||||
KeyCode::KeyY => 0x59, KeyCode::KeyZ => 0x5A,
|
||||
KeyCode::Digit0 => 0x30, KeyCode::Digit1 => 0x31, KeyCode::Digit2 => 0x32, KeyCode::Digit3 => 0x33,
|
||||
KeyCode::Digit4 => 0x34, KeyCode::Digit5 => 0x35, KeyCode::Digit6 => 0x36, KeyCode::Digit7 => 0x37,
|
||||
KeyCode::Digit8 => 0x38, KeyCode::Digit9 => 0x39,
|
||||
KeyCode::ControlLeft | KeyCode::ControlRight => 0x11,
|
||||
KeyCode::AltLeft | KeyCode::AltRight => 0x12,
|
||||
KeyCode::SuperLeft => 0x5B,
|
||||
KeyCode::SuperRight => 0x5C,
|
||||
KeyCode::Enter => 0x0D,
|
||||
KeyCode::Space => 0x20,
|
||||
KeyCode::Escape => 0x1B,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn send_input(target: &str, command: serde_json::Value) {
|
||||
let Ok(mut stream) = TcpStream::connect_timeout(
|
||||
&match target.to_socket_addrs().ok().and_then(|mut addrs| addrs.next()) {
|
||||
Some(address) => address,
|
||||
None => return,
|
||||
},
|
||||
Duration::from_secs(2),
|
||||
) else { return };
|
||||
let _ = stream.set_nodelay(true);
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
|
||||
let Ok(clone) = stream.try_clone() else { return };
|
||||
let mut reader = BufReader::with_capacity(1, clone);
|
||||
let Ok(hello) = read_json_line(&mut reader) else { return };
|
||||
if hello.get("kind").and_then(serde_json::Value::as_str) != Some("windows_agent_hello") { return; }
|
||||
if writeln!(stream, "{}", command).is_err() { return; }
|
||||
if let Ok(response) = read_json_line(&mut reader) {
|
||||
if response.get("kind").and_then(serde_json::Value::as_str) == Some("input_failed") {
|
||||
eprintln!("Windows Agent input failed: {}", response.get("error").and_then(serde_json::Value::as_str).unwrap_or("unknown"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DiagnosticSnapshot<'a> {
|
||||
schema_version: u8,
|
||||
session_id: &'a str,
|
||||
state: &'a str,
|
||||
updated_at_unix_ms: u64,
|
||||
frame_count: u64,
|
||||
frames_per_second: Option<f64>,
|
||||
desktop_width: u32,
|
||||
desktop_height: u32,
|
||||
capture_latency_ms: Option<f64>,
|
||||
encode_latency_ms: Option<f64>,
|
||||
frame_processing_latency_ms: Option<f64>,
|
||||
decode_latency_ms: Option<f64>,
|
||||
presentation_latency_ms: Option<f64>,
|
||||
renderer: &'static str,
|
||||
error_code: Option<&'a str>,
|
||||
}
|
||||
|
||||
struct Diagnostics {
|
||||
path: PathBuf,
|
||||
session_id: String,
|
||||
state: &'static str,
|
||||
frame_count: u64,
|
||||
fps_started: Instant,
|
||||
fps_frames: u32,
|
||||
fps: Option<f64>,
|
||||
size: (u32, u32),
|
||||
capture: Option<f64>,
|
||||
encode: Option<f64>,
|
||||
processing: Option<f64>,
|
||||
decode: Option<f64>,
|
||||
presentation: Option<f64>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Diagnostics {
|
||||
fn new(session_id: &str) -> Result<Self, String> {
|
||||
let directory = env::temp_dir()
|
||||
.join("RemoteDesk")
|
||||
.join("windows-agent-sessions");
|
||||
fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
|
||||
let mut value = Self {
|
||||
path: directory.join(format!("{session_id}.json")),
|
||||
session_id: session_id.to_owned(),
|
||||
state: "connecting",
|
||||
frame_count: 0,
|
||||
fps_started: Instant::now(),
|
||||
fps_frames: 0,
|
||||
fps: None,
|
||||
size: (0, 0),
|
||||
capture: None,
|
||||
encode: None,
|
||||
processing: None,
|
||||
decode: None,
|
||||
presentation: None,
|
||||
error: None,
|
||||
};
|
||||
value.write()?;
|
||||
Ok(value)
|
||||
}
|
||||
fn connected(&mut self) {
|
||||
self.state = "connected";
|
||||
let _ = self.write();
|
||||
}
|
||||
fn frame(&mut self, frame: &Frame) {
|
||||
self.state = "connected";
|
||||
self.frame_count += 1;
|
||||
self.fps_frames += 1;
|
||||
self.size = (frame.width, frame.height);
|
||||
self.capture = frame.capture_latency_ms;
|
||||
self.encode = frame.encode_latency_ms;
|
||||
self.processing = frame.processing_latency_ms;
|
||||
self.decode = Some(frame.decode_latency_ms);
|
||||
let elapsed = self.fps_started.elapsed();
|
||||
if elapsed >= Duration::from_secs(1) {
|
||||
self.fps = Some(f64::from(self.fps_frames) / elapsed.as_secs_f64());
|
||||
self.fps_frames = 0;
|
||||
self.fps_started = Instant::now();
|
||||
}
|
||||
let _ = self.write();
|
||||
}
|
||||
fn presented(&mut self, elapsed: Duration) {
|
||||
self.presentation = Some(elapsed.as_secs_f64() * 1_000.0);
|
||||
let _ = self.write();
|
||||
}
|
||||
fn failed(&mut self, error: &str) {
|
||||
self.state = "failed";
|
||||
self.error = Some(error.chars().take(128).collect());
|
||||
let _ = self.write();
|
||||
}
|
||||
fn terminated(&mut self) {
|
||||
self.state = "terminated";
|
||||
let _ = self.write();
|
||||
}
|
||||
fn write(&mut self) -> Result<(), String> {
|
||||
let snapshot = DiagnosticSnapshot {
|
||||
schema_version: 1,
|
||||
session_id: &self.session_id,
|
||||
state: self.state,
|
||||
updated_at_unix_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX),
|
||||
frame_count: self.frame_count,
|
||||
frames_per_second: self.fps,
|
||||
desktop_width: self.size.0,
|
||||
desktop_height: self.size.1,
|
||||
capture_latency_ms: self.capture,
|
||||
encode_latency_ms: self.encode,
|
||||
frame_processing_latency_ms: self.processing,
|
||||
decode_latency_ms: self.decode,
|
||||
presentation_latency_ms: self.presentation,
|
||||
renderer: "softbuffer-bgra",
|
||||
error_code: self.error.as_deref(),
|
||||
};
|
||||
let data = serde_json::to_vec(&snapshot).map_err(|error| error.to_string())?;
|
||||
let temporary = self.path.with_extension("tmp");
|
||||
fs::write(&temporary, data).map_err(|error| error.to_string())?;
|
||||
fs::rename(temporary, &self.path).map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn frame_limits_reject_mismatched_bgra_size() {
|
||||
assert!(validate_frame_lengths(10, 10, 399, 10).is_err());
|
||||
assert!(validate_frame_lengths(10, 10, 400, 10).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn targets_receive_default_port() {
|
||||
assert_eq!(normalize_target("10.0.0.2").unwrap(), "10.0.0.2:39501");
|
||||
assert_eq!(normalize_target("10.0.0.2:4000").unwrap(), "10.0.0.2:4000");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::OnceLock;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{CallNextHookEx, DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowThreadProcessId, KBDLLHOOKSTRUCT, MSG, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx, WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP};
|
||||
|
||||
static PROCESS_ID: OnceLock<u32> = OnceLock::new();
|
||||
static TARGET: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub fn install(target: String) {
|
||||
let _ = PROCESS_ID.set(std::process::id());
|
||||
let _ = TARGET.set(target);
|
||||
thread::spawn(move || unsafe {
|
||||
let Ok(hook) = SetWindowsHookExW(WH_KEYBOARD_LL, Some(callback), None, 0) else { return };
|
||||
let mut message = MSG::default();
|
||||
while GetMessageW(&mut message, None, 0, 0).as_bool() {
|
||||
let _ = TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
let _ = UnhookWindowsHookEx(hook);
|
||||
});
|
||||
}
|
||||
|
||||
unsafe extern "system" fn callback(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code >= 0 && foreground_is_viewer() {
|
||||
let data = unsafe { &*(lparam.0 as *const KBDLLHOOKSTRUCT) };
|
||||
if matches!(data.vkCode, 0x5B | 0x5C) {
|
||||
let message = wparam.0 as u32;
|
||||
let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN);
|
||||
let released = matches!(message, WM_KEYUP | WM_SYSKEYUP);
|
||||
if pressed || released {
|
||||
send_remote(data.vkCode as u16, pressed);
|
||||
return LRESULT(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe { CallNextHookEx(None, code, wparam, lparam) }
|
||||
}
|
||||
|
||||
fn foreground_is_viewer() -> bool {
|
||||
let Some(pid) = PROCESS_ID.get().copied() else { return false };
|
||||
let window = unsafe { GetForegroundWindow() };
|
||||
if window.0.is_null() { return false }
|
||||
let mut foreground_pid = 0;
|
||||
unsafe { GetWindowThreadProcessId(window, Some(&mut foreground_pid)) };
|
||||
foreground_pid == pid
|
||||
}
|
||||
|
||||
fn send_remote(vk: u16, pressed: bool) {
|
||||
let Some(target) = TARGET.get() else { return };
|
||||
let Some(address) = target.to_socket_addrs().ok().and_then(|mut values| values.next()) else { return };
|
||||
let Ok(mut stream) = TcpStream::connect_timeout(&address, Duration::from_millis(250)) else { return };
|
||||
let _ = stream.set_nodelay(true);
|
||||
let _ = std::io::Write::write_all(&mut stream, format!("{{\"kind\":\"input\",\"input_type\":\"key\",\"code\":{vk},\"down\":{pressed}}}\n").as_bytes());
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" type="image/svg+xml" href="./remotedesk-icon.svg" />
|
||||
<link rel="alternate icon" type="image/x-icon" href="./icon.ico" />
|
||||
<title>RemoteDesk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "remotedesk-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --configLoader runner",
|
||||
"control": "cargo run --manifest-path ../helpers/control-service/Cargo.toml -- --web-root dist --port 4174",
|
||||
"build": "npm run lint && vite build --configLoader runner",
|
||||
"build:check": "npm run lint && node scripts/build-check.mjs",
|
||||
"lint": "tsc --noEmit -p tsconfig.app.json --pretty false && tsc --noEmit -p tsconfig.node.json --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"lucide-react": "0.577.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "6.4.3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#5ed39a"/>
|
||||
<g fill="none" stroke="#101b16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 8.5v3.2M8.4 10.1h3.2"/>
|
||||
<path d="M21.8 8.5v3.2M20.2 10.1h3.2"/>
|
||||
<path d="M10 18.5v3.2M8.4 20.1h3.2"/>
|
||||
<path d="M21.8 18.5v3.2M20.2 20.1h3.2"/>
|
||||
<path d="M12.5 14.2h7M12.5 16.4h7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,10 @@
|
||||
import { build } from 'vite'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
await build({
|
||||
root: fileURLToPath(new URL('..', import.meta.url)),
|
||||
configFile: false,
|
||||
build: {
|
||||
write: false,
|
||||
},
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,549 @@
|
||||
export type RdpProbeStatus =
|
||||
| 'reachable'
|
||||
| 'dns_failed'
|
||||
| 'timeout'
|
||||
| 'refused'
|
||||
| 'unreachable'
|
||||
|
||||
export type RdpProbeResult = {
|
||||
status: RdpProbeStatus
|
||||
reachable: boolean
|
||||
latency_ms: number | null
|
||||
port: number
|
||||
}
|
||||
|
||||
export type LinuxAgentProbeResult = {
|
||||
ok: boolean
|
||||
latency_ms: number
|
||||
protocol_major: number
|
||||
protocol_minor: number
|
||||
terminal: boolean
|
||||
desktop: boolean
|
||||
files: boolean
|
||||
edge_presence_configured: boolean
|
||||
edge_presence_online: boolean
|
||||
edge_signaling_configured: boolean
|
||||
edge_signaling_online: boolean
|
||||
}
|
||||
|
||||
export type RdpProtocolProbeResult = {
|
||||
ok: boolean
|
||||
stage: 'input' | 'dns' | 'tcp' | 'rdp_negotiation' | 'tls'
|
||||
security_protocol?: 'hybrid' | 'hybrid_extended'
|
||||
tcp_latency_ms?: number
|
||||
total_latency_ms?: number
|
||||
certificate_sha256?: string
|
||||
error_code?: string
|
||||
}
|
||||
|
||||
export type LaunchRdpRequest = {
|
||||
address: string
|
||||
username: string
|
||||
resolution: RdpResolution
|
||||
use_multimon: boolean
|
||||
monitor_indices?: number[]
|
||||
client: RdpClientMode
|
||||
fullscreen: boolean
|
||||
certificate_sha256: string | null
|
||||
credential_ref: string | null
|
||||
redirect_clipboard: boolean
|
||||
}
|
||||
|
||||
export type RdpResolution =
|
||||
| { mode: 'follow_window' }
|
||||
| { mode: 'fixed'; width: number; height: number }
|
||||
|
||||
export type RdpClientMode = 'system' | 'native'
|
||||
|
||||
export type LaunchRdpResult = {
|
||||
launched: boolean
|
||||
client?: RdpClientMode
|
||||
session_id?: string
|
||||
}
|
||||
|
||||
export type NativeRdpSessionState =
|
||||
| 'waiting_for_credentials'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'failed'
|
||||
| 'terminated'
|
||||
|
||||
export type NativeRdpResizeState =
|
||||
| 'idle'
|
||||
| 'pending'
|
||||
| 'reconnect_required'
|
||||
| 'reconnecting'
|
||||
| 'confirmed'
|
||||
| 'cancelled'
|
||||
|
||||
export type NativeRdpSessionDiagnostics = {
|
||||
schema_version: 1
|
||||
session_id: string
|
||||
state: NativeRdpSessionState
|
||||
started_at_unix_ms: number
|
||||
updated_at_unix_ms: number
|
||||
frame_count: number
|
||||
frames_per_second: number | null
|
||||
desktop_width: number
|
||||
desktop_height: number
|
||||
monitor_count: number
|
||||
multi_monitor: boolean
|
||||
network_latency_ms: number | null
|
||||
base_network_latency_ms: number | null
|
||||
bandwidth_kbps: number | null
|
||||
decode_latency_ms: number | null
|
||||
presentation_latency_ms: number | null
|
||||
renderer?: string
|
||||
frame_conversion_pixels: number
|
||||
frame_upload_mode?: 'pending' | 'full_frame' | 'dirty_rect' | 'cached_frame'
|
||||
frame_upload_pixels: number
|
||||
reconnect_attempt: number
|
||||
resize_generation: number
|
||||
resize_state: NativeRdpResizeState
|
||||
resize_requested_width: number | null
|
||||
resize_requested_height: number | null
|
||||
resize_failure: 'unsupported' | 'timeout' | null
|
||||
error_code: string | null
|
||||
}
|
||||
|
||||
export type LinuxDesktopSessionDiagnostics = {
|
||||
schema_version: 1
|
||||
session_id: string
|
||||
state: 'connecting' | 'connected' | 'reconnecting' | 'failed' | 'terminated'
|
||||
updated_at_unix_ms: number
|
||||
frame_count: number
|
||||
frames_per_second: number | null
|
||||
desktop_width: number
|
||||
desktop_height: number
|
||||
network_latency_ms: number | null
|
||||
capture_latency_ms: number | null
|
||||
encode_latency_ms: number | null
|
||||
compression_level: number | null
|
||||
compression_ratio_percent: number | null
|
||||
decode_latency_ms: number | null
|
||||
presentation_latency_ms: number | null
|
||||
clipboard_read: boolean
|
||||
clipboard_write: boolean
|
||||
reconnect_attempt: number
|
||||
error_code: string | null
|
||||
}
|
||||
|
||||
function compatibleResolution(resolution: RdpResolution): string {
|
||||
return resolution.mode === 'follow_window'
|
||||
? 'Follow window'
|
||||
: `${resolution.width} x ${resolution.height}`
|
||||
}
|
||||
|
||||
export type ControlCapabilities = {
|
||||
version: string
|
||||
platform: string
|
||||
application: {
|
||||
desktop_shell: boolean
|
||||
shell_kind: 'tauri-2' | 'standalone'
|
||||
}
|
||||
rdp: {
|
||||
probe: boolean
|
||||
protocol_probe: boolean
|
||||
external_launch: boolean
|
||||
native_launch: boolean
|
||||
native_diagnostics: boolean
|
||||
native_multimon: boolean
|
||||
session_network_metrics: boolean
|
||||
session_decode_metrics: boolean
|
||||
region_pixel_conversion: boolean
|
||||
secure_named_pipe: boolean
|
||||
secure_pipe_config_ack: boolean
|
||||
secure_pipe_handshake_timeout: boolean
|
||||
job_object_lifecycle: boolean
|
||||
certificate_pinning: boolean
|
||||
automatic_reconnect: boolean
|
||||
resize_confirmation: boolean
|
||||
clipboard: boolean
|
||||
audio_playback: boolean
|
||||
native_renderer: 'software_framebuffer' | 'd3d11_cpu_upload'
|
||||
d3d11_device_available: boolean
|
||||
d3d11_dirty_rect_upload: boolean
|
||||
credential_manager: boolean
|
||||
credential_store: boolean
|
||||
}
|
||||
linux: {
|
||||
agent_session: boolean
|
||||
agent_probe: boolean
|
||||
terminal_session: boolean
|
||||
desktop_session: boolean
|
||||
x11_desktop: boolean
|
||||
relative_pointer: boolean
|
||||
adaptive_frame_pacing: boolean
|
||||
adaptive_compression: boolean
|
||||
desktop_session_resume: boolean
|
||||
desktop_session_crash_resume: boolean
|
||||
direct_webrtc_signaling: boolean
|
||||
opus_audio: boolean
|
||||
clipboard_read: boolean
|
||||
clipboard_write: boolean
|
||||
wayland_desktop: boolean
|
||||
native_video: boolean
|
||||
native_video_decoder_probe: boolean
|
||||
native_video_surface_presenter: boolean
|
||||
file_transfer: boolean
|
||||
agent_identity_store: boolean
|
||||
}
|
||||
edge: {
|
||||
relay: boolean
|
||||
signed_intent: boolean
|
||||
linux_session_relay: boolean
|
||||
linux_known_device_rendezvous: boolean
|
||||
remote_pairing: boolean
|
||||
}
|
||||
update: {
|
||||
configured: boolean
|
||||
check: boolean
|
||||
install: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type ControlSettings = {
|
||||
edge_api_url: string | null
|
||||
update_manifest_url: string | null
|
||||
update_public_key: string | null
|
||||
auto_check_updates: boolean
|
||||
}
|
||||
|
||||
export type ControlUpdateRelease = {
|
||||
version: string
|
||||
channel: string
|
||||
published_at: string
|
||||
notes?: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
export type ControlUpdateCheck = {
|
||||
current_version: string
|
||||
status: 'up_to_date' | 'available'
|
||||
release?: ControlUpdateRelease
|
||||
}
|
||||
|
||||
type ApiError = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class ControlApiError extends Error {
|
||||
readonly status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ControlApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
const body = await response.json() as T & ApiError
|
||||
if (!response.ok) {
|
||||
throw new ControlApiError(
|
||||
body.error || `control service returned HTTP ${response.status}`,
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export async function probeRdp(address: string): Promise<RdpProbeResult> {
|
||||
const response = await fetch('/api/v1/rdp/probe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address }),
|
||||
})
|
||||
return readJson<RdpProbeResult>(response)
|
||||
}
|
||||
|
||||
export async function negotiateRdp(address: string): Promise<RdpProtocolProbeResult> {
|
||||
const response = await fetch('/api/v1/rdp/negotiate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address }),
|
||||
})
|
||||
return readJson<RdpProtocolProbeResult>(response)
|
||||
}
|
||||
|
||||
export async function getControlCapabilities(): Promise<ControlCapabilities> {
|
||||
const response = await fetch('/api/v1/capabilities', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
return readJson<ControlCapabilities>(response)
|
||||
}
|
||||
|
||||
export async function getControlSettings(): Promise<ControlSettings> {
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
return readJson<ControlSettings>(response)
|
||||
}
|
||||
|
||||
export async function updateControlSettings(settings: ControlSettings): Promise<ControlSettings> {
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
return readJson<ControlSettings>(response)
|
||||
}
|
||||
|
||||
export async function getControlHosts(): Promise<unknown> {
|
||||
const response = await fetch('/api/v1/hosts', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
return readJson<unknown>(response)
|
||||
}
|
||||
|
||||
export async function updateControlHosts(hosts: unknown): Promise<void> {
|
||||
const response = await fetch('/api/v1/hosts', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hosts),
|
||||
})
|
||||
await readJson<{ saved: boolean }>(response)
|
||||
}
|
||||
|
||||
export async function checkControlUpdate(): Promise<ControlUpdateCheck> {
|
||||
const response = await fetch('/api/v1/update/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
return readJson<ControlUpdateCheck>(response)
|
||||
}
|
||||
|
||||
export async function installControlUpdate(version: string): Promise<void> {
|
||||
const response = await fetch('/api/v1/update/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version }),
|
||||
})
|
||||
await readJson<{ accepted: boolean; version: string }>(response)
|
||||
}
|
||||
|
||||
export async function launchRdp(request: LaunchRdpRequest): Promise<LaunchRdpResult> {
|
||||
// Keep a reloaded UI compatible with a control service that has not restarted yet.
|
||||
const compatibleRequest = {
|
||||
...request,
|
||||
resolution: compatibleResolution(request.resolution),
|
||||
}
|
||||
const response = await fetch('/api/v1/rdp/launch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(compatibleRequest),
|
||||
})
|
||||
return readJson<LaunchRdpResult>(response)
|
||||
}
|
||||
|
||||
export async function getNativeRdpSession(
|
||||
sessionId: string,
|
||||
): Promise<NativeRdpSessionDiagnostics> {
|
||||
const response = await fetch(`/api/v1/rdp/session/${encodeURIComponent(sessionId)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
return readJson<NativeRdpSessionDiagnostics>(response)
|
||||
}
|
||||
|
||||
export async function openCredentialManager(): Promise<void> {
|
||||
const response = await fetch('/api/v1/windows/credential-manager', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
await readJson<{ launched: boolean }>(response)
|
||||
}
|
||||
|
||||
export type RdpCredentialStatus = 'missing' | 'ready'
|
||||
|
||||
async function credentialRequest(
|
||||
operation: 'status' | 'set' | 'delete',
|
||||
credentialRef: string,
|
||||
): Promise<Response> {
|
||||
return fetch(`/api/v1/rdp/credential/${operation}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential_ref: credentialRef }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getRdpCredentialStatus(credentialRef: string): Promise<RdpCredentialStatus> {
|
||||
const response = await credentialRequest('status', credentialRef)
|
||||
return (await readJson<{ status: RdpCredentialStatus }>(response)).status
|
||||
}
|
||||
|
||||
export async function configureRdpCredential(credentialRef: string): Promise<void> {
|
||||
const response = await credentialRequest('set', credentialRef)
|
||||
await readJson<{ launched: boolean }>(response)
|
||||
}
|
||||
|
||||
export async function saveRdpCredential(credentialRef: string, account: string, password: string): Promise<void> {
|
||||
const response = await fetch('/api/v1/rdp/credential/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential_ref: credentialRef, account, password }),
|
||||
})
|
||||
await readJson<{ launched: boolean }>(response)
|
||||
}
|
||||
|
||||
export async function deleteRdpCredential(credentialRef: string): Promise<void> {
|
||||
const response = await credentialRequest('delete', credentialRef)
|
||||
await readJson<{ status: 'missing' }>(response)
|
||||
}
|
||||
|
||||
export async function launchLinuxTerminal(request: {
|
||||
address: string
|
||||
user: string
|
||||
certificate_sha256: string
|
||||
pair: boolean
|
||||
agent_public_key?: string | null
|
||||
edge_api_url?: string | null
|
||||
}): Promise<void> {
|
||||
const response = await fetch('/api/v1/linux/terminal/launch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
await readJson<{ launched: boolean }>(response)
|
||||
}
|
||||
|
||||
export async function probeLinuxAgent(
|
||||
address: string,
|
||||
certificateSha256: string,
|
||||
): Promise<LinuxAgentProbeResult> {
|
||||
const response = await fetch('/api/v1/linux/probe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address, certificate_sha256: certificateSha256 }),
|
||||
})
|
||||
return readJson<LinuxAgentProbeResult>(response)
|
||||
}
|
||||
|
||||
export type WindowsAgentProbeResult = {
|
||||
ok: boolean
|
||||
latency_ms: number
|
||||
protocol_major: number
|
||||
protocol_minor: number
|
||||
capture_backend: string
|
||||
preferred_capture_backend: string
|
||||
software_fallback_backend?: string | null
|
||||
software_fallback_available: boolean
|
||||
input_backend: string
|
||||
authenticated: boolean
|
||||
desktop_session: boolean
|
||||
capture_latency_ms?: number | null
|
||||
encode_latency_ms?: number | null
|
||||
frame_processing_latency_ms?: number | null
|
||||
applications: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
export async function probeWindowsAgent(address: string): Promise<WindowsAgentProbeResult> {
|
||||
const response = await fetch('/api/v1/windows-agent/probe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address }),
|
||||
})
|
||||
return readJson<WindowsAgentProbeResult>(response)
|
||||
}
|
||||
|
||||
export type WindowsAgentSessionDiagnostics = {
|
||||
schema_version: 1
|
||||
session_id: string
|
||||
state: 'connecting' | 'connected' | 'failed' | 'terminated'
|
||||
updated_at_unix_ms: number
|
||||
frame_count: number
|
||||
frames_per_second: number | null
|
||||
desktop_width: number
|
||||
desktop_height: number
|
||||
capture_latency_ms: number | null
|
||||
encode_latency_ms: number | null
|
||||
frame_processing_latency_ms: number | null
|
||||
decode_latency_ms: number | null
|
||||
presentation_latency_ms: number | null
|
||||
renderer: string
|
||||
error_code: string | null
|
||||
}
|
||||
|
||||
export async function launchWindowsAgentDesktop(request: { address: string; frames_per_second: number; fullscreen: boolean; mode?: 'desktop' | 'application'; application?: string }): Promise<{ launched: boolean; session_id: string }> {
|
||||
const response = await fetch('/api/v1/windows-agent/desktop/launch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) })
|
||||
return readJson<{ launched: boolean; session_id: string }>(response)
|
||||
}
|
||||
|
||||
export async function getWindowsAgentSession(sessionId: string): Promise<WindowsAgentSessionDiagnostics> {
|
||||
const response = await fetch(`/api/v1/windows-agent/session/${encodeURIComponent(sessionId)}`, { headers: { Accept: 'application/json' } })
|
||||
return readJson<WindowsAgentSessionDiagnostics>(response)
|
||||
}
|
||||
|
||||
export async function launchLinuxDesktop(request: {
|
||||
address: string
|
||||
user: string
|
||||
certificate_sha256: string
|
||||
width: number
|
||||
height: number
|
||||
frames_per_second: number
|
||||
fullscreen: boolean
|
||||
follow_window: boolean
|
||||
capture_input: boolean
|
||||
clipboard_read: boolean
|
||||
clipboard_write: boolean
|
||||
edge_api_url?: string | null
|
||||
}): Promise<{ launched: boolean; session_id: string }> {
|
||||
const response = await fetch('/api/v1/linux/desktop/launch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
return readJson<{ launched: boolean; session_id: string }>(response)
|
||||
}
|
||||
|
||||
export async function getLinuxDesktopSession(
|
||||
sessionId: string,
|
||||
): Promise<LinuxDesktopSessionDiagnostics> {
|
||||
const response = await fetch(
|
||||
`/api/v1/linux/desktop/session/${encodeURIComponent(sessionId)}`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
)
|
||||
return readJson<LinuxDesktopSessionDiagnostics>(response)
|
||||
}
|
||||
|
||||
export async function launchLinuxFileTransfer(request: {
|
||||
address: string
|
||||
user: string
|
||||
certificate_sha256: string
|
||||
direction: 'upload' | 'download'
|
||||
local_path: string
|
||||
remote_path: string
|
||||
edge_api_url?: string | null
|
||||
}): Promise<void> {
|
||||
const response = await fetch('/api/v1/linux/files/launch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
await readJson<{ launched: boolean }>(response)
|
||||
}
|
||||
|
||||
async function linuxAgentIdentityRequest(
|
||||
operation: 'status' | 'delete',
|
||||
certificateSha256: string,
|
||||
): Promise<RdpCredentialStatus> {
|
||||
const response = await fetch(`/api/v1/linux/identity/${operation}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ certificate_sha256: certificateSha256 }),
|
||||
})
|
||||
return (await readJson<{ status: RdpCredentialStatus }>(response)).status
|
||||
}
|
||||
|
||||
export async function getLinuxAgentIdentityStatus(
|
||||
certificateSha256: string,
|
||||
): Promise<RdpCredentialStatus> {
|
||||
return linuxAgentIdentityRequest('status', certificateSha256)
|
||||
}
|
||||
|
||||
export async function deleteLinuxAgentIdentity(certificateSha256: string): Promise<void> {
|
||||
const status = await linuxAgentIdentityRequest('delete', certificateSha256)
|
||||
if (status !== 'missing') throw new Error('控制服务未删除 Linux Agent 身份')
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
LinuxAgentProbeResult,
|
||||
NativeRdpSessionState,
|
||||
RdpClientMode,
|
||||
RdpProbeResult,
|
||||
RdpProtocolProbeResult,
|
||||
RdpResolution,
|
||||
} from './backend'
|
||||
|
||||
export type HostKind = 'windows_rdp' | 'windows_agent' | 'linux_agent'
|
||||
export type HostStatus = 'ready' | 'offline' | 'connecting' | 'probing' | 'unknown'
|
||||
export type NetworkPolicy = 'Smart' | 'Direct preferred' | 'CDN preferred'
|
||||
export type DisplayMode = 'primary' | 'all' | 'custom'
|
||||
export type CredentialState = 'missing' | 'ready' | 'unknown'
|
||||
export type WindowsAgentApplication = { id: string; name: string }
|
||||
|
||||
export type HostDisplay = {
|
||||
id: string
|
||||
name: string
|
||||
resolution: string
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
export type Host = {
|
||||
id: number
|
||||
name: string
|
||||
address: string
|
||||
kind: HostKind
|
||||
mode: string
|
||||
agentApplication?: string
|
||||
agentApplications?: WindowsAgentApplication[]
|
||||
tags?: string[]
|
||||
status: HostStatus
|
||||
favorite: boolean
|
||||
lastUsed: string
|
||||
latency?: number
|
||||
sessionNetworkLatency?: boolean
|
||||
networkBaseLatency?: number
|
||||
bandwidthKbps?: number
|
||||
captureLatency?: number
|
||||
encodeLatency?: number
|
||||
frameProcessingLatency?: number
|
||||
compressionLevel?: number
|
||||
compressionRatio?: number
|
||||
decodeLatency?: number
|
||||
presentationLatency?: number
|
||||
nativeMonitorCount?: number
|
||||
frameRate?: number
|
||||
nativeSessionState?: NativeRdpSessionState
|
||||
nativeRenderer?: string
|
||||
frameUploadMode?: string
|
||||
frameUploadPixels?: number
|
||||
frameConversionPixels?: number
|
||||
qualityUpdatedAt?: number
|
||||
path: string
|
||||
networkPolicy: NetworkPolicy
|
||||
resolution: string
|
||||
customWidth: string
|
||||
customHeight: string
|
||||
gpu: string
|
||||
memoryPath: string
|
||||
recent: boolean
|
||||
strictZeroCopy: boolean
|
||||
displayMode: DisplayMode
|
||||
displays: HostDisplay[]
|
||||
selectedDisplayIds: string[]
|
||||
username: string
|
||||
credentialState: CredentialState
|
||||
agentIdentityState: CredentialState
|
||||
credentialRef: string
|
||||
rdpClient: RdpClientMode
|
||||
nativeFullscreen: boolean
|
||||
captureInput: boolean
|
||||
redirectClipboard: boolean
|
||||
clipboardRead: boolean
|
||||
clipboardWrite: boolean
|
||||
sessionClipboardRead?: boolean
|
||||
sessionClipboardWrite?: boolean
|
||||
agentPublicKey?: string
|
||||
probeDetail?: string
|
||||
}
|
||||
|
||||
export const minRdpDimension = 200
|
||||
export const maxRdpDimension = 8192
|
||||
const credentialRefPrefix = 'RemoteDesk/RDP/'
|
||||
|
||||
function createRef(prefix: string) {
|
||||
const profileKey = typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, '0')).join('')
|
||||
return `${prefix}${profileKey}`
|
||||
}
|
||||
|
||||
export function createCredentialRef() { return createRef(credentialRefPrefix) }
|
||||
export function validCredentialRef(value: unknown): value is string {
|
||||
return typeof value === 'string' && new RegExp(`^${credentialRefPrefix}[A-Za-z0-9_.:@-]{1,128}$`).test(value)
|
||||
}
|
||||
|
||||
export function isStoredHost(value: unknown): value is Host {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const host = value as Partial<Host>
|
||||
return typeof host.id === 'number'
|
||||
&& typeof host.name === 'string'
|
||||
&& typeof host.address === 'string'
|
||||
&& (host.kind === 'windows_rdp' || host.kind === 'windows_agent' || host.kind === 'linux_agent')
|
||||
&& typeof host.mode === 'string'
|
||||
&& Array.isArray(host.displays)
|
||||
&& Array.isArray(host.selectedDisplayIds)
|
||||
}
|
||||
|
||||
export function normalizeTags(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const tags: string[] = []
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue
|
||||
const tag = item.trim().slice(0, 32)
|
||||
if (tag && !tags.includes(tag)) tags.push(tag)
|
||||
if (tags.length >= 20) break
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
export function parseTags(value: string): string[] {
|
||||
return normalizeTags(value.split(','))
|
||||
}
|
||||
|
||||
export function normalizeStoredHost(host: Host): Host {
|
||||
return {
|
||||
...host,
|
||||
tags: normalizeTags(host.tags),
|
||||
mode: host.kind === 'windows_agent' && host.mode !== 'Application' ? 'Desktop' : host.mode,
|
||||
status: 'unknown', latency: undefined, sessionNetworkLatency: undefined, networkBaseLatency: undefined,
|
||||
bandwidthKbps: undefined, captureLatency: undefined, encodeLatency: undefined, frameProcessingLatency: undefined, decodeLatency: undefined,
|
||||
presentationLatency: undefined, frameRate: undefined, nativeSessionState: undefined,
|
||||
sessionClipboardRead: undefined, sessionClipboardWrite: undefined, qualityUpdatedAt: undefined,
|
||||
credentialState: host.kind === 'windows_rdp' ? 'unknown' : 'missing',
|
||||
agentIdentityState: host.kind === 'linux_agent' || host.kind === 'windows_agent' ? 'unknown' : 'missing',
|
||||
credentialRef: validCredentialRef(host.credentialRef) ? host.credentialRef : createCredentialRef(),
|
||||
rdpClient: host.rdpClient === 'native' ? 'native' : 'system', nativeFullscreen: host.nativeFullscreen === true,
|
||||
captureInput: host.captureInput === true, redirectClipboard: host.redirectClipboard !== false,
|
||||
clipboardRead: host.clipboardRead ?? host.redirectClipboard !== false,
|
||||
clipboardWrite: host.clipboardWrite ?? host.redirectClipboard !== false,
|
||||
customWidth: typeof host.customWidth === 'string' ? host.customWidth : '1600',
|
||||
customHeight: typeof host.customHeight === 'string' ? host.customHeight : '900',
|
||||
probeDetail: '尚未检测',
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(host: Host) {
|
||||
if (host.status === 'ready') return host.kind === 'linux_agent' || host.kind === 'windows_agent' ? 'Agent 在线' : '端口可达'
|
||||
if (host.status === 'connecting') return '连接中'
|
||||
if (host.status === 'probing') return '检测中'
|
||||
if (host.status === 'unknown') return '待检测'
|
||||
return '离线'
|
||||
}
|
||||
|
||||
export function probeResultDetail(result: RdpProbeResult) {
|
||||
if (result.status === 'reachable') return `TCP ${result.port} 可达,账号尚未验证`
|
||||
if (result.status === 'dns_failed') return '域名解析失败'
|
||||
if (result.status === 'timeout') return `TCP ${result.port} 连接超时`
|
||||
if (result.status === 'refused') return `TCP ${result.port} 拒绝连接`
|
||||
return `TCP ${result.port} 不可达`
|
||||
}
|
||||
|
||||
export function linuxProbeDetail(result: LinuxAgentProbeResult) {
|
||||
const capabilities = [result.terminal ? '终端' : null, result.desktop ? 'X11 桌面' : null, result.files ? '文件' : null]
|
||||
.filter((value): value is string => value !== null).join('、') || '无会话能力'
|
||||
const edge = result.edge_presence_configured
|
||||
? result.edge_presence_online && result.edge_signaling_online ? ',Edge 在线' : ',Edge 离线' : ''
|
||||
return `Agent v${result.protocol_major}.${result.protocol_minor} 可用,${capabilities}${edge}`
|
||||
}
|
||||
|
||||
export function protocolProbeDetail(result: RdpProtocolProbeResult) {
|
||||
if (result.ok) {
|
||||
const protocol = result.security_protocol === 'hybrid_extended' ? 'NLA Hybrid Extended' : 'NLA Hybrid'
|
||||
const latency = result.total_latency_ms === undefined ? '' : `,${result.total_latency_ms} ms`
|
||||
return `RDP 协商成功(${protocol}${latency}),账号尚未验证`
|
||||
}
|
||||
if (result.error_code === 'timeout') return 'TCP 可达,但 RDP 协商超时'
|
||||
if (result.error_code === 'rdp_negotiation_failed') return 'TCP 可达,但目标未通过 RDP 协商'
|
||||
return `RDP 协商未完成(${result.error_code ?? result.stage})`
|
||||
}
|
||||
|
||||
export function parseRdpDimension(value: string) {
|
||||
if (!/^\d+$/.test(value)) return null
|
||||
const dimension = Number(value)
|
||||
return Number.isSafeInteger(dimension) && dimension >= minRdpDimension && dimension <= maxRdpDimension ? dimension : null
|
||||
}
|
||||
|
||||
export function rdpResolution(host: Host): RdpResolution | null {
|
||||
if (host.resolution === 'Follow window') return { mode: 'follow_window' }
|
||||
if (host.resolution === 'Custom') {
|
||||
const width = parseRdpDimension(host.customWidth)
|
||||
const height = parseRdpDimension(host.customHeight)
|
||||
return width && height ? { mode: 'fixed', width, height } : null
|
||||
}
|
||||
const match = /^(\d+) x (\d+)$/.exec(host.resolution)
|
||||
if (!match) return null
|
||||
const width = parseRdpDimension(match[1])
|
||||
const height = parseRdpDimension(match[2])
|
||||
return width && height ? { mode: 'fixed', width, height } : null
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,283 @@
|
||||
:root {
|
||||
color: #dce2e7;
|
||||
background: #101315;
|
||||
color-scheme: dark;
|
||||
font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
font-synthesis: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; }
|
||||
button, input, select { font: inherit; letter-spacing: 0; }
|
||||
button { color: inherit; }
|
||||
button:focus-visible, .host-row:focus-visible { outline: 2px solid #69d6a0; outline-offset: -2px; }
|
||||
|
||||
.app-shell { min-height: 100vh; background: #101315; }
|
||||
.topbar { height: 56px; display: grid; grid-template-columns: 220px minmax(260px, 560px) minmax(142px, 1fr) auto; align-items: center; gap: 16px; padding: 0 16px; border-bottom: 1px solid #2a3034; background: #15191c; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-size: 15px; font-weight: 700; color: #f1f4f5; }
|
||||
.brand-mark { width: 30px; height: 30px; display: grid; place-items: center; color: #0f1512; background: #5ed39a; border-radius: 6px; overflow: hidden; }
|
||||
.brand-mark img { display: block; width: 30px; height: 30px; }
|
||||
.search-field { height: 34px; display: flex; align-items: center; gap: 9px; padding: 0 10px; border: 1px solid #343b40; border-radius: 6px; background: #0e1113; color: #7f8a91; }
|
||||
.search-field:focus-within { border-color: #5d8f77; box-shadow: 0 0 0 2px #244435; }
|
||||
.search-field input { flex: 1; min-width: 0; border: 0; outline: 0; color: #e8ecee; background: transparent; font-size: 13px; }
|
||||
.search-field kbd { border: 1px solid #343b40; border-radius: 4px; padding: 2px 5px; color: #78838a; font-size: 10px; }
|
||||
.top-actions { justify-self: end; display: flex; align-items: center; gap: 8px; }
|
||||
.topbar-clock { min-width: 142px; display: inline-flex; align-items: center; justify-content: flex-end; gap: 7px; color: #98a39d; font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.icon-button, .row-icon { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #343b40; border-radius: 6px; background: #1a1f22; cursor: pointer; }
|
||||
.icon-button:hover, .row-icon:hover { background: #252b2f; border-color: #475158; }
|
||||
.icon-button.compact { width: 30px; height: 30px; }
|
||||
.icon-button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.icon-button.danger { color: #dd7d7d; }
|
||||
.icon-button.update-available { color: #65d69d; border-color: #3d775b; }
|
||||
.command-button, .connect-button, .secondary-button { min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 6px; padding: 0 13px; border: 1px solid #4fbf87; background: #4fbf87; color: #0d1712; font-weight: 700; font-size: 13px; cursor: pointer; }
|
||||
.secondary-button { border-color: #3b4449; background: #20262a; color: #dce2e7; }
|
||||
.command-button:disabled, .secondary-button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.workspace { height: calc(100vh - 56px); display: grid; grid-template-columns: 188px minmax(520px, 1fr) 310px; }
|
||||
.sidebar { display: flex; flex-direction: column; padding: 18px 12px 12px; border-right: 1px solid #272d31; background: #121619; }
|
||||
.sidebar nav { display: grid; gap: 3px; }
|
||||
.sidebar-label { margin: 18px 10px 5px; color: #69757a; font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.tag-filter { min-height: 31px; }
|
||||
.tag-filter-name { min-width: 0; display: inline-flex; align-items: center; gap: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tag-dot { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: #63c894; }
|
||||
.nav-item { height: 35px; display: flex; align-items: center; justify-content: space-between; padding: 0 10px; border: 0; border-radius: 5px; background: transparent; color: #aab3b8; cursor: pointer; font-size: 13px; text-align: left; }
|
||||
.nav-item:hover { background: #1b2023; color: #e3e7e9; }
|
||||
.nav-item.active { color: #edf2ef; background: #22362d; }
|
||||
.nav-count { color: #728078; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.sidebar-status { margin-top: auto; display: flex; align-items: center; gap: 8px; padding: 10px; border-top: 1px solid #272d31; color: #8d999f; font-size: 11px; }
|
||||
.host-area { min-width: 0; padding: 22px 22px 30px; overflow: auto; }
|
||||
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 16px; }
|
||||
.section-heading h1 { margin: 0 0 4px; font-size: 19px; line-height: 1.2; font-weight: 700; }
|
||||
.section-heading span { color: #7f8a91; font-size: 12px; }
|
||||
.path-summary { display: flex; align-items: center; gap: 7px; color: #93a19a; font-size: 12px; }
|
||||
.host-table { min-width: 690px; border: 1px solid #2a3135; border-radius: 6px; overflow: hidden; background: #15191c; }
|
||||
.table-row { width: 100%; display: grid; grid-template-columns: minmax(190px, 1.5fr) 90px 100px 110px 100px 78px; align-items: center; min-height: 54px; padding: 0 12px; border: 0; border-bottom: 1px solid #272d31; background: transparent; color: #adb7bc; text-align: left; font-size: 12px; }
|
||||
.table-header { min-height: 34px; color: #758087; background: #111517; font-size: 10px; text-transform: uppercase; }
|
||||
.host-row { cursor: pointer; }
|
||||
.host-row:hover { background: #1a2023; }
|
||||
.host-row.selected { background: #1d2924; box-shadow: inset 3px 0 #57c890; }
|
||||
.host-row:last-child { border-bottom: 0; }
|
||||
.host-identity { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.host-icon { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid #30383d; border-radius: 5px; color: #92a19a; background: #171c1f; }
|
||||
.host-identity strong, .host-identity small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.host-identity strong { color: #e2e7e9; font-size: 13px; }
|
||||
.host-identity small { margin-top: 2px; color: #707b82; font-size: 10px; }
|
||||
.host-tags, .tag-editor-preview { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; min-width: 0; }
|
||||
.tag-chip { display: inline-flex; align-items: center; max-width: 110px; min-height: 18px; padding: 1px 6px; overflow: hidden; border: 1px solid #345344; border-radius: 4px; background: #1b3026; color: #8edbb1; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; line-height: 1.3; }
|
||||
.tag-editor-preview { margin: -4px 0 10px; }
|
||||
.host-type-label { display: block; font-size: 11px; font-weight: 600; }
|
||||
.host-mode-label { display: block; margin-top: 3px; color: #7f8b90; font-size: 10px; }
|
||||
.add-host-note { padding: 8px 10px; border: 1px solid #344942; border-radius: 5px; background: #17251f; }
|
||||
.status-cell { display: flex; align-items: center; gap: 7px; }
|
||||
.status-dot { width: 7px; height: 7px; display: inline-block; flex: 0 0 auto; border-radius: 50%; background: #657078; }
|
||||
.status-dot.ready { background: #59d394; box-shadow: 0 0 0 3px #1d3c2e; }
|
||||
.status-dot.connecting { background: #e4b660; box-shadow: 0 0 0 3px #453820; }
|
||||
.status-dot.probing { background: #e4b660; box-shadow: 0 0 0 3px #453820; animation: pulse 1s ease-in-out infinite; }
|
||||
.status-dot.offline { background: #667078; }
|
||||
.status-dot.unknown { background: #87939a; box-shadow: 0 0 0 3px #293136; }
|
||||
.row-actions { display: flex; justify-content: flex-end; gap: 5px; }
|
||||
.row-icon { width: 28px; height: 28px; }
|
||||
.row-icon.primary { color: #65d69d; }
|
||||
.row-icon:disabled { opacity: .32; cursor: not-allowed; }
|
||||
.empty-state { padding: 42px; color: #778289; text-align: center; font-size: 13px; }
|
||||
.inspector-empty { display: grid; align-content: center; justify-items: center; gap: 8px; color: #778289; }
|
||||
.inspector-empty h2 { margin: 0; font-size: 13px; }
|
||||
.inspector { padding: 20px 18px; border-left: 1px solid #272d31; background: #14181b; overflow-y: auto; }
|
||||
.inspector-title { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px; padding-bottom: 16px; }
|
||||
.inspector-title > div:first-child { min-width: 0; }
|
||||
.inspector-actions { display: grid; grid-template-columns: repeat(2, 30px); grid-auto-rows: 30px; justify-content: end; gap: 6px; min-width: 66px; }
|
||||
.inspector-actions .icon-button { width: 30px; height: 30px; min-width: 30px; }
|
||||
.inspector-title > div { display: grid; grid-template-columns: 12px 1fr; align-items: center; }
|
||||
.inspector-title h2 { margin: 0; font-size: 16px; line-height: 1.4; }
|
||||
.inspector-title p { grid-column: 2; margin: 2px 0 0; color: #78838a; font-size: 11px; }
|
||||
.inspector-section { padding: 16px 0; border-top: 1px solid #2a3034; }
|
||||
.inspector-section h3 { margin: 0 0 12px; color: #8d989e; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||
.inspector-section label, .modal label { display: grid; gap: 6px; margin-bottom: 11px; color: #8d989e; font-size: 11px; }
|
||||
.rdp-client-field { display: grid; gap: 6px; margin-bottom: 11px; color: #8d989e; font-size: 11px; }
|
||||
.segmented-control { width: 100%; min-height: 34px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid #333b40; border-radius: 5px; overflow: hidden; background: #0f1315; }
|
||||
.segmented-control button { min-width: 0; display: flex; align-items: center; justify-content: center; gap: 4px; padding: 0 5px; border: 0; border-right: 1px solid #333b40; background: transparent; color: #8e999f; cursor: pointer; font-size: 10px; white-space: nowrap; }
|
||||
.segmented-control button:last-child { border-right: 0; }
|
||||
.segmented-control button:hover:not(:disabled) { color: #e2e7e9; background: #22292c; }
|
||||
.segmented-control button.active { color: #bcebd2; background: #244133; }
|
||||
.segmented-control button:disabled { color: #596268; background: #151a1d; cursor: not-allowed; }
|
||||
.segmented-control.file-direction { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 11px; }
|
||||
.connection-note { min-height: 28px; display: block; color: #717c82; line-height: 1.4; }
|
||||
select, .modal input, .settings-drawer input { width: 100%; height: 32px; padding: 0 9px; border: 1px solid #333b40; border-radius: 5px; outline: 0; color: #dce2e5; background: #0f1315; font-size: 12px; }
|
||||
select:focus, .modal input:focus, .settings-drawer input:focus { border-color: #548b70; }
|
||||
select:disabled { color: #727d83; background: #151a1d; cursor: not-allowed; }
|
||||
.resolution-custom { display: grid; grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr); align-items: end; gap: 6px; margin-bottom: 11px; }
|
||||
.resolution-custom label { min-width: 0; margin: 0; }
|
||||
.resolution-custom span { padding-bottom: 8px; color: #727d83; text-align: center; font-size: 11px; }
|
||||
.resolution-custom input { width: 100%; min-width: 0; height: 32px; padding: 0 8px; border: 1px solid #333b40; border-radius: 5px; outline: 0; color: #dce2e5; background: #0f1315; font-size: 12px; }
|
||||
.resolution-custom input:focus { border-color: #548b70; }
|
||||
.display-picker { display: grid; gap: 5px; margin: 0 0 12px; padding: 0; border: 0; }
|
||||
.display-picker legend { margin-bottom: 6px; color: #8d989e; font-size: 11px; }
|
||||
.display-option { min-height: 38px; grid-template-columns: 18px minmax(0, 1fr) !important; align-items: center; gap: 8px !important; margin: 0 !important; padding: 5px 8px; border: 1px solid #30383d; border-radius: 5px; background: #111517; cursor: pointer; }
|
||||
.display-option input { width: 15px; height: 15px; margin: 0; accent-color: #3a9b6b; }
|
||||
.display-option span, .display-option strong, .display-option small { display: block; min-width: 0; }
|
||||
.display-option strong { overflow: hidden; color: #cbd2d5; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
|
||||
.display-option small { margin-top: 2px; color: #707b82; font-size: 10px; }
|
||||
.metric-row { display: flex; justify-content: space-between; align-items: center; gap: 14px; min-height: 30px; color: #879299; font-size: 11px; }
|
||||
.metric-row span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metric-row strong { max-width: 150px; overflow: hidden; color: #cfd6d9; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
|
||||
.good-text { color: #63d99d !important; }
|
||||
.toggle-row { grid-template-columns: 1fr auto !important; align-items: center; margin-top: 8px; }
|
||||
.toggle-row span, .toggle-row strong, .toggle-row small { display: block; }
|
||||
.toggle-row strong { color: #cbd2d5; font-size: 11px; }
|
||||
.toggle-row small { margin-top: 3px; color: #707b82; font-size: 10px; }
|
||||
.toggle-row input { appearance: none; width: 30px; height: 17px; border-radius: 10px; background: #3a4247; cursor: pointer; position: relative; }
|
||||
.toggle-row input::after { content: ''; position: absolute; width: 13px; height: 13px; left: 2px; top: 2px; border-radius: 50%; background: #b5bec2; transition: transform .15s; }
|
||||
.toggle-row input:checked { background: #3a9b6b; }
|
||||
.toggle-row input:checked::after { transform: translateX(13px); background: white; }
|
||||
.toggle-row input:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.quality-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
|
||||
.quality-heading h3 { margin: 0; }
|
||||
.quality-heading span, .quality-heading time { color: #717c82; font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.quality-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.quality-metrics > div { min-width: 0; padding: 1px 7px; border-left: 1px solid #2a3034; }
|
||||
.quality-metrics > div:first-child { padding-left: 0; border-left: 0; }
|
||||
.quality-metrics > div:last-child { padding-right: 0; }
|
||||
.quality-metrics span, .quality-metrics strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.quality-metrics span { color: #7e898f; font-size: 10px; }
|
||||
.quality-metrics strong { min-height: 19px; margin-top: 4px; color: #cfd5d8; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.quality-details { margin-top: 13px; padding-top: 7px; border-top: 1px solid #252b2f; }
|
||||
.quality-details > div { display: flex; justify-content: space-between; gap: 12px; margin: 7px 0; color: #7e898f; font-size: 11px; }
|
||||
.quality-details strong { max-width: 180px; overflow: hidden; color: #cfd5d8; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.connect-button { width: 100%; margin-top: 8px; }
|
||||
.connection-blockers { margin-top: 14px; padding: 11px 12px; border: 1px solid #6b4c3f; border-radius: 6px; background: #2d2523; color: #f2c7b4; }
|
||||
.connection-blockers h3 { margin: 0 0 7px; color: #f0a98c; font-size: 11px; font-weight: 700; }
|
||||
.connection-blockers ul { margin: 0; padding-left: 18px; font-size: 12px; line-height: 1.55; }
|
||||
.credential-button { width: 100%; margin-top: 10px; }
|
||||
.certificate-input { width: 100%; min-width: 0; height: 32px; padding: 0 9px; border: 1px solid #333b40; border-radius: 5px; outline: 0; color: #dce2e5; background: #0f1315; font: 11px Consolas, "Cascadia Mono", monospace; }
|
||||
.certificate-input:focus { border-color: #548b70; }
|
||||
.certificate-observed { display: grid; gap: 4px; margin: -2px 0 9px; color: #7e898f; font-size: 10px; }
|
||||
.certificate-observed strong { overflow: hidden; color: #cfd5d8; text-overflow: ellipsis; white-space: nowrap; font: 10px Consolas, "Cascadia Mono", monospace; }
|
||||
.certificate-actions { display: grid; grid-template-columns: minmax(0, 1fr); grid-auto-flow: column; grid-auto-columns: 30px; gap: 7px; }
|
||||
.connect-button:disabled { border-color: #394147; background: #252b2f; color: #78838a; cursor: not-allowed; }
|
||||
.modal-backdrop, .drawer-backdrop { position: fixed; inset: 0; display: grid; place-items: center; background: #050708b8; z-index: 20; }
|
||||
.modal { width: min(430px, calc(100vw - 32px)); padding: 20px; border: 1px solid #394248; border-radius: 7px; background: #171c1f; box-shadow: 0 24px 80px #000a; }
|
||||
.modal-heading { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
|
||||
.modal-heading h2 { margin: 0; font-size: 17px; }
|
||||
.modal-heading p { margin: 5px 0 0; color: #7c878d; font-size: 11px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
||||
.drawer-backdrop { place-items: stretch end; }
|
||||
.settings-drawer { width: min(390px, 100vw); height: 100%; overflow-y: auto; padding: 20px; border-left: 1px solid #394248; background: #171c1f; box-shadow: -20px 0 70px #0008; }
|
||||
.drawer-save { width: 100%; }
|
||||
.settings-error { margin: 10px 0 0; color: #ef8f8f; font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.shortcut-settings h3 { display: flex; align-items: center; gap: 6px; }
|
||||
.settings-hint { margin: 8px 0 0; color: #9ca9ad; font-size: 11px; line-height: 1.45; }
|
||||
.update-key-input { font-family: Consolas, "Cascadia Mono", monospace; }
|
||||
.update-status { min-height: 38px; margin-top: 10px; padding: 9px 0; border-top: 1px solid #2a3034; border-bottom: 1px solid #2a3034; color: #879299; font-size: 11px; }
|
||||
.update-status p { margin: 0; line-height: 1.45; overflow-wrap: anywhere; white-space: pre-line; }
|
||||
.update-release > div { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.update-release strong { color: #d7dddf; font-size: 12px; }
|
||||
.update-release span { color: #77838a; font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.update-release > p { margin-top: 7px; color: #9ca6ab; }
|
||||
.update-actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.update-actions > button { flex: 1; padding: 0 8px; }
|
||||
.spin { animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 50% { opacity: .45; } }
|
||||
|
||||
:root[data-theme='light'] { color: #25302b; background: #f5f7f8; color-scheme: light; }
|
||||
[data-theme='light'].app-shell { color: #25302b; background: #f5f7f8; }
|
||||
[data-theme='light'] .topbar { border-color: #dbe1de; background: #ffffff; }
|
||||
[data-theme='light'] .brand { color: #111713; }
|
||||
[data-theme='light'] .topbar-clock { color: #5f6e66; }
|
||||
[data-theme='light'] .search-field { border-color: #cbd3cf; background: #f5f7f8; color: #65736c; }
|
||||
[data-theme='light'] .search-field:focus-within { border-color: #36845e; box-shadow: 0 0 0 2px #d7eee2; }
|
||||
[data-theme='light'] .search-field input { color: #1f2924; }
|
||||
[data-theme='light'] .search-field kbd { border-color: #cbd3cf; color: #66746d; background: #ffffff; }
|
||||
[data-theme='light'] .icon-button, [data-theme='light'] .row-icon { border-color: #cbd3cf; background: #ffffff; }
|
||||
[data-theme='light'] .icon-button:hover, [data-theme='light'] .row-icon:hover { border-color: #94a49c; background: #edf2ef; }
|
||||
[data-theme='light'] .secondary-button { border-color: #c4cec9; background: #f1f4f2; color: #26322c; }
|
||||
[data-theme='light'] .sidebar { border-color: #dfe5e2; background: #f8faf9; }
|
||||
[data-theme='light'] .nav-item { color: #526159; }
|
||||
[data-theme='light'] .nav-item:hover { color: #1f2924; background: #edf2ef; }
|
||||
[data-theme='light'] .nav-item.active { color: #17452f; background: #dff1e7; }
|
||||
[data-theme='light'] .nav-count { color: #68776f; }
|
||||
[data-theme='light'] .sidebar-status { border-color: #dfe5e2; color: #65736c; }
|
||||
[data-theme='light'] .section-heading span, [data-theme='light'] .path-summary { color: #65736c; }
|
||||
[data-theme='light'] .host-table { border-color: #d7dfdb; background: #ffffff; }
|
||||
[data-theme='light'] .table-row { border-color: #e1e6e3; color: #526159; }
|
||||
[data-theme='light'] .table-header { color: #68776f; background: #f2f5f4; }
|
||||
[data-theme='light'] .host-row:hover { background: #f3f7f5; }
|
||||
[data-theme='light'] .host-row.selected { background: #e8f5ee; box-shadow: inset 3px 0 #2b9a65; }
|
||||
[data-theme='light'] .host-icon { border-color: #d5ddd9; color: #65736c; background: #f4f7f5; }
|
||||
[data-theme='light'] .host-identity strong { color: #1d2822; }
|
||||
[data-theme='light'] .host-identity small, [data-theme='light'] .inspector-title p { color: #6c7a73; }
|
||||
[data-theme='light'] .sidebar-label { color: #7a8981; }
|
||||
[data-theme='light'] .tag-chip { border-color: #b9d8c6; background: #edf8f1; color: #28734b; }
|
||||
[data-theme='light'] .status-dot.ready { background: #209a63; box-shadow: 0 0 0 3px #d7f0e3; }
|
||||
[data-theme='light'] .status-dot.connecting { background: #b97817; box-shadow: 0 0 0 3px #f4e6ca; }
|
||||
[data-theme='light'] .status-dot.probing { background: #b97817; box-shadow: 0 0 0 3px #f4e6ca; }
|
||||
[data-theme='light'] .status-dot.offline { background: #8a9690; }
|
||||
[data-theme='light'] .status-dot.unknown { background: #718078; box-shadow: 0 0 0 3px #e1e7e4; }
|
||||
[data-theme='light'] .row-icon.primary, [data-theme='light'] .good-text { color: #167a4c !important; }
|
||||
[data-theme='light'] .empty-state { color: #69776f; }
|
||||
[data-theme='light'] .inspector { border-color: #dfe5e2; background: #ffffff; }
|
||||
[data-theme='light'] .inspector-section { border-color: #dfe5e2; }
|
||||
[data-theme='light'] .inspector-section h3, [data-theme='light'] .inspector-section label, [data-theme='light'] .modal label { color: #5f6e66; }
|
||||
[data-theme='light'] .rdp-client-field { color: #5f6e66; }
|
||||
[data-theme='light'] .segmented-control { border-color: #cbd3cf; background: #ffffff; }
|
||||
[data-theme='light'] .segmented-control button { border-color: #cbd3cf; color: #5f6e66; }
|
||||
[data-theme='light'] .segmented-control button:hover:not(:disabled) { color: #26322c; background: #edf2ef; }
|
||||
[data-theme='light'] .segmented-control button.active { color: #175c3b; background: #dff1e7; }
|
||||
[data-theme='light'] .segmented-control button:disabled { color: #96a19b; background: #eef1f0; }
|
||||
[data-theme='light'] .connection-note { color: #69776f; }
|
||||
[data-theme='light'] select, [data-theme='light'] .modal input, [data-theme='light'] .settings-drawer input { border-color: #cbd3cf; color: #25302b; background: #ffffff; }
|
||||
[data-theme='light'] select:focus, [data-theme='light'] .modal input:focus, [data-theme='light'] .settings-drawer input:focus { border-color: #36845e; }
|
||||
[data-theme='light'] select:disabled { color: #77857e; background: #eef1f0; }
|
||||
[data-theme='light'] .resolution-custom span { color: #77857e; }
|
||||
[data-theme='light'] .resolution-custom input { border-color: #cbd3cf; color: #25302b; background: #ffffff; }
|
||||
[data-theme='light'] .resolution-custom input:focus { border-color: #36845e; }
|
||||
[data-theme='light'] .certificate-input { border-color: #cbd3cf; color: #25302b; background: #ffffff; }
|
||||
[data-theme='light'] .certificate-input:focus { border-color: #36845e; }
|
||||
[data-theme='light'] .certificate-observed strong { color: #28342e; }
|
||||
[data-theme='light'] .display-picker legend { color: #5f6e66; }
|
||||
[data-theme='light'] .display-option { border-color: #d5ddd9; background: #f7f9f8; }
|
||||
[data-theme='light'] .display-option strong { color: #28342e; }
|
||||
[data-theme='light'] .display-option small { color: #69776f; }
|
||||
[data-theme='light'] .metric-row, [data-theme='light'] .quality-details > div { color: #5f6e66; }
|
||||
[data-theme='light'] .metric-row strong, [data-theme='light'] .quality-metrics strong, [data-theme='light'] .quality-details strong, [data-theme='light'] .toggle-row strong { color: #28342e; }
|
||||
[data-theme='light'] .quality-heading span, [data-theme='light'] .quality-heading time, [data-theme='light'] .quality-metrics span { color: #69776f; }
|
||||
[data-theme='light'] .quality-metrics > div, [data-theme='light'] .quality-details { border-color: #dfe5e2; }
|
||||
[data-theme='light'] .toggle-row small, [data-theme='light'] .modal-heading p { color: #69776f; }
|
||||
[data-theme='light'] .toggle-row input { background: #b7c1bc; }
|
||||
[data-theme='light'] .toggle-row input::after { background: #ffffff; }
|
||||
[data-theme='light'] .toggle-row input:checked { background: #26865a; }
|
||||
[data-theme='light'] .connect-button:disabled { border-color: #d1d8d4; color: #77857e; background: #e8ecea; }
|
||||
[data-theme='light'] .connection-blockers { border-color: #e5b9a7; background: #fff5f0; color: #7a3928; }
|
||||
[data-theme='light'] .connection-blockers h3 { color: #a3472e; }
|
||||
[data-theme='light'] .modal-backdrop, [data-theme='light'] .drawer-backdrop { background: #10181447; }
|
||||
[data-theme='light'] .modal, [data-theme='light'] .settings-drawer { border-color: #c9d1cd; background: #ffffff; box-shadow: 0 24px 70px #19251f2e; }
|
||||
[data-theme='light'] .settings-error { color: #aa3030; }
|
||||
[data-theme='light'] .settings-hint { color: #5e6b65; }
|
||||
[data-theme='light'] .icon-button.update-available { color: #167a4c; border-color: #78aa90; }
|
||||
[data-theme='light'] .update-status { border-color: #dfe5e2; color: #5f6e66; }
|
||||
[data-theme='light'] .update-release strong { color: #28342e; }
|
||||
[data-theme='light'] .update-release span, [data-theme='light'] .update-release > p { color: #69776f; }
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.workspace { grid-template-columns: 160px minmax(520px, 1fr); }
|
||||
.inspector { display: none; }
|
||||
.topbar { grid-template-columns: 140px minmax(150px, 1fr) 136px auto; gap: 8px; padding: 0 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.top-actions .command-button { width: 34px; padding: 0; font-size: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body { overflow: auto; }
|
||||
.topbar { height: auto; min-height: 56px; grid-template-columns: 1fr auto; gap: 8px; padding: 10px 12px; }
|
||||
.top-actions { gap: 4px; }
|
||||
.topbar-clock { grid-column: 1 / -1; grid-row: 2; justify-self: end; min-width: 0; }
|
||||
.search-field { grid-column: 1 / -1; grid-row: 3; }
|
||||
.workspace { height: auto; min-height: calc(100vh - 132px); display: block; }
|
||||
.sidebar { padding: 8px; border-right: 0; border-bottom: 1px solid #272d31; }
|
||||
.sidebar nav { display: flex; overflow-x: auto; }
|
||||
.nav-item { min-width: 82px; }
|
||||
.sidebar-status { display: none; }
|
||||
.host-area { padding: 16px 12px; overflow-x: auto; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user