param( [ValidateSet("Maui", "Avalonia")] [string]$AppType = "Maui", [string]$TargetFramework = "net10.0-windows10.0.19041.0", [string]$RuntimeIdentifier = "win-x64", [ValidateSet("Release", "Debug")] [string]$Configuration = "Release", [switch]$SkipInnoSetup, [switch]$SkipVersionBump, [switch]$SkipProcessStop, [switch]$SkipRestore, [string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier)), [string]$BaseIntermediateOutputPath ) $ErrorActionPreference = "Stop" $ScriptPath = $PSScriptRoot if ($AppType -eq "Avalonia") { $ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Avalonia" $TargetFramework = "net10.0" } else { $ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui" } $ProjectFile = Get-ChildItem -Path $ProjectDir -Filter "*.csproj" | Select-Object -First 1 -ExpandProperty FullName $SetupScript = Join-Path $ProjectDir "setup.iss" $ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile) $DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props" function Read-ProjectVersion { param( [Parameter(Mandatory = $true)] [string]$ProjectFile, [string]$DirectoryBuildProps ) $currentVersion = "0.0.0" # 1. Try Directory.Build.props first if ($null -ne $DirectoryBuildProps -and (Test-Path $DirectoryBuildProps)) { [xml]$props = Get-Content $DirectoryBuildProps -Raw $versionNode = $props.SelectSingleNode("//Version") if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) { return $versionNode.InnerText.Trim() } } # 2. Try .csproj [xml]$csproj = Get-Content $ProjectFile -Raw $versionNode = $csproj.SelectSingleNode("//Version") if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) { return $versionNode.InnerText.Trim() } $versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion") if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) { return $versionNode.InnerText.Trim() } return $currentVersion } function Read-InnoSetupAppName { param( [Parameter(Mandatory = $true)] [string]$SetupScript ) if (!(Test-Path $SetupScript)) { return $null } $content = Get-Content $SetupScript -Raw $match = [regex]::Match($content, '#define\s+MyAppName\s+"([^"]+)"') if ($match.Success) { return $match.Groups[1].Value.Trim() } return $null } # Read OutputBaseFilename from setup.iss so we can determine the exact installer file path after ISCC runs. # The value may contain Inno preprocessor macros like {#MyAppName}/{#MyAppVersion}. function Read-InnoSetupOutputBaseFilename { param( [Parameter(Mandatory = $true)] [string]$SetupScript ) if (!(Test-Path $SetupScript)) { return $null } $content = Get-Content $SetupScript -Raw $match = [regex]::Match($content, '^\s*OutputBaseFilename\s*=\s*(.+?)\s*$', [System.Text.RegularExpressions.RegexOptions]::Multiline) if ($match.Success) { return $match.Groups[1].Value.Trim().Trim('"') } return $null } # Resolve the subset of Inno preprocessor macros used by this repo to a concrete file base name. function Resolve-InnoSetupOutputBaseFilename { param( [Parameter(Mandatory = $true)] [string]$Template, [string]$AppName, [string]$Version ) if ([string]::IsNullOrWhiteSpace($Template)) { return $null } $resolved = $Template if (![string]::IsNullOrWhiteSpace($AppName)) { $resolved = $resolved.Replace("{#MyAppName}", $AppName) } if (![string]::IsNullOrWhiteSpace($Version)) { $resolved = $resolved.Replace("{#MyAppVersion}", $Version) } return $resolved.Trim() } function Update-InnoSetupVersion { param( [Parameter(Mandatory = $true)] [string]$SetupScript, [Parameter(Mandatory = $true)] [string]$Version ) if (!(Test-Path $SetupScript)) { return } $issContent = Get-Content $SetupScript $versionFound = $false for ($i = 0; $i -lt $issContent.Count; $i++) { if ($issContent[$i] -like '#define MyAppVersion *') { $issContent[$i] = '#define MyAppVersion "' + $Version + '"' $versionFound = $true break } } if ($versionFound) { Set-Content $SetupScript -Value $issContent -Encoding UTF8 } } function Copy-Directory { param( [Parameter(Mandatory = $true)] [string]$Source, [Parameter(Mandatory = $true)] [string]$Destination ) if (Test-Path $Destination) { Remove-Item -Recurse -Force $Destination } New-Item -ItemType Directory -Path $Destination | Out-Null Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force } function Stop-ProjectProcesses { Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow dotnet build-server shutdown | Out-Null Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow # Aggressively look for anything related to the project or MSBuild/dotnet background tasks $processesToKill = Get-Process | Where-Object { $_.ProcessName -like "*Hua.Todo*" -or $_.ProcessName -eq "MSBuild" -or ($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*")) } if ($processesToKill) { Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow foreach ($p in $processesToKill) { try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch { Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))" } } Start-Sleep -Seconds 2 # Give OS more time to release file handles } else { Write-Host "No conflicting processes found." -ForegroundColor Green } } if (!$SkipProcessStop.IsPresent) { Stop-ProjectProcesses } $currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile -DirectoryBuildProps $DirectoryBuildProps Update-InnoSetupVersion -SetupScript $SetupScript -Version $currentVersion if ($Configuration -ne "Release") { Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow Write-Host "" } if (!$SkipRestore.IsPresent) { Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow $restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal") if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) { # Ensure trailing slash and avoid backslash escaping the quote in CLI $path = $BaseIntermediateOutputPath.TrimEnd('\') + '\' $restoreArgs += "-p:BaseIntermediateOutputPath=$path" } & dotnet @restoreArgs Write-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow $cleanArgs = @("clean", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal") if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) { $path = $BaseIntermediateOutputPath.TrimEnd('\') + '\' $cleanArgs += "-p:BaseIntermediateOutputPath=$path" } & dotnet @cleanArgs } # UseMonoRuntime 在 csproj 内按 TargetFramework 做了条件配置:仅 Android 启用,其它目标关闭。 $maxAttempts = 3 $publishSuccess = $false for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { Write-Host "Publishing $ProjectBaseName (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan $publishArgs = @("publish", $ProjectFile, "--framework", $TargetFramework, "-c", $Configuration, "-r", $RuntimeIdentifier, "--self-contained", "false", "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true") if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) { $path = $BaseIntermediateOutputPath.TrimEnd('\') + '\' $publishArgs += "-p:BaseIntermediateOutputPath=$path" } $publishOutput = (& dotnet @publishArgs 2>&1 | Out-String) $exitCode = $LASTEXITCODE if ($exitCode -eq 0) { $publishSuccess = $true break } Write-Host $publishOutput if ($attempt -lt $maxAttempts -and ($publishOutput -match 'Access to the path .* is denied|being used by another process|Sharing violation')) { Write-Host "⚠️ Build failed due to file lock. Retrying in 3 seconds..." -ForegroundColor Yellow Start-Sleep -Seconds 3 Stop-ProjectProcesses # Try killing processes again before retry continue } Write-Error "MAUI build failed after $attempt attempts." exit 1 } $publishDir = Join-Path $ProjectDir ("bin\{0}\{1}\{2}\publish" -f $Configuration, $TargetFramework, $RuntimeIdentifier) if (!(Test-Path $publishDir)) { $publishDir = (Get-ChildItem -Path (Join-Path $ProjectDir "bin\$Configuration") -Recurse -Directory -Filter publish -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName) } if (!(Test-Path $publishDir)) { Write-Error "Publish directory not found" exit 1 } $publishAppSettings = Join-Path $publishDir "appsettings.json" if (Test-Path $publishAppSettings) { $appSettingsObj = Get-Content $publishAppSettings -Raw | ConvertFrom-Json if ($null -eq $appSettingsObj.WebServer) { $appSettingsObj | Add-Member -MemberType NoteProperty -Name "WebServer" -Value ([pscustomobject]@{}) } $appSettingsObj.WebServer.IsUsingStatic = $true $appSettingsObj | ConvertTo-Json -Depth 32 | Set-Content -Path $publishAppSettings -Encoding UTF8 } else { Write-Error "Publish appsettings.json not found: $publishAppSettings" exit 1 } $desiredExeName = "$ProjectBaseName.exe" $desiredExePath = Join-Path $publishDir $desiredExeName if (!(Test-Path $desiredExePath)) { $candidateExe = $null $preferredCandidateNames = @( "Hua.Todo.Maui.exe", "Hua.Todo.Avalonia.exe", "Hua.Todo.exe" ) foreach ($name in $preferredCandidateNames) { $candidatePath = Join-Path $publishDir $name if (Test-Path $candidatePath) { $candidateExe = Get-Item -Path $candidatePath break } } if ($null -eq $candidateExe) { $exeFiles = Get-ChildItem -Path $publishDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike "*Setup*" } $candidateExe = $exeFiles | Where-Object { Test-Path (Join-Path $publishDir ($_.BaseName + ".dll")) } | Select-Object -First 1 if ($null -eq $candidateExe) { $candidateExe = $exeFiles | Sort-Object Length -Descending | Select-Object -First 1 } } if ($null -ne $candidateExe) { Rename-Item -Path $candidateExe.FullName -NewName $desiredExeName -Force } } if (!(Test-Path $desiredExePath)) { Write-Error "Publish main executable not found: $desiredExeName" exit 1 } if ($AppType -eq "Maui") { $mauiWwwrootDir = Join-Path $ProjectDir "wwwroot" $mauiIndexPath = Join-Path $mauiWwwrootDir "index.html" if (!(Test-Path $mauiIndexPath)) { Write-Error "MAUI wwwroot not found: $mauiIndexPath" exit 1 } $publishWwwroot = Join-Path $publishDir "wwwroot" if (Test-Path $publishWwwroot) { Remove-Item -Recurse -Force $publishWwwroot } New-Item -ItemType Directory -Path $publishWwwroot | Out-Null Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force } $installerPath = $null if (!$SkipInnoSetup.IsPresent) { $ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" if (Test-Path $ISCC) { $innoAppName = Read-InnoSetupAppName -SetupScript $SetupScript $outputDir = Join-Path $ProjectDir "Output" $outputBaseTemplate = Read-InnoSetupOutputBaseFilename -SetupScript $SetupScript $resolvedOutputBase = Resolve-InnoSetupOutputBaseFilename -Template $outputBaseTemplate -AppName $innoAppName -Version $currentVersion $expectedInstallerPath = $null if (![string]::IsNullOrWhiteSpace($resolvedOutputBase)) { $expectedInstallerPath = Join-Path $outputDir ("{0}.exe" -f $resolvedOutputBase) } if (![string]::IsNullOrWhiteSpace($innoAppName)) { # Prevent stale unversioned installer (Output\Hua.Todo.exe) from confusing local release artifacts. $oldInstallerPath = Join-Path $outputDir ("{0}.exe" -f $innoAppName) if (Test-Path $oldInstallerPath) { Remove-Item -Path $oldInstallerPath -Force } } # ISCC sometimes fails with a transient file sharing violation (e.g. antivirus/indexer holding the script/output briefly). $maxAttempts = 3 for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { Write-Host "Compiling installer (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan $isccOutput = (& $ISCC $SetupScript 2>&1 | Out-String) $exitCode = $LASTEXITCODE if ($exitCode -eq 0) { break } Write-Host $isccOutput if ($attempt -lt $maxAttempts -and ($isccOutput -match 'Compile aborted|being used by another process|Sharing violation|process cannot access')) { Start-Sleep -Seconds 2 continue } Write-Error "Packaging failed" exit 1 } if ($null -ne $expectedInstallerPath -and (Test-Path $expectedInstallerPath)) { $installerPath = $expectedInstallerPath } if ($null -eq $installerPath) { if (Test-Path $outputDir) { $installerPath = (Get-ChildItem -Path $outputDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName) } } Write-Host "Setup package created successfully!" -ForegroundColor Green if ($null -ne $installerPath -and (Test-Path $installerPath)) { Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green } } else { Write-Warning "Inno Setup compiler not found. Skipping installer creation." } } if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) { $publishArtifactDir = Join-Path $ArtifactsRoot "publish" $installerArtifactDir = Join-Path $ArtifactsRoot "installer" Copy-Directory -Source $publishDir -Destination $publishArtifactDir if ($null -ne $installerPath -and (Test-Path $installerPath)) { if (Test-Path $installerArtifactDir) { Remove-Item -Recurse -Force $installerArtifactDir } New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null $installerPrefix = if ($AppType -eq "Avalonia") { "hua.todo-avalonia" } else { "hua.todo-maui" } $installerName = "$installerPrefix-$currentVersion-$RuntimeIdentifier-setup.exe" Copy-Item -Path $installerPath -Destination (Join-Path $installerArtifactDir $installerName) -Force } } if (!$SkipVersionBump.IsPresent) { $versionMatch = [regex]::Match($currentVersion, '^(?\d+)\.(?\d+)\.(?\d+)$') if ($versionMatch.Success) { $newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1) # 1. Update Directory.Build.props if exists if (Test-Path $DirectoryBuildProps) { $propsContent = Get-Content $DirectoryBuildProps -Raw if ($propsContent -match "[^<]*") { $propsContent = $propsContent -replace "[^<]*", "$newVersion" Set-Content $DirectoryBuildProps -Value $propsContent -Encoding UTF8 Write-Host "Updated version in Directory.Build.props to $newVersion" -ForegroundColor Green } } # 2. Update .csproj if it still has Version $csprojContent = Get-Content $ProjectFile -Raw if ($csprojContent -match "[^<]*") { $csprojContent = $csprojContent -replace "[^<]*", "$newVersion" Set-Content $ProjectFile -Value $csprojContent -Encoding UTF8 Write-Host "Updated version in $ProjectBaseName.csproj to $newVersion" -ForegroundColor Green } } else { Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow } }