Files
QSfera/scripts/package-windows-desktop.ps1
T
2026-06-10 06:34:34 +03:00

335 lines
12 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 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
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"
}