param( [string]$RuntimeRoot = "$env:USERPROFILE\craft\CraftMaster\windows-cl-msvc2022-x86_64", [string]$OutputRoot = (Join-Path $PSScriptRoot "..\artifacts\windows-desktop"), [string]$Version, [switch]$SkipMsi ) $ErrorActionPreference = "Stop" function Fail([string]$Message) { Write-Error $Message exit 1 } function Get-QSferaDesktopVersion { $versionFile = Join-Path $PSScriptRoot "..\Desktop\VERSION.cmake" if (-not (Test-Path -LiteralPath $versionFile)) { Fail "Desktop version file was not found: $versionFile" } $values = @{} foreach ($line in Get-Content -LiteralPath $versionFile) { if ($line -match '^\s*set\(\s*(MIRALL_VERSION_(MAJOR|MINOR|PATCH|BUILD))\s+"?([^"\s\)]+)"?\s*\)') { $values[$matches[1]] = $matches[3] } } foreach ($key in @("MIRALL_VERSION_MAJOR", "MIRALL_VERSION_MINOR", "MIRALL_VERSION_PATCH")) { if (-not $values.ContainsKey($key)) { Fail "Desktop version file does not define $key." } } $baseVersion = "{0}.{1}.{2}" -f $values["MIRALL_VERSION_MAJOR"], $values["MIRALL_VERSION_MINOR"], $values["MIRALL_VERSION_PATCH"] $build = $values["MIRALL_VERSION_BUILD"] if ([string]::IsNullOrWhiteSpace($build) -or $build -eq "0") { return $baseVersion } return "$baseVersion.$build" } function Copy-RequiredFile([string]$SourceRoot, [string]$RelativePath, [string]$DestinationRoot) { $source = Join-Path $SourceRoot $RelativePath if (-not (Test-Path -LiteralPath $source)) { Fail "Required runtime file is missing: $source" } $destination = Join-Path $DestinationRoot $RelativePath $destinationDirectory = Split-Path -Parent $destination if (-not (Test-Path -LiteralPath $destinationDirectory)) { New-Item -ItemType Directory -Path $destinationDirectory | Out-Null } Copy-Item -LiteralPath $source -Destination $destination -Force } function Copy-DirectoryIfPresent([string]$Source, [string]$Destination) { if (-not (Test-Path -LiteralPath $Source)) { return } if (-not (Test-Path -LiteralPath $Destination)) { New-Item -ItemType Directory -Path $Destination | Out-Null } Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force } function Assert-StageFile([string]$StageDirectory, [string]$RelativePath) { $path = Join-Path $StageDirectory $RelativePath if (-not (Test-Path -LiteralPath $path)) { Fail "Packaged runtime file is missing: $path" } if ((Get-Item -LiteralPath $path).Length -le 0) { Fail "Packaged runtime file is empty: $path" } } function Assert-WindowsRuntimeStage([string]$StageDirectory) { $criticalFiles = @( "qsfera.exe", "qt.conf", "Qt6Core.dll", "Qt6Gui.dll", "Qt6Widgets.dll", "Qt6Network.dll", "Qt6Qml.dll", "Qt6Quick.dll", "Qt6QuickControls2.dll", "qt6keychain.dll", "kdsingleapplication-qt6.dll", "libcrypto-3-x64.dll", "libssl-3-x64.dll", "plugins\platforms\qwindows.dll", "plugins\tls\qschannelbackend.dll", "plugins\QSfera_vfs_cfapi.dll", "plugins\QSfera_vfs_off.dll", "qml\eu\QSfera\gui\qmldir", "qml\QtQuick\Controls\qmldir" ) foreach ($file in $criticalFiles) { Assert-StageFile -StageDirectory $StageDirectory -RelativePath $file } $qtConf = Get-Content -LiteralPath (Join-Path $StageDirectory "qt.conf") -Raw -Encoding ASCII foreach ($entry in @("Plugins=plugins", "Qml2Imports=qml", "Translations=translations")) { if (-not $qtConf.Contains($entry)) { Fail "qt.conf does not contain required entry '$entry'." } } Push-Location $StageDirectory try { $versionOutput = & (Join-Path $StageDirectory "qsfera.exe") --version 2>&1 if ($LASTEXITCODE -ne 0) { Fail "Packaged qsfera.exe failed to start from the staging directory. Output: $($versionOutput | Out-String)" } } finally { Pop-Location } Write-Output "OK: packaged Windows runtime contains required Qt/MSVC/OpenSSL files and qsfera.exe --version starts from the staging directory." } function ConvertTo-WixIdentifier([string]$Prefix, [string]$Value) { $normalized = ($Value -replace '[^A-Za-z0-9_]', '_') if ($normalized.Length -gt 60) { $hash = Get-StringHash $Value $normalized = $normalized.Substring(0, 40) + "_" + $hash.Substring(0, 16) } return "$Prefix$normalized" } function Get-StringHash([string]$Value) { $md5 = [System.Security.Cryptography.MD5]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) return -join ($md5.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) } finally { $md5.Dispose() } } function New-DeterministicGuid([string]$Value) { $md5 = [System.Security.Cryptography.MD5]::Create() try { $bytes = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("QSfera.Desktop.WindowsInstaller|" + $Value)) $bytes[6] = ($bytes[6] -band 0x0f) -bor 0x30 $bytes[8] = ($bytes[8] -band 0x3f) -bor 0x80 return ([guid]::new($bytes)).ToString().ToUpperInvariant() } finally { $md5.Dispose() } } function Escape-Xml([string]$Value) { return [System.Security.SecurityElement]::Escape($Value) } function Remove-PathWithRetry([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return } for ($attempt = 1; $attempt -le 5; $attempt++) { try { Remove-Item -LiteralPath $Path -Recurse -Force return } catch { if ($attempt -eq 5) { throw } Start-Sleep -Seconds 2 } } } function Compress-ArchiveWithRetry([string]$SourcePath, [string]$DestinationPath) { for ($attempt = 1; $attempt -le 5; $attempt++) { try { Compress-Archive -Path $SourcePath -DestinationPath $DestinationPath -Force return } catch { if ($attempt -eq 5) { throw } Start-Sleep -Seconds 2 } } } function Get-StageRelativePath([string]$Path) { $base = $script:StageDirectory.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar $fullPath = [System.IO.Path]::GetFullPath($Path) if (-not $fullPath.StartsWith($base, [System.StringComparison]::OrdinalIgnoreCase)) { Fail "Path is outside of the staging directory: $Path" } return $fullPath.Substring($base.Length).Replace('\', '/') } function New-WixDirectoryXml( [string]$DirectoryPath, [string]$DirectoryId, [System.Collections.Generic.List[string]]$ComponentRefs ) { $builder = [System.Text.StringBuilder]::new() $files = Get-ChildItem -LiteralPath $DirectoryPath -File | Sort-Object Name foreach ($file in $files) { $relative = Get-StageRelativePath $file.FullName $componentId = ConvertTo-WixIdentifier "C_" $relative $fileId = ConvertTo-WixIdentifier "F_" $relative $guid = New-DeterministicGuid $relative [void]$ComponentRefs.Add($componentId) [void]$builder.AppendLine(" ") [void]$builder.AppendLine(" ") [void]$builder.AppendLine(" ") } $directories = Get-ChildItem -LiteralPath $DirectoryPath -Directory | Sort-Object Name foreach ($directory in $directories) { $relative = Get-StageRelativePath $directory.FullName $childDirectoryId = ConvertTo-WixIdentifier "D_" $relative [void]$builder.AppendLine(" ") [void]$builder.Append((New-WixDirectoryXml -DirectoryPath $directory.FullName -DirectoryId $childDirectoryId -ComponentRefs $ComponentRefs)) [void]$builder.AppendLine(" ") } return $builder.ToString() } function New-QSferaMsi([string]$StageDirectory, [string]$MsiPath, [string]$Version) { $wix = Get-Command wix -ErrorAction SilentlyContinue if (-not $wix) { Fail "wix was not found in PATH. Install WiX Toolset 6 with: dotnet tool install --global wix --version 6.*" } $script:StageDirectory = (Resolve-Path -LiteralPath $StageDirectory).Path $componentRefs = [System.Collections.Generic.List[string]]::new() $directoryXml = New-WixDirectoryXml -DirectoryPath $script:StageDirectory -DirectoryId "INSTALLFOLDER" -ComponentRefs $componentRefs $featureRefs = ($componentRefs | ForEach-Object { " " }) -join [Environment]::NewLine $shortcutGuid = New-DeterministicGuid "ApplicationShortcut" $wxsPath = [System.IO.Path]::ChangeExtension($MsiPath, ".wxs") $upgradeCode = "8F2F0633-98AB-4E76-B9E7-3F42F36B5E5A" $wxs = @" $featureRefs $directoryXml "@ $wxs | Set-Content -LiteralPath $wxsPath -Encoding UTF8 & $wix.Source build $wxsPath -o $MsiPath if ($LASTEXITCODE -ne 0) { Fail "WiX MSI build failed with exit code $LASTEXITCODE." } } $runtimeRoot = (Resolve-Path -LiteralPath $RuntimeRoot).Path $runtimeBin = Join-Path $runtimeRoot "bin" $runtimePlugins = Join-Path $runtimeRoot "plugins" $runtimeQml = Join-Path $runtimeRoot "qml" if (-not (Test-Path -LiteralPath $runtimeBin)) { Fail "Runtime bin directory was not found: $runtimeBin" } if ([string]::IsNullOrWhiteSpace($Version)) { $Version = Get-QSferaDesktopVersion } $OutputRoot = (New-Item -ItemType Directory -Path $OutputRoot -Force).FullName $stageName = "QSfera-Desktop-$Version-win-x64" $StageDirectory = Join-Path $OutputRoot $stageName $zipPath = Join-Path $OutputRoot "$stageName.zip" $msiPath = Join-Path $OutputRoot "$stageName.msi" Remove-PathWithRetry $StageDirectory New-Item -ItemType Directory -Path $StageDirectory | Out-Null $requiredRuntimeFiles = @( "qsfera.exe", "qsferacmd.exe", "QSferaGui.dll", "QSferaLibSync.dll", "QSferaResources.dll", "b2-1.dll", "brotlicommon.dll", "brotlidec.dll", "bz2.dll", "kdsingleapplication-qt6.dll", "qt6keychain.dll", "concrt140.dll", "freetype.dll", "harfbuzz.dll", "libsqlite.dll", "libpng16.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll", "msvcp140_atomic_wait.dll", "pcre2-16.dll", "libcrypto-3-x64.dll", "libssl-3-x64.dll", "vcomp140.dll", "vcruntime140.dll", "vcruntime140_1.dll", "vcruntime140_threads.dll", "zlib1.dll", "zstd.dll", "snoretoast.exe" ) foreach ($file in $requiredRuntimeFiles) { Copy-RequiredFile -SourceRoot $runtimeBin -RelativePath $file -DestinationRoot $StageDirectory } $windeployqt = Join-Path $runtimeBin "windeployqt.exe" if (-not (Test-Path -LiteralPath $windeployqt)) { Fail "windeployqt was not found: $windeployqt" } $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $qmlSource = Join-Path $repoRoot "Desktop\src\gui" & $windeployqt ` --release ` --compiler-runtime ` --force ` --qmldir $qmlSource ` --qml-deploy-dir (Join-Path $StageDirectory "qml") ` --plugindir (Join-Path $StageDirectory "plugins") ` --translationdir (Join-Path $StageDirectory "translations") ` (Join-Path $StageDirectory "qsfera.exe") ` (Join-Path $StageDirectory "QSferaGui.dll") ` (Join-Path $StageDirectory "QSferaLibSync.dll") ` (Join-Path $StageDirectory "QSferaResources.dll") if ($LASTEXITCODE -ne 0) { Fail "windeployqt failed with exit code $LASTEXITCODE." } Copy-RequiredFile -SourceRoot $runtimePlugins -RelativePath "QSfera_vfs_cfapi.dll" -DestinationRoot (Join-Path $StageDirectory "plugins") Copy-RequiredFile -SourceRoot $runtimePlugins -RelativePath "QSfera_vfs_off.dll" -DestinationRoot (Join-Path $StageDirectory "plugins") Copy-DirectoryIfPresent -Source (Join-Path $runtimeQml "eu\QSfera") -Destination (Join-Path $StageDirectory "qml\eu\QSfera") @" [Paths] Prefix=. Plugins=plugins Qml2Imports=qml Translations=translations "@ | Set-Content -LiteralPath (Join-Path $StageDirectory "qt.conf") -Encoding ASCII Assert-WindowsRuntimeStage -StageDirectory $StageDirectory Remove-PathWithRetry $zipPath Compress-ArchiveWithRetry -SourcePath (Join-Path $StageDirectory "*") -DestinationPath $zipPath if (-not $SkipMsi) { Remove-PathWithRetry $msiPath New-QSferaMsi -StageDirectory $StageDirectory -MsiPath $msiPath -Version $Version } $stageFiles = Get-ChildItem -LiteralPath $StageDirectory -Recurse -File $stageBytes = ($stageFiles | Measure-Object Length -Sum).Sum Write-Output "QSfera Windows desktop package prepared." Write-Output "Version: $Version" Write-Output "Stage: $StageDirectory" Write-Output "Stage files: $($stageFiles.Count)" Write-Output "Stage bytes: $stageBytes" Write-Output "ZIP: $zipPath" if (-not $SkipMsi) { Write-Output "MSI: $msiPath" }