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"