param( [switch]$SkipBuild, [string]$ManifestBaseUrl = "", [string]$PublishDir = "", [string]$ReleaseNotes = "", [string]$MinSupported = "", [switch]$Mandatory ) $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $RootDir = Split-Path -Parent $ScriptDir $SrcDir = Join-Path $RootDir "src" $VersionFile = Join-Path $SrcDir "version.py" $DefaultConfigDir = Join-Path $RootDir "packaging\default_config" $BuildDir = Join-Path $RootDir "build" $DistDir = Join-Path $RootDir "dist" $ReleaseRoot = Join-Path $RootDir "release" $AppExeName = "CMBot" function Read-VersionValue { param( [string]$Path, [string]$Name ) $pattern = "^\s*$Name\s*=\s*`"([^`"]+)`"" foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) { if ($line -match $pattern) { return $Matches[1] } } throw "Cannot find $Name in $Path" } function Assert-InProject { param([string]$Path) $resolvedRoot = (Resolve-Path -LiteralPath $RootDir).Path if (Test-Path -LiteralPath $Path) { $resolvedPath = (Resolve-Path -LiteralPath $Path).Path if (-not $resolvedPath.StartsWith($resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to modify path outside project: $resolvedPath" } } } function Join-Url { param( [string]$BaseUrl, [string]$Name ) if (-not $BaseUrl) { return $Name } if (-not $BaseUrl.EndsWith("/")) { $BaseUrl = "$BaseUrl/" } return (New-Object -TypeName System.Uri -ArgumentList ([System.Uri]$BaseUrl), $Name).AbsoluteUri } $LauncherName = "Launcher" $AppName = Read-VersionValue -Path $VersionFile -Name "APP_NAME" $AppVersion = Read-VersionValue -Path $VersionFile -Name "APP_VERSION" $ReleaseName = "$AppExeName-$AppVersion" $ReleaseDir = Join-Path $ReleaseRoot $ReleaseName # portable layout root $AppDir = Join-Path $ReleaseDir "app" # becomes app\ on the client $UpdateZip = Join-Path $ReleaseRoot "$ReleaseName.zip" # self-update payload (app\ contents) $PortableZip = Join-Path $ReleaseRoot "$ReleaseName-portable.zip" # initial install $ManifestPath = Join-Path $ReleaseRoot "manifest.json" $AppPyiOut = Join-Path $DistDir $AppExeName # dist\CMBot (onedir) $LauncherPyiOut = Join-Path $DistDir "$LauncherName.exe" # dist\Launcher.exe (onefile) $AppExePath = Join-Path $AppDir "$AppExeName.exe" $LauncherExePath = Join-Path $ReleaseDir "$LauncherName.exe" Write-Host "Building $AppName $AppVersion" $pythonVersionText = & python --version if ($LASTEXITCODE -ne 0) { throw "Python is not available." } if ($pythonVersionText -notmatch "Python 3\.7\.") { throw "Python 3.7 is required, current version: $pythonVersionText" } & python -c "import PyInstaller" if ($LASTEXITCODE -ne 0) { throw "PyInstaller is not installed. Run: pip install -r requirements.txt" } if (-not (Test-Path -LiteralPath $DefaultConfigDir)) { throw "Default config directory not found: $DefaultConfigDir" } New-Item -ItemType Directory -Force -Path $ReleaseRoot | Out-Null foreach ($path in @($BuildDir, $AppPyiOut, $LauncherPyiOut, $ReleaseDir, $UpdateZip, $PortableZip, $ManifestPath)) { Assert-InProject -Path $path if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } if (-not $SkipBuild) { # Main app (onedir, windowed GUI) & python -m PyInstaller ` --noconfirm ` --clean ` --onedir ` --windowed ` --name $AppExeName ` --paths $SrcDir ` --add-data "$SrcDir\resources;resources" ` "$SrcDir\main.py" if ($LASTEXITCODE -ne 0) { throw "PyInstaller (app) build failed." } # Update launcher (onefile; console shows download/swap progress). # Lean: launcher only imports services/* (stdlib), no PySide6/Pillow. & python -m PyInstaller ` --noconfirm ` --clean ` --onefile ` --console ` --name $LauncherName ` --paths $SrcDir ` "$SrcDir\launcher.py" if ($LASTEXITCODE -ne 0) { throw "PyInstaller (launcher) build failed." } } if (-not (Test-Path -LiteralPath $AppPyiOut)) { throw "PyInstaller app output not found: $AppPyiOut" } if (-not (Test-Path -LiteralPath $LauncherPyiOut)) { throw "PyInstaller launcher output not found: $LauncherPyiOut" } # Assemble the portable layout: \Launcher.exe + \app\ New-Item -ItemType Directory -Force -Path $ReleaseDir | Out-Null Copy-Item -LiteralPath $AppPyiOut -Destination $AppDir -Recurse Copy-Item -LiteralPath $DefaultConfigDir -Destination (Join-Path $AppDir "config") -Recurse Set-Content -LiteralPath (Join-Path $AppDir "version.txt") -Value $AppVersion -Encoding ascii -NoNewline Copy-Item -LiteralPath $LauncherPyiOut -Destination $LauncherExePath -Force $ReadmePath = Join-Path $ReleaseDir "README.txt" $Readme = @( "$AppName $AppVersion", "", "Start:", "Double-click Launcher.exe.", "", "Notes:", "- Extract this folder to any writable location (e.g. D:\CMBot or the Desktop).", "- Do NOT put it under C:\Program Files or the C: drive root (not writable).", "- Your settings, templates and exports live in %USERPROFILE%\.cmbot.", "- The app itself is in app\; Launcher.exe checks for updates on start." ) Set-Content -LiteralPath $ReadmePath -Value $Readme -Encoding UTF8 if (-not (Test-Path -LiteralPath $AppExePath)) { throw "Executable not found: $AppExePath" } if (-not (Test-Path -LiteralPath $LauncherExePath)) { throw "Launcher not found: $LauncherExePath" } # Self-update payload: contents of app\ (CMBot.exe at the zip root). manifest.url # points here; the launcher downloads, verifies and swaps it into app\. Compress-Archive -Path (Join-Path $AppDir "*") -DestinationPath $UpdateZip -Force # Initial install: the full portable layout (Launcher.exe + app\). Compress-Archive -Path (Join-Path $ReleaseDir "*") -DestinationPath $PortableZip -Force $ReleaseHash = (Get-FileHash -LiteralPath $UpdateZip -Algorithm SHA256).Hash $ReleaseSize = (Get-Item -LiteralPath $UpdateZip).Length if (-not $MinSupported) { $MinSupported = $AppVersion } $Manifest = [ordered]@{ version = $AppVersion url = Join-Url -BaseUrl $ManifestBaseUrl -Name (Split-Path -Leaf $UpdateZip) sha256 = $ReleaseHash size = $ReleaseSize mandatory = [bool]$Mandatory min_supported = $MinSupported notes = $ReleaseNotes } # Write UTF-8 WITHOUT BOM: Set-Content -Encoding UTF8 emits a BOM that breaks # json.loads in the in-app update check (services/update_service.py). [System.IO.File]::WriteAllText( $ManifestPath, ($Manifest | ConvertTo-Json), (New-Object System.Text.UTF8Encoding $false) ) if ($PublishDir) { # Update server needs only the payload zip + manifest. New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null Copy-Item -LiteralPath $UpdateZip -Destination $PublishDir -Force Copy-Item -LiteralPath $ManifestPath -Destination $PublishDir -Force Write-Host "Published update files to: $PublishDir" } Write-Host "Portable layout: $ReleaseDir (Launcher.exe + app\)" Write-Host "Initial install zip: $PortableZip" Write-Host "Update payload zip: $UpdateZip" Write-Host "Manifest: $ManifestPath"