param()
$ErrorActionPreference = 'Stop'
$Target = 'both'
$Configuration = 'Release'
$Platform = 'x64'
$SkipTests = $false
$SkipExe = $false
foreach ($arg in $args) {
switch -Regex ($arg) {
'^--?target=(.+)$' { $Target = $Matches[1].ToLowerInvariant(); continue }
'^--?configuration=(.+)$' { $Configuration = $Matches[1]; continue }
'^--?platform=(.+)$' { $Platform = $Matches[1]; continue }
'^--?skip-tests$' { $SkipTests = $true; continue }
'^--?skip-msix$' { if ($Target -eq 'both') { $Target = 'exe' }; continue }
'^--?skip-exe$' { $SkipExe = $true; continue }
'^--?no-pause$' { continue }
default { throw "Unknown build argument: $arg" }
}
}
if (@('publish', 'exe', 'msix', 'both') -notcontains $Target) {
throw "Invalid target '$Target'. Use --target=publish|exe|msix|both."
}
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$Solution = Join-Path $Root 'YMhut.Box.Native.sln'
$Project = Join-Path $Root 'src\box-winUI\YMhut.Box.WinUI.csproj'
$ProjectAssets = Join-Path $Root 'src\box-winUI\Assets'
$NuGetConfig = Join-Path $Root 'NuGet.Config'
$PublishRoot = Join-Path $Root 'build\winui\publish'
$MsixStageRoot = Join-Path $Root 'build\winui\msix-stage'
$BuildLogRoot = Join-Path $Root 'build\winui\logs'
$LatestRoot = Join-Path $Root 'latest'
$OutputRoot = Join-Path $Root 'installer_output'
$OutputUpdateInfoRoot = Join-Path $OutputRoot 'update-info'
$ServerPublicRoot = Join-Path $Root 'server\update\public'
$ServerDownloadRoot = Join-Path $ServerPublicRoot 'downloads'
$ToolStateRoot = Join-Path $Root '.cache\tool_state'
$NuGetRoot = Join-Path $Root '.cache\nuget'
$LocalNuGetFeedRoot = Join-Path $NuGetRoot 'feed'
$AppDataRoot = Join-Path $ToolStateRoot 'appdata'
$HomeRoot = Join-Path $ToolStateRoot 'home'
$PackageIdentityName = 'YMhut.Box'
$PackagePublisher = 'CN=YMhut'
$AppName = 'YMhut Box'
$AppExecutable = 'YMhutBox.exe'
$PfxPassword = 'ymhut-box-local'
$PfxPath = Join-Path $OutputRoot 'certs\YMhutBox.pfx'
$CerPath = Join-Path $OutputRoot 'YMhutBox.cer'
$DownloadBaseUri = if ($env:YMHUT_DOWNLOAD_BASE_URI) { $env:YMHUT_DOWNLOAD_BASE_URI.TrimEnd('/') + '/' } else { 'https://update.ymhut.cn/downloads/' }
$UpdateBaseUri = if ($env:YMHUT_UPDATE_BASE_URI) { $env:YMHUT_UPDATE_BASE_URI.TrimEnd('/') + '/' } else { 'https://update.ymhut.cn/update-info/' }
function New-Directory([string] $Path) {
New-Item -ItemType Directory -Force -Path $Path | Out-Null
}
function Write-Utf8NoBomFile([string] $Path, [string] $Value) {
$parent = Split-Path -Parent $Path
if ($parent) {
New-Directory $parent
}
$encoding = New-Object System.Text.UTF8Encoding -ArgumentList $false
[IO.File]::WriteAllText($Path, $Value, $encoding)
}
function Reset-DirectoryInsideRepo([string] $Path) {
$resolvedRoot = [IO.Path]::GetFullPath($Root)
$resolvedPath = [IO.Path]::GetFullPath($Path)
if (-not $resolvedPath.StartsWith($resolvedRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to reset a directory outside the repository: $resolvedPath"
}
if (Test-Path -LiteralPath $resolvedPath) {
Remove-Item -LiteralPath $resolvedPath -Recurse -Force
}
New-Directory $resolvedPath
}
function Remove-DirectoryInsideRepo([string] $Path) {
$resolvedRoot = [IO.Path]::GetFullPath($Root)
$resolvedPath = [IO.Path]::GetFullPath($Path)
if (-not $resolvedPath.StartsWith($resolvedRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove a directory outside the repository: $resolvedPath"
}
if (Test-Path -LiteralPath $resolvedPath) {
Remove-Item -LiteralPath $resolvedPath -Recurse -Force
}
}
function Stop-ProcessesFromDirectory([string] $Directory) {
if (-not (Test-Path -LiteralPath $Directory)) {
return
}
$resolvedDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
$stopped = 0
foreach ($process in Get-Process -ErrorAction SilentlyContinue) {
$processPath = $null
try {
$processPath = $process.Path
} catch {
continue
}
if ([string]::IsNullOrWhiteSpace($processPath)) {
continue
}
$resolvedProcessPath = [IO.Path]::GetFullPath($processPath)
if (-not $resolvedProcessPath.StartsWith($resolvedDirectory, [StringComparison]::OrdinalIgnoreCase)) {
continue
}
Write-Host " Stopping running payload process: $($process.ProcessName) ($($process.Id))"
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
$stopped++
}
if ($stopped -gt 0) {
Start-Sleep -Seconds 2
}
}
function Invoke-Tool([string] $FilePath, [string[]] $Arguments, [string] $FailureMessage) {
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$FailureMessage (exit code $LASTEXITCODE)"
}
}
function Join-ProcessArguments([string[]] $Arguments) {
$quotedArguments = [Collections.Generic.List[string]]::new()
foreach ($item in $Arguments) {
$argument = [string]$item
if ($argument.Length -eq 0) {
$quotedArguments.Add('""')
continue
}
if ($argument -notmatch '[\s"]') {
$quotedArguments.Add($argument)
continue
}
$builder = [Text.StringBuilder]::new()
[void]$builder.Append('"')
$backslashes = 0
foreach ($character in $argument.ToCharArray()) {
if ($character -eq '\') {
$backslashes++
continue
}
if ($character -eq '"') {
[void]$builder.Append(('\' * (($backslashes * 2) + 1)))
[void]$builder.Append('"')
$backslashes = 0
continue
}
if ($backslashes -gt 0) {
[void]$builder.Append(('\' * $backslashes))
$backslashes = 0
}
[void]$builder.Append($character)
}
if ($backslashes -gt 0) {
[void]$builder.Append(('\' * ($backslashes * 2)))
}
[void]$builder.Append('"')
$quotedArguments.Add($builder.ToString())
}
$quotedArguments -join ' '
}
function Get-LogTail([string] $Path, [int] $Lines = 24) {
if (-not (Test-Path -LiteralPath $Path)) {
return ''
}
$text = (Get-Content -LiteralPath $Path -Tail $Lines -ErrorAction SilentlyContinue) -join [Environment]::NewLine
if ([string]::IsNullOrWhiteSpace($text)) { return '' }
$text.Trim()
}
function Invoke-ToolQuiet([string] $FilePath, [string[]] $Arguments, [string] $FailureMessage, [string] $SuccessMessage = '') {
New-Directory $BuildLogRoot
$toolName = [IO.Path]::GetFileNameWithoutExtension($FilePath)
if ([string]::IsNullOrWhiteSpace($toolName)) {
$toolName = ($FilePath -replace '[^A-Za-z0-9_.-]', '_')
}
$stamp = [DateTimeOffset]::Now.ToString('yyyyMMdd-HHmmss-fff')
$stdoutPath = Join-Path $BuildLogRoot "$toolName-$stamp.out.log"
$stderrPath = Join-Path $BuildLogRoot "$toolName-$stamp.err.log"
$process = Start-Process `
-FilePath $FilePath `
-ArgumentList (Join-ProcessArguments $Arguments) `
-NoNewWindow `
-Wait `
-PassThru `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath
$exitCode = $process.ExitCode
if ($exitCode -ne 0) {
$stdoutTail = Get-LogTail $stdoutPath
$stderrTail = Get-LogTail $stderrPath
if ($stdoutTail) {
Write-Host $stdoutTail
}
if ($stderrTail) {
Write-Host $stderrTail
}
throw "$FailureMessage (exit code $exitCode). Logs: $stdoutPath $stderrPath"
}
if (-not [string]::IsNullOrWhiteSpace($SuccessMessage)) {
Write-Host $SuccessMessage
}
}
function Invoke-DotNet([string[]] $Arguments) {
Invoke-Tool 'dotnet' $Arguments 'dotnet command failed'
}
function Get-VersionInfo {
$versionFile = Join-Path $Root 'version.json'
if (-not (Test-Path -LiteralPath $versionFile)) {
return [pscustomobject]@{
Version = '2.0.0'
Build = '1'
Channel = 'stable'
PackageVersion = '2.0.0.1'
}
}
$json = Get-Content -LiteralPath $versionFile -Raw -Encoding UTF8 | ConvertFrom-Json
$version = if ($json.version) { [string]$json.version } else { '2.0.0' }
$build = if ($json.build) { ([string]$json.build -replace '[^0-9]', '') } else { '1' }
if ([string]::IsNullOrWhiteSpace($build)) { $build = '1' }
$channel = if ($json.channel) { [string]$json.channel } else { 'stable' }
$parts = @()
foreach ($part in ($version -split '\.')) {
$digits = ($part -replace '[^0-9]', '')
if ($digits) { $parts += [int]$digits }
}
while ($parts.Count -lt 3) { $parts += 0 }
$parts = $parts | Select-Object -First 3
$packageVersion = (($parts + [int]$build) | ForEach-Object { [Math]::Min($_, 65535) }) -join '.'
[pscustomobject]@{
Version = $version
Build = $build
Channel = $channel
PackageVersion = $packageVersion
}
}
function Find-Executable([string] $Name, [string[]] $Candidates = @(), [string[]] $SearchRoots = @()) {
$fromPath = Get-Command $Name -ErrorAction SilentlyContinue
if ($fromPath) { return $fromPath.Source }
foreach ($candidate in $Candidates) {
if ($candidate -and (Test-Path -LiteralPath $candidate)) {
return (Resolve-Path -LiteralPath $candidate).Path
}
}
foreach ($rootPath in ($SearchRoots | Where-Object { $_ } | Select-Object -Unique)) {
if (-not (Test-Path -LiteralPath $rootPath)) {
continue
}
$tool = Get-ChildItem -LiteralPath $rootPath -Recurse -Filter $Name -File -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending |
Select-Object -First 1 -ExpandProperty FullName
if ($tool) { return $tool }
}
return $null
}
function Find-WindowsSdkTool([string] $Name) {
$roots = @(
(Join-Path $NuGetRoot 'microsoft.windows.sdk.buildtools'),
(Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'),
(Join-Path $env:ProgramFiles 'Windows Kits\10\bin'),
(Join-Path ${env:ProgramFiles(x86)} 'Microsoft SDKs\ClickOnce'),
(Join-Path $env:ProgramFiles 'Microsoft SDKs\ClickOnce')
)
if ($env:WindowsSdkDir) {
$roots += Join-Path $env:WindowsSdkDir 'bin'
}
$candidates = @()
foreach ($rootPath in ($roots | Where-Object { $_ } | Select-Object -Unique)) {
if (Test-Path -LiteralPath $rootPath) {
$candidates += Get-ChildItem -LiteralPath $rootPath -Recurse -Filter $Name -File -ErrorAction SilentlyContinue
}
}
if ($candidates.Count -eq 0) {
return Find-Executable $Name
}
$architecturePreference = switch ($Platform.ToLowerInvariant()) {
'arm64' { @('\arm64\', '\x64\', '\x86\') }
'x86' { @('\x86\', '\x64\', '\arm64\') }
default { @('\x64\', '\amd64\', '\x86\', '\arm64\') }
}
foreach ($architecture in $architecturePreference) {
$tool = $candidates |
Where-Object { $_.FullName.IndexOf($architecture, [StringComparison]::OrdinalIgnoreCase) -ge 0 } |
Sort-Object FullName -Descending |
Select-Object -First 1
if ($tool) {
return $tool.FullName
}
}
($candidates | Sort-Object FullName -Descending | Select-Object -First 1).FullName
}
function Find-InnoSetupCompiler {
$envCandidates = @()
if ($env:YMHUT_INNO_SETUP) {
$envCandidates += if ((Split-Path -Leaf $env:YMHUT_INNO_SETUP) -ieq 'ISCC.exe') {
$env:YMHUT_INNO_SETUP
} else {
Join-Path $env:YMHUT_INNO_SETUP 'ISCC.exe'
}
}
if ($env:INNO_SETUP_HOME) {
$envCandidates += Join-Path $env:INNO_SETUP_HOME 'ISCC.exe'
}
$candidates = @(
$envCandidates,
'E:\Inno Setup 7\ISCC.exe',
(Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 7\ISCC.exe'),
(Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'),
(Join-Path $env:ProgramFiles 'Inno Setup 7\ISCC.exe'),
(Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe')
) | ForEach-Object { $_ }
Find-Executable 'ISCC.exe' -Candidates $candidates
}
function Resolve-InnoLanguageFile([string] $IsccPath) {
$compilerRoot = Split-Path -Parent $IsccPath
$chineseLanguageFile = Join-Path $compilerRoot 'Languages\ChineseSimplified.isl'
if (Test-Path -LiteralPath $chineseLanguageFile) {
return (Resolve-Path -LiteralPath $chineseLanguageFile).Path
}
$repoLanguageFile = Join-Path $Root 'installer\Languages\ChineseSimplified.isl'
if (Test-Path -LiteralPath $repoLanguageFile) {
return (Resolve-Path -LiteralPath $repoLanguageFile).Path
}
Write-Warning "ChineseSimplified.isl was not found under '$compilerRoot\Languages'. Falling back to compiler:Default.isl; custom Chinese task/runtime messages remain active."
return 'compiler:Default.isl'
}
function Save-ScaledLogo {
param(
[string] $SourcePath,
[string] $DestinationPath,
[int] $Width,
[int] $Height,
[int] $Padding,
[string] $BackgroundHex = '#00FFFFFF',
[switch] $Wordmark
)
Add-Type -AssemblyName System.Drawing
$source = [Drawing.Image]::FromFile($SourcePath)
$bitmap = New-Object Drawing.Bitmap($Width, $Height, [Drawing.Imaging.PixelFormat]::Format32bppArgb)
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::HighQuality
$graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.CompositingQuality = [Drawing.Drawing2D.CompositingQuality]::HighQuality
$graphics.TextRenderingHint = [Drawing.Text.TextRenderingHint]::ClearTypeGridFit
$graphics.Clear([Drawing.ColorTranslator]::FromHtml($BackgroundHex))
if ($Wordmark) {
$iconSize = [Math]::Min($Height - ($Padding * 2), [Math]::Min(112, $Width / 3))
$iconX = $Padding + [Math]::Max(0, ($Height - $iconSize) / 5)
$iconY = ($Height - $iconSize) / 2
$graphics.DrawImage($source, [Drawing.RectangleF]::new($iconX, $iconY, $iconSize, $iconSize))
$titleFont = New-Object Drawing.Font('Segoe UI Semibold', [Math]::Max(18, [Math]::Min(34, $Height / 6)))
$subFont = New-Object Drawing.Font('Segoe UI', [Math]::Max(10, [Math]::Min(16, $Height / 12)))
$titleBrush = New-Object Drawing.SolidBrush([Drawing.ColorTranslator]::FromHtml('#111827'))
$subBrush = New-Object Drawing.SolidBrush([Drawing.ColorTranslator]::FromHtml('#56616A'))
try {
$textX = $iconX + $iconSize + $Padding
$titleY = ($Height / 2) - ($titleFont.Size * 1.15)
$graphics.DrawString('YMhut Box', $titleFont, $titleBrush, [Drawing.PointF]::new($textX, $titleY))
$graphics.DrawString('WinUI 3 toolbox', $subFont, $subBrush, [Drawing.PointF]::new($textX + 2, $titleY + ($titleFont.Size * 1.6)))
} finally {
$titleFont.Dispose()
$subFont.Dispose()
$titleBrush.Dispose()
$subBrush.Dispose()
}
} else {
$maxWidth = $Width - ($Padding * 2)
$maxHeight = $Height - ($Padding * 2)
$scale = [Math]::Min($maxWidth / $source.Width, $maxHeight / $source.Height)
$drawWidth = $source.Width * $scale
$drawHeight = $source.Height * $scale
$drawX = ($Width - $drawWidth) / 2
$drawY = ($Height - $drawHeight) / 2
$graphics.DrawImage($source, [Drawing.RectangleF]::new($drawX, $drawY, $drawWidth, $drawHeight))
}
New-Directory (Split-Path -Parent $DestinationPath)
$bitmap.Save($DestinationPath, [Drawing.Imaging.ImageFormat]::Png)
} finally {
$graphics.Dispose()
$bitmap.Dispose()
$source.Dispose()
}
}
function Ensure-MsixAssets {
$sourceIcon = Join-Path $Root 'assets\icons\app_icon.png'
if (-not (Test-Path -LiteralPath $sourceIcon)) {
throw "MSIX source icon was not found: $sourceIcon"
}
New-Directory $ProjectAssets
Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'StoreLogo.png') 50 50 5 '#00FFFFFF'
Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'Square44x44Logo.png') 44 44 4 '#00FFFFFF'
Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'Square150x150Logo.png') 150 150 16 '#00FFFFFF'
Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'LockScreenLogo.png') 70 70 8 '#00FFFFFF'
Save-ScaledLogo $sourceIcon (Join-Path $ProjectAssets 'Wide310x150Logo.png') 310 150 24 '#F5F6F7' -Wordmark
}
function Ensure-LocalDeveloperCertificate {
New-Directory (Split-Path -Parent $PfxPath)
if ((Test-Path -LiteralPath $PfxPath) -and (Test-Path -LiteralPath $CerPath)) {
$secure = ConvertTo-SecureString -String $PfxPassword -AsPlainText -Force
$pfxCert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($PfxPath, $secure)
$cerCert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($CerPath)
if ($pfxCert.Thumbprint -ne $cerCert.Thumbprint) {
Export-Certificate -Cert $pfxCert -FilePath $CerPath -Force | Out-Null
}
Import-Certificate -FilePath $CerPath -CertStoreLocation 'Cert:\CurrentUser\Root' | Out-Null
Import-Certificate -FilePath $CerPath -CertStoreLocation 'Cert:\CurrentUser\TrustedPeople' | Out-Null
return
}
$secure = ConvertTo-SecureString -String $PfxPassword -AsPlainText -Force
$cert = New-SelfSignedCertificate `
-Type CodeSigningCert `
-Subject $PackagePublisher `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-KeyExportPolicy Exportable `
-KeyUsage DigitalSignature `
-FriendlyName 'YMhut Box WinUI local signing'
Export-PfxCertificate -Cert $cert -FilePath $PfxPath -Password $secure | Out-Null
Export-Certificate -Cert $cert -FilePath $CerPath | Out-Null
Import-Certificate -FilePath $CerPath -CertStoreLocation 'Cert:\CurrentUser\Root' | Out-Null
Import-Certificate -FilePath $CerPath -CertStoreLocation 'Cert:\CurrentUser\TrustedPeople' | Out-Null
}
function Assert-ArtifactSignatureMatchesCertificate([string] $Path) {
$cerCert = [Security.Cryptography.X509Certificates.X509Certificate2]::new($CerPath)
$signature = Get-AuthenticodeSignature -FilePath $Path
if (-not $signature.SignerCertificate) {
throw "Signed artifact does not expose a signer certificate: $Path"
}
if ($signature.SignerCertificate.Thumbprint -ne $cerCert.Thumbprint) {
throw "Signed artifact certificate thumbprint '$($signature.SignerCertificate.Thumbprint)' does not match exported certificate '$($cerCert.Thumbprint)'. Rebuild the package and keep the matching YMhutBox.cer next to it."
}
}
function Get-WindowsAppRuntimePackageExtensions {
$projectRoot = Split-Path -Parent $Project
$objRoot = Join-Path $projectRoot 'obj'
if (-not (Test-Path -LiteralPath $objRoot)) {
return ''
}
$manifest = Get-ChildItem -LiteralPath $objRoot -Filter 'AppxManifest.xml' -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like '*\MsixContent\AppxManifest.xml' } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $manifest) {
return ''
}
[xml]$xml = Get-Content -LiteralPath $manifest.FullName -Raw -Encoding UTF8
$namespace = [System.Xml.XmlNamespaceManager]::new($xml.NameTable)
$namespace.AddNamespace('m', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10')
$extensions = $xml.SelectSingleNode('/m:Package/m:Extensions', $namespace)
if (-not $extensions) {
return ''
}
$extensions.OuterXml
}
function Write-AppxManifest([string] $TargetDir, [string] $PackageVersion) {
$packageExtensions = Get-WindowsAppRuntimePackageExtensions
if ([string]::IsNullOrWhiteSpace($packageExtensions)) {
throw 'Windows App SDK package extensions were not found. Build output must include MsixContent\AppxManifest.xml so WinUI activatable classes are registered in the MSIX.'
}
$manifest = @"
$AppName
YMhut
Assets\StoreLogo.png
$packageExtensions
"@
Set-Content -LiteralPath (Join-Path $TargetDir 'AppxManifest.xml') -Value $manifest -Encoding UTF8
}
function Write-AppInstaller([object] $VersionInfo, [string] $MsixFileName) {
New-Directory $OutputUpdateInfoRoot
$appInstallerUri = $UpdateBaseUri + 'winui.appinstaller'
$msixUri = $DownloadBaseUri + $MsixFileName
$appInstallerPath = Join-Path $OutputUpdateInfoRoot 'winui.appinstaller'
$content = @"
"@
Set-Content -LiteralPath $appInstallerPath -Value $content -Encoding UTF8
Copy-Item -LiteralPath $appInstallerPath -Destination (Join-Path $OutputRoot 'winui.appinstaller') -Force
$localAppInstallerPath = Join-Path $OutputRoot 'winui-local.appinstaller'
$localAppInstallerUri = [Uri]::new([IO.Path]::GetFullPath($localAppInstallerPath)).AbsoluteUri
$localMsixUri = [Uri]::new([IO.Path]::GetFullPath((Join-Path $OutputRoot $MsixFileName))).AbsoluteUri
$localContent = @"
"@
Set-Content -LiteralPath $localAppInstallerPath -Value $localContent -Encoding UTF8
}
function Write-LayoutManifests([string] $Directory) {
$files = Get-ChildItem -LiteralPath $Directory -Recurse -File |
ForEach-Object { $_.FullName.Substring($Directory.Length).TrimStart('\', '/') -replace '\\', '/' } |
Sort-Object
$configDir = Join-Path $Directory 'config'
New-Directory $configDir
$required = @($files | Where-Object { $_ -in @('YMhutBox.exe', 'YMhutBox.dll', 'WebView2Loader.dll') })
if ($required.Count -eq 0) {
$required = @('YMhutBox.exe')
}
$manifest = [System.Collections.Generic.List[string]]::new()
if ($versionInfo) {
$manifest.Add('[Release]')
$manifest.Add("Version=$($versionInfo.Version)")
$manifest.Add("Build=$($versionInfo.Build)")
$manifest.Add("Channel=$($versionInfo.Channel)")
$manifest.Add("PackageVersion=$($versionInfo.PackageVersion)")
$manifest.Add('')
}
$manifest.Add('[RequiredFiles]')
foreach ($file in $required) {
$manifest.Add($file)
}
$manifest.Add('')
$manifest.Add('[Files]')
foreach ($file in $files) {
if ($file -ine 'config/install-manifest.ini') {
$manifest.Add($file)
}
}
Set-Content -LiteralPath (Join-Path $configDir 'install-manifest.ini') -Value $manifest -Encoding UTF8
}
function Get-FileSha256([string] $Path) {
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Sync-UpdateServerPackages {
if (-not (Test-Path -LiteralPath $ServerPublicRoot)) {
return
}
New-Directory $ServerDownloadRoot
foreach ($legacyFile in @('manifest.json', 'latest-version.json', 'package-manifest.json', 'incremental-manifest.json', 'modules.json')) {
$legacyPath = Join-Path $ServerPublicRoot $legacyFile
if (Test-Path -LiteralPath $legacyPath) {
Remove-Item -LiteralPath $legacyPath -Force
}
}
foreach ($legacyRoot in @(
(Join-Path $ServerPublicRoot 'packages'),
(Join-Path $ServerPublicRoot 'tool-packages'),
(Join-Path $ServerPublicRoot 'incremental'),
(Join-Path $ServerDownloadRoot 'incremental')
)) {
if (Test-Path -LiteralPath $legacyRoot) {
Remove-Item -LiteralPath $legacyRoot -Recurse -Force
}
}
foreach ($sourceRoot in @($OutputRoot, $OutputUpdateInfoRoot)) {
if (-not (Test-Path -LiteralPath $sourceRoot)) {
continue
}
Get-ChildItem -LiteralPath $sourceRoot -File -ErrorAction SilentlyContinue |
Where-Object {
$_.Name -notmatch '_Light\.' -and (
$_.Name -match '^YMhut_Box_(WinUI_)?Setup_.+\.(exe|msi)$' -or
$_.Name -match '^YMhutBox_.+_x64\.msix$' -or
$_.Name -ieq 'winui.appinstaller'
)
} |
ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $ServerDownloadRoot $_.Name) -Force
}
}
$updateInfoSource = Join-Path $OutputRoot 'update-info.json'
if (Test-Path -LiteralPath $updateInfoSource) {
Copy-Item -LiteralPath $updateInfoSource -Destination (Join-Path $ServerPublicRoot 'update-info.json') -Force
}
Write-Host " Server downloads: $(Join-Path $ServerPublicRoot 'downloads')"
Write-Host " Server update info: $(Join-Path $ServerPublicRoot 'update-info.json')"
}
function Write-UpdateCatalogs([object] $VersionInfo) {
$fullExeName = "YMhut_Box_WinUI_Setup_$($VersionInfo.PackageVersion).exe"
$msixName = "YMhutBox_$($VersionInfo.PackageVersion)_x64.msix"
$appInstallerName = 'winui.appinstaller'
$fullInstallerPath = Join-Path $OutputRoot $fullExeName
$msixOutputPath = Join-Path $OutputUpdateInfoRoot $msixName
if (-not (Test-Path -LiteralPath $msixOutputPath)) {
$msixOutputPath = Join-Path $OutputRoot $msixName
}
$appInstallerPath = Join-Path $OutputRoot $appInstallerName
$fullInstaller = [ordered]@{
fileName = $fullExeName
url = $DownloadBaseUri + $fullExeName
sha256 = if (Test-Path -LiteralPath $fullInstallerPath) { Get-FileSha256 $fullInstallerPath } else { '' }
size = if (Test-Path -LiteralPath $fullInstallerPath) { (Get-Item -LiteralPath $fullInstallerPath).Length } else { 0 }
version = $VersionInfo.PackageVersion
}
$msix = [ordered]@{
fileName = $msixName
url = $DownloadBaseUri + $msixName
sha256 = if (Test-Path -LiteralPath $msixOutputPath) { Get-FileSha256 $msixOutputPath } else { '' }
size = if (Test-Path -LiteralPath $msixOutputPath) { (Get-Item -LiteralPath $msixOutputPath).Length } else { 0 }
version = $VersionInfo.PackageVersion
}
$appInstaller = [ordered]@{
fileName = $appInstallerName
url = $DownloadBaseUri + $appInstallerName
sha256 = if (Test-Path -LiteralPath $appInstallerPath) { Get-FileSha256 $appInstallerPath } else { '' }
size = if (Test-Path -LiteralPath $appInstallerPath) { (Get-Item -LiteralPath $appInstallerPath).Length } else { 0 }
version = $VersionInfo.PackageVersion
}
$manifest = [ordered]@{
manifestVersion = 5
latestVersion = $VersionInfo.PackageVersion
appVersion = $VersionInfo.PackageVersion
version = $VersionInfo.Version
build = $VersionInfo.Build
channel = $VersionInfo.Channel
latest = [ordered]@{
version = $VersionInfo.PackageVersion
fullInstaller = $fullInstaller
msix = $msix
appInstaller = $appInstaller
files = [ordered]@{
fullInstaller = $fullInstaller
msix = $msix
appInstaller = $appInstaller
}
}
messages = [ordered]@{
updateInfo = 'The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.'
distribution = 'The update channel publishes the full offline installer, MSIX, and appinstaller artifacts.'
}
createdAt = (Get-Date).ToUniversalTime().ToString('o')
}
foreach ($legacyFile in @('manifest.json', 'latest-version.json', 'package-manifest.json', 'incremental-manifest.json')) {
$legacyPath = Join-Path $OutputRoot $legacyFile
if (Test-Path -LiteralPath $legacyPath) {
Remove-Item -LiteralPath $legacyPath -Force
}
}
Write-Utf8NoBomFile (Join-Path $OutputRoot 'update-info.json') ($manifest | ConvertTo-Json -Depth 10)
}
function Compress-ReferenceData([string] $Directory) {
$assetsRoot = Join-Path $Directory 'Assets'
$dataRoot = Join-Path $assetsRoot 'data'
if (-not (Test-Path -LiteralPath $dataRoot)) {
return
}
$jsonFiles = @(Get-ChildItem -LiteralPath $dataRoot -Recurse -Filter '*.json' -File -ErrorAction SilentlyContinue)
if ($jsonFiles.Count -eq 0) {
return
}
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
Add-Type -AssemblyName System.Security
$ybinPath = Join-Path $dataRoot 'ymhut-data.ybin'
if (Test-Path -LiteralPath $ybinPath) {
Remove-Item -LiteralPath $ybinPath -Force
}
$memory = [System.IO.MemoryStream]::new()
$archive = [System.IO.Compression.ZipArchive]::new($memory, [System.IO.Compression.ZipArchiveMode]::Create, $true)
try {
foreach ($file in $jsonFiles) {
$entryName = $file.FullName.Substring($assetsRoot.Length).TrimStart('\', '/') -replace '\\', '/'
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $file.FullName, $entryName, [System.IO.Compression.CompressionLevel]::Optimal) | Out-Null
}
} finally {
$archive.Dispose()
}
$plain = $memory.ToArray()
$memory.Dispose()
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$key = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes('YMhut.Box.ReferenceData.YBin.v1'))
} finally {
$sha.Dispose()
}
$aes = [System.Security.Cryptography.Aes]::Create()
try {
$aes.Key = $key
$aes.GenerateIV()
$aes.Mode = [System.Security.Cryptography.CipherMode]::CBC
$aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7
$encryptor = $aes.CreateEncryptor()
try {
$cipher = $encryptor.TransformFinalBlock($plain, 0, $plain.Length)
} finally {
$encryptor.Dispose()
}
$magic = [Text.Encoding]::ASCII.GetBytes('YMHUTYBIN1')
$output = [byte[]]::new($magic.Length + $aes.IV.Length + $cipher.Length)
[Array]::Copy($magic, 0, $output, 0, $magic.Length)
[Array]::Copy($aes.IV, 0, $output, $magic.Length, $aes.IV.Length)
[Array]::Copy($cipher, 0, $output, $magic.Length + $aes.IV.Length, $cipher.Length)
[IO.File]::WriteAllBytes($ybinPath, $output)
} finally {
$aes.Dispose()
}
foreach ($file in $jsonFiles) {
Remove-Item -LiteralPath $file.FullName -Force
}
}
function Compress-SatelliteResourceFolders([string] $Directory) {
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$langRoot = Join-Path $Directory 'resources\lang'
New-Directory $langRoot
$culturePattern = '^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$'
foreach ($folder in Get-ChildItem -LiteralPath $Directory -Directory -ErrorAction SilentlyContinue) {
if ($folder.Name -match $culturePattern -and
(Test-Path -LiteralPath (Join-Path $folder.FullName 'Microsoft.ui.xaml.dll.mui'))) {
$binPath = Join-Path $langRoot "$($folder.Name).bin"
if (Test-Path -LiteralPath $binPath) {
Remove-Item -LiteralPath $binPath -Force
}
$archive = [System.IO.Compression.ZipFile]::Open($binPath, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($file in Get-ChildItem -LiteralPath $folder.FullName -Recurse -File) {
$entryName = $file.FullName.Substring($folder.FullName.Length).TrimStart('\', '/') -replace '\\', '/'
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $file.FullName, $entryName, [System.IO.Compression.CompressionLevel]::Optimal) | Out-Null
}
} finally {
$archive.Dispose()
}
Remove-Item -LiteralPath $folder.FullName -Recurse -Force
}
}
}
function Expand-SatelliteResourcePackages([string] $Directory, [switch] $RemovePackages) {
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$langRoot = Join-Path $Directory 'resources\lang'
if (-not (Test-Path -LiteralPath $langRoot)) {
return
}
$culturePattern = '^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$'
foreach ($package in Get-ChildItem -LiteralPath $langRoot -Filter '*.bin' -File -ErrorAction SilentlyContinue) {
$culture = [IO.Path]::GetFileNameWithoutExtension($package.Name)
if ($culture -notmatch $culturePattern) {
continue
}
$target = Join-Path $Directory $culture
$targetFull = [IO.Path]::GetFullPath($target)
New-Directory $targetFull
$archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName)
try {
foreach ($entry in $archive.Entries) {
$destination = [IO.Path]::GetFullPath((Join-Path $targetFull $entry.FullName))
if (-not $destination.StartsWith($targetFull, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to extract language package entry outside target directory: $($entry.FullName)"
}
if ($entry.FullName.EndsWith('/') -or $entry.FullName.EndsWith('\')) {
New-Directory $destination
continue
}
New-Directory (Split-Path -Parent $destination)
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destination, $true)
}
} finally {
$archive.Dispose()
}
}
if ($RemovePackages) {
Remove-Item -LiteralPath $langRoot -Recurse -Force
}
}
function Get-CanonicalCultureName([string] $Name) {
if ($Name -match '^(?i)zh-cn$') { return 'zh-CN' }
if ($Name -match '^(?i)en-us$') { return 'en-US' }
return $Name
}
function Test-CultureDirectoryName([string] $Name) {
return $Name -match '^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$'
}
function Normalize-LanguageResourceLayout([string] $Directory) {
$allowedCultures = @('zh-CN', 'en-US')
$langRoot = Join-Path $Directory 'lang'
New-Directory $langRoot
$legacyLangRoot = Join-Path $Directory 'resources\lang'
if (Test-Path -LiteralPath $legacyLangRoot) {
Remove-Item -LiteralPath $legacyLangRoot -Recurse -Force
}
foreach ($folder in Get-ChildItem -LiteralPath $Directory -Directory -ErrorAction SilentlyContinue) {
if (-not (Test-CultureDirectoryName $folder.Name)) {
continue
}
$canonical = Get-CanonicalCultureName $folder.Name
if ($allowedCultures -contains $canonical) {
$target = Join-Path $langRoot $canonical
if (Test-Path -LiteralPath $target) {
Remove-Item -LiteralPath $target -Recurse -Force
}
Move-Item -LiteralPath $folder.FullName -Destination $target -Force
continue
}
Remove-Item -LiteralPath $folder.FullName -Recurse -Force
}
foreach ($folder in Get-ChildItem -LiteralPath $langRoot -Directory -ErrorAction SilentlyContinue) {
$canonical = Get-CanonicalCultureName $folder.Name
if ($allowedCultures -notcontains $canonical) {
Remove-Item -LiteralPath $folder.FullName -Recurse -Force
continue
}
if ($folder.Name -cne $canonical) {
$target = Join-Path $langRoot $canonical
if (Test-Path -LiteralPath $target) {
Remove-Item -LiteralPath $target -Recurse -Force
}
Move-Item -LiteralPath $folder.FullName -Destination $target -Force
}
}
}
function Restore-LangResourceLayoutForMsix([string] $Directory) {
$langRoot = Join-Path $Directory 'lang'
if (-not (Test-Path -LiteralPath $langRoot)) {
return
}
foreach ($culture in @('zh-CN', 'en-US')) {
$source = Join-Path $langRoot $culture
if (-not (Test-Path -LiteralPath $source)) {
continue
}
$target = Join-Path $Directory $culture
if (Test-Path -LiteralPath $target) {
Remove-Item -LiteralPath $target -Recurse -Force
}
Move-Item -LiteralPath $source -Destination $target -Force
}
Remove-Item -LiteralPath $langRoot -Recurse -Force
}
function Normalize-PublishLayout([string] $Directory) {
Compress-ReferenceData $Directory
Normalize-AppResourcePri $Directory
Normalize-LanguageResourceLayout $Directory
Remove-DevelopmentArtifacts $Directory
Remove-OldLayoutManifests $Directory
Remove-RootHelperDuplicates $Directory
Assert-UnpackagedLanguageResourcesInLang $Directory
Assert-NoDevelopmentArtifacts $Directory
}
function Assert-UnpackagedLanguageResourcesInLang([string] $Directory) {
$langRoot = Join-Path $Directory 'resources\lang'
$languagePackages = @()
if (Test-Path -LiteralPath $langRoot) {
$languagePackages = @(Get-ChildItem -LiteralPath $langRoot -Filter '*.bin' -File -ErrorAction SilentlyContinue)
}
if ($languagePackages.Count -gt 0) {
throw "Unpackaged EXE/latest layout must keep WinUI satellite resources expanded; found compressed resources\lang packages in $Directory"
}
if (Test-Path -LiteralPath $langRoot) {
throw "Unpackaged EXE/latest layout must not contain resources\lang; use lang\zh-CN and lang\en-US in $Directory"
}
$rootCultureFolders = @(Get-ChildItem -LiteralPath $Directory -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-CultureDirectoryName $_.Name })
if ($rootCultureFolders.Count -gt 0) {
$relativeList = $rootCultureFolders | Select-Object -ExpandProperty Name
throw "Unpackaged EXE/latest layout must not contain root culture folders: $($relativeList -join ', ')"
}
$canonicalLangRoot = Join-Path $Directory 'lang'
foreach ($culture in @('zh-CN', 'en-US')) {
$cultureRoot = Join-Path $canonicalLangRoot $culture
if (-not (Test-Path -LiteralPath $cultureRoot)) {
throw "Unpackaged EXE/latest layout is missing lang\$culture in $Directory"
}
$muiFiles = @(Get-ChildItem -LiteralPath $cultureRoot -Recurse -File -Filter '*.mui' -ErrorAction SilentlyContinue)
$resourceFiles = @(Get-ChildItem -LiteralPath $cultureRoot -Recurse -File -Filter '*.resources.dll' -ErrorAction SilentlyContinue)
if (($muiFiles.Count + $resourceFiles.Count) -eq 0) {
throw "Unpackaged EXE/latest layout lang\$culture contains no satellite resource files in $Directory"
}
}
$unexpectedLangFolders = @(Get-ChildItem -LiteralPath $canonicalLangRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { @('zh-CN', 'en-US') -notcontains $_.Name })
if ($unexpectedLangFolders.Count -gt 0) {
$relativeList = $unexpectedLangFolders | Select-Object -ExpandProperty Name
throw "Unpackaged EXE/latest layout contains unsupported lang cultures: $($relativeList -join ', ')"
}
}
function Ensure-RootResourcePri([string] $Directory) {
$appPriPath = Join-Path $Directory ([IO.Path]::ChangeExtension($AppExecutable, '.pri'))
if (Test-Path -LiteralPath $appPriPath) {
Copy-Item -LiteralPath $appPriPath -Destination (Join-Path $Directory 'resources.pri') -Force
}
}
function Normalize-AppResourcePri([string] $Directory) {
$appPriPath = Join-Path $Directory ([IO.Path]::ChangeExtension($AppExecutable, '.pri'))
if (-not (Test-Path -LiteralPath $appPriPath)) {
return
}
$resourcePriPath = Join-Path $Directory 'resources.pri'
if (Test-Path -LiteralPath $resourcePriPath) {
Remove-Item -LiteralPath $resourcePriPath -Force
}
Move-Item -LiteralPath $appPriPath -Destination $resourcePriPath -Force
}
function Remove-DevelopmentArtifacts([string] $Directory) {
$patterns = @('*.pdb', '*.winmd', '*.ilk', '*.iobj', '*.ipdb', 'workloads*.json')
foreach ($pattern in $patterns) {
Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force }
}
}
function Remove-OldLayoutManifests([string] $Directory) {
$oldFiles = @(
'installer-layout.txt',
'config\installer-layout.dat',
'config\installer-required-files.dat',
'Assets\data\reference-data.dat',
'release-manifest.json',
'version.json',
'Assets\home-globe\textures\solar\solar-texture-manifest.json'
)
foreach ($relative in $oldFiles) {
$path = Join-Path $Directory $relative
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Force
}
}
}
function Assert-NoDevelopmentArtifacts([string] $Directory) {
$blocked = @()
foreach ($pattern in @('*.pdb', '*.winmd', '*.ilk', '*.iobj', '*.ipdb', 'workloads*.json')) {
$blocked += @(Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue)
}
$appPriPath = Join-Path $Directory ([IO.Path]::ChangeExtension($AppExecutable, '.pri'))
if (Test-Path -LiteralPath $appPriPath) {
$blocked += @(Get-Item -LiteralPath $appPriPath)
}
foreach ($relative in @(
'installer-layout.txt',
'config\installer-layout.dat',
'config\installer-required-files.dat',
'Assets\data\reference-data.dat',
'release-manifest.json',
'version.json',
'Assets\home-globe\textures\solar\solar-texture-manifest.json'
)) {
$path = Join-Path $Directory $relative
if (Test-Path -LiteralPath $path) {
$blocked += @(Get-Item -LiteralPath $path)
}
}
if ($blocked.Count -gt 0) {
$relativeList = $blocked |
Select-Object -ExpandProperty FullName -Unique |
ForEach-Object { $_.Substring($Directory.Length).TrimStart('\', '/') -replace '\\', '/' }
throw "Publish layout contains development artifacts: $($relativeList -join ', ')"
}
}
function Remove-RootHelperDuplicates([string] $Directory) {
$helperFolders = @(
@{ Root = 'worker'; Prefix = 'YMhut.Box.Worker' },
@{ Root = 'plugin-host'; Prefix = 'YMhut.Box.PluginHost' },
@{ Root = 'download-host'; Prefix = 'YMhut.Box.DownloadHost' }
)
foreach ($helper in $helperFolders) {
$folder = Join-Path $Directory $helper.Root
if (-not (Test-Path -LiteralPath $folder)) {
continue
}
foreach ($extension in @('.exe', '.dll', '.deps.json', '.runtimeconfig.json')) {
$path = Join-Path $Directory "$($helper.Prefix)$extension"
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Force
}
}
}
}
function Copy-PublishToLatest {
Stop-ProcessesFromDirectory $LatestRoot
Reset-DirectoryInsideRepo $LatestRoot
Copy-Item -Path (Join-Path $PublishRoot '*') -Destination $LatestRoot -Recurse -Force
Normalize-PublishLayout $LatestRoot
Write-LayoutManifests $LatestRoot
}
function Publish-UnpackagedApp([object] $VersionInfo) {
Reset-DirectoryInsideRepo $PublishRoot
Invoke-DotNet @(
'publish', $Project,
'-c', $Configuration,
'-r', 'win-x64',
'--self-contained', 'true',
'-p:WindowsPackageType=None',
'-p:PublishSingleFile=false',
"-p:AssemblyVersion=$($VersionInfo.PackageVersion)",
"-p:FileVersion=$($VersionInfo.PackageVersion)",
"-p:InformationalVersion=$($VersionInfo.PackageVersion)",
'-o', $PublishRoot
)
Normalize-PublishLayout $PublishRoot
Write-LayoutManifests $PublishRoot
Copy-PublishToLatest
}
function Build-InnoInstaller([object] $VersionInfo, [string] $SignTool, [switch] $AllowMissingCompiler, [string] $PayloadDirectory = $LatestRoot) {
$iscc = Find-InnoSetupCompiler
if (-not $iscc) {
if ($AllowMissingCompiler) {
Write-Warning 'Inno Setup compiler (ISCC.exe) was not found. Skipping EXE installer generation for this run.'
return $false
}
throw 'Inno Setup compiler (ISCC.exe) was not found. Install Inno Setup 7, then rerun build.bat --target=exe or build.bat --target=both.'
}
$iss = Join-Path $Root 'installer\ymhut_box_winui.iss'
$chineseMessagesFile = Resolve-InnoLanguageFile $iscc
Invoke-Tool $iscc @(
"/DMyAppVersion=$($VersionInfo.PackageVersion)",
"/DMyAppBuild=$($VersionInfo.Build)",
"/DMyAppChannel=$($VersionInfo.Channel)",
"/DChineseMessagesFile=$chineseMessagesFile",
"/DPayloadDir=$PayloadDirectory",
$iss
) 'Inno Setup build failed'
$setupPath = Join-Path $OutputRoot "YMhut_Box_WinUI_Setup_$($VersionInfo.PackageVersion).exe"
if ((Test-Path -LiteralPath $setupPath) -and $SignTool) {
Sign-Artifact $SignTool $setupPath
}
return $true
}
function Sign-Artifact([string] $SignTool, [string] $Path) {
Ensure-LocalDeveloperCertificate
Invoke-ToolQuiet $SignTool @('sign', '/fd', 'SHA256', '/f', $PfxPath, '/p', $PfxPassword, $Path) "Signing failed for $Path" " Signed: $Path"
Assert-ArtifactSignatureMatchesCertificate $Path
}
function Clear-PeCertificateDirectory([string] $Path) {
if (-not (Test-Path -LiteralPath $Path)) {
return $false
}
$bytes = [IO.File]::ReadAllBytes($Path)
if ($bytes.Length -lt 0x200 -or
$bytes[0] -ne 0x4D -or
$bytes[1] -ne 0x5A) {
return $false
}
$peOffset = [BitConverter]::ToInt32($bytes, 0x3C)
if ($peOffset -lt 0 -or $peOffset + 0x18 -ge $bytes.Length) {
return $false
}
if ($bytes[$peOffset] -ne 0x50 -or
$bytes[$peOffset + 1] -ne 0x45 -or
$bytes[$peOffset + 2] -ne 0 -or
$bytes[$peOffset + 3] -ne 0) {
return $false
}
$optionalHeaderSize = [BitConverter]::ToUInt16($bytes, $peOffset + 20)
$optionalHeaderOffset = $peOffset + 24
if ($optionalHeaderOffset + $optionalHeaderSize -gt $bytes.Length) {
return $false
}
$optionalMagic = [BitConverter]::ToUInt16($bytes, $optionalHeaderOffset)
$dataDirectoryOffset = switch ($optionalMagic) {
0x10B { $optionalHeaderOffset + 96 }
0x20B { $optionalHeaderOffset + 112 }
default { return $false }
}
$certificateDirectoryOffset = $dataDirectoryOffset + (4 * 8)
if ($certificateDirectoryOffset + 8 -gt $optionalHeaderOffset + $optionalHeaderSize) {
return $false
}
$certificateTableOffset = [BitConverter]::ToUInt32($bytes, $certificateDirectoryOffset)
$certificateTableSize = [BitConverter]::ToUInt32($bytes, $certificateDirectoryOffset + 4)
if ($certificateTableOffset -eq 0 -and $certificateTableSize -eq 0) {
return $false
}
for ($i = 0; $i -lt 8; $i++) {
$bytes[$certificateDirectoryOffset + $i] = 0
}
[IO.File]::WriteAllBytes($Path, $bytes)
return $true
}
function Repair-MsixStageThirdPartyPayloads {
$knownInvalidPe = Get-ChildItem -LiteralPath (Join-Path $MsixStageRoot 'Tools') -Recurse -Filter 'xiangqi.exe' -File -ErrorAction SilentlyContinue |
Where-Object { $_.FullName.IndexOf('\XIANGQI\', [StringComparison]::OrdinalIgnoreCase) -ge 0 } |
Select-Object -First 1
if ($knownInvalidPe -and (Clear-PeCertificateDirectory $knownInvalidPe.FullName)) {
Write-Host ' MSIX payload normalized: Tools\...\XIANGQI\xiangqi.exe'
}
}
function Build-MsixPackage([object] $VersionInfo, [string] $MakeAppx, [string] $SignTool) {
if (-not $MakeAppx) { throw 'Windows SDK MakeAppx.exe was not found. Install Windows 10/11 SDK.' }
if (-not $SignTool) { throw 'Windows SDK SignTool.exe was not found. Install Windows 10/11 SDK.' }
$msixFileName = "YMhutBox_$($VersionInfo.PackageVersion)_x64.msix"
$msixPath = Join-Path $OutputRoot $msixFileName
Reset-DirectoryInsideRepo $MsixStageRoot
Copy-Item -Path (Join-Path $PublishRoot '*') -Destination $MsixStageRoot -Recurse -Force
Expand-SatelliteResourcePackages $MsixStageRoot -RemovePackages
Restore-LangResourceLayoutForMsix $MsixStageRoot
Write-AppxManifest $MsixStageRoot $VersionInfo.PackageVersion
$appPriPath = Join-Path $MsixStageRoot ([IO.Path]::ChangeExtension($AppExecutable, '.pri'))
if (Test-Path -LiteralPath $appPriPath) {
Copy-Item -LiteralPath $appPriPath -Destination (Join-Path $MsixStageRoot 'resources.pri') -Force
Remove-Item -LiteralPath $appPriPath -Force
}
$stageAssets = Join-Path $MsixStageRoot 'Assets'
New-Directory $stageAssets
Copy-Item -LiteralPath (Join-Path $ProjectAssets '*') -Destination $stageAssets -Recurse -Force
Repair-MsixStageThirdPartyPayloads
Invoke-ToolQuiet $MakeAppx @('pack', '/d', $MsixStageRoot, '/p', $msixPath, '/o') 'MakeAppx packaging failed' " MSIX package: $msixPath"
Sign-Artifact $SignTool $msixPath
New-Directory $OutputUpdateInfoRoot
Copy-Item -LiteralPath $msixPath -Destination (Join-Path $OutputUpdateInfoRoot $msixFileName) -Force
Write-AppInstaller $VersionInfo $msixFileName
Write-MsixInstallHelpers $msixFileName
}
function Write-MsixInstallHelpers([string] $MsixFileName) {
$psPath = Join-Path $OutputRoot 'Install-YMhutBoxMsix.ps1'
$cmdPath = Join-Path $OutputRoot 'Install-YMhutBoxMsix.cmd'
$ps = @"
`$ErrorActionPreference = 'Stop'
`$root = Split-Path -Parent `$MyInvocation.MyCommand.Path
`$certPath = Join-Path `$root 'YMhutBox.cer'
`$msixPath = Join-Path `$root '$MsixFileName'
function Test-Administrator {
`$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
`$principal = [Security.Principal.WindowsPrincipal]::new(`$identity)
return `$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-not (Test-Path -LiteralPath `$certPath)) {
throw "Certificate file was not found: `$certPath"
}
if (-not (Test-Path -LiteralPath `$msixPath)) {
throw "MSIX package was not found: `$msixPath"
}
`$cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new(`$certPath)
`$signature = Get-AuthenticodeSignature -FilePath `$msixPath
if (-not `$signature.SignerCertificate) {
throw "MSIX signer certificate could not be read: `$msixPath"
}
if (`$signature.SignerCertificate.Thumbprint -ne `$cert.Thumbprint) {
throw "The MSIX package is signed with `$(`$signature.SignerCertificate.Thumbprint), but YMhutBox.cer is `$(`$cert.Thumbprint). Rebuild the MSIX and install the matching certificate."
}
if (-not (Test-Administrator)) {
Write-Host 'Administrator permission is required once to trust the local MSIX signing certificate.'
`$arguments = "-NoProfile -ExecutionPolicy Bypass -File ```"`$PSCommandPath```""
Start-Process -FilePath powershell.exe -ArgumentList `$arguments -Verb RunAs
exit
}
Write-Host 'Trusting YMhut Box local signing certificate for this computer...'
foreach (`$store in @('Cert:\LocalMachine\Root', 'Cert:\LocalMachine\TrustedPeople', 'Cert:\CurrentUser\Root', 'Cert:\CurrentUser\TrustedPeople')) {
Import-Certificate -FilePath `$certPath -CertStoreLocation `$store | Out-Null
if (-not (Get-ChildItem -Path `$store | Where-Object Thumbprint -eq `$cert.Thumbprint)) {
throw "Certificate was not found in `$store after import. Run this script from an elevated PowerShell window."
}
}
Write-Host 'Installing YMhut Box MSIX package...'
Add-AppxPackage -Path `$msixPath -ForceUpdateFromAnyVersion
Write-Host 'Done.'
"@
Set-Content -LiteralPath $psPath -Value $ps -Encoding UTF8
$cmd = @"
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install-YMhutBoxMsix.ps1"
if errorlevel 1 (
echo.
echo YMhut Box MSIX installation failed.
pause
exit /b 1
)
echo.
echo YMhut Box MSIX installation completed.
pause
"@
Set-Content -LiteralPath $cmdPath -Value $cmd -Encoding ASCII
}
New-Directory $AppDataRoot
New-Directory $HomeRoot
New-Directory $NuGetRoot
New-Directory $LocalNuGetFeedRoot
New-Directory $OutputRoot
$env:APPDATA = $AppDataRoot
$env:LOCALAPPDATA = $AppDataRoot
$env:HOME = $HomeRoot
$env:DOTNET_CLI_HOME = Join-Path $ToolStateRoot 'dotnet'
$env:NUGET_PACKAGES = $NuGetRoot
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
New-Directory $env:DOTNET_CLI_HOME
$versionInfo = Get-VersionInfo
$makeAppx = Find-WindowsSdkTool 'MakeAppx.exe'
$signTool = Find-WindowsSdkTool 'SignTool.exe'
Write-Host "YMhut Box WinUI build"
Write-Host " Target: $Target"
Write-Host " Version: $($versionInfo.Version) build $($versionInfo.Build) ($($versionInfo.PackageVersion))"
Ensure-MsixAssets
Invoke-DotNet @('restore', $Solution, '--configfile', $NuGetConfig, '--ignore-failed-sources')
if (-not $SkipTests) {
Invoke-DotNet @('test', $Solution, '-c', $Configuration, '--no-restore')
}
Publish-UnpackagedApp $versionInfo
if (($Target -in @('exe', 'both')) -and -not $SkipExe) {
$exeBuilt = Build-InnoInstaller $versionInfo $signTool -AllowMissingCompiler:($Target -eq 'both')
if (-not $exeBuilt -and $Target -eq 'exe') {
throw 'EXE installer generation was requested but Inno Setup compiler was not found.'
}
}
if ($Target -in @('msix', 'both')) {
Build-MsixPackage $versionInfo $makeAppx $signTool
}
Write-UpdateCatalogs $versionInfo
Sync-UpdateServerPackages
Write-Host ''
Write-Host 'Build complete.'
Write-Host " Publish payload: $PublishRoot"
Write-Host " Latest mirror: $LatestRoot"
Write-Host " Update info JSON:$(Join-Path $OutputRoot 'update-info.json')"
Write-Host " Server downloads:$ServerDownloadRoot"
Write-Host " Server update info: $(Join-Path $ServerPublicRoot 'update-info.json')"
Write-Host " Output: $OutputRoot"