d53828c150
### 新增功能 - **Linux 官方支持**:新增 Hua.Todo.Avalonia 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS - **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化 - **SQLite DateTime 兼容修复**:新增 LenientUtcDateTimeStringConverter,解决历史遗留的 DateTime 脏数据解析问题 - **用户文档完善**:新增 docs/manual/新手指南.md 和 docs/manual/用户指南.md - **部署文档**:新增 docs/manual/部署文档.md,详细说明多平台发布流程 ### 优化与修复 - **发布脚本整理**:拆分/对齐各平台发布入口,新增 publish.ps1 作为统一入口 - **Windows WebView2 优化**:数据目录调整到 %LocalAppData%\Hua.Todo\WebView2,修复 Runtime 误判问题 - **MAUI 多平台构建**:在 Windows 开发机上默认仅构建 Android + Windows 目标 - **SPA 路由回落**:修复 Release 模式下 /swagger 路径的 404 问题 - **Swagger 输出**:补齐 Dynamic API 端点,避免接口缺失 ### 文档更新 - **版本记录**:更新 v1.2.0 开发进度和功能列表 - **技术设计文档**:添加 Avalonia 项目架构和模块设计 - **项目结构**:更新 README.md 中的项目结构说明 ### 其他变更 - 新增 Directory.Build.props 和更新 Directory.Build.targets - 调整 src/Hua.Todo.Avalonia 项目配置和资源文件 - 更新 src/Hua.Todo.Web 前端资源文件 - 修复 src/Hua.Todo.Maui 相关配置和打包脚本
451 lines
17 KiB
PowerShell
451 lines
17 KiB
PowerShell
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, '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\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 "<Version>[^<]*</Version>") {
|
|
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
|
|
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 "<Version>[^<]*</Version>") {
|
|
$csprojContent = $csprojContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
|
|
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
|
|
}
|
|
}
|