377 lines
17 KiB
PowerShell
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
|
|
}
|