[CmdletBinding()] param( [ValidatePattern('^[0-9A-Fa-f]{40}$')] [string]$SigningCertificateThumbprint, [ValidatePattern('^https://')] [string]$TimestampUrl = 'https://timestamp.digicert.com', [ValidateSet('m0-preview', 'stable')] [string]$ReleaseChannel = 'm0-preview', [ValidateSet('zh-cn', 'en-us')] [string]$Culture = 'zh-cn' ) $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'Authenticode.psm1') -Force $repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) $artifactsRoot = [IO.Path]::GetFullPath((Join-Path $repoRoot 'artifacts')) $targetRoot = [IO.Path]::GetFullPath((Join-Path $repoRoot 'target')) $buildRoot = $null $previousRollForward = $env:DOTNET_ROLL_FORWARD $previousRustFlags = $env:RUSTFLAGS $env:RUSTFLAGS = '-C target-feature=+crt-static' $wixVersion = '4.0.6' $wixPackageHash = 'a94dd42ae1fb56b32da180e2173ceda4f0d10b4c8871c5ee59ecb502131a1eb6' $wixPackageUri = "https://api.nuget.org/v3-flatcontainer/wix/$wixVersion/wix.$wixVersion.nupkg" $signingEnabled = -not [string]::IsNullOrWhiteSpace($SigningCertificateThumbprint) if ($signingEnabled) { $timestamp = [Uri]$TimestampUrl if (-not $timestamp.IsAbsoluteUri -or $timestamp.Scheme -ne 'https' -or -not [string]::IsNullOrEmpty($timestamp.UserInfo) -or -not [string]::IsNullOrEmpty($timestamp.Query) -or -not [string]::IsNullOrEmpty($timestamp.Fragment)) { throw 'TimestampUrl must be a credential-free HTTPS URL without query or fragment' } } function Invoke-Checked { param( [Parameter(Mandatory)] [scriptblock]$Command, [Parameter(Mandatory)] [string]$Description ) & $Command if ($LASTEXITCODE -ne 0) { throw "$Description failed with exit code $LASTEXITCODE" } } function Assert-PathWithin { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Root ) $fullPath = [IO.Path]::GetFullPath($Path) $prefix = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar if (-not $fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to modify a path outside $Root" } } function Get-WixTool { $toolsRoot = Join-Path $targetRoot 'tools' $packagePath = Join-Path $toolsRoot "wix.$wixVersion.nupkg" $toolRoot = Join-Path $toolsRoot "wix-$wixVersion" $wix = Join-Path $toolRoot 'tools\net6.0\any\wix.exe' Assert-PathWithin -Path $packagePath -Root $targetRoot Assert-PathWithin -Path $toolRoot -Root $targetRoot New-Item -ItemType Directory -Force -Path $toolsRoot | Out-Null if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) { $downloadPath = "$packagePath.download-$PID" Assert-PathWithin -Path $downloadPath -Root $targetRoot try { Invoke-WebRequest -Uri $wixPackageUri -OutFile $downloadPath $downloadHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant() if ($downloadHash -ne $wixPackageHash) { throw "Downloaded WiX package hash mismatch: $downloadHash" } Move-Item -LiteralPath $downloadPath -Destination $packagePath } finally { if (Test-Path -LiteralPath $downloadPath) { Remove-Item -LiteralPath $downloadPath -Force } } } $packageHash = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($packageHash -ne $wixPackageHash) { throw "Cached WiX package hash mismatch: $packageHash" } if (-not (Test-Path -LiteralPath $wix -PathType Leaf)) { New-Item -ItemType Directory -Force -Path $toolRoot | Out-Null Expand-Archive -LiteralPath $packagePath -DestinationPath $toolRoot -Force } if (-not (Test-Path -LiteralPath $wix -PathType Leaf)) { throw 'WiX executable is missing after package extraction' } return $wix } function Get-StableWixId { param( [Parameter(Mandatory)][string]$Prefix, [Parameter(Mandatory)][string]$Value ) $bytes = [Text.Encoding]::UTF8.GetBytes($Value.ToLowerInvariant()) $sha = [Security.Cryptography.SHA256]::Create() try { $digest = ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '') } finally { $sha.Dispose() } return "${Prefix}_$($digest.Substring(0, 24))" } function New-WixElement { param( [Parameter(Mandatory)][Xml.XmlDocument]$Document, [Parameter(Mandatory)][string]$Name, [hashtable]$Attributes = @{} ) $element = $Document.CreateElement($Name, 'http://wixtoolset.org/schemas/v4/wxs') foreach ($attribute in $Attributes.GetEnumerator()) { [void]$element.SetAttribute([string]$attribute.Key, [string]$attribute.Value) } return $element } function New-WixPayloadSource { param( [Parameter(Mandatory)][string]$PayloadRoot, [Parameter(Mandatory)][string]$OutputPath ) $files = @(Get-ChildItem -LiteralPath $PayloadRoot -Recurse -File | Sort-Object FullName) if ($files.Count -eq 0) { throw 'Installer payload is empty' } $document = [Xml.XmlDocument]::new() [void]$document.AppendChild($document.CreateXmlDeclaration('1.0', 'utf-8', $null)) $wix = New-WixElement -Document $document -Name 'Wix' [void]$document.AppendChild($wix) $directoryFragment = New-WixElement -Document $document -Name 'Fragment' $directoryRef = New-WixElement -Document $document -Name 'DirectoryRef' -Attributes @{ Id = 'INSTALLFOLDER' } [void]$directoryFragment.AppendChild($directoryRef) [void]$wix.AppendChild($directoryFragment) $groupFragment = New-WixElement -Document $document -Name 'Fragment' $componentGroup = New-WixElement -Document $document -Name 'ComponentGroup' -Attributes @{ Id = 'ApplicationFiles' } [void]$groupFragment.AppendChild($componentGroup) [void]$wix.AppendChild($groupFragment) $directoryNodes = @{ '' = $directoryRef } foreach ($file in $files) { $rootUri = [Uri]((Resolve-Path -LiteralPath $PayloadRoot).Path.TrimEnd('\') + '\') $fileUri = [Uri]((Resolve-Path -LiteralPath $file.FullName).Path) $relativePath = [Uri]::UnescapeDataString($rootUri.MakeRelativeUri($fileUri).ToString()).Replace('/', '\') $relativeDirectory = [IO.Path]::GetDirectoryName($relativePath) $currentPath = '' $parent = $directoryRef if ($relativeDirectory) { foreach ($part in $relativeDirectory.Split([IO.Path]::DirectorySeparatorChar, [StringSplitOptions]::RemoveEmptyEntries)) { $currentPath = if ($currentPath) { "$currentPath\$part" } else { $part } if (-not $directoryNodes.ContainsKey($currentPath)) { $directory = New-WixElement -Document $document -Name 'Directory' -Attributes @{ Id = Get-StableWixId -Prefix 'D' -Value $currentPath Name = $part } [void]$parent.AppendChild($directory) $directoryNodes[$currentPath] = $directory } $parent = $directoryNodes[$currentPath] } } $componentId = Get-StableWixId -Prefix 'C' -Value $relativePath $component = New-WixElement -Document $document -Name 'Component' -Attributes @{ Id = $componentId Guid = '*' } $fileElement = New-WixElement -Document $document -Name 'File' -Attributes @{ Id = Get-StableWixId -Prefix 'F' -Value $relativePath Source = $file.FullName KeyPath = 'yes' } [void]$component.AppendChild($fileElement) [void]$parent.AppendChild($component) $componentRef = New-WixElement -Document $document -Name 'ComponentRef' -Attributes @{ Id = $componentId } [void]$componentGroup.AppendChild($componentRef) } $settings = [Xml.XmlWriterSettings]::new() $settings.Indent = $true $settings.Encoding = [Text.UTF8Encoding]::new($false) $writer = [Xml.XmlWriter]::Create($OutputPath, $settings) try { $document.Save($writer) } finally { $writer.Dispose() } } Push-Location $repoRoot try { Invoke-Checked -Description 'Native Rust client release build' -Command { & cargo.exe build --release --jobs 2 -p remotedesk-native-gui -p remotedesk-credential-store -p remotedesk-native-video -p remotedesk-rdp-session -p remotedesk-rdp-viewer -p remotedesk-windows-agent-viewer } $metadata = (& cargo.exe metadata --no-deps --format-version 1 | ConvertFrom-Json) if ($LASTEXITCODE -ne 0) { throw 'Unable to read Cargo workspace metadata' } $helperPackage = $metadata.packages | Where-Object name -eq 'remotedesk-native-video' if ($null -eq $helperPackage) { throw 'remotedesk-native-video is missing from Cargo metadata' } $version = [string]$helperPackage.version if ($version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { throw "MSI requires a three-part numeric version: $version" } $hostLine = (& rustc.exe -vV | Select-String '^host: ').Line if (-not $hostLine) { throw 'Unable to determine the Rust host target' } $hostTarget = $hostLine.Substring(6).Trim() $architecture = if ($hostTarget.StartsWith('x86_64-')) { 'x64' } elseif ($hostTarget.StartsWith('aarch64-')) { 'arm64' } else { throw "Unsupported installer architecture: $hostTarget" } $wix = Get-WixTool $uiExtension = Join-Path $targetRoot 'tools\wix-ui-4.0.6\wixext4\WixToolset.UI.wixext.dll' if (-not (Test-Path -LiteralPath $uiExtension -PathType Leaf)) { throw "WiX UI extension is missing: $uiExtension" } $signTool = if ($signingEnabled) { Get-RemoteDeskSignTool } else { $null } $signingCertificate = if ($signingEnabled) { Get-RemoteDeskSigningCertificate -Thumbprint $SigningCertificateThumbprint } else { $null } $buildRoot = Join-Path $artifactsRoot ".package-installer-$PID-$([guid]::NewGuid().ToString('N'))" $payloadRoot = Join-Path $buildRoot 'payload' $binRoot = Join-Path $payloadRoot 'bin' $docsRoot = Join-Path $payloadRoot 'docs' $intermediateRoot = Join-Path $buildRoot 'wixobj' $payloadSource = Join-Path $buildRoot 'payload.wxs' $installerName = "RemoteDesk-M0-$version-$Culture-windows-$architecture.msi" $temporaryInstaller = Join-Path $buildRoot $installerName $installerPath = Join-Path $artifactsRoot $installerName Assert-PathWithin -Path $buildRoot -Root $artifactsRoot Assert-PathWithin -Path $installerPath -Root $artifactsRoot New-Item -ItemType Directory -Force -Path $artifactsRoot | Out-Null New-Item -ItemType Directory -Force -Path $binRoot, $docsRoot, $intermediateRoot | Out-Null Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-native.exe') -Destination (Join-Path $binRoot 'remotedesk.exe') Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-credential-store.exe') -Destination $binRoot Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-native-video.exe') -Destination $binRoot Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-rdp-session.exe') -Destination $binRoot Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-rdp-viewer.exe') -Destination $binRoot Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-windows-agent-viewer.exe') -Destination $binRoot Copy-Item -LiteralPath (Join-Path $repoRoot 'packaging\windows\Apply-RemoteDesk-Update.ps1') -Destination $payloadRoot Copy-Item -LiteralPath (Join-Path $repoRoot 'packaging\windows\README-INSTALLER.md') -Destination (Join-Path $payloadRoot 'README.md') Copy-Item -LiteralPath (Join-Path $repoRoot 'docs\user-guide.md') -Destination $docsRoot Copy-Item -LiteralPath (Join-Path $repoRoot 'docs\implementation-status.md') -Destination $docsRoot Copy-Item -LiteralPath (Join-Path $repoRoot 'docs\online-updates.md') -Destination $docsRoot Copy-Item -LiteralPath (Join-Path $repoRoot 'client\README.md') -Destination $docsRoot $manifest = [ordered]@{ product = 'RemoteDesk' version = $version channel = $ReleaseChannel culture = $Culture distribution = 'msi' install_scope = 'per-user' target = $hostTarget runnable_ui = 'bin/remotedesk.exe' desktop_shell = 'winit-wgpu-egui' credential_store = 'bin/remotedesk-credential-store.exe' native_helper = 'bin/remotedesk-native-video.exe' rdp_probe_helper = 'bin/remotedesk-rdp-session.exe' rdp_viewer = 'bin/remotedesk-rdp-viewer.exe' windows_agent_viewer = 'bin/remotedesk-windows-agent-viewer.exe' update_helper = 'Apply-RemoteDesk-Update.ps1' signed = $signingEnabled limitations = @( 'Native IronRDP uses merged dirty-rect D3D11 CPU texture uploads and swap-chain presentation when available; it is not hardware decode or zero-copy' 'The RDP protocol probe validates negotiation and TLS certificate identity but stops before NLA authentication' 'Native IronRDP custom monitor subsets are not available; all-local-monitor mode uses RDPEDISP' 'Linux Terminal is available; Linux graphical desktop, WebRTC, and CDN connector are not included' 'Online updates require an administrator-configured signed HTTPS manifest and public key' ) } $manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $payloadRoot 'manifest.json') -Encoding utf8 if ($signingEnabled) { Get-ChildItem -LiteralPath $binRoot -Filter '*.exe' -File | Sort-Object Name | ForEach-Object { Invoke-RemoteDeskSignTool -SignTool $signTool -Thumbprint $signingCertificate.Thumbprint ` -Path $_.FullName -TimestampServer $TimestampUrl } Get-ChildItem -LiteralPath $payloadRoot -Filter '*.ps1' -File | Sort-Object Name | ForEach-Object { Invoke-RemoteDeskPowerShellSigning -Certificate $signingCertificate -Path $_.FullName ` -TimestampServer $TimestampUrl } } $hashLines = Get-ChildItem -LiteralPath $payloadRoot -Recurse -File | Sort-Object FullName | ForEach-Object { $rootUri = [Uri]((Resolve-Path -LiteralPath $payloadRoot).Path.TrimEnd('\') + '\') $fileUri = [Uri]((Resolve-Path -LiteralPath $_.FullName).Path) $relative = [Uri]::UnescapeDataString($rootUri.MakeRelativeUri($fileUri).ToString()).Replace('\', '/') $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() "$hash $relative" } $hashLines | Set-Content -LiteralPath (Join-Path $payloadRoot 'SHA256SUMS.txt') -Encoding ascii New-WixPayloadSource -PayloadRoot $payloadRoot -OutputPath $payloadSource $env:DOTNET_ROLL_FORWARD = 'Major' Invoke-Checked -Description 'WiX MSI build' -Command { & $wix build ` -arch $architecture ` -ext $uiExtension ` -culture $Culture ` -d "ProductVersion=$version" ` -d "PayloadRoot=$payloadRoot" ` -d "IconPath=$(Join-Path $PSScriptRoot 'icon.ico')" ` -intermediateFolder $intermediateRoot ` -pdbtype none ` -out $temporaryInstaller ` (Join-Path $PSScriptRoot 'RemoteDesk.wxs') ` $payloadSource } if (-not (Test-Path -LiteralPath $temporaryInstaller -PathType Leaf)) { throw 'WiX did not produce the expected MSI' } Move-Item -LiteralPath $temporaryInstaller -Destination $installerPath -Force if ($signingEnabled) { Invoke-RemoteDeskSignTool -SignTool $signTool -Thumbprint $signingCertificate.Thumbprint ` -Path $installerPath -TimestampServer $TimestampUrl } $installerHash = (Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash.ToLowerInvariant() "$installerHash $installerName" | Set-Content -LiteralPath (Join-Path $artifactsRoot 'INSTALLER-SHA256SUMS.txt') -Encoding ascii Write-Output "Installer: $installerPath" Write-Output "SHA256: $installerHash" Write-Output "Authenticode: $(if ($signingEnabled) { "signed ($($signingCertificate.Thumbprint))" } else { 'unsigned development package' })" } finally { if ($null -eq $previousRollForward) { Remove-Item Env:DOTNET_ROLL_FORWARD -ErrorAction SilentlyContinue } else { $env:DOTNET_ROLL_FORWARD = $previousRollForward } if ($null -eq $previousRustFlags) { Remove-Item Env:RUSTFLAGS -ErrorAction SilentlyContinue } else { $env:RUSTFLAGS = $previousRustFlags } if ($buildRoot -and (Test-Path -LiteralPath $buildRoot)) { Assert-PathWithin -Path $buildRoot -Root $artifactsRoot Remove-Item -LiteralPath $buildRoot -Recurse -Force } Pop-Location }