# Regenerate gallery\index.json from every *.zip under gallery\packages\. # # Layout convention: gallery\packages\\\-.zip # Each zip must contain a manifest.json at its root. The manifest's "host" # field decides which host tab the package belongs to (missing => platform, # backward compatible with plain extension packages). # # Rules (do not relax): # - All plugin packages live in this single index.json. # - 0.1.0 skeleton packages are excluded (removed from the product). # - downloadUrl is stored relative (packages\...); GalleryClient rewrites it # against whatever source root is actually configured. # # Invoked by each project's post-build target: parallel builds launch several # copies at once, so the whole regeneration runs under a named mutex. $ErrorActionPreference = 'Stop' function Invoke-WithRetry { param([scriptblock]$Action, [int]$Attempts = 5) for ($i = 1; ; $i++) { try { return & $Action } catch { if ($i -ge $Attempts) { throw } Start-Sleep -Milliseconds (250 * $i) } } } function Invoke-Regenerate { $root = Split-Path -Parent $PSScriptRoot $galleryDir = Join-Path $root 'gallery' $packagesDir = Join-Path $galleryDir 'packages' $indexPath = Join-Path $galleryDir 'index.json' if (-not (Test-Path $packagesDir)) { throw "packages directory not found: $packagesDir" } Add-Type -AssemblyName System.IO.Compression.FileSystem $sha = [System.Security.Cryptography.SHA256]::Create() $entries = New-Object System.Collections.Generic.List[object] $seen = @{} $zipFiles = Invoke-WithRetry { Get-ChildItem -Path $packagesDir -Recurse -Filter '*.zip' | Sort-Object FullName } foreach ($zipFile in $zipFiles) { # The producing build may still be flushing the zip; retry the open. $zip = Invoke-WithRetry { [System.IO.Compression.ZipFile]::OpenRead($zipFile.FullName) } try { $manifestEntry = $zip.Entries | Where-Object { $_.FullName -replace '\\', '/' -eq 'manifest.json' } | Select-Object -First 1 if (-not $manifestEntry) { Write-Warning "skip (no manifest.json at root): $($zipFile.Name)" continue } $reader = New-Object System.IO.StreamReader($manifestEntry.Open(), [System.Text.Encoding]::UTF8) try { $manifest = $reader.ReadToEnd() } finally { $reader.Dispose() } $j = $manifest | ConvertFrom-Json if (-not $j.id -or -not $j.version) { Write-Warning "skip (manifest missing id/version): $($zipFile.Name)" continue } if ($j.version -eq '0.1.0') { Write-Host "exclude 0.1.0 package: $($j.id)" continue } $dedupKey = "$($j.id)@$($j.version)" if ($seen.ContainsKey($dedupKey)) { Write-Warning "skip duplicate: $dedupKey ($($zipFile.Name))" continue } $seen[$dedupKey] = $true # Hash + size come from the packaged zip itself. $fs = Invoke-WithRetry { [System.IO.File]::OpenRead($zipFile.FullName) } try { $hashBytes = $sha.ComputeHash($fs) $size = $fs.Length } finally { $fs.Dispose() } $hashHex = ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant() $relativeUrl = $zipFile.FullName.Substring($galleryDir.Length + 1) -replace '\\', '/' $entry = [ordered]@{ id = [string]$j.id name = [string]$j.name version = [string]$j.version publisher = if ($j.publisher) { [string]$j.publisher } else { $null } description = if ($j.description) { [string]$j.description } else { $null } downloadUrl = $relativeUrl sha256 = $hashHex size = $size } if ($j.host) { $entry['host'] = [string]$j.host } if ($j.category) { $entry['category'] = [string]$j.category } if ($j.engine) { $entry['engine'] = [string]$j.engine } if ($j.kind) { $entry['kind'] = [string]$j.kind } # 构建形态(platform 包区分 Debug/Release 用;缺省不带该字段 → 消费方按 Release 处理) if ($j.flavor) { $entry['flavor'] = [string]$j.flavor } if ($j.guid) { $entry['guid'] = [string]$j.guid } # 本版特性:来自包内 manifest.json 的 notes 字段(扩展管理页"本版特性"区展示) if ($j.notes) { $entry['notes'] = [string]$j.notes } $tfLine = ($manifest -split "`r?`n") | Where-Object { $_ -match 'targetFramework' } | Select-Object -First 1 if ($tfLine) { $entry['targetFramework'] = ($tfLine -replace '.*:\s*"([^"]+)".*', '$1') } if ($j.references) { $entry['references'] = @($j.references) } if ($j.dependencies -and @($j.dependencies).Count -gt 0) { $entry['dependencies'] = @($j.dependencies) } # 运行环境(包内 manifest.json 的 targetFramework:在线仓库尚未下载包时的唯一来源); # 取值直接按 JSON 文本提取,避免依赖 PS 对象成员访问(在本机环境下试过会静默取到空值) # 宿主要求:{ id, minVersion } → "TeamPLT>=2.1.0" if ($j.requiresHost) { $rhText = if ($j.requiresHost.minVersion) { "$($j.requiresHost.id)>=$($j.requiresHost.minVersion)" } else { [string]$j.requiresHost.id } $entry['requiresHost'] = $rhText } if ($j.arch -and @($j.arch).Count -gt 0) { $entry['arch'] = @($j.arch) } if ($j.nativeDependencies -and @($j.nativeDependencies).Count -gt 0) { $entry['nativeDependencies'] = @($j.nativeDependencies) } # versions[]:index.json 契约里"多版本"就装在这个数组里(SDK 的 GalleryPackage.AllVersions 按它读取), # 平台扩展管理页的"版本"下拉也只认这一个来源 —— 缺了它,界面上永远只显示顶层 version 那一版, # 旧版本即使 zip 就在仓库里也选不到、装不了、回滚不了。 $entry['versions'] = @([ordered]@{ version = [string]$j.version downloadUrl = $relativeUrl sha256 = $hashHex size = $size releasedAt = (Get-Date).ToString('yyyy-MM-dd') status = 'stable' notes = if ($j.notes) { [string]$j.notes } else { $null } }) $entries.Add([PSCustomObject]$entry) Write-Host ("indexed {0} {1} [{2}]" -f $j.id, $j.version, $(if ($j.host) { $j.host } else { 'platform' })) } finally { $zip.Dispose() } } $sha.Dispose() # 合并同 id 的多条为一个条目:packages[] 里每个 id 只应出现一次,多版本装进各自的 versions[]。 # 不做这步的话,同 id 的每个版本各占一条,平台按 id 取一条展示 —— 表现为"版本下拉只剩最新一版"。 function Get-VersionKey([string]$v) { $parsed = $null if ([version]::TryParse($v, [ref]$parsed)) { return $parsed } return [version]'0.0' } $byId = [ordered]@{} foreach ($e in $entries) { $key = [string]$e.id if ($byId.Contains($key)) { $cur = $byId[$key] $mergedVersions = @($cur.versions) + @($e.versions) # 顶层字段必须对齐到"更高的版本",而不是沿用先遇到的那条。 # dependencies / references / kind / description 等各版本可能不同 —— # 例如 1.0.0 依赖后来废弃的契约包,1.0.1 改依赖新契约包。 # 若顶层留在旧版(文件序通常旧版在前),平台按 id 读到的就是旧依赖, # 表现为"明明装了新版,却提示旧依赖未安装"。故版本更高时整体替换,再挂回累计版本列表。 if ((Get-VersionKey $e.version) -gt (Get-VersionKey $cur.version)) { $cur = $e.PSObject.Copy() } $cur.versions = $mergedVersions $byId[$key] = $cur } else { $byId[$key] = $e } } $merged = New-Object System.Collections.Generic.List[object] foreach ($key in $byId.Keys) { $p = $byId[$key] $versions = @($p.versions | Sort-Object -Property @{ Expression = { $v = $null if ([version]::TryParse([string]$_.version, [ref]$v)) { $v } else { [version]'0.0' } } } -Descending) if ($versions.Count -eq 0) { $merged.Add($p); continue } # 顶层字段对齐到最高版本,保证只读顶层字段的旧消费方也拿到最新版。 $top = $versions[0] $p.versions = $versions $p.version = [string]$top.version $p.downloadUrl = $top.downloadUrl $p.sha256 = $top.sha256 $p.size = $top.size if (-not $p.notes -and $top.notes) { $p.notes = $top.notes } $merged.Add($p) } # Stable order: host (platform first), then id. $sorted = @($merged | Sort-Object @{ Expression = { if ($_.host) { $_.host } else { 'platform' } } }, @{ Expression = { $_.id } }) $index = [ordered]@{ schemaVersion = 1 generatedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') packages = $sorted } $json = $index | ConvertTo-Json -Depth 10 Invoke-WithRetry { [System.IO.File]::WriteAllText($indexPath, $json, (New-Object System.Text.UTF8Encoding($false))) } Write-Host "REGEN DONE: $($sorted.Count) package(s) -> $indexPath" } # Serialize parallel post-build invocations within this session. $mutex = New-Object System.Threading.Mutex($false, 'Local\TeamAAS-regen-gallery-index') $hasHandle = $false try { if (-not $mutex.WaitOne([TimeSpan]::FromMinutes(5))) { throw 'timed out waiting for gallery index lock' } $hasHandle = $true Invoke-Regenerate } finally { if ($hasHandle) { $mutex.ReleaseMutex() } $mutex.Dispose() }