Files
RemoteDesk/packaging/windows/package-installer.ps1
T

377 lines
17 KiB
PowerShell

[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
}