From ac428fb5ec49d539cc86f9d94ae7c27db489bb63 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 18 Jun 2026 10:56:53 +0800 Subject: [PATCH] feat: build.ps1 produces Launcher.exe + portable layout - build a lean onefile Launcher.exe from src/launcher.py (stdlib-only, no PySide6/Pillow) alongside the onedir app build - release dir is now the portable layout: Launcher.exe + app\ (CMBot.exe + config + version.txt) - emit two zips: CMBot-.zip (self-update payload = app\ contents, what manifest.url points to) and CMBot--portable.zip (initial install) - launcher: harden logging setup for --windowed builds / read-only roots Actual PyInstaller build must run on Windows; script is syntax-verified. Co-Authored-By: Claude Opus 4.8 --- scripts/build.ps1 | 90 ++++++++++++++++++++++++++++++++--------------- src/launcher.py | 27 +++++++++----- tasks.md | 2 +- 3 files changed, 81 insertions(+), 38 deletions(-) diff --git a/scripts/build.ps1 b/scripts/build.ps1 index f4918e8..816d4c3 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -61,14 +61,19 @@ function Join-Url { 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 -$ReleaseZip = Join-Path $ReleaseRoot "$ReleaseName.zip" +$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" -$PyInstallerOutputDir = Join-Path $DistDir $AppExeName -$ExePath = Join-Path $ReleaseDir "$AppExeName.exe" +$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" @@ -91,7 +96,7 @@ if (-not (Test-Path -LiteralPath $DefaultConfigDir)) { New-Item -ItemType Directory -Force -Path $ReleaseRoot | Out-Null -foreach ($path in @($BuildDir, $PyInstallerOutputDir, $ReleaseDir, $ReleaseZip, $ManifestPath)) { +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 @@ -99,6 +104,7 @@ foreach ($path in @($BuildDir, $PyInstallerOutputDir, $ReleaseDir, $ReleaseZip, } if (-not $SkipBuild) { + # Main app (onedir, windowed GUI) & python -m PyInstaller ` --noconfirm ` --clean ` @@ -109,49 +115,73 @@ if (-not $SkipBuild) { --add-data "$SrcDir\resources;resources" ` "$SrcDir\main.py" if ($LASTEXITCODE -ne 0) { - throw "PyInstaller build failed." + 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 $PyInstallerOutputDir)) { - throw "PyInstaller output not found: $PyInstallerOutputDir" +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" } -Copy-Item -LiteralPath $PyInstallerOutputDir -Destination $ReleaseDir -Recurse -Copy-Item -LiteralPath $DefaultConfigDir -Destination (Join-Path $ReleaseDir "config") -Recurse -New-Item -ItemType Directory -Force -Path (Join-Path $ReleaseDir "logs") | Out-Null -New-Item -ItemType Directory -Force -Path (Join-Path $ReleaseDir "output") | Out-Null -Set-Content -LiteralPath (Join-Path $ReleaseDir "version.txt") -Value $AppVersion -Encoding ascii -NoNewline +# 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 $AppExeName.exe.", + "Double-click Launcher.exe.", "", - "Folders:", - "- config: default config and custom templates.", - "- logs: runtime logs.", - "- output: default export folder.", - "", - "Do not delete dependency files in this folder." + "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 $ExePath)) { - throw "Executable not found: $ExePath" +if (-not (Test-Path -LiteralPath $AppExePath)) { + throw "Executable not found: $AppExePath" +} +if (-not (Test-Path -LiteralPath $LauncherExePath)) { + throw "Launcher not found: $LauncherExePath" } -Compress-Archive -Path (Join-Path $ReleaseDir "*") -DestinationPath $ReleaseZip -Force -$ReleaseHash = (Get-FileHash -LiteralPath $ReleaseZip -Algorithm SHA256).Hash -$ReleaseSize = (Get-Item -LiteralPath $ReleaseZip).Length +# 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 $ReleaseZip) + url = Join-Url -BaseUrl $ManifestBaseUrl -Name (Split-Path -Leaf $UpdateZip) sha256 = $ReleaseHash size = $ReleaseSize mandatory = [bool]$Mandatory @@ -167,12 +197,14 @@ $Manifest = [ordered]@{ ) if ($PublishDir) { + # Update server needs only the payload zip + manifest. New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null - Copy-Item -LiteralPath $ReleaseZip -Destination $PublishDir -Force + Copy-Item -LiteralPath $UpdateZip -Destination $PublishDir -Force Copy-Item -LiteralPath $ManifestPath -Destination $PublishDir -Force Write-Host "Published update files to: $PublishDir" } -Write-Host "Release created: $ReleaseDir" -Write-Host "Release zip: $ReleaseZip" +Write-Host "Portable layout: $ReleaseDir (Launcher.exe + app\)" +Write-Host "Initial install zip: $PortableZip" +Write-Host "Update payload zip: $UpdateZip" Write-Host "Manifest: $ManifestPath" diff --git a/src/launcher.py b/src/launcher.py index a48dd4a..f6dfe6e 100644 --- a/src/launcher.py +++ b/src/launcher.py @@ -214,16 +214,27 @@ def main(argv=None): args = parser.parse_args(argv) root = Path(args.install_root) if args.install_root else _default_install_root() - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(message)s", - handlers=[ - logging.StreamHandler(), - logging.FileHandler(str(root / "launcher.log"), encoding="utf-8"), - ], - ) + _setup_logging(root) return run(root, no_launch=args.no_launch) +def _setup_logging(root): + """Configure logging defensively for both console and --windowed builds. + + A PyInstaller --windowed exe has no stdout/stderr (StreamHandler(None) would + fail), and a read-only install root makes the file handler fail. Add each + handler only when it can be created so the launcher never crashes on logging. + """ + handlers = [] + if sys.stderr is not None: + handlers.append(logging.StreamHandler()) + try: + handlers.append(logging.FileHandler(str(root / "launcher.log"), encoding="utf-8")) + except OSError: + pass + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", + handlers=handlers) + + if __name__ == "__main__": sys.exit(main()) diff --git a/tasks.md b/tasks.md index c31ecd9..ec85a4a 100644 --- a/tasks.md +++ b/tasks.md @@ -934,7 +934,7 @@ - [x] 文档:`docs/10` 改为便携 + `Launcher.exe` + `~/.cmbot` 模型(§3/§4/§5/§8/§9/§11/§16) - [x] `get_data_dir()` 三级回退:`CMBOT_DATA_DIR` → 打包态 `~/.cmbot` → 开发态项目根;`tests/test_file_service.py` 5 个单测 - [x] `src/launcher.py`:复用 `update_service`,下载 zip→SHA-256→解压→`app/app.old` 切换→启动;安装根可写性检测;首次把 `app\config\` 默认模板播种到 `~/.cmbot`;`update_service` 扩展(`UpdateInfo.sha256/size/min_supported`、绝对 url 解析、`make_auth_header`/`download`);`tests/test_launcher.py` 11 个单测 + 本地 HTTP server 真实端到端验证 -- [ ] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile),发布 zip 含 `Launcher.exe` + `app\` +- [x] `build.ps1` 增产 `Launcher.exe`(PyInstaller onefile,console);发布目录改为便携布局 `Launcher.exe` + `app\`;产出两个 zip——自更新载荷 `CMBot-.zip`(app\ 内容,manifest.url 指向它)与便携安装包 `CMBot--portable.zip`;`launcher.py` 日志初始化健壮化(--windowed/只读根不崩)。语法校验通过;实际 PyInstaller 构建需在 Windows 跑 - [ ] 退休 `scripts/update.ps1` 与 `scripts/install_local.ps1` - [ ] 端到端实测(解压到 D 盘运行、自更新、回滚)