Add Windows desktop packaging script
This commit is contained in:
@@ -0,0 +1 @@
|
||||
artifacts/
|
||||
@@ -0,0 +1,334 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user