param( [string]$PythonLauncher = "py", [string]$PythonVersion = "-3.12", [switch]$SkipIcon, [switch]$SkipBackend, [switch]$SkipFrontend, [switch]$SkipInstaller, [switch]$Sign, [string]$CertificateFile, [string]$CertificatePassword, [string]$PublisherName, [string]$TimestampServer = "http://timestamp.digicert.com", [string[]]$VoiceSmokeKeys = @(), [switch]$ForceSign, [switch]$CleanBackend ) $ErrorActionPreference = "Stop" $Root = Resolve-Path (Join-Path $PSScriptRoot "..") $Frontend = Join-Path $Root "chatlab-web\frontend" $Backend = Join-Path $Root "chatlog_fastAPI" $Electron = Join-Path $Root "electron-launcher" $Resources = Join-Path $Electron "build-resources" $Release = Join-Path $Root "release" function Invoke-Python312($Arguments) { $pyCommand = Get-Command $PythonLauncher -ErrorAction SilentlyContinue if ($pyCommand) { try { & $PythonLauncher $PythonVersion -V | Out-Null if ($LASTEXITCODE -eq 0) { & $PythonLauncher $PythonVersion @Arguments return } } catch { Write-Host "Python launcher $PythonLauncher $PythonVersion is not available, falling back to user Python312." } } $fallback = Join-Path $env:LOCALAPPDATA "Programs\Python\Python312\python.exe" if (-not (Test-Path $fallback)) { throw "Python 3.12 was not found. Install it first or pass -PythonLauncher/-PythonVersion." } & $fallback @Arguments } function Reset-Dir($Path) { if (Test-Path $Path) { $resolved = Resolve-Path $Path if (-not $resolved.Path.StartsWith($Root.Path)) { throw "Refusing to remove path outside project: $($resolved.Path)" } Remove-Item -LiteralPath $resolved.Path -Recurse -Force } New-Item -ItemType Directory -Force -Path $Path | Out-Null } function Copy-Dir($Source, $Dest) { if (-not (Test-Path $Source)) { throw "Missing required source: $Source" } New-Item -ItemType Directory -Force -Path (Split-Path $Dest) | Out-Null Copy-Item -LiteralPath $Source -Destination $Dest -Recurse -Force } function Set-OptionalEnv($Name, $Value) { if ([string]::IsNullOrWhiteSpace($Value)) { Remove-Item -Path "Env:$Name" -ErrorAction SilentlyContinue } else { Set-Item -Path "Env:$Name" -Value $Value } } function Test-ForbiddenReleaseFile($File) { $inBackendInternal = $File.FullName -match '[\\/]backend[\\/]_internal[\\/]' return $File.Name -eq ".env" -or $File.Name -like "knowledge*.db" -or ($File.FullName -match "\\__pycache__\\" -and -not $inBackendInternal) -or $File.Name -like "*.pfx" -or $File.Name -like "*.p12" -or $File.Name -like "*.pvk" -or $File.Name -like "*.cer" -or $File.Name -like "*.crt" -or $File.Name -like "*.key" -or $File.FullName -match "\\certs\\" } Set-Location $Root $resolvedCertificateFile = $null if ($CertificateFile) { if (-not (Test-Path -LiteralPath $CertificateFile)) { throw "Certificate file was not found: $CertificateFile" } $resolvedCertificateFile = (Resolve-Path -LiteralPath $CertificateFile).Path if ($resolvedCertificateFile.StartsWith($Root.Path)) { throw "For safety, keep the code signing certificate outside the project folder: $resolvedCertificateFile" } } $envCertificateFile = if ($resolvedCertificateFile) { $resolvedCertificateFile } else { $env:CHATLAB_PFX_FILE } $envCertificateFile = if ($envCertificateFile) { $envCertificateFile.Trim() } else { "" } $shouldSign = $Sign -or -not [string]::IsNullOrWhiteSpace($envCertificateFile) if ($ForceSign -and -not $shouldSign) { throw "-ForceSign requires -CertificateFile or CHATLAB_PFX_FILE." } if ($shouldSign) { if (-not $envCertificateFile) { throw "Signing was requested, but no certificate was provided. Use -CertificateFile or CHATLAB_PFX_FILE." } if (-not (Test-Path -LiteralPath $envCertificateFile)) { throw "Certificate file was not found: $envCertificateFile" } $envCertificateFile = (Resolve-Path -LiteralPath $envCertificateFile).Path if ($envCertificateFile.StartsWith($Root.Path)) { throw "For safety, keep the code signing certificate outside the project folder: $envCertificateFile" } Set-OptionalEnv "CHATLAB_PFX_FILE" $envCertificateFile if ($CertificatePassword) { Set-OptionalEnv "CHATLAB_PFX_PASSWORD" $CertificatePassword Set-OptionalEnv "WIN_CSC_KEY_PASSWORD" $CertificatePassword Set-OptionalEnv "CSC_KEY_PASSWORD" $CertificatePassword } if ($PublisherName) { Set-OptionalEnv "CHATLAB_CERT_PUBLISHER_NAME" $PublisherName } Set-OptionalEnv "CHATLAB_TIMESTAMP_SERVER" $TimestampServer if ($ForceSign) { Set-OptionalEnv "CHATLAB_FORCE_SIGN" "1" } Write-Host "Code signing enabled. Certificate: $envCertificateFile" } else { Remove-Item -Path "Env:CHATLAB_PFX_FILE" -ErrorAction SilentlyContinue Remove-Item -Path "Env:CHATLAB_PFX_PASSWORD" -ErrorAction SilentlyContinue Remove-Item -Path "Env:CHATLAB_CERT_PUBLISHER_NAME" -ErrorAction SilentlyContinue Remove-Item -Path "Env:CHATLAB_FORCE_SIGN" -ErrorAction SilentlyContinue Remove-Item -Path "Env:WIN_CSC_KEY_PASSWORD" -ErrorAction SilentlyContinue Remove-Item -Path "Env:CSC_KEY_PASSWORD" -ErrorAction SilentlyContinue Write-Host "Code signing disabled. Unsigned installer build is allowed." } if (-not $SkipIcon) { & node (Join-Path $Root "scripts\make-icon.cjs") } $logoDest = Join-Path $Electron "build\company-logo.jpg" $logoSrc = Join-Path $Frontend "public\company-logo.jpg" if (Test-Path $logoSrc) { Copy-Item -LiteralPath $logoSrc -Destination $logoDest -Force Write-Host "company-logo.jpg synced to electron-launcher\build\" } if (-not $SkipFrontend) { $dist = Join-Path $Frontend "dist" if (Test-Path $dist) { Remove-Item -LiteralPath (Resolve-Path $dist).Path -Recurse -Force } Push-Location $Frontend & npm.cmd run build Pop-Location } if (-not $SkipBackend) { Push-Location $Backend # 默认复用 PyInstaller 的分析缓存(build\ 目录),代码未大改时增量打包可从 ~9 分钟降到 1-2 分钟。 # 仅在升级依赖或缓存异常时用 -CleanBackend 做一次全量重建。 $pyArgs = @("-m", "PyInstaller", "ChatLabBackend.spec", "--noconfirm") if ($CleanBackend) { $pyArgs += "--clean" Write-Host "Backend: full clean rebuild (--clean)." } else { Write-Host "Backend: incremental build (reusing cache). Use -CleanBackend for a full rebuild." } Invoke-Python312 $pyArgs Pop-Location } Reset-Dir $Resources New-Item -ItemType Directory -Force -Path $Release | Out-Null Copy-Item -LiteralPath (Join-Path $Root "chatlog.exe") -Destination (Join-Path $Resources "chatlog.exe") -Force $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" $chatlogVersionOutput = & (Join-Path $Resources "chatlog.exe") version 2>&1 $chatlogVersionExitCode = $LASTEXITCODE $ErrorActionPreference = $previousErrorActionPreference if ($chatlogVersionExitCode -ne 0) { throw "chatlog.exe version check failed:`n$chatlogVersionOutput" } Write-Host "chatlog.exe version: $chatlogVersionOutput" foreach ($voiceKey in $VoiceSmokeKeys) { if ([string]::IsNullOrWhiteSpace($voiceKey)) { continue } try { $response = Invoke-WebRequest -Uri "http://127.0.0.1:5030/voice/$voiceKey" -UseBasicParsing -TimeoutSec 15 if ($response.StatusCode -ge 400 -or $response.RawContentLength -le 0) { throw "HTTP $($response.StatusCode), length=$($response.RawContentLength)" } Write-Host "voice smoke passed: $voiceKey ($($response.RawContentLength) bytes)" } catch { throw "voice smoke failed for $voiceKey. Do not ship this installer until chatlog can read WeChat voice media. $($_.Exception.Message)" } } Copy-Dir (Join-Path $Root "lib") (Join-Path $Resources "lib") Copy-Dir (Join-Path $Frontend "dist") (Join-Path $Resources "frontend") Copy-Dir (Join-Path $Backend "dist\ChatLabBackend") (Join-Path $Resources "backend") Copy-Item -LiteralPath (Join-Path $Root "DISCLAIMER.md") -Destination (Join-Path $Resources "DISCLAIMER.md") -Force Copy-Item -LiteralPath (Join-Path $Root "LICENSE") -Destination (Join-Path $Resources "LICENSE") -Force $forbidden = Get-ChildItem -LiteralPath $Resources -Recurse -Force | Where-Object { Test-ForbiddenReleaseFile $_ } if ($forbidden) { $names = ($forbidden | Select-Object -ExpandProperty FullName) -join "`n" throw "Sensitive or cache files found in release resources:`n$names" } Get-ChildItem -LiteralPath $Resources -Recurse -File | Select-Object FullName, Length | Out-File -Encoding UTF8 (Join-Path $Release "manifest.txt") if (-not $SkipInstaller) { Push-Location $Electron & npm.cmd run build Pop-Location Copy-Item -Path (Join-Path $Electron "dist\*.exe") -Destination $Release -Force Copy-Item -Path (Join-Path $Electron "dist\*.blockmap") -Destination $Release -Force -ErrorAction SilentlyContinue $releaseForbidden = Get-ChildItem -LiteralPath $Release -Recurse -Force | Where-Object { Test-ForbiddenReleaseFile $_ } if ($releaseForbidden) { $names = ($releaseForbidden | Select-Object -ExpandProperty FullName) -join "`n" throw "Sensitive certificate, private data, or cache files found in release output:`n$names" } $installer = Get-ChildItem -LiteralPath $Release -Filter "ChatLab-Setup-*.exe" -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($installer) { $signature = Get-AuthenticodeSignature -FilePath $installer.FullName if ($shouldSign -or $ForceSign) { if ($signature.Status -ne "Valid") { throw "Installer signing verification failed: $($signature.Status) $($signature.StatusMessage)" } Write-Host "Installer signature verified: $($signature.SignerCertificate.Subject)" } else { Write-Host "Installer is unsigned by design for this build." } } } Write-Host "Desktop build completed. Output: $Release"