param( [string]$SourceDir = "", [Parameter(Mandatory = $true)] [string]$ConfigFile, [string]$OutputDir = "", [string]$ZipFile = "" ) $ErrorActionPreference = "Stop" $RepoRoot = Split-Path -Parent $PSScriptRoot if ([string]::IsNullOrWhiteSpace($SourceDir)) { $SourceDir = Join-Path $RepoRoot "out/bin/Release" } if ([string]::IsNullOrWhiteSpace($OutputDir)) { $OutputDir = Join-Path $RepoRoot "dist/UpdateClient" } if ([string]::IsNullOrWhiteSpace($ZipFile)) { $ZipFile = Join-Path $RepoRoot "dist/UpdateClient.zip" } $source = (Resolve-Path $SourceDir).Path $config = (Resolve-Path $ConfigFile).Path $settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json function Normalize-RelativePath([string]$PathValue) { return ($PathValue -replace '\\', '/').Trim('/') } function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) { $full = (Resolve-Path $FullPath).Path if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) { return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/')) } return Normalize-RelativePath $Fallback } $configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json" $configRelativeParent = Split-Path $configRelativePath -Parent $runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent) if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" } function Join-RelativePath([string]$Base, [string]$Child) { $baseNorm = Normalize-RelativePath $Base $childNorm = Normalize-RelativePath $Child if ([string]::IsNullOrWhiteSpace($baseNorm)) { return $childNorm } if ([string]::IsNullOrWhiteSpace($childNorm)) { return $baseNorm } return "$baseNorm/$childNorm" } function Get-InstallDirectoryId([string]$RuntimeDir) { $normalized = ([IO.Path]::GetFullPath($RuntimeDir) -replace '\\', '/').TrimEnd('/') $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized) $hash = $sha.ComputeHash($bytes) return -join ($hash | ForEach-Object { $_.ToString("x2") }) } finally { $sha.Dispose() } } function Get-UserDataManifestCandidate([string]$RuntimeDir, [string]$ManifestName) { $localData = [Environment]::GetFolderPath("LocalApplicationData") if ([string]::IsNullOrWhiteSpace($localData)) { return "" } $installId = Get-InstallDirectoryId $RuntimeDir return Join-Path $localData "Marsco\UpdateClientSDK\installations\$installId\update\manifest_cache\$ManifestName" } $requiredFields = @( "app_id", "channel", "current_version", "client_token", "launch_token", "license_key", "main_executable", "launcher_executable", "updater_executable", "bootstrap_executable" ) foreach ($field in $requiredFields) { if (-not $settings.$field) { throw "Config file is missing required field: $field" } } $mainExecutable = Normalize-RelativePath $settings.main_executable $launcherExecutable = Normalize-RelativePath $settings.launcher_executable $updaterExecutable = Normalize-RelativePath $settings.updater_executable $bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable $requiredFiles = @( (Join-RelativePath $runtimeDirRelative $mainExecutable), (Join-RelativePath $runtimeDirRelative $launcherExecutable), (Join-RelativePath $runtimeDirRelative $updaterExecutable), (Join-RelativePath $runtimeDirRelative $bootstrapExecutable) ) foreach ($name in $requiredFiles) { $nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar if (-not (Test-Path (Join-Path $source $nativeName))) { throw "Source directory is missing required file: $name" } } $debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or $_.Extension -in @('.pdb', '.ilk') } if ($debugArtifacts) { throw "Source directory contains Debug artifacts. Clean the Release output directory and rebuild Release first. Example: $($debugArtifacts[0].FullName)" } $expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable $mainLeafName = Split-Path $mainExecutable -Leaf $duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object { $relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/')) $relative -ne $expectedMainPath } | Select-Object -First 1 if ($duplicateMain) { throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)" } $manifestName = "manifest_$($settings.current_version).json" $runtimeDirAbsolute = if ([string]::IsNullOrWhiteSpace($runtimeDirRelative)) { $source } else { Join-Path $source ($runtimeDirRelative -replace '/', [IO.Path]::DirectorySeparatorChar) } $sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName" $manifestCandidates = @( (Get-UserDataManifestCandidate $runtimeDirAbsolute $manifestName), (Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)), (Join-Path $source "update/manifest_cache/$manifestName") ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $sourceManifest = $manifestCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $sourceManifest -or -not (Test-Path $sourceManifest)) { $searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine throw "Missing signed Manifest cache for current version: $manifestName. Complete online update/verification for this version before packaging. Searched paths:$([Environment]::NewLine)$searched" } if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force } New-Item $OutputDir -ItemType Directory -Force | Out-Null Get-ChildItem $source -Force | Where-Object { $_.Name -notin @("update", "update_temp") } | Copy-Item -Destination $OutputDir -Recurse -Force $outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar) if (Test-Path $outputRuntimeUpdateDir) { Remove-Item $outputRuntimeUpdateDir -Recurse -Force } $outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar) if (Test-Path $outputRuntimeTempDir) { Remove-Item $outputRuntimeTempDir -Recurse -Force } $outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar) $outputConfigDir = Split-Path $outputConfigPath -Parent New-Item $outputConfigDir -ItemType Directory -Force | Out-Null Copy-Item $config $outputConfigPath -Force @("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object { $runtimeFile = Join-Path $outputConfigDir $_ if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force } } $manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar) New-Item $manifestDir -ItemType Directory -Force | Out-Null Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force $zipParent = Split-Path $ZipFile -Parent New-Item $zipParent -ItemType Directory -Force | Out-Null if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal Write-Host "Package directory: $OutputDir" Write-Host "ZIP file: $ZipFile"