diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d4f588ed --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +artifacts/ diff --git a/scripts/package-windows-desktop.ps1 b/scripts/package-windows-desktop.ps1 new file mode 100644 index 00000000..57fd5d72 --- /dev/null +++ b/scripts/package-windows-desktop.ps1 @@ -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(" ") + [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 + +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" +}