Files
zyt/app/scripts/build_windows.ps1
T
2026-08-11 09:12:51 +08:00

220 lines
9.6 KiB
PowerShell

[CmdletBinding()]
param(
[string]$Python = ".venv-build\Scripts\python.exe",
[switch]$SkipFrontendInstall
)
$ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
$Spec = Join-Path $ProjectRoot "packaging\doctor_workstation.spec"
function Invoke-FrozenGate {
param(
[Parameter(Mandatory = $true)][string]$Executable,
[Parameter(Mandatory = $true)][string]$Argument,
[Parameter(Mandatory = $true)][string]$GateName
)
$TempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
$SmokeRoot = Join-Path $TempRoot ("doctor-workstation-smoke-" + [guid]::NewGuid().ToString("N"))
$SmokeRoot = [System.IO.Path]::GetFullPath($SmokeRoot)
if (-not ($SmokeRoot.StartsWith($TempRoot, [System.StringComparison]::OrdinalIgnoreCase)) -or
-not ([System.IO.Path]::GetFileName($SmokeRoot)).StartsWith("doctor-workstation-smoke-")) {
throw "Refusing to use an unsafe smoke-test directory: $SmokeRoot"
}
$Environment = @{
"APPDATA" = (Join-Path $SmokeRoot "AppData\Roaming")
"LOCALAPPDATA" = (Join-Path $SmokeRoot "AppData\Local")
"XDG_CONFIG_HOME" = (Join-Path $SmokeRoot "xdg\config")
"XDG_STATE_HOME" = (Join-Path $SmokeRoot "xdg\state")
"XDG_CACHE_HOME" = (Join-Path $SmokeRoot "xdg\cache")
"DOCTOR_CONFIG_DIR" = (Join-Path $SmokeRoot "doctor\config")
"DOCTOR_LOG_DIR" = (Join-Path $SmokeRoot "doctor\logs")
"DOCTOR_API_BASE_URL" = "https://127.0.0.1:9"
"DOCTOR_DEMO_MODE" = "true"
"DOCTOR_VIDEO_MODE" = "embedded"
"DOCTOR_VIDEO_WEB_URL" = ""
"DOCTOR_VERIFY_SSL" = "true"
"DOCTOR_LOG_LEVEL" = "INFO"
"DOCTOR_SMOKE_TEST" = "1"
"HTTP_PROXY" = "http://127.0.0.1:9"
"HTTPS_PROXY" = "http://127.0.0.1:9"
"ALL_PROXY" = "http://127.0.0.1:9"
"NO_PROXY" = ""
"QT_QPA_PLATFORM" = "offscreen"
}
$PreviousEnvironment = @{}
$StandardOutput = Join-Path $SmokeRoot "stdout.txt"
$StandardError = Join-Path $SmokeRoot "stderr.txt"
New-Item -ItemType Directory -Path $SmokeRoot -Force | Out-Null
New-Item -ItemType Directory -Path $Environment["APPDATA"] -Force | Out-Null
New-Item -ItemType Directory -Path $Environment["LOCALAPPDATA"] -Force | Out-Null
try {
foreach ($Name in $Environment.Keys) {
$PreviousEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process")
[Environment]::SetEnvironmentVariable($Name, $Environment[$Name], "Process")
}
# Windows PowerShell 5 can leave ExitCode unset on the object returned
# by Start-Process. System.Diagnostics.Process retains the real code
# and lets us drain redirected streams without risking a pipe deadlock.
$ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
$ProcessInfo.FileName = $Executable
$ProcessInfo.Arguments = $Argument
$ProcessInfo.UseShellExecute = $false
$ProcessInfo.CreateNoWindow = $true
$ProcessInfo.RedirectStandardOutput = $true
$ProcessInfo.RedirectStandardError = $true
$Process = New-Object System.Diagnostics.Process
$Process.StartInfo = $ProcessInfo
if (-not $Process.Start()) {
throw "Unable to start the frozen application smoke test"
}
$OutputTask = $Process.StandardOutput.ReadToEndAsync()
$ErrorTask = $Process.StandardError.ReadToEndAsync()
$TimedOut = -not $Process.WaitForExit(30000)
if ($TimedOut -and -not $Process.HasExited) {
$Process.Kill()
}
$Process.WaitForExit()
$OutputTask.Wait()
$ErrorTask.Wait()
Set-Content -LiteralPath $StandardOutput -Value $OutputTask.Result -Encoding UTF8
Set-Content -LiteralPath $StandardError -Value $ErrorTask.Result -Encoding UTF8
$DiagnosticFiles = @($StandardOutput, $StandardError)
$DiagnosticFiles += Get-ChildItem -LiteralPath $SmokeRoot -Recurse -Filter "*.log" -File |
Select-Object -ExpandProperty FullName
$Diagnostics = ($DiagnosticFiles | Where-Object { Test-Path -LiteralPath $_ } |
ForEach-Object { Get-Content -LiteralPath $_ -Raw -ErrorAction SilentlyContinue }) -join "`n"
if ($TimedOut) {
throw "$GateName timed out after 30 seconds`n$Diagnostics"
}
if ($Process.ExitCode -ne 0) {
throw "$GateName failed with exit code $($Process.ExitCode)`n$Diagnostics"
}
if ($Diagnostics -match "(?im)traceback \(most recent call last\)|unhandled exception|uncaught exception|fatal python error") {
throw "$GateName logs contain an unhandled exception"
}
Write-Host "$GateName passed ($Argument, isolated offscreen mode)."
}
finally {
foreach ($Name in $Environment.Keys) {
[Environment]::SetEnvironmentVariable($Name, $PreviousEnvironment[$Name], "Process")
}
if (Test-Path -LiteralPath $SmokeRoot) {
Remove-Item -LiteralPath $SmokeRoot -Recurse -Force
}
}
}
function Assert-FrozenMultimedia {
param([Parameter(Mandatory = $true)][string]$Artifact)
foreach ($RequiredName in @(
"QtMultimedia.pyd",
"QtMultimediaWidgets.pyd",
"Qt6Multimedia.dll",
"Qt6MultimediaWidgets.dll"
)) {
$RequiredFile = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter $RequiredName -File |
Where-Object { $_.FullName -like "*\PySide6\*" } |
Select-Object -First 1
if (-not $RequiredFile) {
throw "Frozen Qt multimedia component is missing: $RequiredName"
}
}
$PluginDirectories = @(Get-ChildItem -LiteralPath $Artifact -Recurse -Directory |
Where-Object { $_.Name -eq "multimedia" -and $_.Parent.Name -eq "plugins" })
if ($PluginDirectories.Count -eq 0) {
throw "Frozen PySide6 plugins/multimedia directory is missing"
}
foreach ($BackendName in @("ffmpegmediaplugin.dll", "windowsmediaplugin.dll")) {
$Backend = $PluginDirectories |
ForEach-Object {
Get-ChildItem -LiteralPath $_.FullName -Filter $BackendName -File -ErrorAction SilentlyContinue
} |
Select-Object -First 1
if (-not $Backend) {
throw "Frozen Qt multimedia backend is missing: $BackendName"
}
}
Write-Host "Frozen Qt multimedia file gate passed."
}
if (-not [System.IO.Path]::IsPathRooted($Python)) {
$Python = Join-Path $ProjectRoot $Python
}
if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) {
throw "Build Python was not found: $Python"
}
$Npm = (Get-Command npm.cmd -ErrorAction Stop).Source
Push-Location $ProjectRoot
try {
if (-not $SkipFrontendInstall) {
& $Npm ci --prefix $CompanionRoot --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw "npm ci failed" }
}
& $Npm run build --prefix $CompanionRoot
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
& $Python -m PyInstaller --noconfirm --clean $Spec
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" }
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
$Resources = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "qtwebengine_resources*.pak" -File | Select-Object -First 1
$Companion = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "index.html" -File |
Where-Object { $_.FullName -like "*video_companion_dist*" } |
Select-Object -First 1
if (-not $Helper) { throw "QtWebEngineProcess.exe is missing from the artifact" }
if (-not $Resources) { throw "QtWebEngine Chromium resources are missing from the artifact" }
if (-not $Companion) { throw "video_companion_dist is missing from the artifact" }
$PythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
if ($LASTEXITCODE -ne 0 -or -not $PythonBase) {
throw "Unable to resolve the build Python runtime directory"
}
foreach ($RuntimeDllName in @("libssl-3-x64.dll", "libcrypto-3-x64.dll")) {
$SourceRuntimeDll = Join-Path $PythonBase "DLLs\$RuntimeDllName"
$FrozenRuntimeDll = Join-Path $Artifact "_internal\$RuntimeDllName"
if (-not (Test-Path -LiteralPath $SourceRuntimeDll -PathType Leaf)) {
throw "Build Python runtime dependency is missing: $SourceRuntimeDll"
}
if (-not (Test-Path -LiteralPath $FrozenRuntimeDll -PathType Leaf)) {
throw "Frozen Python runtime dependency is missing: $FrozenRuntimeDll"
}
$SourceHash = (Get-FileHash -LiteralPath $SourceRuntimeDll -Algorithm SHA256).Hash
$FrozenHash = (Get-FileHash -LiteralPath $FrozenRuntimeDll -Algorithm SHA256).Hash
if ($SourceHash -ne $FrozenHash) {
throw "Frozen $RuntimeDllName does not match the build Python runtime"
}
}
$Executable = Join-Path $Artifact "DoctorWorkstation.exe"
if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) {
throw "Frozen application entry point is missing: $Executable"
}
Assert-FrozenMultimedia -Artifact $Artifact
Invoke-FrozenGate `
-Executable $Executable `
-Argument "--media-smoke-test" `
-GateName "Frozen Qt multimedia smoke gate"
Invoke-FrozenGate `
-Executable $Executable `
-Argument "--smoke-test" `
-GateName "Frozen application entry smoke gate"
Write-Host "Build complete: $Artifact"
}
finally {
Pop-Location
}