2026-09-17 14:45:23 +08:00

127 lines
7.5 KiB
PowerShell

param(
[Parameter(Mandatory=$true)][string]$ProjectPath,
[ValidateSet('debug','release')][string]$Mode='debug',
[string]$ApiUrl='http://127.0.0.1:9996/api',
[string]$FlutterPath='',
[string]$OutputPath='',
[string]$BuildName='',
[string]$BuildNumber='',
[ValidateRange(2,8)][int]$GradleHeapGB=4,
[switch]$RunTests,
[switch]$CheckOnly,
[switch]$InstallOnly,
[string]$ApkPath=''
)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
$OutputEncoding = [Console]::OutputEncoding
$buildLock=$null
$transcribing=$false
$previousGradleOpts=$env:GRADLE_OPTS
function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
$ErrorActionPreference='Continue'
& $Executable @Arguments 2>&1 | ForEach-Object { Write-Output "$_" }
if ($LASTEXITCODE -ne 0) { throw "Command failed (exit $LASTEXITCODE): $Executable" }
}
function Read-Property([string]$Name) {
$propertyFile=Join-Path $ProjectPath 'android/local.properties'
if (Test-Path -LiteralPath $propertyFile) {
$line=Get-Content -LiteralPath $propertyFile | Where-Object { $_.StartsWith($Name+'=') } | Select-Object -First 1
if ($line) { return $line.Substring($Name.Length+1).Replace('\\','\').Replace('\:',':') }
}
return ''
}
try {
$ProjectPath=(Resolve-Path -LiteralPath $ProjectPath).Path
if (!(Test-Path -LiteralPath (Join-Path $ProjectPath 'pubspec.yaml'))) { throw 'Select the Flutter project folder containing pubspec.yaml.' }
Set-Location -LiteralPath $ProjectPath
if (!$OutputPath) { $OutputPath=Join-Path $ProjectPath 'packages' }
$OutputPath=[IO.Path]::GetFullPath($OutputPath)
$null=New-Item -ItemType Directory -Force -Path $OutputPath
$jobPath=Join-Path $OutputPath ((Get-Date -Format 'yyyyMMdd-HHmmss-fff')+'-'+$Mode)
$null=New-Item -ItemType Directory -Path $jobPath
Start-Transcript -LiteralPath (Join-Path $jobPath 'build.log') -Force | Out-Null
$transcribing=$true
Write-Output "PACKAGER_OUTPUT=$jobPath"
$lockDirectory=Join-Path $ProjectPath '.dart_tool'
$null=New-Item -ItemType Directory -Force -Path $lockDirectory
$buildLock=[IO.File]::Open((Join-Path $lockDirectory 'yd-packager.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None)
$sdkPath=Read-Property 'sdk.dir'
if (!$sdkPath) { $sdkPath=$env:ANDROID_HOME }
if (!$sdkPath) { $sdkPath=$env:ANDROID_SDK_ROOT }
$adb=if ($sdkPath) { Join-Path $sdkPath 'platform-tools/adb.exe' } else { '' }
if (!$adb -or !(Test-Path -LiteralPath $adb)) { $command=Get-Command adb.exe -ErrorAction SilentlyContinue; if ($command) { $adb=$command.Source } }
if ($InstallOnly) {
if (!$adb -or !(Test-Path -LiteralPath $adb)) { throw 'ADB not found. Install Android SDK platform-tools.' }
if (!(Test-Path -LiteralPath $ApkPath) -or [IO.Path]::GetExtension($ApkPath) -ne '.apk') { throw 'A valid APK file is required.' }
$deviceLines=& $adb devices
$devices=@($deviceLines | Where-Object { $_ -match '^\S+\s+device$' } | ForEach-Object { ($_ -split '\s+')[0] })
if ($devices.Count -ne 1) { throw 'Connect exactly one authorized Android device. Check USB debugging authorization.' }
Invoke-Checked $adb @('-s',$devices[0],'install','-r',$ApkPath)
Write-Output 'PACKAGER_INSTALL_OK'
exit 0
}
[Uri]$endpoint=$null
if (![Uri]::TryCreate($ApiUrl,[UriKind]::Absolute,[ref]$endpoint) -or $endpoint.Scheme -notin @('http','https') -or !$endpoint.Host -or $endpoint.UserInfo -or $endpoint.Query -or $endpoint.Fragment) {
throw 'API URL must be an absolute HTTP/HTTPS address, without credentials, query or fragment.'
}
$ApiUrl=$ApiUrl.TrimEnd('/')
if ($endpoint.AbsolutePath -eq '/') {
$ApiUrl += '/api'
Write-Output "API root URL normalized to: $ApiUrl"
}
if ($Mode -eq 'release' -and $endpoint.Scheme -ne 'https') { throw 'Release requires HTTPS. Use Debug for HTTP testing.' }
if ($BuildName -and $BuildName -notmatch '^\d+\.\d+\.\d+(?:[.-][A-Za-z0-9.-]+)?$') { throw 'Invalid version name. Example: 1.0.0' }
if ($BuildNumber -and ($BuildNumber -notmatch '^[1-9]\d{0,8}$')) { throw 'Version code must be a positive integer (max 9 digits).' }
if (!$FlutterPath) {
$flutterSdk=Read-Property 'flutter.sdk'
if ($flutterSdk) { $FlutterPath=Join-Path $flutterSdk 'bin/flutter.bat' }
}
if (!$FlutterPath) { $command=Get-Command flutter.bat -ErrorAction SilentlyContinue; if ($command) { $FlutterPath=$command.Source } }
if (!$FlutterPath -or !(Test-Path -LiteralPath $FlutterPath)) { throw 'Flutter was not found. Select flutter.bat in the tool.' }
if (!$sdkPath -or !(Test-Path -LiteralPath $sdkPath)) { throw 'Android SDK not found. Configure android/local.properties or ANDROID_HOME.' }
Write-Output "Project: $ProjectPath"
Write-Output "Flutter: $FlutterPath"
Write-Output "Android SDK: $sdkPath"
Write-Output "API: $ApiUrl"
Write-Output 'NOTE: Existing in-app API settings override the compiled default URL.'
if ($Mode -eq 'release') { Write-Output 'WARNING: Current project uses a debug signing key for Release. Not for production publishing.' }
Invoke-Checked $FlutterPath @('--version')
if ($CheckOnly) { Write-Output 'PACKAGER_CHECK_OK'; exit 0 }
if ($RunTests) {
Invoke-Checked $FlutterPath @('analyze')
Invoke-Checked $FlutterPath @('test')
}
# User-level gradle.properties overrides project properties. An explicit
# JVM system property wins for this child process, without changing globals.
$heapOption='-Dorg.gradle.jvmargs="-Xmx'+$GradleHeapGB+'g -XX:MaxMetaspaceSize=1g -XX:ReservedCodeCacheSize=256m -Dfile.encoding=UTF-8"'
$env:GRADLE_OPTS=($previousGradleOpts+' '+$heapOption).Trim()
Write-Output "Gradle build heap: ${GradleHeapGB}GB; metaspace: 1GB (process-local override)."
# A fresh Gradle daemon avoids accumulated heap pressure on this workstation.
$gradle=Join-Path $ProjectPath 'android/gradlew.bat'
if (Test-Path -LiteralPath $gradle) {
Push-Location (Join-Path $ProjectPath 'android')
try { Invoke-Checked $gradle @('--stop') } finally { Pop-Location }
}
$arguments=@('build','apk',('--'+$Mode),('--dart-define=API_BASE_URL='+$ApiUrl))
if ($BuildName) { $arguments+=('--build-name='+$BuildName) }
if ($BuildNumber) { $arguments+=('--build-number='+$BuildNumber) }
Invoke-Checked $FlutterPath $arguments
$sourceApk=Join-Path $ProjectPath ('build/app/outputs/flutter-apk/app-'+$Mode+'.apk')
if (!(Test-Path -LiteralPath $sourceApk)) { throw 'Build completed without the expected APK.' }
$targetApk=Join-Path $jobPath ('YD-Pattern-Scoring-'+$Mode+'.apk')
Copy-Item -LiteralPath $sourceApk -Destination $targetApk
$hash=(Get-FileHash -LiteralPath $targetApk -Algorithm SHA256).Hash
[ordered]@{ builtAt=(Get-Date).ToString('o'); mode=$Mode; apiBaseUrl=$ApiUrl; gradleHeapGB=$GradleHeapGB; versionName=$BuildName; versionCode=$BuildNumber; apk=$targetApk; sha256=$hash; signing='Project Gradle signing configuration (currently debug key)' } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $jobPath 'build-info.json') -Encoding UTF8
Write-Output "SHA256=$hash"
Write-Output "PACKAGER_APK=$targetApk"
exit 0
} catch {
Write-Output ('PACKAGER_ERROR='+$_.Exception.Message)
exit 1
} finally {
$env:GRADLE_OPTS=$previousGradleOpts
if ($buildLock) { $buildLock.Dispose() }
if ($transcribing) { Stop-Transcript | Out-Null }
}