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"
+}