[CmdletBinding()] param( [switch]$ValidateOnly ) $ErrorActionPreference = "Stop" $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $BuildScript = Join-Path $PSScriptRoot "build_windows.ps1" $BuildEnvironment = Join-Path $ProjectRoot ".venv-build" $ProjectUvCache = Join-Path $ProjectRoot ".uv-cache" $BuildPython = Join-Path $BuildEnvironment "Scripts\python.exe" $FallbackPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe" $CompanionRoot = Join-Path $ProjectRoot "video_companion" $PackageLock = Join-Path $CompanionRoot "package-lock.json" $Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation" $Executable = Join-Path $Artifact "DoctorWorkstation.exe" $DistributionRoot = Join-Path $ProjectRoot "dist" $ReleaseLauncherTemplate = Join-Path $ProjectRoot "packaging\windows\start_release.bat" $ReleaseLauncher = Join-Path $DistributionRoot "Start_DoctorWorkstation.bat" $InstallerDefinition = Join-Path $ProjectRoot "packaging\windows\doctor_workstation.iss" $EnsureInstallerCompiler = Join-Path $PSScriptRoot "ensure_inno_setup.ps1" $InstallerMessagesFile = Join-Path $ProjectRoot ".build-tools\inno-languages\ChineseSimplified.isl" $VersionSource = Join-Path $ProjectRoot "src\doctor_workstation\__init__.py" $MediaSmokeHook = Join-Path $ProjectRoot "packaging\runtime_media_smoke.py" $WindowsIcon = Join-Path $ProjectRoot "resources\branding\app-icon.ico" function Test-BuildPython { param([Parameter(Mandatory = $true)][string]$Candidate) if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) { return $false } try { & $Candidate -c "import sys, httpx, PyInstaller, PySide6; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" 2>$null return $LASTEXITCODE -eq 0 } catch { return $false } } function Find-Application { param([Parameter(Mandatory = $true)][string]$Name) $Command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($Command) { return $Command.Source } return $null } try { foreach ($RequiredFile in @( $BuildScript, $PackageLock, (Join-Path $ProjectRoot "uv.lock"), $VersionSource, $MediaSmokeHook, $WindowsIcon, $ReleaseLauncherTemplate, $InstallerDefinition, $EnsureInstallerCompiler )) { if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) { throw "Required build file is missing: $RequiredFile" } } $VersionText = [System.IO.File]::ReadAllText($VersionSource) $VersionMatch = [regex]::Match( $VersionText, '(?m)^\s*__version__\s*=\s*"([^"]+)"' ) if (-not $VersionMatch.Success) { throw "Unable to read the application version from $VersionSource" } $ProjectVersion = $VersionMatch.Groups[1].Value $Npm = Find-Application -Name "npm.cmd" $Node = Find-Application -Name "node.exe" if (-not $Npm -or -not $Node) { throw "Node.js and npm are required to build the video companion." } $NodeVersionText = (& $Node --version).Trim().TrimStart("v") $NodeVersion = [version]$NodeVersionText if ($NodeVersion.Major -lt 20) { throw "Node.js 20 or newer is required; found $NodeVersionText" } $Uv = Find-Application -Name "uv" if ($ValidateOnly) { if (-not $Uv -and -not (Test-BuildPython -Candidate $BuildPython) -and -not (Test-BuildPython -Candidate $FallbackPython)) { throw "Neither uv nor a usable Python environment with build dependencies was found." } Write-Host "Windows package entry validation passed. No artifacts were generated." exit 0 } if ($Uv) { Write-Host "Preparing locked Python build dependencies in $BuildEnvironment ..." $PreviousProjectEnvironment = [Environment]::GetEnvironmentVariable( "UV_PROJECT_ENVIRONMENT", "Process" ) $PreviousUvCache = [Environment]::GetEnvironmentVariable( "UV_CACHE_DIR", "Process" ) New-Item -ItemType Directory -Path $ProjectUvCache -Force | Out-Null [Environment]::SetEnvironmentVariable( "UV_PROJECT_ENVIRONMENT", $BuildEnvironment, "Process" ) [Environment]::SetEnvironmentVariable( "UV_CACHE_DIR", $ProjectUvCache, "Process" ) Push-Location $ProjectRoot try { & $Uv sync --frozen --extra build if ($LASTEXITCODE -ne 0) { throw "uv sync for build dependencies failed with exit code $LASTEXITCODE" } } finally { Pop-Location [Environment]::SetEnvironmentVariable( "UV_PROJECT_ENVIRONMENT", $PreviousProjectEnvironment, "Process" ) [Environment]::SetEnvironmentVariable( "UV_CACHE_DIR", $PreviousUvCache, "Process" ) } } elseif (Test-BuildPython -Candidate $BuildPython) { Write-Host "Using existing build environment: $BuildPython" } elseif (Test-BuildPython -Candidate $FallbackPython) { $BuildPython = $FallbackPython Write-Host "Using existing project environment with build dependencies: $BuildPython" } else { throw "Install uv or prepare .venv-build with the project's build dependencies." } if (-not (Test-BuildPython -Candidate $BuildPython)) { throw "The prepared build Python is unusable: $BuildPython" } Write-Host "Installing locked video companion dependencies..." & $Npm ci --prefix $CompanionRoot --no-audit --no-fund if ($LASTEXITCODE -ne 0) { throw "npm ci failed with exit code $LASTEXITCODE" } Write-Host "Running the existing Windows release build..." & $BuildScript -Python $BuildPython -SkipFrontendInstall if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) { throw "Build completed without the expected executable: $Executable" } $ReleaseZip = Join-Path $DistributionRoot ( "DoctorWorkstation-Windows-x64-$ProjectVersion.zip" ) $InstallerBaseName = "DoctorWorkstation-Setup-Windows-x64-$ProjectVersion" $InstallerArtifact = Join-Path $DistributionRoot "$InstallerBaseName.exe" $ChecksumFile = Join-Path $DistributionRoot "SHA256SUMS.txt" Copy-Item -LiteralPath $ReleaseLauncherTemplate -Destination $ReleaseLauncher -Force if (Test-Path -LiteralPath $ReleaseZip) { Remove-Item -LiteralPath $ReleaseZip -Force } Write-Host "Creating the distributable ZIP..." $SevenZip = Find-Application -Name "7z.exe" if ($SevenZip) { Push-Location $DistributionRoot try { & $SevenZip a -tzip -mx=5 -mmt=on $ReleaseZip ` ".\DoctorWorkstation" ".\Start_DoctorWorkstation.bat" if ($LASTEXITCODE -ne 0) { throw "7-Zip failed with exit code $LASTEXITCODE" } } finally { Pop-Location } } else { Compress-Archive ` -LiteralPath @($Artifact, $ReleaseLauncher) ` -DestinationPath $ReleaseZip ` -CompressionLevel Optimal } if (-not (Test-Path -LiteralPath $ReleaseZip -PathType Leaf)) { throw "Packaging completed without the expected ZIP: $ReleaseZip" } if (Test-Path -LiteralPath $InstallerArtifact) { Remove-Item -LiteralPath $InstallerArtifact -Force } Write-Host "Preparing the pinned Inno Setup compiler..." $InnoCompiler = (& $EnsureInstallerCompiler | Select-Object -Last 1) if (-not $InnoCompiler -or -not (Test-Path -LiteralPath $InnoCompiler -PathType Leaf)) { throw "Unable to locate the Inno Setup compiler" } if (-not (Test-Path -LiteralPath $InstallerMessagesFile -PathType Leaf)) { throw "Simplified Chinese installer messages are missing: $InstallerMessagesFile" } Write-Host "Creating the Windows installer..." & $InnoCompiler ` "/DAppVersion=$ProjectVersion" ` "/DSourceDir=$Artifact" ` "/DOutputDir=$DistributionRoot" ` "/DSetupBaseName=$InstallerBaseName" ` "/DChineseMessagesFile=$InstallerMessagesFile" ` "/DAppIconFile=$WindowsIcon" ` $InstallerDefinition if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed with exit code $LASTEXITCODE" } if (-not (Test-Path -LiteralPath $InstallerArtifact -PathType Leaf)) { throw "Installer compilation completed without the expected artifact: $InstallerArtifact" } $ReleaseHash = (Get-FileHash -LiteralPath $ReleaseZip -Algorithm SHA256).Hash $InstallerHash = (Get-FileHash -LiteralPath $InstallerArtifact -Algorithm SHA256).Hash $ChecksumLines = @( "$InstallerHash $([System.IO.Path]::GetFileName($InstallerArtifact))", "$ReleaseHash $([System.IO.Path]::GetFileName($ReleaseZip))" ) $Utf8WithoutBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText( $ChecksumFile, (($ChecksumLines -join "`r`n") + "`r`n"), $Utf8WithoutBom ) Write-Host "Windows package complete." -ForegroundColor Green Write-Host "Artifact: $Artifact" -ForegroundColor Green Write-Host "Installer: $InstallerArtifact" -ForegroundColor Green Write-Host "Release ZIP: $ReleaseZip" -ForegroundColor Green Write-Host "Installer SHA-256: $InstallerHash" -ForegroundColor Green Write-Host "ZIP SHA-256: $ReleaseHash" -ForegroundColor Green exit 0 } catch { $FailureExitCode = if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) { $LASTEXITCODE } else { 1 } Write-Host "Windows packaging failed." -ForegroundColor Red Write-Host $_.Exception.Message -ForegroundColor Red exit $FailureExitCode }