[CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Version, [string]$MinVersion = '', [string]$ReleaseId = '', [string]$BaselineManifest = '', [switch]$NoRestart, [switch]$ValidateOnly ) $ErrorActionPreference = 'Stop' $workspace = Split-Path -Parent $MyInvocation.MyCommand.Path $project = Join-Path $workspace 'mikrotik' $launcher = Join-Path $project 'launcher' $releaseRoot = Join-Path $env:ProgramData 'NetworkCustomers\UpdateReleases' $inbox = Join-Path $releaseRoot 'inbox' $baselines = Join-Path $project 'admin\updates\distributed\baselines' New-Item -ItemType Directory -Force -Path $inbox, $baselines | Out-Null if ($Version -notmatch '^\d+(\.\d+){1,3}([+-][A-Za-z0-9._-]+)?$') { throw "Invalid version: $Version" } if ([string]::IsNullOrWhiteSpace($ReleaseId)) { $ReleaseId = 'desktop-' + $Version + '-' + (Get-Date -Format 'yyyyMMddHHmmss') } if ($ReleaseId -notmatch '^[A-Za-z0-9._-]{3,120}$') { throw 'Invalid release identifier.' } $baselineHashes = @{} if ($BaselineManifest) { $baseline = Get-Content -LiteralPath $BaselineManifest -Raw | ConvertFrom-Json foreach ($item in @($baseline.files)) { $baselineHashes[[string]$item.path] = [string]$item.sha256 } } # Build the delta from the complete distributable application tree. The old # implementation listed only the launcher and version.php, which meant that # PHP/CSS/JS fixes were never delivered to linked copies. Keep machine data, # secrets, caches and generated/user content outside the update channel. function Test-UpdatableTarget([string]$Target) { $path = $Target.Replace('\', '/').TrimStart('/').ToLowerInvariant() if ([string]::IsNullOrWhiteSpace($path)) { return $false } $exactExcludes = @( 'install_config.php', 'config.php', 'db.php', 'admin/install_config.php', 'admin/config.php', 'admin/db_config.php', 'storage/backup_runtime/backup.lock', 'storage/subscriber_router_setups/app.key' ) if ($exactExcludes -contains $path) { return $false } $prefixExcludes = @( 'mobile/', 'mikrotik/', 'exp_pb/', 'runtime/', 'launcher/', 'admin/cache/', 'admin/backups/', 'admin/uploads/', 'admin/updates/distributed/inbox/', 'admin/updates/distributed/packages/', 'admin/updates/distributed/baselines/', 'admin/tests/artifacts/', 'backups/', 'storage/subscriber_contracts/generated/', 'storage/subscriber_contracts/qa-', 'storage/subscriber_contracts/templates/' ) foreach ($prefix in $prefixExcludes) { if ($path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return $false } } $name = [IO.Path]::GetFileName($path) if ($name -like '*.log' -or $name -like 'unins*.exe' -or $name -like 'unins*.dat' -or $name -like 'unins*.msg') { return $false } if ($path -notmatch '/' -and $name -match '\.(png|jpe?g|gif|webp|xml|html)$') { return $false } if ($name -like 'audit-*.png' -or $name -like 'audit-*.html' -or $name -like 'agent-preview-*.png' -or $name -like 'desktop-smoke-*.png') { return $false } return $true } $sources = @() Get-ChildItem -LiteralPath $project -File -Recurse | ForEach-Object { $target = $_.FullName.Substring($project.Length + 1).Replace('\', '/') if (Test-UpdatableTarget $target) { $sources += [pscustomobject]@{ Source = $_.FullName; Target = $target } } } # The launcher sources are intentionally excluded above. Only the verified # x86 runtime unit is installed on clients, with canonical destination names. $sources += @( [pscustomobject]@{ Source = (Join-Path $launcher 'AgentPortalLauncher.final.exe'); Target = 'launcher/AgentPortalLauncher.exe' }, [pscustomobject]@{ Source = (Join-Path $launcher 'OutputDeviceAgent.x86.exe'); Target = 'launcher/OutputDeviceAgent.exe' }, [pscustomobject]@{ Source = (Join-Path $launcher 'WebView2Loader.dll'); Target = 'launcher/WebView2Loader.dll' }, [pscustomobject]@{ Source = (Join-Path $launcher 'Microsoft.Web.WebView2.Core.dll'); Target = 'launcher/Microsoft.Web.WebView2.Core.dll' }, [pscustomobject]@{ Source = (Join-Path $launcher 'Microsoft.Web.WebView2.WinForms.dll'); Target = 'launcher/Microsoft.Web.WebView2.WinForms.dll' } ) Get-ChildItem -LiteralPath (Join-Path $launcher 'runtimes') -File -Recurse | ForEach-Object { $relative = $_.FullName.Substring($launcher.Length + 1).Replace('\', '/') $sources += [pscustomobject]@{ Source = $_.FullName; Target = ('launcher/' + $relative) } } $sources = @($sources | Sort-Object Target -Unique) $requiredTargets = @( 'admin/version.php', 'admin/airmax_monitor_lib.php', 'admin/assets/js/ua_link_approval_prompt.js', 'launcher/AgentPortalLauncher.exe' ) $sourceTargets = @{}; foreach ($source in $sources) { $sourceTargets[$source.Target] = $true } foreach ($requiredTarget in $requiredTargets) { if (-not $sourceTargets.ContainsKey($requiredTarget)) { throw "Required update target is missing from the distributable tree: $requiredTarget" } } if ($ValidateOnly) { Write-Host "Update source validation passed. Distributable files: $($sources.Count)" -ForegroundColor Green return } $allFiles = @() $changedFiles = @() $currentTargets = @{} foreach ($source in $sources) { if (-not (Test-Path -LiteralPath $source.Source -PathType Leaf)) { throw "Missing client runtime file: $($source.Source)" } $hash = (Get-FileHash -LiteralPath $source.Source -Algorithm SHA256).Hash.ToLowerInvariant() $entry = [ordered]@{ path = $source.Target source = ('payload/' + $source.Target) size = [long](Get-Item -LiteralPath $source.Source).Length sha256 = $hash local_source = $source.Source } $allFiles += $entry $currentTargets[$source.Target] = $true if (-not $baselineHashes.ContainsKey($source.Target) -or $baselineHashes[$source.Target] -ne $hash) { $changedFiles += $entry } } if ($changedFiles.Count -eq 0) { throw 'No changed client files were found.' } $deleteFiles = @($baselineHashes.Keys | Where-Object { -not $currentTargets.ContainsKey($_) } | Sort-Object) $manifest = [ordered]@{ schema = 1 release_id = $ReleaseId version = $Version min_version = $MinVersion roles = @('branch', 'agent') restart_required = -not $NoRestart.IsPresent files = @($changedFiles | ForEach-Object { [ordered]@{ path = $_.path; source = $_.source; size = $_.size; sha256 = $_.sha256 } }) delete_files = @($deleteFiles) created_at = (Get-Date).ToUniversalTime().ToString('o') } Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $zipPath = Join-Path $inbox ($ReleaseId + '.zip') if (Test-Path -LiteralPath $zipPath) { throw "Release package already exists and is immutable: $zipPath. Bump the application version/release id." } $stream = [IO.File]::Open($zipPath, [IO.FileMode]::Create, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) try { $zip = [IO.Compression.ZipArchive]::new($stream, [IO.Compression.ZipArchiveMode]::Create, $true) try { $manifestEntry = $zip.CreateEntry('manifest.json', [IO.Compression.CompressionLevel]::Optimal) $writer = [IO.StreamWriter]::new($manifestEntry.Open(), [Text.UTF8Encoding]::new($false)) try { $writer.Write(($manifest | ConvertTo-Json -Depth 8)) } finally { $writer.Dispose() } foreach ($item in $changedFiles) { $entry = $zip.CreateEntry($item.source, [IO.Compression.CompressionLevel]::Optimal) $input = [IO.File]::OpenRead($item.local_source) $output = $entry.Open() try { $input.CopyTo($output) } finally { $output.Dispose(); $input.Dispose() } } } finally { $zip.Dispose() } } finally { $stream.Dispose() } $baseline = [ordered]@{ version = $Version files = @($allFiles | ForEach-Object { [ordered]@{ path = $_.path; size = $_.size; sha256 = $_.sha256 } }) } $baselinePath = Join-Path $baselines ($Version + '.json') $baseline | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $baselinePath -Encoding UTF8 Write-Host "Created signed-off client delta input: $zipPath" -ForegroundColor Green Write-Host "Changed files: $($changedFiles.Count) / $($allFiles.Count)" -ForegroundColor Green Write-Host "Baseline: $baselinePath" -ForegroundColor Green