395 lines
14 KiB
PowerShell
395 lines
14 KiB
PowerShell
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(" <Component Id=`"$componentId`" Guid=`"$guid`">")
|
|
[void]$builder.AppendLine(" <File Id=`"$fileId`" Source=`"$(Escape-Xml $file.FullName)`" KeyPath=`"yes`" />")
|
|
[void]$builder.AppendLine(" </Component>")
|
|
}
|
|
|
|
$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(" <Directory Id=`"$childDirectoryId`" Name=`"$(Escape-Xml $directory.Name)`">")
|
|
[void]$builder.Append((New-WixDirectoryXml -DirectoryPath $directory.FullName -DirectoryId $childDirectoryId -ComponentRefs $ComponentRefs))
|
|
[void]$builder.AppendLine(" </Directory>")
|
|
}
|
|
|
|
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 { " <ComponentRef Id=`"$_`" />" }) -join [Environment]::NewLine
|
|
$shortcutGuid = New-DeterministicGuid "ApplicationShortcut"
|
|
$wxsPath = [System.IO.Path]::ChangeExtension($MsiPath, ".wxs")
|
|
$upgradeCode = "8F2F0633-98AB-4E76-B9E7-3F42F36B5E5A"
|
|
|
|
$wxs = @"
|
|
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
|
<Package Name="QSfera Desktop" Manufacturer="QSfera" Version="$Version" UpgradeCode="$upgradeCode" Scope="perMachine">
|
|
<MajorUpgrade DowngradeErrorMessage="A newer version of QSfera Desktop is already installed." />
|
|
<MediaTemplate EmbedCab="yes" />
|
|
<Feature Id="MainFeature" Title="QSfera Desktop" Level="1">
|
|
$featureRefs
|
|
<ComponentRef Id="ApplicationShortcut" />
|
|
</Feature>
|
|
<StandardDirectory Id="ProgramFiles64Folder">
|
|
<Directory Id="INSTALLFOLDER" Name="QSfera">
|
|
$directoryXml
|
|
</Directory>
|
|
</StandardDirectory>
|
|
<StandardDirectory Id="ProgramMenuFolder">
|
|
<Directory Id="ApplicationProgramsFolder" Name="QSfera">
|
|
<Component Id="ApplicationShortcut" Guid="$shortcutGuid">
|
|
<Shortcut Id="ApplicationStartMenuShortcut" Name="QSfera" Target="[INSTALLFOLDER]qsfera.exe" WorkingDirectory="INSTALLFOLDER" Description="QSfera Desktop" />
|
|
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall" />
|
|
<RegistryValue Root="HKCU" Key="Software\QSfera\Desktop" Name="StartMenuShortcut" Type="integer" Value="1" KeyPath="yes" />
|
|
</Component>
|
|
</Directory>
|
|
</StandardDirectory>
|
|
</Package>
|
|
</Wix>
|
|
"@
|
|
|
|
$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"
|
|
}
|